过去的自己,你好。
今天我来教你单向链表,不知道你何时会看到这篇文章,也不知道你此刻对C语言的掌握,我会尽可能讲解细一点。
讲链表之前我先讲一下数组。相信你不管怎样肯定用过数组对吧,数组就是一种数据的组织形式,特点是所有的数据在逻辑上是顺序摆放的,通过数组名和数组下标
就可以找到某个元素,而且在内存中存放的物理地址也是连续的,定义数组时,一旦中括号中的数字确定了,数组中元素个数也就确定了。那么问题来了,因为数组
的大小需要提前确定,所以定义数组时必须统计好程序中用了多少元素,用多了,数组越界;用少了,浪费内存。
链表也是一种逻辑上顺序数据结构,但是他在物理地址上不一定是连续的,那如何确定各个元素之间的关系呢?通过指针来实现。数组中每个元素只有一个部分
---数据(单个数据或结构体),不同于数组,链表的每个元素包含两个部分:数据域和指针域。数据域的作用和数组是一样的,用于存放数据(单个数据或结构
体),而指针域则存放表征逻辑关系的相邻元素的指针,单向链表中即指向下一个节点(链表中的元素称为节点NODE)。举个例子,一个节点是一个有门牌号的
房间,房间中存放在有各种“水果”(数据域)和“下个有水果的房间的门牌号”(指针域)。假设第一个房间中放了猕猴桃,第二个房间放了火龙果,第三个房间
放了橙子,我突然想吃橙子,那我不知不觉就知道了第一个房间号,我去了那个房间,但是我看到了猕猴桃(最不喜欢吃),失望之余我就看了下这个房间中的“下
个有水果的房间的门牌号”,顺着这个门牌号我就来到了第二个房间,可是发现的是火龙果(没吃过),压抑着怒火继续看下一个房间号并去第三个房间,终于在第
三个房间找到了橙子。这样三个房间就组成了一个链表,链表的头总会提供出来便于你从第一个房间开始找。从上面的这个例子你也看出来链表的缺点是不能想数值
那样直接查找数据,必须从头开始查。但是好处就是,你随时可以在最后一个房间中留下“下一个房间的门牌号”来增加链表的长度,这个节点的增加是可以在你代
码运行的过程中进行的(数组是在编译时确定的),这个特性在你日后的模块化编程时会相当有用。链表就介绍这么多,不知道你能理解多少,下面我用C语言实现
一遍,希望你通过代码进一步理解链表的概念和相关的操作。
首先要定义一个节点,如上所说一个节点有数据域和指针域,我们假设数据是个32位无符号整型变量,而指针域则指向下一个节点。
1 typedef struct _LINK_ 2 { 3 uint32_t dwData; 4 struct _LINK_ *ptNext; 5 }LINK;
第1行:typedef struct _LINK_ 是个组合,实际上是两个步骤:1)定义一个结构体struct _LINK_; 2)给这个结构体起一个好几的别名typedef struct _LINK_ LINK(用LINK代替struct_LINK_);
第3行:定义了数据域中的数据,这里之用一个32位无符号整型变量。
第4行:定义了指针域中,这里需要说明一下,既然我已经用typedef将struct
_LINK_替换为LINK,那为什么在这里还要继续用这么麻烦的写法定义ptNext呢?原因是代码编译到这里的时候,还没有完成typedef的工
作,也就是说,编译器优先定义结构体,然后再去typedef,这时的LINK还不能被编译器识别,故这里依旧需要用struct
_LINK_来定义指向与自身类型相同的指针用于存放下一个节点的地址。
完成节点结构体的定义,就要创建一个链表,创建链表之前,需要创建一个节点。创建节点代码如下:
1 LINK* Alloc_Node(uint32_t dwData) 2 { 3 LINK* ptNode = NULL; 4 5 ptNode = (LINK*)malloc(sizeof(LINK)); 6 7 if(NULL != ptNode) 8 { 9 ptNode->dwData = dwData; 10 ptNode->ptNext = NULL; 11 } 12 return ptNode; 13 }
第1行:该函数的功能是创建一个节点,创建节点的目的也是为了存放数据,所以函数入参为对应数据域的数据。节点分配成功后,将会返回节点指针,所以函数返回类型定义为LINK*。若节点分配不成功,则会返回NULL。
第2行:定义一个节点指针,用于存放新建节点的地址。
第4行:通过动态分配malloc函数从内存的堆中分配一个大小为LINK结构体大小的空间,并返回这个内存空间的首地址(malloc返回默认类型为
void*,这里需要进行强制类型转换为LINK*)。关于malloc动态分配和堆的相关知识我以后通过其他文章告诉你,你只需要知道这里是人为地从内
存中分配了一块够用的地址给你就行了。
第7行:malloc如果申请内存失败,即内存的堆中没有足够的空间的话,那就会返回NULL,为了避免对空指针的操作,在这里进行一次判断,若ptNode为控指针,则不作任何处理直接到第12行返回NULL,以表征节点创建失败。
第9行:将入参数据填写到新建节点的数据域。
第10行:将新建节点的指针域中下一个节点地址设置为NULL,以表征这个是最后一个节点。这一步非常重要,因为malloc从堆空间获得的空间中,很可
能已经有存在过其它数据,若不对指针域进行赋值,有可能会出现非NULL的情况,导致链表找不到NULL标记而持续寻找下次,在操作非法指针之后便会出
错。
第12行:返回新建节点的指针。
新建节点后,就要将节点加入链表了,单向链表一般在链表尾部增加新节点,所以需要先找到最后一个节点,代码如下:
1 LINK* Add_Node(LINK* ptNode, uint32_t dwData) 2 { 3 LINK* ptNew = NULL; 4 ptNew = Alloc_Node(dwData); 5 6 if(NULL != ptNew) 7 { 8 if(NULL != ptNode) 9 { 10 while (ptNode->ptNext != NULL) 11 { 12 ptNode = ptNode->ptNext; 13 } 14 ptNode->ptNext = ptNew; 15 } 16 } 17 return ptNew; 18 }
第1行:函数的入参有两个,第一个是链表的头节点地址,之前我说过,链表的访问需要从头开始找,所以提供头节点地址是必要的;第二个参数就新建节点的数据。如果在链表中新增节点成功后,会返回该节点的地址,若不成功,则返回NULL。
第3行:定义一个指针用于存放新建节点的地址。
第4行:通过Alloc_Node函数新建一个节点,并将入参数据增加到新建节点的数据域中。
第6行:判断是否成功新建节点,若不成功,ptNew的值为NULL,函数将直接返回NULL表示链表添加节点失败。
第8行:若新建节点成功,则判断头节点地址是不是NULL,如果是NULL,说明该链表是个空链表,没有任何节点,所以这里需要直接返回新建节点的指针,
而在上层则需要将存放头地址的指针变量赋值该新建节点的指针,以创建第一个节点。这里检查ptNode是否为NULL还有另一个意义,就是在后续的查找
中,需要用到ptNode的指针域,如果ptNode为NULL,则ptNode->ptNext就是个非法操作。
第10行:查询本节点是否是最后一个节点,这个while(ptNode->ptNext != NULL)在链表操作中非常关键。如果本节点的指针域不是NULL,那就不是最后一个节点。
第12行:既然不是最后一个节点,那我们就继续找,将下一个节点的地址作为下次查询的节点。只要还没有找到最后一个节点,就会不停执行该步骤。
第14行:当本节点的指针域为NULL,即本节点为最后一个节点时,第10行的while的条件将不成立,进而执行第14行操作:将新建节点的指针存入链
表最后一个节点的指针域,这样新建节点就变成了链表中最后一个节点了,上文中我也说明了新建节点的指针域必然是NULL,这样就保证了链表最后一个节点的
指针域为NULL。
第17行:如果操作成功,函数返回新建节点的指针。
就这样链表就可以顺利地建立和增加啦,想增加链表时直接使用Add_Node函数即可,想增加几个节点就执行几次。既然链表已经创建好了,我总要使用吧,一个最简单的需求就是我要查找链表中的某个具体数据,这就是链表的查找函数,代码如下:
1 LINK* Find_Node(LINK* ptNode, uint32_t dwData) 2 { 3 while (ptNode != NULL) 4 { 5 if (ptNode->dwData == dwData) 6 { 7 return ptNode; 8 } 9 ptNode = ptNode->ptNext; 10 } 11 return NULL; 12 }
第1行:和增加链表节点的函数一样,查找函数的入参有两个:一个是头节点指针(很关键,又被使用了),另一个是需要查找的数据。
第3行:又见while,这里是检查本节点是否为空指针,如果是NULL,有两种情况:1)头节点的地址就是NULL,这是个空链表,必须狠狠返回个NULL;2)已经找遍了链表,没有找到有相同数据的节点,此时的NULL实际上是最后一个节点的指针域的地址。
第5行:只要本节点不是NULL,就进行数据判断。
第7行:如果本节点的数据域的数据等于要查找的数据,则返回本节点的指针。
第9行:如果本节点的数据域的数据不等于要查找的数据,则继续找下一个节点。
第11行:如第3行所述,没有找到指定节点后,返回NULL。
写到这里有点累了,已经半夜11点多了,要不要吃完面呢??不过既然这篇文章的本意是为了教育你这个过去的我,那我就做个好榜样吧,避免增肥,抵制诱惑,你也一样要多多锻炼,不要长膘。
好了,我们继续吧,反正你读这篇文章应该没有我写着累,我们就继续吧。上面我们已经完成了链表的查找函数,查找的对象是某一个节点,下面我再写一个统计节点总数的函数,代码如下:
1 uint8_t Count_Node(LINK* ptNode) 2 { 3 uint8_t i = 0; 4 while(ptNode != NULL) 5 { 6 i++; 7 ptNode = ptNode->ptNext; 8 } 9 printf("We have %d node(s)\n", i); 10 return i; 11 }
第1行:函数的入参一如既往地是那个链表的头节点地址,函数返回值为链表所有节点的个数。
第3行:定义临时变量用于统计节点个数。
第4行:老面孔while再次出现,如果本节点为NULL,则说明最后一个节点已经统计过了,对于空链表而言,本身也是NULL,直接返回0个节点。
第6行:既然本节点不是NULL,那么i就自增加。
第7行:既然本节点不是NULL,那么就继续看下一个节点。
第9行:当找到了最后一个节点后,串口打印有多少个节点。
第10行:返回统计的节点数。
笔记本电池快要坚持不住了,今晚就先写到这吧,明天继续讲最后三个链表操作函数。
第二天。。。。
睡了一觉神清气爽,我们接着讲,昨天我讲了查找函数,实际上就是把链表历遍了一遍,今天我再升级一下--打印整个链表的数据,代码如下:
1 uint8_t Print_Link(LINK* ptNode) 2 { 3 uint8_t i = 0; 4 while(ptNode != NULL) 5 { 6 i++; 7 printf("Node address is 0x%x, Node data is %d, Next node address is 0x%x.\n", ptNode, ptNode->dwData,ptNode->ptNext); 8 ptNode = ptNode->ptNext; 9 } 10 printf("We have %d node(s)\n", i); 11 return i; 12 }
第1行:依旧是需要头指针地址作为参数,我们依旧会统计节点个数并通过函数返回。
第3行:定义临时变量i用于统计节点数量。
第4行:又见while,如果本节点为NULL,则说明最后一个节点已经统计过了,对于空链表而言,本身也是NULL,直接返回0个节点。
第6行:如果本节点不会NULL,则i自增。
第7行:在这里打印当前节点地址,当前节点数据和下一个节点的地址。
第8行:继续找下一个节点。
第10行:打印统计的总节点数。
第11行:返回节点数量。
现在已经完成了节点的创建、增加节点到链表中、查找某个节点、统计链表节点数、打印整个链表,下面讲个稍微复杂点的操作,删除某个节点,代码如下:
1 void Delet_Node(LINK** ptHd,LINK *ptDel) 2 { 3 LINK *ptNode = NULL; 4 if((ptHd == NULL)||(*ptHd == NULL)||(ptDel == NULL)) 5 { 6 return; 7 } 8 if(*ptHd == ptDel) 9 { 10 *ptHd = (*ptHd)->ptNext; 11 ptDel->ptNext = NULL; 12 free(ptDel); 13 } 14 else 15 { 16 ptNode = *ptHd; 17 while(ptNode->ptNext != NULL) 18 { 19 if(ptNode->ptNext == ptDel) 20 { 21 ptNode->ptNext = ptNode->ptNext->ptNext; 22 ptDel->ptNext = NULL; 23 free(ptDel); 24 break; 25 } 26 ptNode = ptNode->ptNext; 27 } 28 } 29 return; 30 }
第1行:留心一下这里的第一个入参LINK** ptHd,因为你花了好久才搞明白LINK** 的含义(或许现在的我也没有搞明白)。首先LINK* 定义的变量时个指针,这个指针指向的是一个节点,那LINK**定义的变量也是个指针,这个指针指向一个变量,这个变量里存放着一个指向节点的指针(有点 绕,多读几遍)。简单说来,LINK* ptHd,ptHd里的数据是头节点的地址,而LINK** ptHd,ptHd里的数据就是存放头节点地址的地址(还是有点绕),用上面的例子来讲,LINK* ptHd中的ptHd是第一个房间的门牌号,而LINK** ptHd中的ptHd就是盒子的位置,这个盒子里只有第一个房间的门牌号,只要你知道这个盒子在哪里,你就能知道第一个房间在哪里。也就是说,你在使用链 表的时候会知道两个地址:一个是头节点的地址,另一个是存放头节点地址的地址。那为什么要这样做呢,不是一般有头节点地址就可以了吗?是的,如果你不对头 节点进行删除,是可以这么做,但如果你要删除的节点就是头节点的话,你需要将第二个节点作为头节点进行保存,即需要更新存放头节点地址的变量的值,所以你 需要将这个变量的地址作为入参传入函数;那么第二个入参就是需要删除的节点的地址。
第3行:定义一个节点指针,方便后面节点查找。
第4行:对入参合法性进行判断。
第6行:如果ptHd为NULL,即盒子的地址就是没有的,显然没有办法继续操作,直接return;如果*ptHd为NULL,即链表时空链表,不能继续操作,直接return;如果ptDel为NULL,即没有给你需要删除的节点地址,就是在耍你,果断return。
第8行:判断需要删除的节点是不是头节点,头节点的删除操作和其他节点的删除操作有区别。
第10行:如果需要删除的是头节点,那需要将头节点的下一个节点的地址存放在存储头节点的变量里。
第11行:既然原来的第二个节点已经变成头节点了,这是就可以放心操作原来的头节点了,将原头节点的指针域修改为NULL,这样这个节点就和链表再无瓜
葛。这里注意第10行和第11行操作顺序,如果反过来先将原来头节点的指针域修改为NULL,那就再也找不到之后的节点了,所以要“先过河,再拆桥”(你
三国杀玩的还是不错的)。
第12行:既然这个节点已经准备删除了,那么这个节点所用的内存也应该释放,因为节点是malloc动态分配出来的,所以这里需要通过free来主动释
放。这样这个节点的堆空间可以被重新使用,很有可能会投胎转世变成末尾节点,这个你可以自己试试,创建个3个节点的链表,删除头节点,再增加个节点,很有
可能会发现被删除的头结点投胎做末尾节点了。
第14行:如果需要删除的节点不是头结点,那需要另一个方式进行操作。
第16行:获取头节点地址。
第17行:又又见while,如果本节点的下一个节点的地址为NULL,说明本节点已经是最后一个节点了,在上一次对比检测中已经确认最后一个节点也不是需要删除的节点,及链表中没有找到要删除的节点,直接退出。
第19行:判断本节点的下一个节点是否为需要删除的节点。
第21行:如果本节点的下一个节点是需要删除的节点,那就先过河:将下个节点的下个节点地址给本节点的指针域。这里多讲一下,为什么用
ptNode->ptNext,而不是用ptNode呢。这里涉及到ptNode的性质,ptNode只是一个临时变量,用于保持当前节点的地址,
如果要删除本节点,那必须修改上一个节点的指针域为下一个节点的地址,如果用ptNode的方式就无法找到上一个节点,这个就是单向链表的缺点。故这里用
ptNode->ptNext来进行查找,这样当找到需要删除的节点时,其实找到的是需要删除节点的上一个节点,那么就可以对
ptNode->ptNext进行操作了(留个小问题给你,如果这行改为ptNode =
ptNode->ptNext会怎样)。用这种方法进行查找会跳过头节点,但是我们已经在开始的时候就判断是否为头节点,故放心继续吧。
第22行:上面已经过河了,那么就可以拆桥了:将需要删除的节点的指针域改为NULL,此生不相见。
第23行:彻底消失,这辈子没机会再见了,下辈子吧。
第24行:既然已经删除成功,那么就不要有所留恋,直接break再返回吧。
第26行:如果我们查找的节点不是需要删除的节点,那么继续查找下一个。
一个删除节点的操作占了这么大篇幅,正所谓请神容易送神难嘛。在坚持一下,既然我们已经成功删除某个节点了,那么要不删除这个链表试试?下面有请现场代码的报道。“代码你好,请问现场是什么样的?”, “主持人你好,现场是我这个样的”:
1 void Delet_Link(LINK** ptHd) 2 { 3 LINK* ptNode = NULL; 4 LINK* ptDel = NULL; 5 if((ptHd == NULL)||(*ptHd == NULL)) 6 { 7 return; 8 } 9 ptNode = *ptHd; 10 while(ptNode != NULL) 11 { 12 ptDel = ptNode; 13 ptNode = ptNode->ptNext; 14 ptDel->ptNext = NULL; 15 free(ptDel); 16 } 17 *ptHd = NULL; 18 return; 19 }
第1行:一回生,二回熟,三回手牵手,再次见到LINK**,不多解释,看上删除某个节点函数的解释。
第3行:定义临时变量ptNode便于查找节点。
第4行:定义临时变量ptDel用于保存需要删除的节点。
第5行:入参合法性检查。
第7行:如果ptHd是NULL,说明上层调用者忘记提供参数;如果*ptHd为NULL,说明是个空链表,果断return。
第9行:将头节点地址给ptNode。
第10行:Hi, WHILE, how old are you!(怎么老是你),判断本节点是否为NULL,如果为NULL说明链表已经历遍完成。
第12行:备份当前节点的地址给ptDel
第13行:既然已经有backup了,就义无反顾地迈向下一个节点吧。
第14行:“报告ptDel书记,群众ptHd已经成功转移,请ptDel书记指示”,“好,乡亲们安全我也就放心了,切断后路,让鬼子永远找到乡亲们!!”。
第15行:“报告ptDel书记,后路已经切断,接下来的任务请指示!”,“和鬼子同归于尽!!同志们,我们来世继续为祖国效力!!”。
第17行:尘归尘、土归土,英雄们留下了未来给后人,缺没有留下名字,即使那个当初领导大家的节点,也毅然地将自己的名字从历史上划去,留下了个充满希望的NULL给后人成长 -剧终-。
关于单向链表就写这么多吧,感觉越写越不正经了,再写下去就少儿不宜了,呵呵。最后再说一下,你之前看的ZigBee协议栈Z-stack中的时间功能就
是用链表实现的,还有很多小型OS或协议栈都用到了链表,所以我建议你先好好把链表掌握再去啃这些OS/协议栈。对了,你日后会自己写一个硬件无相关的软
件定时器模块,就是用的链表,不过有些不完善的地方,希望你看完这篇文章后再认真修改一下。好了,就这样吧。