链表这东西,只要是学C语言的人,早晚都得正面撞上它一次。作为C语言里最经典、也最考验指针功底的数据结构,链表几乎是所有高校《数据结构》课程的必讲内容,也是技术面试里出现频率极高的基础题。很多人一听到链表就觉得头疼——指针绕来绕去,增删改查一堆操作,debug的时候更是折磨人。但你只要真正动手写过一遍,把每个操作的指针变化在纸上画清楚,就会发现链表其实比数组更符合人类对“一串数据”的直觉想象。
这篇博文我尽量用大白话把链表的增删改查讲透。从节点的定义开始,到创建、遍历、插入、删除、修改,再到常见崩溃问题的排查技巧,全部附上完整可运行的C语言代码。不管你是刚学完指针的初学者,还是准备面试想快速捡起链表的开发者,跟着这篇文章一步步实操下来,你完全可以搞定链表的核心操作。
1. 先从本质理解链表:为什么它比数组更灵活
1.1 数组的局限与链表的诞生背景
先想一个问题:数组在内存里是什么样子的?它是一段连续的存储空间,就像一排门牌号连在一起的公寓,1号、2号、3号,挨得紧紧的。这种连续存储的好处是访问效率极高,通过下标直接算出内存地址,时间复杂度O(1)。但坏处也很明显——一旦确定长度就很难扩展,如果数组越界,程序就直接崩溃;如果要在中间插入一个元素,得把后面的所有元素整体往后搬,时间复杂度O(n)。
链表就是为了解决这些问题而生的。它不再要求数据连续存放,每个“节点”可以散落在内存的任何角落,节点之间通过指针搭桥牵线,像一条铁链一样串起来。正因为每个节点都带着指向下一个节点的指针,所以它天然支持动态扩展,想加就加,想删就删,只要内存够用,链表的长度几乎没有上限。
我用一个生活化的类比帮你看清楚:数组就像电影院里一排固定编号的座位,每一个座位都是提前焊死在地板上的。而链表就像一串用绳结拴在一起的灯笼,每个灯笼知道自己后面跟着哪盏灯笼,灯笼之间距离多远都无所谓,想在哪两盏之间塞一个新灯笼,只需解开绳子再重新系上。
1.2 节点的自我指代:结构体指针的奇妙用法
链表的基本单位是“节点”(Node)。每个节点至少包含两个部分:数据域和指针域。数据域用来存放真正要存储的数据,指针域存放指向下一个节点的地址。
实现这个结构,靠的就是C语言中结构体里能够包含指向自身类型的指针这个特性:
typedef struct Node { int data; // 数据域,这里先用int类型做演示 struct Node *next; // 指针域,指向下一个节点 } Node;注意看这个定义,struct Node *next指向的是一个尚未定义完整、但已经声明了标签struct Node的类型。这就像写信时地址栏写上“递给下一个人”,虽然你还没见到那个人,但你知道地址的格式是对的。C语言允许在结构体内部使用指向自身类型的指针,因为指针只占固定大小(比如64位系统上占8字节),编译器不需要知道结构体的完整大小就能算出指针成员的位置。
搞清楚这个基础定义,后面的增删改查全部都是在跟next这个指针打交道。可以说,链表的每一次操作,本质都是一次“指针的重新指向”和“内存的分配释放”。
2. 动手前的准备:链表节点的定义与辅助函数
2.1 创建节点的通用工具函数
写链表操作之前,我强烈建议先封装一个createNode函数。这样后面不管你在头插、尾插还是中间插入,每次需要新节点的时候只要调用它就行了,代码会清爽很多。
#include <stdio.h> #include <stdlib.h> typedef struct Node { int data; struct Node *next; } Node; // 分配一个节点,data为初始值,next默认为NULL Node* createNode(int data) { Node *newNode = (Node*)malloc(sizeof(Node)); if (newNode == NULL) { printf("内存分配失败\n"); exit(1); } newNode->data = data; newNode->next = NULL; return newNode; }这里有几个细节值得说一句。malloc(sizeof(Node))是根据节点结构体实际大小分配内存,不要直接写死一个数字,不同系统、不同对齐规则下sizeof(Node)可能不一样。还有一个容易犯的错误是分配后忘记检查返回值,如果内存耗尽malloc会返回NULL,这时候直接操作newNode->data就是解引用空指针,程序必崩。所以检查返回值不是一句废话,而是C程序员的保命习惯。
2.2 头指针:整个链表的“门牌号”
链表的入口是一个头指针head,它保存着第一个节点的地址。如果链表为空,head就是NULL。所有对链表的遍历、插入、删除操作,都要从head出发,顺着next一路走下去。
所以我们在测试程序里这样初始化一个空链表:
int main() { Node *head = NULL; // 空链表,头指针还没有指向任何节点 // 后续所有增删改查操作都围绕head展开 return 0; }有的教材会把链表设计成带有“头节点”(也就是第一个节点不存数据,只作为哨兵)的形式。这种做法在某些场景下确实能简化边界判断,但对初学者来说,带哨兵和非哨兵两种写法混着看容易绕晕。所以这篇文章我统一采用不带头节点的写法,即head直接指向第一个真正存储数据的节点。这种写法代码更直观,缺点是某些操作的边界条件需要自己小心处理,正好借机锻炼边界意识。
2.3 为什么把打印封装成独立函数
后面调试链表时,我几乎每个操作完都会打印一遍链表,看看结果对不对。所以封装一个printList函数是刚需:
void printList(Node *head) { Node *cur = head; while (cur != NULL) { printf("%d -> ", cur->data); cur = cur->next; } printf("NULL\n"); }这个函数的核心逻辑就一句:永远不要修改head本身,而是用一个临时指针cur去遍历。如果你直接写while (head != NULL)然后head = head->next,打印完链表之后,head就变成NULL了,整个链表就再也找不到了。这种低级错误在真实开发中屡见不鲜,而且特别难排查——表面上程序没崩,但数据“神秘消失”了。记住这个原则:所有需要遍历链表的函数,都用局部指针变量去迭代,不要动传入的头指针。
3. 链表“查”与“读”:遍历、定位、按值查找
3.1 遍历打印:从第一个节点走到 NULL
遍历是链表最基础的操作,前面的printList已经展示了框架。它的时间复杂度是 O(n),因为链表不支持随机访问,必须从head开始,一个节点接一个节点地走。这个和数组通过arr[i]一步到位有着本质区别。
遍历的核心逻辑可以拆成三个关卡:
- 初始化一个指针
cur,指向头节点; - 判断
cur是否为NULL,是就停止; - 每次循环末尾更新
cur = cur->next,让它向后移动。
很多人写这个循环的问题在于搞不清cur = cur->next和cur->next = cur的区别。前者是“把下一个节点的地址交给cur”,让cur向后走;后者是“把cur的地址回填给当前节点的next”,相当于把链表链接方向颠倒,一旦执行,后面的节点就丢了。遍历用的是前者,千万别写反。
3.2 按值查找与按位置查找
实际开发中,“查”往往有两种目标:按值查找(链表里有没有data等于某个数的节点),以及按位置查找(第几个节点是什么)。
按值查找的代码如下:
Node* findByValue(Node *head, int target) { Node *cur = head; while (cur != NULL) { if (cur->data == target) { return cur; // 找到了,返回该节点的地址 } cur = cur->next; } return NULL; // 遍历完都没有找到,返回NULL }按位置查找(以0为起始下标)的写法是:
Node* findByIndex(Node *head, int index) { Node *cur = head; int pos = 0; while (cur != NULL && pos < index) { cur = cur->next; pos++; } return cur; // 如果index超过链表长度,cur会变成NULL,返回NULL即可 }注意while循环的退出条件里cur != NULL和pos < index是并列的,不能只写pos < index。万一index比链表总长度还大,cur早早就走到NULL了,再去取cur->data就是在访问空指针,程序直接段错误。
3.3 查的复杂度分析与实际应用场景
链表查找的时间复杂度是 O(n),最坏情况下要遍历整条链表才能确定目标是否存在。有人会问:既然数组随机访问是 O(1),为什么链表还能有一席之地?原因很简单——链表的优势在增删,不在查。插入和删除操作在已知前置节点的情况下,链表的复杂度是 O(1),而数组是 O(n),因为数组需要搬移元素。
实际项目中的常见搭配是“哈希表 + 链表”:哈希表用来做O(1)查找,链表用来维护插入顺序、处理缓存淘汰策略等。比如经典的LRU缓存淘汰算法,内层就是个双向链表,配合哈希表索引。你学链表的时候如果能往这个思路上靠一靠,会对以后理解更复杂的数据结构有很大帮助。
4. 链表“增”:头插、尾插、任意位置插入
4.1 头插法:三步完成“抢首座”
头插法在最前面插入一个新节点。它的核心步骤只有三步:
- 创建新节点
newNode; - 把
newNode->next指向原来的第一个节点(即head); - 把头指针
head更新为newNode。
代码实现:
void insertAtHead(Node **head, int data) { Node *newNode = createNode(data); newNode->next = *head; // 新节点指向原来的第一个节点 *head = newNode; // 头指针指向新节点 }这里有一个很重要的细节:为什么参数是Node **head而不是Node *head?因为我们要修改head本身的值,让它指向新的节点。C语言函数参数是值传递,如果你传入Node *head,在函数内部修改head,改的是形参的副本,函数外面head还是老样子。因此需要传入指针的指针,也就是Node **head,这样在函数内部对*head的赋值才能反映到外部。
如果你实在觉得二级指针绕,还有一种妥协方案:让插入函数返回新的头指针,调用时重新赋值。比如:
Node* insertAtHeadV2(Node *head, int data) { Node *newNode = createNode(data); newNode->next = head; return newNode; } // main里这样调用: // head = insertAtHeadV2(head, 42);两种方法都行,但你要理解二级指针的用法,因为后面很多链表操作都要靠它来解决“修改头指针”的问题。
4.2 尾插法:走到最后再接上
尾插法需要先找到链表的最后一个节点,把它的next指向新节点。
void insertAtTail(Node **head, int data) { Node *newNode = createNode(data); if (*head == NULL) { // 空链表,新节点就是第一个节点 *head = newNode; return; } Node *cur = *head; while (cur->next != NULL) { cur = cur->next; // 走到最后一个节点 } cur->next = newNode; }注意循环条件写的是cur->next != NULL,而不是cur != NULL。如果用cur != NULL,循环会直到cur为NULL才停,此时cur已经不是最后一个节点了,而是最后一个节点后面的空位,你根本拿不到“最后一个节点”的地址。只有判断cur->next != NULL,循环停止时cur才是真正有数据、且没有后继节点的那个“末班车”。
另外,不要忘记单独处理*head == NULL的情况。空链表没有“最后一个节点”,此时头插和尾插的效果是一样的,直接把*head指向新节点即可。这个边界条件写漏的话,空链表状态下做尾插就会访问空指针的next,程序直接崩溃。
4.3 任意位置插入:先定位,再接线
任意位置插入相对麻烦一点,需要先找到插入位置的前一个节点,然后进行指针重新接线。假设position从0开始计数,我想在position位置插入一个新节点。
void insertAtIndex(Node **head, int data, int position) { if (position < 0) { printf("位置非法\n"); return; } Node *newNode = createNode(data); if (position == 0) { // 插在头部,等价于头插 newNode->next = *head; *head = newNode; return; } // 找到当前位于 position-1 位置的节点 Node *cur = *head; int i = 0; while (cur != NULL && i < position - 1) { cur = cur->next; i++; } if (cur == NULL) { printf("插入位置超出链表长度\n"); free(newNode); // 别忘了释放刚才分配的内存 return; } // 接线:新节点先指向cur的后继,然后cur的next再指向新节点 newNode->next = cur->next; cur->next = newNode; }这段代码的核心在最后两句。为什么顺序必须是:
newNode->next = cur->next; // 先让新节点指向后一个节点 cur->next = newNode; // 再让前一个节点指向新节点不能颠倒?因为一旦先执行cur->next = newNode,原来cur后面的整条子链表就断了,此时再想访问cur->next得到的其实是newNode,原来的后续节点全部“失联”,相当于你把一根铁链从中间解开,然后还没挂上新的一环就把原来的后半段扔了。所以顺序必须固定:先动新节点的next,再动前置节点的next。你可以想象成两车追尾后防抱死,必须先插保险销再拔旧销子。
5. 链表“删”:删除指定节点
5.1 删除的核心逻辑拆解
删除操作的核心是四个字——“跳过节点”。如果我删掉的是节点B,而它的前一个节点是A,那么只要让 A->next 直接指向 B->next,再把 B 所占用的内存释放掉,链表就“无缝愈合”了。这就像两个人中间隔了一把椅子,你把这把椅子拖走,前后两人之间的距离就自然闭合了。
要分两种情况理解:
情况一:删除头节点。此时要把head更新为原来第二个节点。
if (*head == target) { *head = (*head)->next; free(target); return; }情况二:删除非头节点。需要先找到它的前驱节点prev,然后执行prev->next = target->next,最后free(target)。
写一个完整函数:
void deleteNode(Node **head, int targetValue) { if (*head == NULL) { printf("链表为空,无法删除\n"); return; } // 如果要删除的是第一个节点 if ((*head)->data == targetValue) { Node *tmp = *head; *head = (*head)->next; free(tmp); return; } // 否则遍历查找待删除节点 Node *prev = *head; Node *cur = (*head)->next; while (cur != NULL && cur->data != targetValue) { prev = cur; cur = cur->next; } if (cur == NULL) { printf("没找到要删除的值: %d\n", targetValue); return; } prev->next = cur->next; // 跳过cur free(cur); // 释放cur }删除操作中,我之前见过不少新手会用两个循环来找前驱:先while (cur->data != target),然后再用一个prev去记前一个节点。这样写虽然也能工作,但相当绕,而且容易写错。实际上维护prev和cur两个指针同步向后走,一趟循环就够了——prev始终是cur前面的节点。
5.2 释放内存与野指针的防范
很多初学者写完free(cur)就以为自己完成了,“删除”大功告成。其实还有一件事没做:让 cur 这个局部指针不要继续被使用,或者至少不要再解引用。
free释放的是内存块,并不是指针变量本身。释放之后,cur里存的地址依然是一个地址,但从这一刻起它指向的内存已经被归还给堆管理器了,这块区域可能被后续代码重新分配给别的数据。这时如果你再写cur->data,就是访问一块已经不属于你的内存——这叫野指针访问,程序不会立刻报错,但会读出随机值,甚至触发段错误。
有个简单的规矩:释放完节点之后,如果这个指针后面还用得上,立即给它赋NULL。对于局部指针变量,只要保证后面不去用它就行。
另外,删除整个链表(也叫销毁链表)时,更是要想清楚。正确做法是:
void destroyList(Node **head) { Node *cur = *head; while (cur != NULL) { Node *tmp = cur; // 先保存当前节点 cur = cur->next; // 指针先移动到下一个节点 free(tmp); // 再释放当前节点 } *head = NULL; // 置空头指针,防止野指针 }这里的关键是先保存下一个节点地址,再释放当前节点。如果先free(cur)再cur = cur->next,cur->next这一句读的是一个已经释放的内存区域,属于访问已经free的内存,行为未定义,几乎必然出问题。这个顺序要记牢:先取next,再free当前。
5.3 删除时常见的边界情况总结
| 场景 | 处理方式 | 常见错误 |
|---|---|---|
| 链表为空 | 直接提示并返回 | 不判断空链表直接访问 head->data |
| 删除的节点是头节点 | 头指针往后移,释放原头节点 | 忘记更新头指针,导致链表入口丢失 |
| 删除的节点在中间 | 前驱 next 指向后继 | 顺序颠倒,先改 next 导致断链 |
| 删除的节点是尾节点 | 前驱 next 设为 NULL | 删除后尾节点 next 仍指向旧地址,遍历越界 |
| 找不到目标值 | 打印提示,返回 | 然后去 free 一个空指针,或者什么都不做造成“假删除” |
这五种场景在面试里简直就是送分题和送命题的区别。能答出“如果删除的是头节点怎么办”的人很多,但能主动把五种情况都考虑到并写出代码的人,才算真的理解了链表。
6. 链表“改”:节点值的修改与综合演练
6.1 节点值的直接修改
链表的修改相对简单,只要找到目标节点,直接更新它的data就好:
void updateNode(Node *head, int oldValue, int newValue) { Node *cur = head; while (cur != NULL) { if (cur->data == oldValue) { cur->data = newValue; printf("已将 %d 修改为 %d\n", oldValue, newValue); return; } cur = cur->next; } printf("没有找到值 %d,修改失败\n", oldValue); }这个函数不用二级指针,因为我们不修改头指针,也不改变链表的链接结构,只修改节点内部的数据域。C语言按值传递传给函数的是头指针的副本,但副本指向的目标还是原来那个节点,所以通过副本去修改cur->data生效的是真正的节点数据。这类操作只需要一级指针就够了。
6.2 综合演练:用链表实现一个学生成绩管理系统
光看单个函数的讲解可能还是有点飘,我把这些操作串起来,做一个极简的“学生成绩管理系统”。用链表存储若干学生的成绩,支持增删改查四种操作。这个例子对应了很多人搜索的“学生管理系统”,非常典型。
#include <stdio.h> #include <stdlib.h> #include <string.h> typedef struct Student { char name[20]; int score; struct Student *next; } Student; Student* createStudent(const char *name, int score) { Student *s = (Student*)malloc(sizeof(Student)); if (s == NULL) { printf("内存分配失败\n"); exit(1); } strcpy(s->name, name); s->score = score; s->next = NULL; return s; } // 按名字查找并打印 void searchStudent(Student *head, const char *name) { Student *cur = head; while (cur != NULL) { if (strcmp(cur->name, name) == 0) { printf("找到学生:%s,成绩:%d\n", cur->name, cur->score); return; } cur = cur->next; } printf("查无此人:%s\n", name); } // 按名字修改成绩 void updateScore(Student *head, const char *name, int newScore) { Student *cur = head; while (cur != NULL) { if (strcmp(cur->name, name) == 0) { cur->score = newScore; printf("已将 %s 的成绩更新为 %d\n", name, newScore); return; } cur = cur->next; } printf("未找到学生:%s\n", name); } // 按名字删除 void deleteStudent(Student **head, const char *name) { if (*head == NULL) return; if (strcmp((*head)->name, name) == 0) { Student *tmp = *head; *head = (*head)->next; free(tmp); printf("已删除学生:%s\n", name); return; } Student *prev = *head; Student *cur = (*head)->next; while (cur != NULL && strcmp(cur->name, name) != 0) { prev = cur; cur = cur->next; } if (cur == NULL) { printf("未找到学生:%s\n", name); return; } prev->next = cur->next; free(cur); printf("已删除学生:%s\n", name); } void printStudents(Student *head) { Student *cur = head; while (cur != NULL) { printf("姓名:%s,成绩:%d\n", cur->name, cur->score); cur = cur->next; } } int main() { Student *head = NULL; // 模拟数据 head = createStudent("张三", 85); head->next = createStudent("李四", 92); head->next->next = createStudent("王五", 78); printStudents(head); searchStudent(head, "李四"); updateScore(head, "王五", 88); printStudents(head); deleteStudent(&head, "张三"); printStudents(head); return 0; }这个例子能跑通,就说明你对链表增删改查的基本功已经过关了。你可以把它当成一个支点,后续往两个方向扩展:一是把数据域从单个成绩变成更多字段(学号、年龄、班级等),二是把查找条件从“按名字”扩展成“按学号”“按分数区间”等。每次改动都是一次对链表操作的强化训练。
6.3 链表的销毁与内存管理习惯
学习阶段很多人不在意内存释放,反正程序一结束,操作系统会回收全部内存。但在长期运行的服务端程序里,内存泄漏是会积累的——每次操作泄漏一点点,跑个几天几夜之后,内存就被吃光了,程序最终被系统杀掉。所以养成“谁分配,谁释放;不用就释放”的习惯非常重要。
在测试链表程序时,每次跑完增删改查,最好都调用一次destroyList(&head),确认链表被完整销毁,再用工具(比如Linux下的Valgrind)检查有没有内存泄漏:
gcc -g -o test test.c valgrind --leak-check=full ./test反正我自己的习惯是,任何涉及动态内存的练习代码,都会主动跑一遍Valgrind,看到“All heap blocks were freed”才安心。这个习惯帮我提前拦截了无数潜在的内存错误。
7. 常见问题与排查技巧实录
7.1 一运行就崩溃:段错误(Segmentation Fault)
段错误是链表初学者遇到最多的问题,频率高得惊人。它的本质是程序访问了没有权限访问的内存地址,通常是解引用空指针或野指针导致的。常见的触发场景有:
- 链表为空时直接访问
head->data,此时head是NULL; - 遍历时循环条件写错,导致指针走到
NULL之后还在解引用; - 节点被
free之后,还被再次访问。
排查这类问题,我的经验是先加打印,确定崩在哪一行。只要把每次访问指针前的关键值打印出来,四五个printf下去,基本就能锁定是哪个指针出了问题。用调试器(gdb)也可以,但新手往往还不熟悉断点操作,先用打印的方式上手更快。
举个例子,假设你的程序一执行就崩,但你不知道崩在哪里,可以在每个函数入口和关键循环前后加上:
printf("debug: 进入删除函数,head=%p\n", head);这样你能看到崩之前最后一个输出是什么,自然就锁定到对应的代码段了。
7.2 链表打印出来出现环或者死循环
如果链表里某个节点的 next 指针错误地指回了前面的节点,那么遍历就会陷入无限循环——程序会一直打印数字直到永不停止。这种情况经常出现在插入或删除操作中“接线”顺序写反的时候。
判断链表是否有环的经典办法是“快慢指针”,但调试阶段有一个更简单粗暴的方法:在打印循环里加一个计数器,如果打印节点数超过链表应有长度的某个阈值,说明几乎可以肯定有环。比如链表总共3个节点,却打印了100个还没停,那必然是有环了。
遇到这种情况,立刻检查你的插入和删除代码,重点看有没有“先用cur->next覆盖了后面链表的入口”。记住一条铁律:断开任何链接之前,必须先把后续节点的地址另存一份,或者确保已经通过另一个指针记下了它。
7.3 内存泄漏:程序跑完,内存没回来
内存泄漏不像崩溃那么显眼,程序能正常运行,但每次运行都会丢失一小块内存。在链表场景里,最常见的泄漏点就是删除节点时忘了free,或者插入失败时没有释放已经malloc出来的新节点。
比如我在 4.3 节代码里有一句:
if (cur == NULL) { printf("插入位置超出链表长度\n"); free(newNode); // 别忘了释放刚才分配的内存 return; }这里就属于一种“提前失败”的路径。新节点已经malloc好了,但你要插入的位置不存在,如果直接 return,新节点就永远失去释放的机会。开发的时候这种“分支里有没有释放”最容易遗漏,所以要养成分支审视的习惯:凡是提前 return 的地方,都要检查自己分配过的内存是否被妥善处理了。
7.4 常见问题速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 一运行就段错误 | 解引用空指针 | 检查 head 是否为 NULL,检查循环结束后 cur 是否为 NULL |
| 打印结果缺失前半段 | 头指针被修改 | 查看遍历函数里是否直接操作了 head,而不是用临时变量 |
| 插入后链表丢失 | 改变了头指针却没有通过二级指针或返回值更新 | 看看是不是直接传了一级指针头指针 |
| 删除后链表断裂 | 前驱节点的 next 没有正确指向后继 | 检查删除时的接线顺序 |
| 释放后程序崩溃 | 悬空指针还指向已释放内存 | 检查 free 后是否仍然继续使用该指针 |
| 程序运行很久后内存暴涨 | 内存泄漏 | 用 Valgrind 检查每次 malloc 是否都有对应 free |
这张表你可以打印出来贴在电脑旁边,每遇到一个问题就先对照一遍。我接触过的绝大多数链表bug,最后都逃不出这几类。
8. 链表还能怎么玩:常用扩展与面试关注点
8.1 链表的排序:插入排序与归并排序
既然有链表,就有人会想在链表上做排序。比较简单的写法是插入排序——每次从未排序部分取出一个节点,在已排序部分找到合适的位置插进去,这个过程正好用上我们前面学的“任意位置插入”逻辑。但插入排序的时间复杂度是O(n²),数据量大了不太行。
追求效率的话,最适合链表的是归并排序。因为链表天然支持在中间切分(通过快慢指针找中点),然后递归合并两个有序链表,时间复杂度是稳定O(n log n),而且不需要额外的数组空间。面试中如果碰到“链表排序”,考官通常期待你写出归并排序而不是冒泡排序或者插入排序。
8.2 链表的反转:一道很经典的入门题
链表反转也是高频考点。它的核心思想是:准备三个指针prev、cur和nextTemp,每次把cur->next指向prev,三个指针集体向后移动一个位置。最终prev会变成新链表的头节点。
一个很常见的坑是,反转时先把cur->next改成prev,然后却发现找不到原来的下一个节点了——这就是为什么反转代码里总要有一个nextTemp提前保存下一站。如果你把这条规则想清楚了,就理解了链表里“先保存后修改”的通用法则,其他后续操作也会一通百通。
8.3 双向链表和循环链表:一个顺手的扩展
单链表的不足是只能从头往后走,如果想找前一个节点,只能再开一个循环从头遍历。双向链表就是在每个节点里多存一个prev指针,代价是每个节点多占一份内存,换来的是查找前驱的O(1)效率。循环链表则是把最后一个节点的next指回头节点,形成一个环。这两种结构在很多项目的底层都有应用,包括操作系统的任务队列、缓存的LRU链表等。
我个人的建议是,先把单链表练熟,再花两天时间把双向链表和循环链表的增删查改写一遍。等你把三种链表都写过一轮,C语言指针那块基本就没有能难倒你的内容了。
8.4 为什么面试这么爱问链表
不得不承认,面试官喜欢链表是有理由的。链表题目代码量不大,但对指针、内存管理、边界条件的考察力度极大。一段十几行的插入函数,可以同时检验你懂不懂二级指针、会不会处理空链表、是否考虑尾插的特殊性,这比很多大而全的项目更能体现基本功。所以不要觉得链表是“大学课上考完就忘了”的知识,它就是打开后续数据结构大门的第一把钥匙。
这里面最核心的能力,其实不是背诵操作步骤,而是能在脑子里面画出指针动态变化的图。如果你现在写链表操作还需要在纸上画半天才能理清,那很正常,练多了之后,这些指针的移动会慢慢变成直觉。
我在带新人时经常说一句话:链表题,画图十分钟,写代码三分钟,调试能花三小时。不是因为代码难,而是因为很多人的思维还停留在“内存是连续空间”的定式里,看到指针跳来跳去就发懵。等你真的把每一根“next线”都画顺了,链表的增删改查对你来说就是顺水推舟的事。
换一个角度说,链表也是通向“内存管理”这个C语言核心主题的最佳实践场。malloc的每一块内存,都有对应的free在等它;每一个指针,都有明确的指向归宿。这种“所有权”的意识,如果你能从链表开始建立起来,那以后写任何C项目,都会受益很多。