简介:面向C语言学习者的一份数据结构链表实例PDF,集中演示了链表十九种常用操作的完整写法。资源以单个PDF文件打包,体积仅57KB,内容精炼、便于下载后随时查阅。目前已吸引734人学习,适合正在学习C语言数据结构、特别是链表部分的初学者对照理解,也可供备考或复习者快速参考。PDF中的示例代码覆盖了链表创建、遍历打印、结点计数、判空检查、冒泡排序等基础操作,同时包含按值或按位置进行查找、修改、插入、删除,以及元素交换和整表释放等进阶操作;代码附有注释和参考来源,结构清晰,读者可直接运行或按需修改复用,是链表编程练习中一份实用的参考材料。
1. 一份能跑的 C 语言单链表十九种操作:先说清楚它到底值不值得你花时间
C 语言数据结构里,链表永远是最绕不开的一块。这份实例代码把单链表的十九种操作全部塞进了一个 .c 文件里,从建表、遍历、查长度,到按位置查找、按值查找、改值,再到头插、尾插、指定位置插入、有序插入、冒泡排序、四种删除、交换节点、整表清空,基本把面试和考试里能问到的单链表场景都覆盖了。它适合三类人:正在补数据结构基础的学生、准备考研或面试的开发者、以及做嵌入式或单片机开发需要自己维护链表的工程师。但我要先泼一盆冷水:这份代码能编译、能运行,却藏着好几个"看着对、跑起来不对"的典型写法,比如length参数从头到尾都没刷新过,比如指定位置插入在position = 1时会插到第二个节点后面。正因为有这些坑,它反而比那些完美封装的链表库更适合拿来逐行拆解。我按"先看懂内存模型,再逐类拆操作,最后统一排坑"的顺序把它过一遍。
2. 动手前先看懂两个关键点:节点内存模型与二级指针
读这份代码的第一个门槛不是十九个函数,而是理解它为什么几乎每个函数都要传Node **pHead。不搞懂这一点,后面看insertHeadList和modifyElem时会一直犯迷糊。
2.1 节点结构体与内存分配:malloc、memset、判空三件套
先看节点的定义,这是整份代码的地基:
typedef int elemType; typedef struct NODE { elemType element; /* 数据域,存放元素值 */ struct NODE *next; /* 指针域,指向下一个节点 */ } Node;typedef int elemType这行值得多说一句。作者没有直接用int element,而是先给int起了个别名叫elemType,好处是将来想把数据域换成float、换成结构体,只需要改这一行,后面所有scanf("%d", ...)、printf("%d", ...)的格式符再跟着调一遍就行,主逻辑完全不用动。这是一种非常朴素的"泛型化"思路,在课程设计和工程里都很常见,比写死int要聪明。
再看建表时分配节点的标准动作:
p1 = (Node *)malloc(sizeof(Node)); if (p1 == NULL) exit(0); memset(p1, 0, sizeof(Node));这三行是一个组合拳。malloc在堆上分配一块大小为sizeof(Node)的内存,返回void *,所以要强转成Node *;分配失败时返回NULL,if (p1 == NULL)就是兜底这个情况;memset(p1, 0, sizeof(Node))把整块内存清零,避免后续读到未初始化的脏数据。
提示:
memset在这里不是必需的,但它能消灭一类特别难查的 bug——malloc 出来的内存里next可能是任意值,如果忘了赋NULL,遍历链表走到末尾时会继续访问一个野地址,直接段错误。清零后再人工赋一遍p1->next = NULL,双保险。
2.2 为什么几乎每个函数都传 Node **pHead
C 语言是值传递,函数拿到的永远是参数的副本。想在一个函数里改变调用者持有的指针变量本身——比如头插之后让pList指向新节点——只传Node *pHead是做不到的,你得传pList的地址,也就是Node **pHead。
看头插函数最典型:
void insertHeadList(Node **pHead) { Node *p1; p1 = (Node *)malloc(sizeof(Node)); if (p1 == NULL) exit(0); memset(p1, 0, sizeof(Node)); printf("Please enter a number to be inserted:"); scanf("%d", &p1->element); p1->next = (*pHead); /* 新节点的 next 指向原来的第一个节点 */ (*pHead) = p1; /* 把头指针改写为新节点 */ }p1->next = (*pHead)是把新节点串到链表头部,但如果不写(*pHead) = p1,调用者手里的pList仍然指向旧节点,新插入的节点就彻底找不到了——这就是传二级指针的原因。用一张简陋的话说:一级指针能改"指针指向的内容",二级指针才能改"指针本身指向哪里"。
在 main 里,调用方式是insertHeadList(&pList),注意取了地址。而像printList(pHead)这种只遍历、不改链表结构的函数,传一级指针就够了,因为它只读节点里的数据。
2.3 十九种操作按什么规律组织
把这十九种操作按职责分类,能看出来作者其实是按"增删改查 + 排序"的思路写的,只是没有刻意归类:
| 分类 | 函数 | 核心行为 |
|---|---|---|
| 建表/销毁 | creatList、clearList | 从无到有、从有到无 |
| 遍历/查询 | printList、sizeList、isEmptyList | 读链表,不改结构 |
| 查找 | getElement、getElemAddr | 按下标找、按值找 |
| 修改 | modifyElem | 改指定位置的值 |
| 插入 | insertHeadList、insertLastList、isAddPos、OrrderList | 头插、尾插、按位置插、有序插 |
| 删除 | DelHeadList、DelLastList、DelPos、Delx | 删头、删尾、按下标删、按值删 |
| 排序/交换 | Arrange、exchange2pos | 冒泡排序、交换两个节点的数据 |
这个分类表本身就是一个复习提纲:如果你能不看代码,把每一行的"核心行为"自己写出来,单链表的基本操作就过关了。后面三章我就按这个顺序,把查询、插入、删除、排序里的关键函数逐段拆开讲,顺带把参数的含义和边界情况交代清楚。
3. 创建链表与基础遍历:creatList、printList、sizeList 的完整解读
3.1 creatList:以输入为正数作为终止条件的建表方式
建表函数是整份代码里最容易出内存问题的函数,原作者的处理方式很直白——读一个数,只要为正,就挂进链表;读到 0 或负数,直接结束建表。
void creatList(Node **pHead) { printf("Please enter the list:\n"); Node *p1, *p2; p1 = p2 = (Node *)malloc(sizeof(Node)); if (p1 == NULL || p2 == NULL) exit(0); memset(p1, 0, sizeof(Node)); scanf("%d", &p1->element); p1->next = NULL; while(p1->element > 0) { if (*pHead == NULL) (*pHead) = p1; else p2->next = p1; p2 = p1; p1 = (Node *)malloc(sizeof(Node)); if (p1 == NULL) exit(0); memset(p1, 0, sizeof(Node)); scanf("%d", &p1->element); p1->next = NULL; } }这段的流程我拆成四步看:
第一步,先给p1、p2分配第一块内存,p1和p2指向同一个节点,读入第一个数到p1->element。这里p1 == NULL || p2 == NULL的写法其实是重复判断,因为p1和p2是同一块内存,判一个就够了,但不影响正确性。
第二步,进入while(p1->element > 0)循环。第一次进来时*pHead == NULL,直接把p1作为链表的头节点;后续进来的数,走p2->next = p1把新节点挂到当前尾节点后面。p2始终记录"已经挂好的最后一个节点",p1是"正在处理的新节点"。
第三步,挂完当前节点后,重新分配一块内存给下一个p1,再读一个数。注意这一步p2 = p1把新节点的位置记下来,但此时p1已经被重新 malloc,p2仍然指着链表的尾部。
第四步,如果下一次读到的数是 0 或负数,循环直接退出——但此刻的p1已经分配了内存、读入了不满足条件的数据,它没有被挂上链表,也没有被free。这就是一个内存泄漏点,后面避坑章我会单独讲。
参数上要注意:scanf("%d", &p1->element)要求输入必须是整数,中间不能混入字母或符号,否则scanf返回 0,p1->element保留旧值,循环会卡死在你意想不到的地方。
3.2 printList 与 sizeList:遍历链表的两种写法
遍历是单链表最基础的操作,打印和数长度本质上是同一件事:从头走到尾,每经过一个节点做一次处理。
void printList(Node *pHead) { if (NULL == pHead) printf("The list is empty\n"); else while(NULL != pHead) { printf("%d ", pHead->element); pHead = pHead->next; } printf("\n"); } int sizeList(Node *pHead) { int size = 0; while(pHead != NULL) { size ++; pHead = pHead->next; } return size; }两个函数都用了同一个遍历模式:一个临时指针从头部出发,每轮循环"读当前节点信息,再pHead = pHead->next挪到下一个节点",直到指针变成NULL。这个模式你得写到肌肉记忆里,后面查找、定位、删除无不建立在它之上。
printList里有个细节:它修改的是形参pHead,不是 main 里的pList。pHead是pList的副本,函数里把它一路往后挪,调用者的指针纹丝不动。这正是值传递的特性——要想遍历链表而不破坏头指针,就放心大胆地复用这个形参。
sizeList的返回值类型是int,链表长度理论上可以超过 21 亿,但实际场景里单链表存到这个量级不太现实,所以够用。它每次都要从头到尾走一遍,时间复杂度 O(n),如果频繁调用,开销会累积。
3.3 isEmptyList 与初始化:检查空表的用法与缺陷
void isEmptyList(Node *pHead) { if (pHead == NULL) { printf("The list is empty\n"); exit(0); } }main 里Node *pList = NULL;先把头指针置空,然后调creatList(&pList)建表,再调isEmptyList(pList)检查。问题在于isEmptyList发现空表时直接exit(0),把整个程序干掉了,而不是返回一个状态值让调用方决定怎么处理。
在单文件 demo 里这种写法能跑,但你想把它改造成一个可复用的库函数时,就会撞墙——调用方想自己处理空表场景,结果程序直接退出了。常见的做法是:
int isEmptyList(Node *pHead) { return (pHead == NULL) ? 1 : 0; }让调用方拿返回值来判断。这是"把决策权交还给调用者"的设计思想,后面第 6 章我会把exit(0)泛滥的问题集中讲。
4. 查找、修改与指定位置插入删除:六个高频操作的代码级拆解
4.1 getElement 与 getElemAddr:按下标找元素、按值找位置
查找操作有两条路线:按下标找值和按值找位置。这两个函数正好各代表一条。
void getElement(Node *pHead, int num) { for (int i = 1; i < num; ++i) pHead = pHead->next; printf("The value of the %dth element is:%d\n", num, pHead->element); }getElement的逻辑是从第一个节点开始,走num - 1步到达第num个节点。注意它没有判空,也没有判断num是否超出链表长度。如果num大于节点总数,pHead会一路挪过NULL,循环结束于空指针,然后pHead->element直接触发段错误。main 里调用前做了if (n > length || n < 1)的防护,所以单跑这个 demo 没问题,但函数本身是裸奔的。
getElemAddr的情况更有意思:
int getElemAddr(Node *pHead, int number) { int i = 1; while(pHead != NULL) { if (pHead->element == number) return i; i++; pHead = pHead->next; } return 0; }函数名和注释都说"返回该结点的地址",但实际返回的是i——也就是这个值在链表中的序号(第几个节点),不是内存地址。真正拿到节点地址应该返回Node *类型,这里却返回了int。看 main 里的打印语句:printf("The location of the number is:%d\n", addr),说明作者自己也是把它当"位置"用的,命名和实现没对齐,这是一个容易误导阅读者的点。返回值 0 表示没找到,这在设计上是合理的,但和"地址为 0"的语义撞了车,换成-1会更明确。
4.2 modifyElem 与 insertHeadList:改值操作如何不污染头指针
修改第n个节点的值,看起来很简单,作者却做了一个很隐蔽的处理:
void modifyElem(Node **pList, int addr, int number) { Node *pHead; int i = 1; pHead = *pList; while(pHead != NULL) { if (i == addr) break; pHead = pHead->next; i++; } pHead->element = number; }关键在第二行:pHead = *pList先把二级指针解引用一次,把链表头指针的值拷贝给局部变量pHead。之后所有遍历操作都走pHead,不碰*pList。原代码注释写得很直白:"在此处如果直接更改 pList 指向的话,主函数中调用 printList 就会从 addr 处开始打印"——一旦你在遍历中移动了*pList,调用者手里的头指针就被改了,后面的printList(pList)会从中间某个节点开始打印。
这个细节值得背下来:凡是拿到Node **pHead的函数,想遍历链表时先Node *p = *pHead拷贝一份,绝对不要动*pHead本身。头插、头删例外,那本来就是故意要改头指针。
insertHeadList前面已经拆过,头插的关键就是记住两句话:新节点接管旧链表首节点的身份(p1->next = *pHead),然后头指针更新(*pHead = p1)。malloc出来的节点要记得memset清零,避免p1->next残留垃圾值。
4.3 isAddPos 与 DelPos:指定位置插入删除的前驱定位
指定位置插入和删除是链表操作里最容易写错的一对,难点全在前驱节点的定位上。
void isAddPos(Node **pHead, int length) { Node *p1, *p2; int position, i; printf("Please enter the insert position:"); scanf("%d", &position); if (position > length || position <= 0) { printf("Input error, the program ends\n"); exit(0); } p1 = (Node *)malloc(sizeof(Node)); p2 = (*pHead); if (p1 == NULL) exit(0); memset(p1, 0, sizeof(Node)); printf("Please enter a number to be inserted:"); scanf("%d", &p1->element); for (i = 1; i < position - 1; ++i) p2 = p2->next; p1->next = p2->next; p2->next = p1; }先说正常流程。假设要在第 3 个位置插入,p2初始指向第 1 个节点,for循环i从 1 到position - 2(即 1),走一次,p2变成第 2 个节点——这就是新节点的前驱。然后p1->next = p2->next让新节点指向原来的第 3 个节点,p2->next = p1让前驱指向新节点,插入完成。
但position = 1时,for循环一次都不走,p2还指着第 1 个节点,执行完插入后链表变成"原第 1 个节点 → 新节点 → 原第 2 个节点",新节点实际上排在第二位。想让新节点成为新表头,必须单独处理position = 1的情况,走insertHeadList的逻辑。作者没做这个分支判断,所以这个函数所谓的"指定位置插入",对表头位置是失效的。
删除指定位置的函数DelPos有同样的问题:
void DelPos(Node **pHead, int length) { int n, i; Node *p1, *p2; p1 = (*pHead); p2 = p1->next; printf("Please enter the serial number number to delete:"); scanf("%d", &n); if (n < 1 || n > length) exit(0); for (i = 1; i < n - 1; ++i) { p2 = p2->next; p1 = p1->next; } p1->next = p2->next; free(p2); }p2被初始化为第 2 个节点,p1是第 1 个节点。n = 2时,循环不走,p1是第 1 个节点(待删节点的前驱),p2是第 2 个节点(待删节点),p1->next = p2->next把第 1 个节点直接链到第 3 个节点,然后free(p2),删除成功。但n = 1时,p1是第 1 个节点(待删节点),p2是第 2 个节点,执行完p1->next = p2->next后,删除的是第 2 个节点,第 1 个节点根本没被释放。想删表头,得调DelHeadList。这两处边界错误放一起看特别有意思:作者把"链表第一个节点"默认当成了不可变更的前驱,所有定位循环都以它为起点,于是 position=1 / n=1 这两个特例全军覆没。
5. 排序、有序插入、交换与清空:剩下六个边界操作的实现细节
5.1 Arrange:不交换节点只交换数据的冒泡排序
void Arrange(Node **pHead, int length) { Node *p1; p1 = (*pHead); int i, j, temp; for (i = length; i > 0; --i) { for(j = i - 1; j > 0; --j) { if ((p1->element) > (p1->next->element)) { temp = p1->element; p1->element = p1->next->element; p1->next->element = temp; } p1 = p1->next; } p1 = (*pHead); } }这是标准冒泡排序的链表实现:外层循环控制轮数,i从length递减;内层循环做相邻比较,j决定每轮比较次数。每轮把当前范围内最大的数一路交换到末尾,下一轮范围缩小一个节点。p1 = (*pHead)在每轮结束重置回头部,这是最容易写漏的一行——忘了它,下一轮就从错误的位置开始比较了。
这个实现刻意选择了交换数据域(element)而不是交换节点。交换节点需要同时改前驱的next和节点的next,涉及三个节点的指针调整,在单链表里极易写错。交换数据则简单得多,一个temp就够了,代价是排序过程中节点之间的相对位置不变,只是数据在节点间搬移。对这份 demo 代码来说,这个取舍是明智的,因为它的目的是演示冒泡排序,不是演示链表节点的移动。时间复杂度是 O(n²),空间复杂度 O(1),和数组冒泡排序完全一致。
5.2 OrrderList:有序链表插入的三种分支
int OrrderList(Node **pHead, int length) { Node *p1, *p2; p1 = (*pHead); p2 = (Node *)malloc(sizeof(Node)); if (p2 == NULL) exit(0); memset(p2, 0, sizeof(Node)); printf("Enter the value of the element to be inserted:"); scanf("%d", &p2->element); if (p2->element < p1->element) { p2->next = p1; (*pHead) = p2; return 1; } while(p1->next != NULL && p2->element > (p1->next->element)) p1 = p1->next; if (p1->next == NULL) { p2->next = NULL; p1->next = p2; return 1; } else { p2->next = p1->next; p1->next = p2; return 1; } }有序插入的前提是链表已经排好序,这个函数怎么保证这一点?靠的是调用顺序——main 里先调Arrange做冒泡排序,再调OrrderList插入新元素。函数内部按三种情况分支:
第一种,新值比头节点还小,直接走头插逻辑:p2->next = p1,(*pHead) = p2,新节点成为链表头,仍然有序。
第二种,新值比所有节点都大,while循环会一直走到链尾,p1停在最后一个节点,p1->next == NULL成立,把新节点挂到尾部,有序性保持。
第三种,新值落在中间,while循环在p1->next->element >= p2->element时停下,此时p1是新节点的前驱,p2->next = p1->next让新节点指向原本 > 它的那个节点,p1->next = p2把新节点插入链中。
这个函数没判"链表为空"的情况。如果*pHead是NULL,第一行p1 = (*pHead)就拿到空指针,判断p2->element < p1->element会崩溃。空链表上做有序插入,正确做法是直接让*pHead = p2。作者在 main 里先建表、排序再调用,刚好绕开了这个崩溃路径。
5.3 exchange2pos 与 clearList:交换数据与整表销毁
void exchange2pos(Node **pHead, int length) { Node *p1, *p2; int n1, n2, i, j, temp; printf("Please enter the first number:"); scanf("%d", &n1); printf("Please enter the second number:"); scanf("%d", &n2); if (n1 < 1 || n1 > length || n2 < 1 || n2 > length) exit(0); p1 = p2 = (*pHead); for (i = 1; i < n1; ++i) p1 = p1->next; for (j = 1; j < n2; ++j) p2 = p2->next; temp = p1->element; p1->element = p2->element; p2->element = temp; }交换两个位置节点的值,实现思路和排序如出一辙:先各自走到n1、n2对应的节点,然后通过temp交换element数据。这里有个隐藏问题:如果n1 == n2,两个循环走到的节点是同一个,交换前后值一样,不报错但没意义;如果n1和n2都传了旧length(建表后没刷新),边界判断同样是失真的。
清空链表是整份代码里少有的"每一步顺序都不能反"的操作:
void clearList(Node **pHead) { Node *p1; p1 = (*pHead); while(p1 != NULL) { p1 = p1->next; /* 先记住下一个节点的地址 */ free((*pHead)); /* 再释放当前头节点 */ (*pHead) = p1; /* 头指针重新指向下一个节点 */ } }顺序反了的后果是:先free((*pHead))再取p1->next,p1指向的是一块已经被释放的内存,读它的next属于野指针访问。正确姿势永远是"先保存下一个节点,再释放当前节点,最后更新头指针"。这个循环结束后,链表的每个节点都被free,*pHead变为NULL,整条链表归零。
6. 避坑:这份链表实例里最值得记下来的五个常见问题
6.1 输入一个非正数就退出:creatList 的终止条件是个双刃剑
现象:建表时输入0或负数,链表直接结束创建,你想往链表里存一个0值或者负数,永远存不进去。
原因:while(p1->element > 0)把"正数"当成了有效数据的标记,0和负数天然被当成结束信号。这在 demo 里没问题,但一旦数据域需要支持负数(比如温度、差分信号、余额),这套逻辑就废了。
解决:换一个独立的结束标志,不再用数据的符号位。常见做法是单独读一行指令——比如输入q或-9999作为终止符,或者先问一句"是否继续输入"。如果坚持用数字做标记,至少把结束条件改成读取次数上限,比如「最多读 100 个数」,循环到了上限强制结束,并free掉最后的临时节点。
6.2 length 永远不更新:插入删除后校验范围全部失真
现象:先用length = sizeList(pList)取了链表长度,接着做了一次头插、一次尾插、一次指定位置插入,再往后调用isAddPos(&pList, length)和DelPos(&pList, length)时,length还是建表时的老值,位置校验和边界判断全线漂移。
原因:main 里length只被赋值一次,后续没有任何一行length = sizeList(pList)或者length++ / length--。插入和删除函数内部也不维护长度信息,它们接收的length纯粹是调用方传进来的快照。
解决:最笨也最可靠的办法是每次操作前先重新sizeList一次;工程上一般把链表封装成结构体,里面带一个int size字段,插入成功size++,删除成功size--,头指针和长度永远绑定在一起。这个实例的选择是"快照式 length",你复刻它的 demo 时不需要改,但移植到别处一定要记住它是会过期的。
6.3 insertLastList 拖着一个过期的 length 参数
现象:连续执行insertHeadList(&pList)再insertLastList(&pList, length),新节点没有出现在链表末尾,而是插到了中间某个位置。
原因:insertLastList的实现是"从头部走n - 1步,挂到第n个节点后面",它默认链表的长度就是建表时的length。但前一步头插已经让链表多了一个节点,旧length指向的不再是表尾。尾插的正确做法根本不需要length,直接遍历到p1->next == NULL再挂新节点:
void insertLastList(Node **pHead) { Node *p1, *p2; p2 = (*pHead); p1 = (Node *)malloc(sizeof(Node)); if (p1 == NULL) exit(0); memset(p1, 0, sizeof(Node)); printf("Please enter a number to be inserted:"); scanf("%d", &p1->element); p1->next = NULL; while(p2->next != NULL) p2 = p2->next; p2->next = p1; }解决:删掉int n这个参数,让函数自己走到链表尽头。如果*pHead是空表,还得先做一次空表判断,直接让*pHead = p1。这是链表操作里"能用遍历解决,就别依赖长度参数"的典型教训。
6.4 position=1 和 n=1:指定位置插入删除的两个隐蔽死角
现象:调用isAddPos(&pList, length)想插到第一个位置,结果是新节点排在第二位,原第一个节点跑到最前面;调用DelPos(&pList, length)想删第一个节点,结果删掉的是第二个节点,第一个节点还在。
原因:前面 4.3 节拆解过,两个函数的定位循环都从"第一个节点是前驱"这个假设出发,position = 1和n = 1时循环一次不走,逻辑上直接错位。作者显然默认了"第一个位置的操作应该由头插/头删函数负责",但isAddPos和DelPos的入口校验又放行了1,于是这两个边界值落进了一个没人处理的夹缝。
解决:在函数入口把1单独分流。比如isAddPos里:
if (position == 1) { insertHeadList(pHead); return; }DelPos同理:
if (n == 1) { DelHeadList(pHead); return; }这样1不再是沉默的边界值,而是显式转移给专门的函数处理。这个"特例分流"的思路,写任何有边界值的函数都可以套用。
6.5 内存泄漏与 exit(0):creatList 退出时那个没人管的 p1
现象:程序正常跑完,用 valgrind 检查时报告definitely lost,并且isEmptyList在空表时让整个程序戛然而止。
原因:creatList的while循环退出时,p1已经 malloc 了一块内存但没挂进链表,也没有free,这块内存成了孤儿。另外这份代码里exit(0)出现在四五个错误分支里,它做的是进程级退出,不是函数返回——调用方想做后续处理(比如打印友好提示)都没有机会。
解决:循环结束后补上释放:
if (p1 != NULL) free(p1);把exit(0)改成return错误码或提前返回,让调用方决定是否终止程序。一个库函数里出现exit(0)基本等于"老子不干了",这在课程设计里能接受,放到工程里就是灾难。
7. 把这份链表代码彻底吃透的三个技巧:断点对比、辅助打印与内存扫描
看链表代码最容易犯的错是"眼睛以为懂了,跑起来立刻翻车"。我的习惯是拿到一份链表代码,先不改逻辑,直接上三样工具把它从头到尾验证一遍。
第一,用 gdb 在关键函数前后给头指针打断点。比如在insertHeadList的*pHead = p1这一行打断点,先print p1->element看新节点的值,再next单步,print *pHead确认头指针已经变成新节点。头插最容易犯的错就是忘了*pHead = p1这一行,断点一打,哪里写错了当场现行。gdb 里还可以print pHead->element检查链表中途某个节点的值,配合x/4gx pHead看内存里的next指针指向,排查节点头尾接错很快。
第二,写一个dumpList辅助函数,每次操作后打印链表当前长度和全部元素。这个函数不用讲究什么算法,就是sizeList加printList的合体,但它的价值在于让每次操作的结果立刻可见。我习惯在每个printList调用后面再补一行printf("len=%d\n", sizeList(pList)),这样头插、尾插、删除后长度该变 1 变 1,该变 -1 变 -1,一眼就能看出length失效的问题。
第三,把代码编出来跑一遍 valgrind 内存检测,这是验证链表内存管理唯一靠谱的手段:
gcc -g -o linklist linklist.c valgrind --leak-check=full --show-leak-kinds=all ./linklist-g选项让 gcc 生成调试符号,valgrind 报错时能精确到源码行号。跑完你会看到definitely lost的块数和字节数,对照 6.5 节那个没人释放的p1,再回去看代码,内存模型的印象会深很多。
从那以后,我每次拿到别人写的链表代码,第一件事不是读逻辑,而是先问三个问题:哪些函数会改动头指针、长度变量什么时候会过期、每个malloc是不是都有对应的free。这三个问题过完,代码里大部分的坑基本都浮出水面了。这份十九种操作的实例,你花一小时顺着这个思路走一遍,收获会比读十遍教科书都大。希望帮到你。
本文还有配套的精品资源,点击获取