大二那年我第一次在《数据结构》作业里写单链表插入,连续崩了三个晚上。代码逻辑看着明明没问题:先 malloc 一个节点,把数据填进去,然后“接上”链表。可一运行,要么什么都不打印,要么直接 Segmentation Fault。后来一个学长看了一眼我的代码,指了指其中一行:p->next = p;。当时没反应过来,他又补了一句:“你想想这会儿 p->next 本来是啥,被你这一赋值覆盖掉以后,后面的节点还找得着吗?”那天我才真正意识到,单链表的核心根本不是什么“用结构体串一串数据”,而是指针在内存里怎么指、指完之后原来指向的东西丢没丢——也就是标题里那六个字:指针与动态内存操作。
这篇东西想跟你把单链表整个过一遍。从最基础的节点结构体开始,到插入、删除、反转、排序这些操作里指针怎么摆弄,再到 malloc 和 free 背后那些“看不见的坑”。不管你是在校生赶数据结构实验,还是准备面试前突击算法题,又或者是自学 C 语言想真正搞懂指针,这篇应该都能让你少走几步弯路。我会尽量用平时跟同事交流的口吻,把每一步为什么这么写、不这么写会怎样,都摊开来讲。
1. 从结构体到节点:先搞清楚链表里存的到底是什么
很多教材讲单链表,上来就甩一段 typedef struct Node { int data; struct Node *next; } Node;,然后就开始讲操作。但新手最困惑的不是操作,而是这个结构体本身:为什么 next 的类型是 struct Node *?为什么不能直接写 struct Node next;?
1.1 自引用结构体:为什么 next 必须是指针
你试着在结构体里直接放一个“下一个节点”的完整副本:
c复制struct Node {
int data;
struct Node next; // 错误
};
这样写编译器直接报错,因为这个结构体的大小是无穷大:next 里还有 next,next 里还有 next……套娃套到天荒地老。但改成指针就不一样了:
c复制struct Node {
int data;
struct Node *next;
};
指针在 64 位系统上固定占 8 个字节,它只存“下一个节点在哪块内存”,不存“下一个节点本身”。这就好比你手里攥着一张写着朋友家庭住址的纸条(地址),而不是把朋友整栋房子搬过来。这样的话,每个节点的大小是固定的:一个 int(4字节)加一个指针(8字节),在常见 64 位平台上对齐后通常占 16 字节。
明白了这个,后面所有操作的本质就一句话:我们是在不断地改写“纸条上的地址”,让节点按不同顺序串起来。 头插法是把新节点的纸条先写上新地址,再把全局入口改写;删除是绕过一个节点,让前一个节点的纸条直接写后一个节点的地址。
1.2 链式存储和数组存储的本质差异
学链表前先学数组,很容易让人陷入一个思维定式:数据必须排排坐,像电影院座位一样连续挨着。数组确实是这么干的,所以才能用 a[i] 这种下标直接算出第 i 个元素的内存地址:起点 + i * sizeof(元素),时间复杂度 O(1)。代价是插入和删除很痛苦:要在数组中间插一个元素,得把后面所有元素往后搬。
链表反过来了。它的节点可以散布在内存的任意角落,彼此之间靠指针维持联系。你 malloc 一个新节点,它可能落在堆区哪个位置你根本管不着,这不重要,因为你可以把它的地址写进前一个节点的 next 里。这样插一个新节点,最坏情况也只是改两个指针,不需要搬动任何既有数据。
两种结构没有绝对优劣,看场景:
| 操作 | 数组 | 单链表 |
|---|---|---|
| 按下标/随机访问 | O(1) | O(n)(必须从头走到尾) |
| 头部插入 | O(n)(整体后移) | O(1) |
| 已知位置的后插 | O(n) | O(1) |
| 已知位置删除 | O(n)(整体前移) | O(1)(改指针即可) |
| 内存空间 | 连续,快但碎片化难用 | 离散,灵活但每个节点多出一个指针的开销 |
链表第一个好处是插入删除快,第二个好处是内存不需要连续。但它的致命弱点也很明显:不能随机访问。你想找第 n 个节点,就得老老实实从头开始走 n-1 步,哪怕内存物理上还有更快的路径也不能抄近道——因为你手里只有第一个节点的地址,后面的地址全藏在各个节点的 next 里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 指针操作前的底层储备:head、游标与野指针
链表操作写多了你会发现,翻来覆去就那么几个指针变量的戏份:head、cur、prev、next。但戏份越简单,演砸的越多。很多人出错不是不懂逻辑,而是对“指针变量本身也是变量”这件事没有肌肉记忆。
2.1 指针变量也是变量:赋值到底改了什么
不少人对 p = p->next; 这句的理解是“p 往前走了一步”,但实际发生的是:把 p->next 里存的那个地址(也就是下一个节点的内存地址)复制给了 p 自己。之后你再访问 p->data,访问的就是下一个节点的数据域了。
画个图理解会更直观。假设有三个节点 A、B、C,地址分别是 0x100、0x200、0x300,那么 A 的 next 字段里存着 0x200,B 的 next 字段里存着 0x300。一开始 cur = head,cur 的值是 0x100。执行 cur = cur->next 后,因为 cur 本身在 0x100 那块内存里取 next 字段(值是 0x200)并赋给自己,于是 cur 就变成了 0x200。此刻 cur 不再“指向”A 节点了,它变成了 B 节点的入口。
这个“改地址”的瞬间,新手最容易犯的错是:遍历时直接动 head。
c复制void printList(struct Node *head) {
while (head != NULL) {
printf("%d ", head->data);
head = head->next; // 把 head 改掉了
}
}
功能上这个函数没问题,遍历完链表也能完整打印。问题在于:传入的是 struct Node * 指针,在函数内 head = head->next 实际上把函数内部的局部变量 head 改了,不会影响函数外的头指针。原因在于 C 语言函数传参是值传递,传进来的是“头指针的值”——即第一个节点的地址,不是头指针本身。所以函数内改 head 变量没问题。但如果你在同一个函数里先遍历了一遍,还想用 head 做别的操作,就发现 head 已经走到 NULL 了。所以在工程代码里,遍历用游标已是共识:
c复制struct Node *cur = head;
while (cur != NULL) {
// 处理 cur->data
cur = cur->next;
}
head 保持不变,想再用随时用。这个细节看起来小,却是很多“链表只走了一遍就丢了”的 bug 源头。
2.2 野指针、NULL 与防御性编程
热词里“野指针”出现频率极高,确实值得单独说。野指针就是“值不确定”的指针,它不知道指向哪块内存。它跟 NULL 指针不一样:NULL 指针至少明确指向“哪儿都不去”,你可以在使用前判断;野指针是瞎指,你不知道它指到哪,访问起来要么读到垃圾数据,要么直接触犯操作系统的内存保护,进程崩溃。
野指针最常见的三个来源:
- 指针变量未初始化。局部变量如果没赋值,里面存的是栈上残留的随机值,这时候你拿它当指针用,指哪算哪。
- free 之后没置 NULL。free 只是把内存还给堆管理器,指针变量还留着原来的地址值,但它指向的内存可能马上被分配给别的用途,再访问就是 use-after-free。
- 返回了局部变量的地址。函数里的局部变量存在栈上,函数返回后栈帧销毁,拿到的地址已经“不属于你”了。
所以写链表操作,我个人的习惯是:声明指针变量时一律初始化,要么 NULL,要么指向合法的节点;free 完一个节点后立刻设为 NULL。别嫌啰嗦,这一行在 debug 时能帮你挡住大量因“读已释放的内存”导致的诡异问题。
3. 单链表的插入与删除:指针变换的“动线设计”
链表最核心的操作就两个维度:插入、删除。插入分头插、尾插、中间插;删除也分删头、删尾、删中间。下面逐个拆解指针变化的先后顺序,每一步都解释为什么不能换序。
3.1 头插法:为什么新节点要先接后链,再更新头指针
头插法的代码看起来短得不能再短:
c复制struct Node *newNode = (struct Node *)malloc(sizeof(struct Node));
newNode->data = value;
newNode->next = head;
head = newNode;
但注意顺序:newNode->next = head; 必须在 head = newNode; 之前。如果你把顺序写反了——先 head = newNode; 再 newNode->next = head;——会发生什么?head 已经指向新节点了,再执行 newNode->next = head,新节点的 next 指向自己,原来的整条链表从此失联。这就是我开篇犯的那个错误。
这个顺序问题可以概括成一句口诀:先用新指针保存旧状态,再改写全局入口。 在链表操作里,“保存旧状态”和“修改连接”的先后顺序几乎决定了所有 bug 的来源。
头插法的时间复杂度 O(1),因为入口 head 就是现成的“前一个节点”。代价是顺序跟插入顺序完全相反:依次头插 1、2、3,打印出来是 3、2、1。所以需要保持原始顺序时,得用尾插。
3.2 尾插法:为什么先从头走到尾,然后只改一个指针
尾插法有两种思路。一种是每次都从头遍历到尾,找到最后一个节点,然后 tail->next = newNode。这种做法插入本身是 O(1),但找尾巴是 O(n),所以整体 O(n)。
另一种更工程化的做法是维护一个 tail 指针,每次都记录链表的尾节点:
c复制void appendNode(struct Node **head, struct Node **tail, int value) {
struct Node *newNode = (struct Node *)malloc(sizeof(struct Node));
newNode->data = value;
newNode->next = NULL;
if (*head == NULL) {
*head = newNode;
*tail = newNode;
return;
}
(*tail)->next = newNode;
*tail = newNode;
}
这里用了二级指针。为什么?因为当链表为空时,需要修改函数外部的头指针 head 的值;如果只传一级指针 struct Node *head,函数内改的是副本,外部 head 还是 NULL。传二级指针 struct Node **head,才能在函数里通过 *head = newNode 真正改写外部的头指针。
如果你不想用二级指针,那就让函数返回新头:struct Node *appendNode(struct Node *head, ...) { ... return head; }。两种风格都可以,但要注意一致,别混着用。实际工程里两种都很常见:C 风格回调函数往往喜欢返回新头,大型项目有时候为了统一错误处理,也常看到二级指针的写法。
3.3 中间插入:需要两个指针的配合
在某个已知节点 p 之后插入新节点:
c复制newNode->next = p->next;
p->next = newNode;
顺序同样不能反。如果先 p->next = newNode;,p 原本的下一个节点地址就丢了,后面的链全断。这也是链表插入中最常见的一类“断链”错误。
如果要在某个值第一次出现的位置之前插入,就需要两个指针:一个 prev 记录当前节点的前一个节点,一个 cur 当前节点。从头开始遍历,找到目标位置后:
c复制struct Node *prev = NULL;
struct Node *cur = head;
while (cur != NULL && cur->data != target) {
prev = cur;
cur = cur->next;
}
if (cur == NULL) {
// 没找到,可以选择尾插或返回错误
}
newNode->next = cur;
if (prev == NULL) {
head = newNode;
} else {
prev->next = newNode;
}
注意 prev == NULL 的情况,这表示目标节点就是头节点,插入操作退化成头插。
中间插入的复杂度:找位置 O(n),插入 O(1)。所以“在链表中插入”整体是 O(n),但“在已知位置插入”是 O(1)——这两者经常被混淆,面试容易被追问。
3.4 删除节点:先用旁边节点接好链,再释放内存
删除的指针逻辑其实比插入简单——把要删的节点绕过去就行。麻烦的是绕过去之后的那句 free(cur) 放哪儿。
c复制struct Node *prev = NULL;
struct Node *cur = head;
while (cur != NULL && cur->data != target) {
prev = cur;
cur = cur->next;
}
if (cur == NULL) return; // 不存在
if (prev == NULL) {
head = cur->next;
} else {
prev->next = cur->next;
}
free(cur);
删除头节点时,prev == NULL,所以 head = cur->next 这一句是在更新链表的入口;删除中间节点时,prev->next = cur->next 是在跳过当前节点。然后才能 free(cur)。
如果先把 free(cur) 放前面,然后才执行 prev->next = cur->next,会出现什么?cur 指向的那块内存已经被释放了,再去读 cur->next 就是典型的 use-after-free。在某些系统上你可能“侥幸”还能读出来,但这种行为完全未定义,内存被复用后就是悬空指针读垃圾。
再给一个细节:free(cur) 之后,要不要 cur = NULL?从安全性上讲,最好要,尤其是后面还有可能在这个函数里继续引用 cur。如果 cur 还有别的指针别名指向它(比如后面有个 temp = cur),那光把 cur 置 NULL 也救不了别的指针。所以最靠谱的防御是:释放之前想清楚还有谁指向这片内存,释放之后所有指向这片内存的指针都要置 NULL,或者保证再也不会用了。
3.5 删除整个链表:逐个释放,边释放边保存 next
删单节点很多人会,但“删除整个链表”这个操作,几乎每年都有人踩同一个坑:
c复制void destroyList(struct Node *head) {
struct Node *cur = head;
while (cur != NULL) {
free(cur);
cur = cur->next; // 大错特错!
}
}
问题出在 free(cur) 之后,cur->next 还能用吗?显然不能。你已经把 cur 指向的内存还回去了,再去读取 next 字段就是悬空访问。正确做法是先用临时变量保存 next,再释放当前节点:
c复制void destroyList(struct Node *head) {
struct Node *cur = head;
while (cur != NULL) {
struct Node *next = cur->next;
free(cur);
cur = next;
}
}
这其实就是“先保存后释放”的又一体现。跟前面插入的顺序口诀一模一样:在修改或释放一个数据结构之前,先把你需要的信息拿出来存好。
4. 单链表反转:指来指去之间的“乾坤大挪移”
反转链表是所有链表题里地位最高的一道题,没有之一。因为它小巧、经典,却实实在在地考查你对指针操作的理解。网上有迭代法、递归法、头插法等各种姿势,我重点讲迭代法,因为它最贴合“指针操作”这个主题。
4.1 迭代反转:三指针法
反转的核心思想:把每个节点的 next 指向前一个节点。但单链表只有一个方向的链,如果你直接把当前节点的 next 改了,你就丢失了它原本的下一个节点,后面的遍历就断了。所以需要一个临时指针先把下一个节点存住。
三指针法:
c复制struct Node *reverseList(struct Node *head) {
struct Node *prev = NULL;
struct Node *cur = head;
while (cur != NULL) {
struct Node *next = cur->next; // 先保存后继
cur->next = prev; // 掉转枪头
prev = cur; // prev 前移
cur = next; // cur 前移
}
return prev; // 新头
}
逐行过一遍:
next = cur->next:把当前节点的后继地址复制给临时变量 next,这样就算改掉cur->next也不会迷路。cur->next = prev:让当前节点指向前驱。第一次循环时 prev 是 NULL,所以反转后第一个节点的 next 是 NULL,正好变成新链表的尾巴。prev = cur:prev 指向当前节点,供下一轮使用。cur = next:cur 回到原本的下一个节点,继续循环。
循环结束后,prev 停在原链表的最后一个非 NULL 节点上,它就是反转后的新头,所以直接返回 prev。这里必须返回新头,因为调用者的 head 变量还指着原来的头(反转后变成了尾巴),如果继续用旧的 head 去遍历,你会发现它指向的节点的 next 已经是 NULL 了,整个链表“只剩一个节点”的假象。
我在指导别人写反转时,最喜欢让他们自己画一张 3 个节点的图,用箭头标出每轮循环里 prev、cur、next 的位置变化。画完基本就懂为什么需要三个指针了。其实这个三指针就是“保存→修改→推进”三个动作的循环,跟前面所有操作的内在逻辑完全一致。
4.2 递归反转:代码短,但理解门槛高
递归反转的代码只有 4 行:
c复制struct Node *reverseListRecursive(struct Node *head) {
if (head == NULL || head->next == NULL) {
return head;
}
struct Node *newHead = reverseListRecursive(head->next);
head->next->next = head;
head->next = NULL;
return newHead;
}
关键就一句 head->next->next = head;。拿两个节点举例:假设 A->B->NULL。调用 reverseListRecursive(A) 时,递归到 B,B 的 next == NULL,返回 B,于是 newHead = B。回到 A 这一层,head->next 是 B,所以 head->next->next = head 等价于 B->next = A,把 B 指向 A;然后 A->next = NULL,完成反转,返回 newHead 也就是 B。
递归版虽然简洁,但有两个明显缺点:一是空间复杂度 O(n),因为递归调用栈会随链表长度线性增长。链表长度过万就可能爆栈;二是不如迭代直观,出 bug 时不好调试。所以实际工程和大多数面试场合,我更推荐迭代版。递归版适合用来加深对递归本身的理解,但别把它当默认答案。
4.3 反转最容易崩的三种现场
-
空链表 / 单节点链表:迭代法里 while 循环直接不执行(空)或只执行一次(单节点),返回 prev 前是 head,都对。但如果面试官没提醒,你忘记对空链表做判断,递归版就可能出问题——不过实际上递归版的边界条件已经包含了 NULL 的情况,所以安全。
-
循环里弄丢 next:比如忘记保存 next 就执行
cur->next = prev,那 cur 根本走不到下一个节点,程序会卡在死循环或者访问到野指针。典型的“纸上逻辑对,一跑就崩”。 -
返回的 head 不对:返回了旧 head,后面一访问就发现链表只有一节。所以反转后一定要用返回值更新调用方的头指针。
4.4 双指针与链表的结合:有序链表的合并
热词里出现“双指针合并有序数组”,其实双指针合并有序链表是更进阶的变体。数组版双指针核心是 i、j 两个数组下标,链表版就变成 p、q 两个节点指针。
c复制struct Node *mergeLists(struct Node *a, struct Node *b) {
struct Node dummy;
struct Node *tail = &dummy;
dummy.next = NULL;
while (a != NULL && b != NULL) {
if (a->data <= b->data) {
tail->next = a;
a = a->next;
} else {
tail->next = b;
b = b->next;
}
tail = tail->next;
}
if (a != NULL) tail->next = a;
if (b != NULL) tail->next = b;
return dummy.next;
}
这里用了一个本地哨兵节点 dummy,它不存放有效数据,只是给合并链提供一个稳定的“头”。好处是不用每次都判断 tail 是否为 NULL,也不用在循环里分“首节点”和“后续节点”两种情况处理,代码简洁很多。之前说的“如果想改头指针,要么用二级指针,要么返回新头”,这里返回新头就该返回 dummy.next,而对函数外部原本的 a、b 两个链表没有任何影响——因为合并后它们已经“交织”在一起了。
5. 动态内存操作:malloc 与 free 的底层逻辑和排查方法
标题里“动态内存操作”六个字,重量不亚于“指针”。很多链表题的 bug 根源不在连杆逻辑上,而在 malloc 和 free 的使用姿势上。
5.1 malloc 到底做了什么
malloc(n) 做的事情是:从堆区找一块大小为 n 字节的连续内存块,返回它的地址。它不负责清零,也就是说这块内存里可能残留着之前使用时的任意数据。如果你拿到内存后忘记初始化,直接把里面的“随机值”当成有效数据用,就产生了野指针或者数据混乱。
解决内存残留问题有两个办法:calloc 会在分配的同时把所有字节清零;或者你 malloc 之后手动给字段赋值。链表节点一般只有两个字段,手动赋值是常态:
c复制struct Node *newNode = (struct Node *)malloc(sizeof(struct Node));
if (newNode == NULL) {
// 分配失败处理
return;
}
newNode->data = value;
newNode->next = NULL;
这里 next = NULL 尤其重要。如果你不置 NULL,这个节点的 next 就是个野指针,后续遍历时 while (cur != NULL) 判断可能永远不成立,直接访问野地址。
5.2 内存泄漏、double free、use-after-free
三个常见错误背熟:
- 内存泄漏:malloc 出来的内存没人负责 free,堆空间慢慢变少。链表操作里最常见的泄漏是“删除节点后没有 free”,比如只把节点摘下来却忘了释放,或者用引用计数管理链表节点时计数错误。长期运行的服务如果泄漏严重,迟早 OOM。
- double free:同一块内存被 free 两次。第一次 free 把内存还给堆管理器,第二次 free 时堆管理器发现这块内存已经不是合法状态,直接 abort。某些场景下,两个指针变量指向同一节点,你在两个地方分别调用 free,就会引发这个。所以删除节点时一定要想清楚你这块内存的所有权归谁。
- use-after-free:free 之后还去读写这块内存。链表删节点时先 free 再访问
cur->next就是典型。这类 bug 最阴险的是:内存刚释放还没被复用,你侥幸能读出正确值;等它被复用成别的数据,错误才会暴露,而且表现极其随机。
用一句话总结三个错误:malloc 让你租了一块地,free 是把地还回去;还完之后再踩进去叫 use-after-free,还两次叫 double free,只租不还叫内存泄漏。
5.3 用 valgrind 和 gdb 定位指针问题
学链表时如果不开 sanitizer 或 valgrind,等于在钢丝上骑自行车还不系安全绳。Linux 下最推荐 valgrind:
bash复制valgrind --leak-check=full --show-leak-kinds=all ./your_program
valgrind 会逐行报告:哪一行 malloc 的内存 leak 了,哪一行发生了 invalid read/write(往往就是 use-after-free),哪个地方 double free。我第一次学会用 valgrind 后,身边同学定位链表 bug 的速度至少快了三倍。
gdb 调试链表的常用操作:
bash复制gdb ./your_program
break main
run
print head
print *head
print head->next
gdb 里可以直接打印指针本身的值和它指向的结构体内容。看到 head 和 head->next 的地址值是否符合预期,往往立刻就能发现问题。还有一个技巧:在循环里打断点,每走一步打印 cur、prev 的地址,跟在纸上画的图对比,几步就能定位到是哪个指针在更新之后跑偏了。
5.4 智能指针与链表的杂谈
热词搜到“智能指针”“C++智能指针”,这里多说两句:C 语言里你必须手动管理内存,但 C++ 的 std::shared_ptr 和 std::unique_ptr 可以自动释放。如果你用 C++ 写链表练习——虽然很多学校强行要求 C 语言——可以用 RAII 减少内存泄漏。但注意:std::shared_ptr 做链表循环引用会导致内存泄漏问题,结构上很容易产生循环引用(比如双向链表中头尾互指)。所以工程上的链表实现,要么用原始指针配合清楚的所有权规则,要么用 std::unique_ptr 表示“唯一所有权”,把它当智能指针版的 next。
我个人建议:学习阶段先把 C 的 malloc/free 吃透,因为只有手动管理过,你才知道 RAII 帮你省了哪些事,也才知道那些“自动”背后是有代价的。
6. 实际刷题与实验调试:链表题的几个高频变体和通用策略
前面讲的都是基础操作,但热词里还有“数据结构排序算法”“双指针合并有序数组”“python单链表逆序”“判断链表回文”这些拓展话题。单链表知识点跟这些题是联动的,这里挑几个最有代表性的展开。
6.1 快慢指针找中间节点与环检测
链表没有下标,想找中间节点最朴素的方法是先遍历一遍数长度,再走一半,时间复杂度 O(n),空间 O(1)。快慢指针法则更优雅:slow 每次走一步,fast 每次走两步。fast 到终点时,slow 正好在中间(或中间偏右,取决于偶数长度时的偏好)。
c复制struct Node *findMiddle(struct Node *head) {
if (head == NULL) return NULL;
struct Node *slow = head;
struct Node *fast = head;
while (fast != NULL && fast->next != NULL) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
为什么有效?因为快指针速度是慢指针的两倍,两者的路程差恰好是慢指针走过的长度。快指针跑完全程 L,慢指针只跑了 L/2,正好在中间。同样的思想可以检测环:如果链表有环,快慢指针最终会相遇;如果没有环,快指针会先走到 NULL。
这个算法给我最大的启发是:指针不仅能存地址,还能通过“移动速度差”携带信息。 在链表中,“两个指针错开几步走”几乎是一种万能解题姿势。
6.2 链表排序:插入排序与归并排序
单链表的排序跟数组排序不一样:没法 O(1) 随机访问,所以快排这种重度依赖下标的算法在链表上并不舒服。最实用的是归并排序,它天然适合链表,因为归并操作本来就是“合并两条有序链表”,而我们前面已经写过了。
链表归并排序的思路:用快慢指针找到中点,把链表切成两半,递归地对两半排序,然后合并。核心代码其实就是把“找中点”和“合并两个有序链表”拼起来。由于每次切分不需要额外数组空间,链表归并排序的空间复杂度是 O(log n)(递归栈),比数组归并排序的 O(n) 漂亮很多。
链表插入排序也经常被拿来当链表题练手:
c复制struct Node *insertionSortList(struct Node *head) {
struct Node *sorted = NULL;
struct Node *cur = head;
while (cur != NULL) {
struct Node *next = cur->next;
// 在 sorted 链里找到合适位置插入
if (sorted == NULL || sorted->data >= cur->data) {
cur->next = sorted;
sorted = cur;
} else {
struct Node *p = sorted;
while (p->next != NULL && p->next->data < cur->data) {
p = p->next;
}
cur->next = p->next;
p->next = cur;
}
cur = next;
}
return sorted;
}
这里用了一个经典策略:从原链表拆节点,往新链表里插入。每次从原链表的头部拆一个节点出来,插入到已排好序的 sorted 链表中。因为新链表头可能变化,所以最后返回 sorted。这个题能同时练到“链表头的更新”“插入位置找前驱”和“保序的指针操作”,非常推荐自己从头写一遍。
6.3 调试链表的通用策略:先画图,再打印,最后再加断言
链表调试跟普通数组调试不太一样。数组报错一般就在越界那一下,链表的错误却往往“爆”在很久之后,因为一个节点连错,可能要到访问下一节点时才崩。所以我总结了一套调试顺序,几乎能应对九成链表 bug:
- 画图。任何一次 debug 前,先在纸上画出当前链表的形状和各个指针变量指向的位置。画的过程会逼你把指针状态理清楚,很多逻辑矛盾在画图阶段就暴露了。
- 打印关键信息。用辅助函数
printList(head)在每一步操作后打印链表内容,或者在关键操作处打印当前指针地址。这样你能直观看到“这一步之后链到底断在哪”。 - 加断言。比如删除后断言
prev->next == cur->next,反转后断言newHead->data == 旧链表尾节点的 data。断言能把“条件满足时才成立的原理”显式写下来,一旦原理被破坏,程序当场报错,而不至于带病运行。 - 最后才逐行读代码。当你把图、打印、断言都用过还找不到问题时,再开始逐行推演代码。这时候你已经排除了大量外部干扰,剩下的往往就是代码里某一个具体的指针赋值顺序。
6.4 链表的边界条件清单
面试和考试里,链表题最常挂在边界条件上。每次写完链表函数,先过一遍下面这张清单:
| 输入情况 | 常见错误 | 正确预期 |
|---|---|---|
| 空链表 | 循环直接访问 head->data | 函数返回 NULL 或空结果 |
| 只有一个节点 | 删除/反转后 head 未更新 | 删除后 head 变为 NULL;反转后 head 不变 |
| 两个节点 | 反转时丢链 | 反转后第二条链变第一条 |
| 目标节点是头节点 | 删除/插入时忘记更新 head | 用 prev == NULL 分支单独处理 |
| 目标不存在 | 遍历到 NULL 还继续访问 | 提前判断 cur == NULL 并退出 |
| 循环链表/带环链表 | 结束条件不成立,死循环 | 用快慢指针或哨兵限制步数 |
我认识不少同学写链表题喜欢“先把主逻辑写完再修边界”,但实际效率很低。更好的做法是在写之前就明确边界条件,在主逻辑的每个分支里把边界一并处理掉。链表这种结构,容错率很低,边界条件处理得干净,代码整体就会很稳。
7. 个人经验:学指针和链表,最绕不开的仍是“动手写”
最后分享一点体会。我见过很多人在学链表时疯狂看视频、看资料,觉得“看懂了”就等于“会了”。但指针和链表恰恰是那种“光看永远学不会”的东西。因为看视频时,画面上箭头指来指去很清晰;一旦自己面对黑乎乎的终端,面对 Segmentation Fault,才会真正理解“哪个指针还没指对”。
建议你亲手把今天这篇里所有代码都敲一遍,尤其是这三段:
- 头插法建链再反转,验证反转后打印顺序。
- 按值删除节点,把所有边界情况都测一遍。
- 用 valgrind 跑一遍,看看内存是不是都释放干净了。
敲完以后试着改改需求:比如把节点数据域改成字符串、把链表改成双向链表、把插入改成“按值有序插入”。每改一次,都会逼你再思考一遍指针和内存的关系。
单链表只是数据结构的入门,但它给的“用指针组织动态数据”的思路,会一直延续到二叉树、图、跳表、LRU 缓存这类更复杂的结构里。当时那个让我崩溃三天的插入 bug,如今回头看简单得可笑,但它带给我的收获,也不是看多少篇教程能替代的。
