4.9.1链表的引入
4.9.1.1、从数组的缺陷说起
- (1)数组由两个缺陷。一个是数组中所有元素的类型必须一致。数组的元素个数必须事先指定,并且一旦指定后不能更改。
- (2)如何解决数组的2个缺陷:数组的第一个缺陷考结构体解决。结构体允许其中的元素类型不相同,因此解决了数组的第一个缺陷。因此结构体是因为数组不能解决某些问题而被发明出来的
- (3)如何解决数组的第二个缺陷?我们希望数组的大小能够实时扩展, 比如一开始我们定义了元素个数是10,后来程序运行时觉得不够因此扩展为20。普通的数组显然不行,我们可以对数组进行封装以达到目的,也可以使用一个新的数据结构来解决,这个新的数据结构就是链表
- 总结:几乎可以这样理解:链表就是一个元素个数可以实时变大/变小的数组。
4.9.1.2、大学为什么要有新校区
- (1)学校初建的时候就类似 变量的定义并初始化,因为旁边全是荒地,因此学校的大小是由自己定的。但是学校建立了之后,旁边慢慢的也有了其他的建筑(就类似于,这个变量分配后,内存的相邻的区域又分配了其他变量与这个变量地址相连)这时候你的校园发展感觉不够用了想要扩展,却发现邻居已经住满了,已经没法扩展了,这时候学校扩展有两条思路,第一种是拆迁,第二是搬迁,第三外部扩展
- (2)拆迁基本行不通,不管是显示生活中还是程序运行中,因为成本太高了。
- (3)搬迁可以行得通。程序中解决数组大小扩展的一个思路就是整体搬迁。具体的思路是:先在空白内存出简历一个大的数组,然后把原来的数组中的元素整个复制到新数组的头部。然后再释放掉原来数组的内存空间,并且把我们新的数组去替代原来的数组。这种可变数组早C语言中不支持,但是在更高级语言中java c++是支持的。
- (4)外部扩展是做常见的,也是最合理的,他的思路就是化整为0,在原来的不动的情况下,扩展新的分基地。外部扩展在学校的例子中就是新校区。外部扩展在编程中解决数组问题的方案就是链表
4.9.1.3、链表是什么样子的?
- (1)顾名思义,链表就是用锁链连接起来的表。
这里的表只的是一个个节点(一个个校区),节点中有内存可以用爱存储数据(所以叫表,数据表)。
这里的锁链指的是锁链各个表的方法,C语言中用来连接2块表
- (2)链表是由若干个节点组成的(链表的各个节点是类似的),节点是由有效数据和指针组成的。有效数据区域用来存储信息完成任务的,指针区域用于指向链表的下一个节点从而构成链表。
4.9.1.4、时刻别忘了链表是用来干嘛的
- (1)时刻谨记:链表就是用来解决数组的大小不能动态扩展的问题,所以链表其实就是当数组用的。直白点:链表能完成的任务用链表也能完成,用数组能完成的任务用链表也能完成。但是灵活性不一样。
- (2)简单点:链表就是用来存储数据的。链表用来存数据相对于数组来说优点就是灵活性,需要多少个就动态分配多少个,不占用额外的内存。数组的优势是使用简单(简单粗暴)。
4.9.2、单链表的实现
4.9.2.1、单链表的节点构成
- (1)链表是由节点组成的,节点包含了有效数据部分和指针。
- (2)定义的struct node 只是一个结构体,本身并没有变量生成,也不占内存。结构体定义相当于链表节点定义了一个模板,但是还没有一个节点,将来子啊实际创建李安表示需要一个节点时,用这个模板来复制即可。
4.9.2.2、堆内存的申请和使用
- (1)链表的内存要求比较灵活,不能用栈,也不能用data数据段
- (2)使用堆内存来创建一个链表节点的步骤:1、申请堆内存,大小为一个节点的大小(检查申请结果是否正确)。2、清理申请到的堆内存。3、把申请到的堆内存当作一个新节点;4、填充新节点的有效数据和指针区域
4.9.2.3、链表的头指针
- (1)头指针不是一个节点,而是一个普通指针,只占4字节。头指针的类型是struct node * 类型的,所以它才能指向链表的节点
- (2)一个典型的链表的实现就是:头指针指向链表的第一个节点,然后第一个节点中的指针指向下一个节点,然后以此类推,一直到最后一个节点。这样就构成了一个链
4.9.2.4、实战:构建一个简单的单链表
(1)目标:构建一个链表,将一个数据(譬如1,2,3三个数字)存储在链表中。
4.9.3、单链表的算法值插入节点
4.9.3.1、继续上节,访问链表中各个节点的数据
- (1)只能用头指针,不能用各个字节自己的指针,因为实际当中我们保存链表的时候是不会保存各个节点的指针的,只能通过头指针来访问链表节点
- (2)前一个节点内部的pNext指针能帮助我们找到下一个指节点
4.9.3.2创建节点的代码封装成一个函数
- (1)封装时的关键点就是函数的接口(函数参数和返回值)的设计
-
#include <stdio.h> #include <stdlib.h> #include <strings.h> struct node { int data; struct node * pNext; }; struct node * creat_node(int data) { struct node * p = (struct node *)malloc(sizeof(struct node)); if(NULL == p) { printf("malloc error"); return NULL; } bzero(p, sizeof(struct node)); p->data = data; p->pNext = NULL; return p; } int main(void) { struct node * pHeader = NULL; pHeader = creat_node(1); pHeader->pNext = creat_node(2); pHeader->pNext->pNext = creat_node(3); printf("pHeadef->data = %d. ", pHeader->data); printf("pHeader->pNext.data = %d. ", pHeader->pNext->data); printf("pHeader->pNext->pNext.data = %d. ", pHeader->pNext->pNext->data); return 0; }
4.9.3.3、从链表头部插入新节点
4.9.3.4、从链表尾部插入新节点
(1)尾部插入链表简单,因为前面已经建立的
struct node { int data; struct node * pNext; }; //创建节点 struct node * creat_node(int data) { struct node * p = (struct node *)malloc(sizeof(struct node)); if(NULL == p) { printf("malloc error"); return NULL; } memset(p, 0, sizeof(struct node)); //给申请的堆内存清0 //bzero(p, sizeof(struct node)); p->data = data; p->pNext = NULL; return p; } //从尾部插入 int insert_tail(struct node * pHeader, struct node * new) { struct node * p = pHeader; if(NULL != p->pNext) { p = p->pNext; } p->pNext = new; } int main(void) { struct node * pHeader = creat_node(1); insert_tail(pHeader, creat_node(2)); insert_tail(pHeader, creat_node(3)); printf("pHeader->data = %d. ", pHeader->data); printf("pHeader->pNext->data = %d. ", pHeader->pNext->data); printf("pHeader->pNext->pNext->data = %d. ", pHeader->pNext->pNext->data); return 0; }
#include <stdio.h> #include <stdlib.h> #include <string.h> struct node { int data; struct node * pNext; }; //创建节点 struct node * creat_node(int data) { struct node * p = (struct node *)malloc(sizeof(struct node)); if(NULL == p) { printf("malloc error"); return NULL; } memset(p, 0, sizeof(struct node)); //给申请的堆内存清0 //bzero(p, sizeof(struct node)); p->data = data; p->pNext = NULL; return p; } //从尾部插入 int insert_tail(struct node * pHeader, struct node * new) { int cnt = 0; struct node * p = pHeader; while(NULL != p->pNext) { p = p->pNext; cnt++; } p->pNext = new; pHeader->data = cnt + 1; } int main(void) { struct node * pHeader = creat_node(0); insert_tail(pHeader, creat_node(1)); insert_tail(pHeader, creat_node(2)); insert_tail(pHeader, creat_node(3)); printf("beader node data = %d. ", pHeader->data); printf("node1 data = %d. ", pHeader->pNext->data); printf("node2 data = %d. ", pHeader->pNext->pNext->data); printf("node3 data = %d. ", pHeader->pNext->pNext->pNext->data); return 0; }
4.9.4、单链表的算法之插入节点续
4.9.4.1、详解链表头部插入函数
4.9.4.2、什么是头节点
(1)问题:因为我们在insert_tail中直接默认了头指针指向的有一个节点,因此如果程序中定义了头指针后就直接insert_tail后会出现段错误。我们不得不在定义头指针之后先creat_node后创建一个新节点给头指针初始化,否则不能避免这个错误,但是这样解决让程序看起来逻辑有点不太顺,看起来第一个节点和其他的节点有点不同,显得有些另类。
(2)链表还有另外一种用法,就是把头指针指向的第一个节点当作头结点使用。头结点的特点是:第一,他紧跟在头指针的后面。第二,头结点的数据部分是空的(有时候不是空的,而是存储整个链表的节点数目 )。指针部分指向下一个节点也就是第一个节点。
(3)这样看来头结点和其他节点确实不太一样。我们在创建链表时添加节点的方法也不同。头结点在创建头指针时一并创建并且和头指针关联起来,后面的真正的存储数据的节点用节点添加的函数来完成,譬如insert_node
(4)链表有没有头结点是不同的,体现在链表的插入节点、删除节点、遍历节点、解析链表的各个算法函数都不太。所以如果一个链表设计的时候有头结点,那么后面所有的算法都应该这样来处理。如果设计的时候没有头结点,那么后面所有算法都应该按照没有头结点来做,实际编程中,两种节点都有人用,所以在实际编程中,一应要看别人有没有使用头结点。
4.9.5.从链表头部插入新节点
- (1)注意写代码过程中的箭头符号,和说话过程中的指针指向,这是两码事,容易搞混。箭头符号实际上是用指针方式来访问结构体,所以箭头符号实质上是访问结构体的成员。更清楚的来说,程序中的箭头和链表的链接没有任何关系。链表中的节点通过指针指向来链接,在编程中变现为赋值语句。实质是把后一个节点的首地址赋值给前一个节点的pNext指针。
- (2)链表可以从头部插入,也可以从尾部插入,也可以从两头插入,头部插入和尾部插入对于链表本身来说几乎没有差别,但是有时候对于业务逻辑有差别。
void insert_head(struct node * pHeader, struct node * new) { new->pNext = pHeader->pNext; pHeader->pNext = new; pHeader->data += 1; }
4.9.6、单链表的算法值遍历节点
4.9.6.1、什么是遍历
- (1)遍历就是把单链表中的各个节点挨个拿出来,就叫做遍历
- (2)遍历的要点:一是不能遗漏、二是不能重复、追求效率
4.9.6.2、如何遍历单链表
- (1)分析一个数据结构如何遍历,关键是分析这个数据结构本身的特点,然后根据本身提点来制定它的遍历算法。
- (2)单链表的特点就是由很多节点组成,头指针+头结点为整个链表的起始,最后一个节点的特点是它内部的pNext指针值为NULL。从起始到结尾中间由各个节点内部的pNext指针来挂接。由起始到结尾的路径有且只有一条。单链表的这些特点就决定了他的遍历算法
- (3)遍历方法:从头指针+头结点开始,顺着链表挂接指针,一次访问链表的各个节点,取出这个节点的数据,然后再往下一个节点直到最后一个节点,结束返回。
4.9.6.3、编程实战
- (1)写一个链表遍历的函数,void bianli(struct node *pHeader )
4.9.7.单链表的算法之删除节点
4.9.7.1、为什么要删除节点
- (1)一直在强调的一点,链表用来干嘛?
- (2)有时候链表欧街店中的数据不想要了,因此要删掉这个节点
4.9.7.2、删除节点的2个步骤
- (1)第一步:找到要删除的节点
- (2)第二步:删除这个节点
4.9.7.3、如何找到待删除的节点
- (1)通过遍历来查找节点。从头指针+头结点开始,顺着链表依次将各个节点拿出来,按照一定的方法对比,找到我们要删除的那个节点。
4.9.7.4、如何删除一个节点
- (1)待删除的节点不是尾节点的情况:首先把待删除的节点的前一个节点的pNext指令指向待删除的节点的后一个节点的首地址(这样就把这个节点从链表中摘出来了)再将这个摘出来的节点free掉接口。
- (2)待删除的节点是尾节点的情况:首先把待删除的尾节点的前一个节点的pNext指针指向NULL(这时候就相当于原来尾节点前面的一个节点变成了新的尾节点)然后将摘出来的节点释放掉
int delete_node(struct node *pHeader, int data) { struct node *p = pHeader; struct node *pPrev = NULL; while(NULL != p->pNext) { pPrev = p; p = p->pNext; if(p->data == data) { //找到这个节点,处理这个节点 //分为2个情况:一个是普通节点,另一个是尾节点 if(NULL == p->pNext) { pPrev->pNext = NULL; free(p); } else { //普通指针 pPrev->pNext = p->pNext; free(p); } //处理完成后退出循环 return 0; } } printf("没找到这个节点. "); return -1; }
4.9.7.5、注意堆内存的释放
- (1)前面季节可我们写的代码最终都没有释放堆内存,当程序结束了的情况下哪些没有free的堆内存也被释放了。
- (2)有时候我们的程序运行时间很久,对于这种程序,程序不断的运行。这时候malloc的程序,如果没有free就一直会占用内存,知道你free它,或者程序结束。
4.9.8.单链表的算法之逆序
4.9.8.1、什么是链表的逆序
- (1)链表的逆序又叫反向,意思就是把链表中所有的有效节点在链表中的顺序给反过来。
4.9.8.2、单链表逆序算法分析1
- (1)当我们对数据结构进行一个操作时,就需要一套算法,这就是数据结构和算法的关系
- (2)朱老师总结:算法有2个层次,第一个层次是数学和逻辑上的算法,第二个层次是编程语言来实现算法。
- (3)从逻辑上来讲。链表的逆序有很多种算法,这些方法都能实现最终的需要,但是效率是不一样的。彼此的可扩展型、容错性等不同。
- (4)思路:首先遍历原链表,然后将原链表中的头指针和头结点作为新链表的头指针和头结点,原链表中的有效节点挨个依次取出来,采用头插入的方法
- (5)链表逆序 = 遍历+头插入的方法
4.9.8.3、编程实战
4.9.9双链表的引入和基本实现
4.9.9.1、单链表的局限性
(1)单链表是对数组的扩展,决绝了书序的大小的比较死板不容易扩展的问题,使用堆内存来存储数据,将数据分散到各个节点之间,其各个节点之间的内存可以不相连,节点之间的通过指针进行单向链接,有利于利用碎片化内存。
(2)单链表指各个节点之间由指针进行单向连接,这样实现具有局限性,局限性主要体现在只能经由指针单向移动(一旦指针移动过摸个节点,就再也无法回来,如果要再次操作这个节点,除非从头指针开始重头开始遍历一次。)因此单链表的某些操作就比较麻烦,有些操作就比较局限。回忆单链表的所有操作(插入,删除节点,遍历,逆序,从单链表中取出某个数),因为单链表的单项移动性导致了很多麻烦。
总结:单链表的单项移动导致我们在操作单链表时,当前链表只能想后移动,不能向前移动,因此不自由,不利于解决更复杂的算法。
4.9.9.2、解决思路:有效数据+2个指针的节点(双链表)
(1)单链表的节点 = 有效数据+指针
(2)双向链表的节点 = 有效数据+2个指针(一个指针指向)
4.9.10、双链表的封装之插入节点
4.9.10.1、尾部插入
4.9.10.2、头部插入
4.9.11、双链表的算法之遍历节点
- (1)双链表是单链表的一个交集。双链表中如何完全无视pPrev指针。则双链表就变成了单链表,这就决定了,双链表的正向遍历和单链表是一样的。
- (2)双链表中因为多了pPrev指针,所以双链表可以前向遍历(从链表的尾节点向前依次遍历得到头结点)。但是前向遍历的意义并不大。主要是很少有需要当前到了尾节点需要前向遍历的情况。
总结:双链表是对单链表的一种有成本的扩展,但是这个扩展在有些时候意义不大,在另一些时候意义就比较大。因此在实际业务中要根据实际使用情况写出合适的链表
4.9.12、双链表的算法之删除节点
4.9.13、linux内核链表
4.9.13.1、前述链表数据区域的局限性
- (1)之前定义数据区域时直接int data;我们认为我们的链表中需要存储一个int类型的数,但是实际上现实编程中链接中的节点不可能这么简单。而是多种多样的。
- (2)一般实际项目中的链表,节点中存储的数据其实是一个结构体,这个结构体包含若干个成员,这些成员加起来一起构成了我们的节点的数据区域。
4.9.13.2、一般性解决思路:数据区封装为一个结构体
- (1)因为链表实际解决的问题是多种多样的,所以内部数据区域的结构体构成也是多种多样的,这样也导致了不同程序当中的链表总体构成是多种多样的。导致的问题就是我们无法通过泛性的、普遍使用的操作来访问所有的链表。这就意味着我们设计一个链表就得写一套链表的操作函数这个函数包括节点的创建,删除,逆序等
- (2)实际上仔细分析,不同的链表索然方法不能通用,但是实际上内部的方法是相同的,知只是函数的局部地区有所不同。(链表的操作是相同的,而涉及到数据区域的操作有不同)。
- (3)鉴于以上2点,我们的思路就是能不能有一种办法吧所有链表的操作方法的共同的地方提取出来用一套标准的方案来世见,然后把不同的地方留着让链表是闲着自己去处理
4.9.13.3、内核链表的设计思路
- (1)内核链表中自己实现了一个纯链表(纯链表就是没有数据区域,只有前后向指针)的封装,以及纯链表的各种操作函数(节点创建、插入、删除、遍历.....)这个纯链表本身没有任何用处,它的用法是给我们具体链表作为核心来调用。
4.9.13.4、list.h文件简介
- (1)内核中核心纯链表的实现在include/linux/list.h文件中
- (2)list.h中就是纯链表的完整封装,包含节点定义和各种链表操作方法
4.9.14、内核链表的基本算法和使用简介
4.9.14.1、内核链表的节点创建、删除、遍历等
4.9.14.2、内核链表的使用实践
- (1)问题:内核链表只有纯链表,没有数据区域,怎么使用?
- (2)设计的使用方法是将内核链表作为将来整个数据结构的结构体的一个成员内嵌进去。
4.9.15.什么是状态机
4.9.15.1、有限状态机
- (1)常说的状态机是有限状态机FSM。FSM指的是有有限个状态(一般是一个状态变量的值),这个机器同时能从外部接收信号和输入信息,机器在接收到外部输入的信号后会综合考虑当前自己的状态和用户输入信息,然后机器作出动作:跳转到另一个状态。
- (2)考虑状态机的关键点:当前状态、外部输入、下一个状态
4.9.15.2、两种状态机:Moore型和Mealy型
- (1)Moore型状态机特点:输出只与当前的状态无关(与输入信号无关)。相对简单,考虑状态机的下一个状态是只需要考虑它的当前状态就行了。
- (2)Mealy星状态机:输出不只和当前状态有关,还与输入信号有关。状态机接收到输入信号需要跳转到下一个状态时,状态机综合考虑两个条件(当前状态、输入值)后才决定跳转到哪个状态。
4.9.15.3、状态机的主要用途:电路设计、FPGA程序设计、软件设计
- (1)电路设计中广泛使用了状态机思想
- (2)FPGA程序设计
- (3)软件设计(框架类型的设计,譬如操作系统的GUI系统,消息机制)
4.9.15.4、状态机解决了什么问题
- (1)我们平时写的程序都是顺序执行的,这种程序有个特点:程序的大体执行流程是既定的。程序的执行时遵照一定的大的方向有迹可循的。
- (2)但是偶尔会碰到这样的程序:外部不一定会按照既定流程来给程序输入信息,程序还需要完全能够接收并响应外部的这些输入信号,还要能作出符合逻辑的输出。
4.9.16.C语言实现简单的状态机
- 4.9.16.1题目开锁状态机。功能描述:用户连续输入正确的密码则会开锁,如果密码输入过程错误会退回到初始状态重新计入密码,即:用户只需要连续输入正确的密码即可开锁,输入错误不用撤销,也不用删除。
#include <stdio.h> //给状态机定义状态集 typedef enum { STATE1, STATE2, STATE3, STATE4, STATE5, STATE6, STATE7, }STATE; int main(void) { int num = 0; //current记录状态机的当前状态,初始为状态1,用户每输入一个正确的密码就向后走一步,一直到STATE6,锁就开了,其中只要有一个输入对不上就重新输入 STATE current_state = STATE1; //实现用户输入密码的循环 printf("请依次输入6位密码,密码正确则开锁,请先输入第一位密码:"); while(1) { scanf("%d", &num); //printf("输入的数字为:%d", num); //在这里处理用户的本次输入 switch(current_state) { case STATE1: if(num == 1) { current_state = STATE2; printf(" 输入正确,请输入第二位密码: "); } else { printf("少侠你又调皮了,重新输入密码吧. "); current_state = STATE1; printf(" 请输入第一位密码:"); } break; case STATE2: if(num == 2) { current_state = STATE3; printf(" 输入正确,请输入第三位密码:"); } else { printf("少侠你又调皮了,重新输入密码吧. "); current_state = STATE1; printf(" 请输入第一位密码:"); } break; case STATE3: if(num == 3) { current_state = STATE4; printf(" 输入正确,请输入第四位密码:"); } else { printf("少侠你又调皮了,重新输入密码吧. "); current_state = STATE1; printf(" 请输入第一位密码:"); } break; case STATE4: if(num == 4) { current_state = STATE5; printf(" 输入正确,请输入第五位密码:"); } else { printf("少侠你又调皮了,重新输入密码吧. "); current_state = STATE1; printf(" 请输入第一位密码:"); } break; case STATE5: if(num == 5) { current_state = STATE6; printf(" 输入正确,请输入第六位密码:"); } else { printf("少侠你又调皮了,重新输入密码吧. "); current_state = STATE1; printf(" 请输入第一位密码:"); } break; case STATE6: if(num == 6) { current_state = STATE7; printf(" 输入完全正确"); } else { printf("少侠你又调皮了,重新输入密码吧. "); current_state = STATE1; printf(" 请输入第一位密码:"); } break; default: current_state = STATE1; break; } if(current_state == STATE7) { printf("已开锁. "); break; } } return 0; }
4.9.17、多线程简介
4.9.17.1、操作系统下的并行执行机制
- (1)并行就是说多个任务同时被执行。并行分为微观上的并行和宏观上的并行。
- (2)宏观上的并行就是从长时间段来看(相对于人来说),多个任务是同时进行的。微观上的并行就是真的在并行执行。
- (3)操作系统要求实现宏观上的并行。宏观上的并行有2种情况,第一种是宏观上的并行,第二种是微观上的并行
- (4)理论上来说,单核CPU本身只有一个核心,同时只能执行一条指令,这种CPU只能实现宏观上的并行,微观上的一定是串行的。微观上的并行要求多核CPU,多核CPU的多个核心可以同时微观上执行多个指令,因此可以达到微观上的并行,从而提升宏观上的并行度。
4.9.17.2、进程和线程的区别和联系
- (1)进程和线程是操作系统的两种不同软件技术,目的是实现宏观上的并行(通俗一点来说就是让多个程序同时在一个机器上运行,达到宏观上看起来并行执行的效果。)
- (2)进程和线程在实现并行效果的原理上不同,而且这个差异和操作系统有关,譬如windows中进程线程差异比较大,而在linux中进程和线程差异不大,在Linux中线程就是轻量级的进程
- (3)不管是多进程还是多线程,最终的目标都是实现并行执行。
4.9.17.3、多线程的优势
- (1)前些年多进程多一些,这些年多线程开始用的多
- (2)线代操作系统设计时,考虑到了多核CPU的优化问题,保证了多线程程序运行,操作系统会优先将多个线程放在多个核心分别单独运行。所以说多核心给多线程程序提供了完美的运行环境,所以在多核心CPU上使用多线程程序有极大的好处。
4.9.17.4、线程同步和锁
- (1)多线程程序运行时要注意线程之间的同步