刷 LeetCode Hot100 的时候,看到"23.反转链表"这题,很多人第一反应是:就这?三分钟写完。但我在实际面试中见过太多次,同一个候选人写这三分钟,能写出三种不同版本的 bug。反转链表能排进 Hot100,靠的不是算法难度,而是它用最少的代码行数,考察了你对链表最核心的操作——指针指向的重新编排。这篇从入门到进阶,把迭代、递归、区间反转、K 个一组翻转,以及手撕代码容易踩的坑一次说清。不管你是刚接触链表的初学者,还是准备面试想把手撕代码练稳的老手,都值得认真复盘一遍。
1. 反转链表为什么是Hot100里的"基本功试金石"
1.1 一道看起来简单但暗藏杀机的题目
先看原始题目:给定单链表的头节点 head,反转链表,并返回反转后的链表头节点。
示例:输入 1->2->3->4->5,输出 5->4->3->2->1。
就这么简单的一句话。链表本身的定义也简单:每个节点存一个 val,加一个 next 指针。可就是这道题,让很多候选人在白板前面红耳赤。原因不是不理解"要反转",而是动手写的时候,指针操作的顺序一旦错了,链表就断成一截一截的。
这道题能进 Hot100 的靠前位置,恰恰是因为它把链表最核心的三个能力压缩在一起:找节点、记住后继、改指向。这三个动作的循环复用,就是链表的"乘法口诀"。后面所有链表题,不管包装得多复杂,拆开看都是这三件事。
1.2 链表操作的最小模型
为什么链表操作比数组操作更容易写错?数组是连续内存,arr[i] 和 arr[i+1] 之间的关系是天然存在的,你不用管。链表不一样,每个节点散落在内存各处,节点之间唯一的联系就是 next 指针。你改了一个节点的 next,就等于把这个节点和后继的联系"剪断"了,如果没有提前记住后面的节点,整个链表当场断裂。
反转链表这件事,本质就是:把每个节点的 next 指针从"指向后一个节点"改成"指向前一个节点"。方向全部掉头之后,原来的头变成尾,原来的尾变成头。
把这句话刻在脑子里,后面所有解法都是这句话的工程化实现。理解了这一点,再看迭代法和递归法,就是两种组织"改指针"这个动作的思路而已。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 迭代法:三指针如何一步步"掰弯"链表
2.1 核心思想:让每根指针掉头
迭代法的思路非常朴素:从头开始遍历,每经过一个节点,就把这个节点的 next 改成指向前驱。因为单链表只有 next 指针,没有 prev 指针,所以我们需要自己维护一个 prev 变量来记录当前节点的前驱。
同时,在修改 cur.next 之前,必须先保存 cur 原本的下一个节点。这就是整个算法的核心三步骤:先存后继,再改指向,最后整体前进。
整个过程用一句话概括:把每个节点的"指向后方"掰成"指向前方"。就像一排人排队,每个人都转过身去搭前面一个人的肩膀,队伍整体就反过来了。但每个人转身之前,必须先记住自己身后原来站着谁,不然一转完就找不到下一个了。
2.2 图解三指针的推进过程
以链表 1->2->3->null 为例,文字模拟一遍完整执行过程,你会很清楚指针是怎么移动的。
初始状态:
- prev = null
- cur = 1
- 链表状态:1->2->3->null
第一步:
- next_node = cur.next,也就是 2。
- cur.next = prev,也就是把 1 的 next 置为 null,链表变成 null<-1 2->3->null。
- prev = cur,prev 变成 1。
- cur = next_node,cur 变成 2。
第二步:
- next_node = cur.next,也就是 3。
- cur.next = prev,把 2 的 next 置为 1,链表变成 null<-1<-2 3->null。
- prev = 2。
- cur = 3。
第三步:
- next_node = cur.next,也就是 null。
- cur.next = prev,把 3 的 next 置为 2,链表变成 null<-1<-2<-3。
- prev = 3。
- cur = null。
循环结束,返回 prev,就是新链表的头节点 3。你可以看到,每次循环只做三件事,但链表在一次次循环中逐步"掉头"。
2.3 迭代代码与逐行注释
python复制class Solution:
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
# prev 初始为 None,因为第一个节点反转后要指向空
prev = None
cur = head
# 只要当前节点不为空,就还有节点需要处理
while cur:
# 第一步:保存当前节点的原始后继
# 因为下一步就要把 cur.next 改成 prev,不改之前不记住,后续节点就丢了
next_node = cur.next
# 第二步:反转指向,让当前节点指向前驱
cur.next = prev
# 第三步:整体前进,prev 和 cur 各往后挪一位
prev = cur
cur = next_node
# 循环结束时 cur 已经走到 null,prev 停在原链表最后一个节点
# 也就是新链表的头节点
return prev
这段代码可以说是反转链表的标准答案,面试时能五分钟内写完并解释清楚每一步,基本就过关了。
2.4 为什么必须先保存后继节点?
这是整个迭代法最容易翻车的点。cur.next = prev 这行赋值,会直接把当前节点的 next 覆盖掉。如果不先把原来的后继存到 next_node 里,那么 cur 后面的节点就再也找不到了,链表从中间被剪断,后续遍历直接拿到 null。
有人在想:我能不能先不存,等一会儿再从别人的指针里找回它?不行。因为这是单链表,没有任何一个其他节点指向 cur 的原后继。等你把 cur.next 改了,那个后继节点就彻底"失联"了。这就是单链表最残酷的地方:你只有一条路可以走,这条路断了,后面的世界就没了。
这个场景类比一下,就像你在一个单向通道里往前走,每路过一个路口就把身后的标志牌拆掉。万一你忘了记下一个路口在哪,你就再也找不到它了。所以保存后继不是可选项,是必须项。
2.5 循环终止条件为什么是 cur 而不是 cur.next
很多新手会把循环条件写成 while cur.next,然后遇到两个经典问题:
第一个问题:空链表时,head 为 null,cur.next 直接空指针异常,程序当场崩溃。
第二个问题:循环在最后一个节点就停了,最后一个节点的 next 还没有被反转。
终止条件写成 while cur 的话,两种边界都自动处理了:
- 空链表时,cur 直接是 null,循环不进入,返回 prev(也就是 null),结果正确。
- 非空链表时,每次循环处理一个节点,处理完最后一个节点后,cur 变成 null,循环退出。此时所有节点的 next 都已经完成反转。
这一步想清楚了,迭代法的正确性就有保障了。
2.6 C++/Java 和 Python 的差异提醒
Python 写这道题非常省心,因为变量绑定不需要关心指针本身的内存地址。但如果你用 C++ 写,有一个细节要特别注意:如果函数签名定义为 ListNode* reverseList(ListNode* head),那没问题,返回新头即可。如果面试官要求你写一个 void reverseList(ListNode*& head),那就要传入指针引用,才能在函数内部把调用方的 head 指向新头。用 Java 的兄弟,对象引用天然支持在方法内修改对象内部状态,倒是不用纠结这个问题,但要注意别把方法参数搞成传值。
这些语言层面的差异,说是"细节",其实很能体现一个候选人写代码的功底。面试官一眼就能看出你到底是背的模板,还是真的理解指针语义。
3. 递归法:让函数自己解决"后面那段"
3.1 递归怎么想?
迭代法是"从头开始一步步掰",递归法的视角完全反过来:先假设 head 后面的链表已经全部反转好了,然后我只需要把 head 接到这段反转好链表的尾部,就完成了整个链表的反转。
这句话是递归解法的灵魂。递归函数 reverseList(head) 的职责是:传入一个链表的头节点,返回这个链表反转后的新头。
以 1->2->3->4->5 为例,head 是 1。我调用 reverseList(head.next),也就是 reverseList(2),它的返回值是反转后 2->3->4->5 这段链表的新头,也就是 5。现在整条链表的状态是:1->2<-3<-4<-5 中 2 指向 3 的方向已经反了,但 1 还指着 2。我只需要把 1 接到 2 的后面,并且把 1 的 next 断掉,就完成了全部反转。
怎么把 1 接到 2 的后面?head.next 就是 2,那么 head.next.next = head,就把 2 的 next 指向 1 了。这里有点绕,但仔细想一下:head.next 是 2,head.next.next 就是 2 的 next。把 2 的 next 从指向 3(或 null)改成指向 1,1 就成功挂在 2 后面了。
3.2 递归终止条件
递归必须要有终止条件,反转链表的终止条件是两个:
- head 为 null:空链表,反转后还是空,直接返回 null。
- head.next 为 null:链表只有一个节点,反转后还是它自己,直接返回 head。
python复制if not head or not head.next:
return head
为什么 head.next 为 null 就返回?一个节点不存在"需要反转"的问题,它的 next 本来就是 null,反转之后还是这个节点自己。
3.3 完整代码
python复制class Solution:
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
# 递归终止条件:空链表或只有一个节点
if not head or not head.next:
return head
# 递归反转 head.next 为头节点的子链表
# new_head 是反转后子链表的新头,也就是原链表的尾节点
new_head = self.reverseList(head.next)
# 让 head 的下一个节点的 next 指回 head,完成相邻两个节点的反转
head.next.next = head
# head 现在变成反转后子链表的尾节点,它的 next 必须置空,否则会成环
head.next = None
return new_head
代码四行,但每一行都有讲究,尤其是第 9 行的 head.next.next = head 和第 11 行的 head.next = None。
3.4 递归展开:到底发生了什么
以 1->2->3 为例,把递归的完整执行过程拆开看:
- reverseList(1):head=1,head.next=2,不满足终止条件。调用 reverseList(2)。
- reverseList(2):head=2,head.next=3,不满足终止条件。调用 reverseList(3)。
- reverseList(3):head=3,head.next=null,满足终止条件,返回 3。
- 回到 reverseList(2) 的调用处,new_head=3。执行 head.next.next = head,即 3.next = 2,链表变成 1->2<->3(3 指回 2,2 还指向 3)。接着执行 head.next = None,即 2.next = null,链表变成 1->2<-3。返回 new_head=3。
- 回到 reverseList(1) 的调用处,new_head=3。执行 head.next.next = head,即 2.next = 1,链表变成 1<->2<-3。接着执行 head.next = None,即 1.next = null,链表变成 1<-2<-3。返回 new_head=3。
最终 3->2->1,反转完成。注意第 4、5 步其实是"归"的阶段才执行反转操作,很多初学者以为递归就是一路往深处走,忘了归的时候还有一段逻辑要跑,这是理解递归法的关键。
3.5 迭代和递归怎么选?
面试官很喜欢追问:这两个方法哪个好?
| 维度 | 迭代法 | 递归法 |
|---|---|---|
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(1) | O(n),来自递归调用栈 |
| 代码量 | 略长,约 10 行 | 很短,约 6 行 |
| 主要风险 | 指针操作顺序写错 | 忘记断链形成环,或者链表太长栈溢出 |
| 实际工程倾向 | 首选,空间可控 | 不推荐,栈深度不可控 |
结论很明确:工程实现里,几乎无脑选迭代。递归虽然代码看着优雅,但空间复杂度 O(n) 是硬伤。面试场景下,你可以先口述递归的优雅思路,展示思维灵活性,然后写迭代版本,最后主动分析两者复杂度差异。这一套组合拳打下来,基本能把这题吃透。
4. 面试现场的变形考法:区间反转与K个一组翻转
反转链表本身只是开胃菜。面试官通常不会只考这一道裸题,而是把它包装成更复杂的题。这里列几个高频变体,思路都建立在基础反转之上。
4.1 反转链表 II:反转从 left 到 right 的区间
力扣 92 题,要求反转链表中从位置 left 到位置 right 的区间,其余部分保持不变。
思路是把链表切成三段:left 之前不动,left 到 right 之间反转,right 之后不动,最后拼起来。但有一个经典陷阱:如果 left=1,也就是从第一个节点开始反转,那 left 之前根本没有节点,怎么拼接?答案是引入 dummy 哑节点。
python复制class Solution:
def reverseBetween(self, head: Optional[ListNode], left: int, right: int) -> Optional[ListNode]:
# 重点:dummy 节点指向 head,处理 left=1 时找不到前驱的问题
dummy = ListNode(-1, head)
pre = dummy
# 让 pre 走到 left 节点的前一个位置
for _ in range(left - 1):
pre = pre.next
# cur 是区间内第一个要反转的节点
cur = pre.next
prev = None
# 循环 right - left + 1 次,反转整个区间
for _ in range(right - left + 1):
next_node = cur.next
cur.next = prev
prev = cur
cur = next_node
# 关键拼接:
# pre.next 原本指向区间第一个节点,反转后它变成区间最后一个节点
# 它的 next 要接上 cur(区间后面剩余的第一个节点)
pre.next.next = cur
# pre 的 next 要指向反转后的区间新头 prev
pre.next = prev
return dummy.next
这段代码最后两行是精髓。很多人反转完区间就不知道接下来怎么接回去了。你只要记住:pre.next 这个节点,在反转前是区间的头,反转后变成了区间的尾。既然是尾,它的 next 就要接上 cur(区间后面的第一个节点);而 pre 作为区间前驱,它的 next 要指向反转后的新区间头 prev。这两行写对了,整个链表就完整串起来了。
4.2 K 个一组翻转链表
力扣 25 题,比区间反转再难一档:把链表按每 K 个节点一组翻转,最后一组不足 K 个就不动。
核心思路分四步:
- 用一个指针从当前组的起点出发,数一数剩余节点够不够 K 个。不够就直接返回。
- 够的话,对这一组 K 个节点做一次局部反转。
- 把这一组的尾部接上下一组的头部。
- 移动指针到下一组的起始位置,重复以上过程。
实现时,可以拆出一个辅助函数 reverseK 用来反转"从给定节点开始的 K 个节点",然后在主循环里反复调用。主循环的难点还是 dummy 节点的维护和 prev 指针的更新。只要你把反转链表的迭代法吃透了,这题剩下的就是"多套一层循环"的体力活。
4.3 回文链表也用到反转
力扣 234 题,判断一个链表是不是回文链表。最优解之一:先快慢指针找到链表中点,把后半段链表反转,然后从两端同时向中间遍历比较。这里的"反转后半段",用的就是最基础的反转链表。
所以在 Hot100 里,反转链表的地位非常特殊:它既是独立题目,又是其他题目的"基础设施"。你把它练熟了,等于同时给回文链表、反转链表 II、K 个一组翻转这几道题打了底。这也是为什么我强烈建议,这道题一定要练到条件反射级别的熟练度。
5. 手撕代码时的翻车点:空指针、死循环与边界条件
5.1 最常见的翻车:指针顺序写错
迭代法的三轮操作顺序一旦乱套,代码就废了。我见过最典型的错误,是把更新 cur 的时候写成了 cur = cur.next:
python复制cur.next = prev
prev = cur
cur = cur.next # 这里 cur.next 已经被改成 prev 了,永远在原链表上前进不了
第二行执行完,cur 的 next 已经指向 prev。第三行再用 cur.next 去更新 cur,拿到的就是 prev,不是原来的后继,整个遍历就乱了。正确写法一定是先把 next_node 存下来,最后用 next_node 更新 cur。
这种错误在紧张的时候特别容易犯,因为人一急就容易"偷懒",觉得多存一个变量麻烦。但恰恰是这个"多余"的变量,是整个算法的保险丝。写代码时宁可多写一行,也不要省略关键保存动作。
5.2 递归忘记断链导致成环
递归法的 head.next = None 这步,漏掉概率非常高。如果漏掉,会出现什么情况?以 1->2->3 为例,递归走到 head=1 时,head.next=2,head.next.next = head 执行后变成 2.next=1,也就是 2 指向 1。但如果 1.next 没有置 null,那 1 仍然指向 2。最终形成 1<->2 互相指的环,遍历时陷入死循环。
LeetCode 判题系统检测到环,通常会报 "cycle detected" 或者直接超时。所以刷题时如果遇到莫名超时,第一反应应该是:我是不是哪里没断链形成了环。
这里分享一个调试技巧:本地测试时,可以在反转完链表后,从新头开始数节点数量,如果数量跟输入不一致,或者遍历超过 N 步还没到 null,基本可以断定有环。
5.3 空链表和单节点的边界
两个边界场景:
- head 为 null:迭代法 while cur 直接跳过,返回 null。递归法第一个 if 拦截,也返回 null。都没问题。
- head.next 为 null:整个链表只有一个节点,反转之后还是它自己。
很多初学者会在返回前写一堆 if 判断,其实迭代法完全不需要。它的边界逻辑天然正确:空链表返回 prev(null),单节点返回第一个节点。递归法也不需要额外处理,终止条件已经覆盖。
5.4 面试追问:递归空间复杂度与优化
面试官问完这题,十有八九会追问一句:"递归的空间复杂度是多少?能不能优化?"这时候你要立刻反应过来:
- 迭代法:O(1) 空间,循环内只用了三个固定变量。
- 递归法:O(n) 空间,因为每层递归都会压一个栈帧,直到最深那一层才开始回溯。
更深入一步,面试官可能追问:"如果链表有一百万个节点,递归会怎样?"答案是栈溢,程序直接崩。这也是工程上不用递归做大规模链表反转的根本原因。
我面试别人的时候,如果候选人写完迭代法之后能主动补一句"这题还可以用递归写,但是空间复杂度会更高,实际工程一般不推荐",这道题基本就过关了。因为这说明他不是在背模板,而是真的理解两种写法的本质差异和适用场景。
5.5 刷题建议:练到"闭眼能写"
最后说一个非常具体的建议:把反转链表练到闭着眼睛都能写出来的程度。
标准是什么?从打开编辑器到提交通过,控制在两分钟以内。手写过程不需要思考,手指自己就知道下一步该做什么。这听起来有点夸张,但链表类的题就是这样——基础操作越丝滑,后面做变体题越不容易乱。
具体怎么练?我的建议是:
- 先在 LeetCode 上把迭代法 AC 一遍,确保理解每一步;
- 不看代码,自己在本地手写一遍,写错了就对比错在哪;
- 换个语言再写一遍,比如 Python 写熟了用 Java 或 C++ 再写一遍,加深对指针语义的理解;
- 把递归法也写熟,至少能手写出正确代码;
- 去刷反转链表 II 和回文链表,检验自己是不是真的会了。
还有一个实用技巧:本地练习时,建议自己写一个链表的构造和打印函数。LeetCode 的输入输出都封装好了,但实际面试或者工作里,链表数据结构经常要你自己维护。会构造、会打印、会释放,这些基本功配合反转链表一起练,才是真正的"链表达人"。
反转链表这道题,看着简单,背后的信息量其实很密。它既是新手入门的第一个坎,也是老手面试时的送分题。区别只在于,你是真的理解了指针的每一步移动,还是只是背下了一段代码。花四十分钟把这题彻底吃透,后面再遇到链表相关题目,你会发现世界一下子简单了很多。
