1. 反转链表到底在反转什么
链表反转是LeetCode上出镜率最高的题目之一,没有之一。我刷了这么多题,面试里被问到的链表题,十道里有六道跟反转沾边——要么是完整反转,要么是反转前K个,要么是两两交换,要么是区间反转。所以把这个基础款吃透,后面那些变种就是在这个骨架上加参数而已。
先把问题说清楚。给你一个单链表的头节点,比如1 -> 2 -> 3 -> 4 -> 5 -> NULL,要求你把它变成5 -> 4 -> 3 -> 2 -> 1 -> NULL。看起来简单,但真正动手写的时候,很多人会在指针操作上翻车。为什么?因为单链表只能单向遍历,你把当前节点的next指向前一个节点之后,就找不到后面的节点了。所以核心难点就一个:在改变指针方向之前,先把后面的路记下来。
这篇文章适合谁看?如果你刚开始刷LeetCode,对指针操作还不太有手感,那这篇就是写给你的。如果你已经刷过一些题但每次写反转链表都要想半天,那这篇也能帮你把思路固化下来。我会用C语言来写,因为C的指针操作最赤裸,没有语法糖帮你兜底,把C写明白了,其他语言就是换个写法的事。
2. 解题思路的拆解与方案选型
2.1 为什么迭代法是最优先的选择
反转链表的解法主要有两种:迭代和递归。我先说结论——面试里优先写迭代法。原因不复杂:迭代法空间复杂度是O(1),递归法虽然代码短,但空间复杂度是O(n),因为每一层递归都要压栈。而且递归法在链表特别长的时候有栈溢出的风险,虽然LeetCode的测试用例一般不会长到那个程度,但面试官如果追问“有没有更优的空间方案”,你递归写到一半就尴尬了。
迭代法的核心思想用一句话概括:用三个指针,一边遍历一边掉头。这三个指针分别是:
prev:指向前一个节点,初始为NULLcurr:指向当前节点,初始为headnext:临时存储下一个节点,防止断链
你可以把整个过程想象成一群人排队,原本每个人都面朝前方,现在要求所有人转身面朝后方。你不能让一个人直接转,因为他转过去之后,他前面那个人就找不到后面的人了。所以你需要一个人一个人地处理,每处理一个人之前,先记住他后面是谁。
2.2 递归法的适用场景与局限
递归法不是不能用,但你要清楚它的适用边界。递归的思路是从最后一个节点开始反转,然后逐层往回走。代码确实简洁,但有几个实际问题:
第一,当链表长度超过几万的时候,递归深度就危险了。第二,递归法的思路在面试中解释起来反而更绕,面试官如果让你画图说明,迭代法画起来更直观。第三,很多变种题(比如反转区间、K个一组反转)用迭代法更容易控制边界条件。
所以我的建议是:先把迭代法练到闭着眼睛都能写对,递归法作为知识储备了解即可。面试的时候主动写迭代,如果面试官问“还有别的方法吗”,你再把递归思路讲出来,这样节奏就很舒服。
2.3 三个指针的初始状态为什么这样设
很多人会问,为什么prev要初始化为NULL?因为反转之后,原来的头节点会变成尾节点,而尾节点的next必须是NULL。你初始把prev设为NULL,第一次循环的时候curr->next = prev就自然把第一个节点的next置为NULL了,不需要额外处理尾节点。
那curr初始为head这个没什么好说的,从头开始遍历。关键是next指针,它必须在每次改变curr->next之前就保存好curr->next的原始值。顺序不能乱:
next = curr->next; // 先记住后面的路 curr->next = prev; // 再掉头 prev = curr; // prev往前挪 curr = next; // curr往前挪这四行的顺序是铁律,任何一行提前或延后都会导致断链或者死循环。我见过有人把curr->next = prev写在第一行,结果next拿到的已经是修改后的值了,整个链表直接断掉。
3. 完整代码实现与逐行解析
3.1 迭代法完整实现
先给出结构体定义,这是LeetCode的标准定义:
struct ListNode { int val; struct ListNode *next; };然后是反转函数:
struct ListNode* reverseList(struct ListNode* head) { struct ListNode *prev = NULL; struct ListNode *curr = head; struct ListNode *next = NULL; while (curr != NULL) { next = curr->next; curr->next = prev; prev = curr; curr = next; } return prev; }就这十几行,但每一行都有讲究。我来逐行拆解。
prev = NULL:前面说过了,为了处理尾节点。curr = head:从头开始。next = NULL:初始化而已,进循环后第一件事就是给它赋值。
循环条件是curr != NULL,不是curr->next != NULL。为什么?因为你要处理每一个节点,包括最后一个节点。如果用curr->next != NULL作为条件,最后一个节点就不会被处理,它的next还指向倒数第二个节点,结果就是尾节点没有正确置NULL。
循环体内四行代码的顺序前面已经强调过了。循环结束后,curr已经是NULL了,prev指向的是原来的最后一个节点,也就是反转后的头节点。所以返回prev。
3.2 手动模拟执行过程
光看代码可能还不够直观,我带你走一遍1 -> 2 -> 3 -> NULL这个例子。
初始状态:prev = NULL,curr = 1,next = NULL。
第一轮循环:
next = curr->next,所以next = 2curr->next = prev,所以1->next = NULLprev = curr,所以prev = 1curr = next,所以curr = 2
此时链表状态:1 -> NULL,prev指向1,curr指向2。
第二轮循环:
next = curr->next,所以next = 3curr->next = prev,所以2->next = 1prev = curr,所以prev = 2curr = next,所以curr = 3
此时链表状态:2 -> 1 -> NULL,prev指向2,curr指向3。
第三轮循环:
next = curr->next,所以next = NULLcurr->next = prev,所以3->next = 2prev = curr,所以prev = 3curr = next,所以curr = NULL
此时链表状态:3 -> 2 -> 1 -> NULL,prev指向3,curr为NULL。
循环条件不满足,退出。返回prev,也就是节点3,反转完成。
注意:手动模拟看起来简单,但真正写代码的时候,很多人会在
prev和curr的移动顺序上犯错。记住一个原则——先保存,再掉头,最后移动。
3.3 递归法实现与对比
递归法的代码是这样的:
struct ListNode* reverseList(struct ListNode* head) { if (head == NULL || head->next == NULL) { return head; } struct ListNode *newHead = reverseList(head->next); head->next->next = head; head->next = NULL; return newHead; }递归的终止条件是链表为空或者只有一个节点,直接返回。否则先递归处理后面的部分,得到反转后的新头节点newHead。然后关键的两行:head->next->next = head让后一个节点指回当前节点,head->next = NULL断开当前节点原来的指向。
这两行的顺序也不能反。如果先执行head->next = NULL,那head->next->next就找不到原来的后一个节点了。
递归法的空间复杂度是O(n),因为递归调用栈的深度等于链表长度。迭代法是O(1)。时间复杂度两者都是O(n),每个节点处理一次。
| 对比维度 | 迭代法 | 递归法 |
|---|---|---|
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(1) | O(n) |
| 代码长度 | 稍长 | 更短 |
| 栈溢出风险 | 无 | 链表过长时有 |
| 面试推荐度 | 优先 | 备选 |
4. 实操中容易踩的坑与排查方法
4.1 指针操作顺序错误导致断链
这是最常见的错误,没有之一。我刚开始写的时候也犯过,把curr->next = prev写在next = curr->next前面,结果next拿到的是已经被修改的值,链表从中间断开,后面的节点全部丢失。
排查方法很简单:在纸上画三个格子代表三个指针,每执行一行就更新一次格子的指向,画完一轮循环你就知道哪里错了。不要偷懒在脑子里模拟,链表操作必须画图。
4.2 循环条件写错导致尾节点未处理
有人把while (curr != NULL)写成while (curr->next != NULL),结果最后一个节点没有被反转,它的next还指向倒数第二个节点,形成环或者尾节点没有正确置NULL。
这个错误的排查方法是:用两个节点的链表测试。1 -> 2 -> NULL,如果反转后变成2 -> 1 -> 2 -> ...这种环,那就是循环条件写错了。
4.3 返回值写错
循环结束后应该返回prev,不是curr,也不是head。因为curr已经是NULL了,head现在是尾节点。返回prev才是反转后的新头节点。
这个错误比较隐蔽,因为如果你在LeetCode上提交,返回错误的节点会导致输出完全不对,但代码逻辑看起来没问题。记住一个口诀:循环结束,prev是新头。
4.4 空链表和单节点链表的边界处理
空链表head == NULL的情况,迭代法里curr = head = NULL,循环条件不满足,直接返回prev = NULL,正确。单节点链表1 -> NULL,循环执行一次,prev变成1,curr变成NULL,返回1,也正确。
所以迭代法天然处理了这两种边界情况,不需要额外加if判断。但递归法必须加head == NULL || head->next == NULL的判断,否则会空指针解引用。
提示:写完代码后,至少用三种情况测试——空链表、单节点、三节点以上的正常链表。这三种覆盖了所有边界。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 输出只有第一个节点 | next保存时机错误 | 检查四行代码顺序 |
| 输出形成环 | 循环条件写错 | 用两节点链表测试 |
| 输出顺序不对 | 返回值写错 | 确认返回的是prev |
| 程序崩溃 | 空指针解引用 | 检查递归终止条件 |
| 尾节点next不为NULL | 循环少执行一次 | 检查while条件 |
5. 从反转链表延伸出去的变种题
5.1 反转前K个节点
这是反转链表的直接变种。思路是先找到第K个节点,记录它的next,然后把前K个节点当成一个独立的链表反转,最后把反转后的尾节点接上之前记录的next。
关键点在于:反转之前要先保存第K+1个节点的位置,否则反转之后你就找不到后面了。这个变种题在面试中出现频率很高,因为它同时考察了链表遍历、反转和边界处理。
5.2 K个一组反转链表
这是上一题的升级版,要求每K个节点一组进行反转,最后不足K个的保持原样。思路是先用一个指针走K步,如果走不满K步就直接返回,如果走满了就反转这K个节点,然后递归或迭代处理后面的部分。
这道题的难点在于组与组之间的衔接。反转完一组之后,原来的头节点变成了尾节点,它需要指向下一组反转后的新头节点。所以你需要额外记录上一组的尾节点。
5.3 反转指定区间
给你一个链表和两个整数left和right,要求反转从位置left到位置right的节点。这道题需要你先走到left位置的前一个节点,然后对left到right这一段执行反转,最后把前后接起来。
这道题最容易出错的地方是left等于1的情况,也就是从头节点开始反转。这时候你没有“前一个节点”,需要用一个哑节点(dummy node)来简化处理。哑节点是链表题里的万能技巧,很多边界问题加一个哑节点就迎刃而解。
5.4 两两交换链表中的节点
这是K等于2的特殊情况。可以用迭代法,也可以用递归法。迭代法的思路是每次处理两个节点,交换它们的指向,然后移动到下一对。递归法的思路更简洁,但空间复杂度是O(n)。
这道题在面试中经常作为反转链表的follow-up出现,面试官想看看你能不能把学到的指针操作迁移到新场景。
6. 我刷这道题的一些个人体会
反转链表这道题,我前前后后刷了不下十遍。不是因为我笨,而是因为每次隔一段时间不写,指针操作的细节就会模糊。后来我总结了一个方法:不要背代码,背口诀。
口诀就是八个字:先存后改,先移后判。
“先存后改”说的是在改变curr->next之前,必须先保存curr->next的原始值。“先移后判”说的是在判断循环条件之前,必须先移动指针。这八个字覆盖了迭代法最容易出错的两个点。
另外,我强烈建议你在纸上画图。链表题不像数组题,数组题你在脑子里过一遍就行,链表题必须画图。我见过太多人在面试的时候代码写得飞快,但面试官让他画图解释的时候就开始卡壳。画图不仅能帮你理清思路,还能让面试官看到你的思考过程。
还有一点,C语言写链表题的时候,struct ListNode *这个类型名太长了,写起来很烦。我一般会在函数开头用typedef简化一下,或者直接用ListNode *如果题目已经typedef过了。但要注意,LeetCode的C语言环境里,结构体定义是固定的,你不能改,所以只能在函数内部用局部变量的时候尽量简洁。
最后说一个心态问题。反转链表是链表题的基础,但基础不代表简单。很多人觉得这道题太简单了,刷一遍就过了,结果面试的时候被问到变种题就懵了。我的建议是:把反转链表当成一个模板来练,练到你能在五分钟内写出无bug的迭代版本,然后再去刷变种题。这样你的链表题正确率会有质的提升。
后续如果你想把链表这块彻底吃透,我建议按这个顺序刷:反转链表 -> 反转前K个 -> K个一组反转 -> 反转区间 -> 两两交换 -> 重排链表 -> 合并两个有序链表 -> 合并K个有序链表。这一套下来,链表的基本操作你就全覆盖了。