LeetCode 第 206 题,反转链表。这道题在算法面试里的出场频率有多高?几乎可以这么说——只要你想去后端、客户端或者任何需要写代码的岗位,面试官大概率会在某个环节把这道题摆到你面前。题目很短:给你单链表的头节点 head,把整条链表反转过来,返回新链表的头节点。几行代码就能写完,但恰恰是这几行代码,能把一个人对链表、指针、递归和循环的理解水平测得很透。
我一个很深的体会是,反转链表的基础解法谁都能背下来,难的是被人追问“为什么循环结束要返回 prev”“为什么递归里要把 head.next 置空”时不卡壳。很多刷题多的人,恰恰在这种追问面前露怯。所以接下来我不打算只丢一个答案,而是把反转链表从题目分析、迭代与递归两种解法、复杂度与边界条件、经典变体到面试表达方式,完整拆一遍。
不管你是正在准备面试的应届生、想补算法基础的转行开发者,还是带新人做技术分享的团队骨干,这篇内容都可以当作一套完整的反转链表教学素材来用。读完之后,你不仅能写对代码,还能把每一步的原理讲清楚,遇到任何变形题也能从容应对。
1. 先看题目和输入输出:单链表反转到底在考什么
1.1 题目描述与输入输出示例
原题要求很简单:给定单链表的头节点 head,反转链表,并返回反转后链表的头节点。进阶要求是用迭代和递归两种方式分别实现。
举个例子,输入一条链表:
code复制1 -> 2 -> 3 -> 4 -> 5
输出就应该是:
code复制5 -> 4 -> 3 -> 2 -> 1
注意输入只说给了头节点,没有给链表的长度,也没有给尾节点,这意味着你不能预知链表的规模,只能老老实实从头走到尾。很多人在这一看“简单”的题上写错,往往不是思路不对,而是对链表的单向特性处理不够谨慎。
1.2 链表的节点定义
在开始写解法之前,先把节点结构摆出来。C++ 版一般是这样的:
cpp复制struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
Python 版则是这样:
python复制class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
这个结构最关键的一点是:每个节点只有一个 next 指针,指向它后面的节点,没有 prev 指针。也就是说,站在当前节点,你能知道“谁在我后面”,但永远无法直接知道“谁在我前面”。这一点恰恰是整个反转链表题目的命门。
1.3 单向链表带来的天然难点
数组反转很简单,用双指针从两头往中间交换值就行。链表不行,因为指针方向是固定的,只能从 head 往后走,走过去了就回不来。
反转链表的本质,是让每个节点的 next 指向前一个节点。但问题来了:单向链表里本来就没有“前一个节点”这个概念,你必须自己用一个变量把它记下来。更麻烦的是,当你把当前节点的 next 改成前一个节点之后,当前节点原来的下一个节点就丢了——因为你是通过 next 找到它的,现在 next 已经指到别处去了。
用一个生活化的类比理解这件事:把单链表想象成一列向右看的队伍,每个人只能看到自己前面那个人的后脑勺。现在要把整列队伍反向,让每个人都转过头来看向自己原来后面的人。问题在于,一个人转过头之后,原本站在他前面的人他就看不见了;而且他身后的人也会因为他的转身而失去视野。所以你需要两个人帮忙:一个人站在他原来的位置,代替他记住“前一个人在哪”,另一个人站到他身后,替他记住“后一个人在哪”。这个类比里的“两个人”——其实就是解题时的 prev 和 temp。
1.4 为什么大家叫它“算法1”
很多算法训练营和刷题路线都把反转链表排在链表类题目的第一位,这不是没理由的。一方面它题目本身短、上手快;另一方面它背后牵涉的知识点非常集中:遍历链表、修改指针指向、处理边界条件、理解递归。后面的反转部分链表、K 个一组反转链表、回文链表判断等一系列经典题,全都建立在这一道题的逻辑之上。
可以说,反转链表是链表题的“九九乘法表”。背下来不难,但真正理解并能迁移到变形题里,才是及格线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 迭代解法的推导过程:为什么需要三个指针
2.1 第一版尝试:只用一个 cur 会发生什么
很多初学者看到这道题,第一反应是:用一个 cur 从头到尾遍历,每到一个节点,就把它的 next 指向前一个节点。直觉上好像可行,但代码一写就出问题。
python复制# 错误示范:没有保存后继节点
def reverseList(head):
prev = None
cur = head
while cur is not None:
cur.next = prev # 此时 cur 原来的下一个节点丢了
prev = cur
cur = cur.next # 错误!cur.next 已经被改成 prev 了
return prev
这个版本跑起来会是什么现象?以 1->2->3 为例:第一轮循环结束时,1.next 变成了 None,prev 指向 1,然后执行 cur = cur.next。但此时 cur 是 1,cur.next 是 None,所以循环直接结束,链表只剩一个节点,2 和 3 全部丢失。
如果把 cur = cur.next 改成 cur = temp 来补救,temp 又没定义。这个错误版本的价值在于它暴露了核心矛盾:改 next 之前,必须先把原来的后继节点存起来,否则就“断链”了。
2.2 三指针是怎么推出来的
知道了问题,解法就顺理成章了:
- 需要一个 prev 指针,记录当前节点的前驱,也就是反转后当前节点的 next 应该指向的位置;
- 需要一个 cur 指针,指向当前待处理的节点;
- 需要一个 temp 指针,在当前节点的 next 被修改之前,临时保存它的原始后继。
每个节点的处理流程只有三步:
- 保存后继:temp = cur.next
- 反转指向:cur.next = prev
- 整体推进:prev = cur,cur = temp
顺序不能乱。第二步必须在第一步之后,否则 temp 拿不到原始后继;第三步必须在第二步之后,否则 cur 的 next 已经被改掉,原始后继就找不到了。这个“先保存、再修改、后移动”的顺序,是所有链表指针操作题的通用节奏。
2.3 完整代码与逐步演示
迭代版本的 Python 实现:
python复制def reverseList(head):
prev = None
cur = head
while cur is not None:
temp = cur.next # 1. 保存后继
cur.next = prev # 2. 反转指向
prev = cur # 3. 前驱前进
cur = temp # 4. 当前节点前进
return prev
C++ 实现基本一样:
cpp复制class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr;
ListNode* cur = head;
while (cur != nullptr) {
ListNode* temp = cur->next;
cur->next = prev;
prev = cur;
cur = temp;
}
return prev;
}
};
用 1->2->3 这条短链表走一遍,你会看得更清楚:
| 轮次 | 操作前 cur | 操作前 prev | temp 保存 | 反转后的 cur.next | 操作后 prev | 操作后 cur |
|---|---|---|---|---|---|---|
| 第 1 轮 | 1 | null | 2 | null | 1 | 2 |
| 第 2 轮 | 2 | 1 | 3 | 1 | 2 | 3 |
| 第 3 轮 | 3 | 2 | null | 2 | 3 | null |
每一轮结束后的链表状态分别是:
- 第 1 轮后:1->null,2->3
- 第 2 轮后:2->1->null,3->null
- 第 3 轮后:3->2->1->null
循环结束,返回 prev,也就是 3。整个过程干净利落。
2.4 两个容易被追问的细节
面试官很喜欢在写完正确代码之后追问两个问题,答不上来会显得理解不够扎实。
第一个:为什么循环条件是 cur != null,而不是 cur.next != null?
因为 cur 本身就是待处理对象。链表里的每个节点都要被反转,最后一个节点也逃不掉。如果写成 cur.next != null,循环会在最后一个节点之前停下,最后一个节点的 next 没有改,整条链表的反转就不完整。测试用例用两个节点就能立刻看出问题。
第二个:为什么返回 prev,而不是 cur?
循环结束时,cur 已经越过链表末尾变成 null,而 prev 正好停在最后被处理的节点上,也就是新链表的头节点。至于为什么初始 prev 是 null,是为了让原链表的头节点反转后成为尾节点,它的 next 指向 null。这一步是链表不成环的保证。
3. 递归解法的核心直觉:把反转拆成“头节点+剩余链表”
3.1 递归视角下的子问题
如果你只会迭代,面试基本及格;如果能把递归写法讲明白,印象分会明显更高。递归的关键不是背代码,而是换一种角度看问题。
反转整条链表可以拆成两步:
- 先把第 2 个节点到第 n 个节点这一“剩余链表”反转;
- 再把原来的第 1 个节点接到反转后剩余链表的尾部。
用 1->2->3->4->5 举例:先把 2->3->4->5 反转成 5->4->3->2,然后把 1 接到 2 的后面,也就是让 2.next = 1,整体就变成 5->4->3->2->1。
递归函数处理的“当前节点”是 head;“剩余链表”就是 head.next 开头的链表。也就是说,reverseList(head) 可以转写成 reverseList(head.next),再处理 head 和剩余链表之间的关系。
3.2 递归终止条件
递归必须有终止条件,否则会无限调用直到栈溢出。这道题的终止条件是:
python复制if head is None or head.next is None:
return head
head 为 null 对应空链表,直接返回 null 没问题;head.next 为 null 说明当前链表只有一个节点,反转后它仍然是自己,直接返回 head 也没问题。这个条件另一个好处是:它能同时覆盖空链表和单节点这两个边界场景,不需要单独写判断。
3.3 关键代码与逐步推演
递归版本的 Python 实现:
python复制def reverseList(head):
if head is None or head.next is None:
return head
new_head = reverseList(head.next) # 先反转剩余链表
head.next.next = head # 把当前节点接到剩余链表的尾部
head.next = None # 断开当前节点原来的 next
return new_head
用 1->2->3 做一次完整推演:
- 调用 reverseList(1),head 是 1,1.next 是 2,不满足终止条件,继续调用 reverseList(2)。
- 调用 reverseList(2),2.next 是 3,继续调用 reverseList(3)。
- 调用 reverseList(3),3.next 是 null,满足终止条件,返回 3。
- 回到 reverseList(2):new_head = 3。此时 head 是 2,head.next 是 3。执行 head.next.next = head,也就是 3.next = 2;再执行 head.next = None,也就是 2.next = None。返回 3。
- 回到 reverseList(1):new_head = 3。此时 head 是 1,head.next 是 2。执行 2.next = 1;再执行 1.next = None。返回 3。
最终链表变成 3->2->1。每一步都只处理了当前头节点和剩余链表的关系,没有去关注更远的节点,这正是递归“只思考一层、剩下的交给子调用”的威力。
3.4 为什么 head.next.next = head 是安全的
这句代码是整个递归解法里最容易被问的一句话,也是很多人想不通的地方。
关键在于:当 reverseList(head.next) 返回时,head.next 在剩余链表里已经变成了尾节点。而一个尾节点的 next 本来就是 null,它的 next 目前是“空闲”的。所以你执行 head.next.next = head,本质上就是把这条反转后的剩余链表的尾部,接到当前头节点上,没有任何正在使用的指针会被覆盖。
如果你的递归函数里没有执行 head.next = None,那么对于原头节点这条分支,最终会留下一个环。因为在最后一步处理完 head = 1 之后,2.next 已经指向 1,如果 1.next 还指向 2,那么 1 和 2 就会形成互相指向的死循环。所以 head.next = None 必须写,它不是可有可无的清理工作,而是保证链表结构正确的关键。
3.5 递归写法的两个易错点
第一个易错点:返回值写错。很多初学者在递归函数里习惯性地写 return head,觉得“反正递归到最后返回的就是结果”。但 head 是当前递归层的头节点,反转后它变成了尾节点,返回它得到的是错的结果。正确做法是把递归调用的结果保存成 new_head,最后返回 new_head。
第二个易错点:忘记断开 head.next。前面已经解释了,如果不把 head.next 置空,原头节点作为新尾节点之后还会指着原来的第二个节点,链表就会成环。这一点在面试里也常被当作考察细节的点。
4. 复杂度、边界条件和容易被忽略的遍历细节
4.1 时间复杂度为什么是 O(n)
这个复杂度其实很好解释:每个节点恰好被处理一次。迭代法里,cur 从头走到尾,每个节点进一次循环;递归法里,每个节点执行一次 head.next.next = head 和 head.next = None。
所以不管链表多长,操作次数都和节点数成正比,时间复杂度是 O(n)。在这道题上不存在更优的可能,因为每个节点的 next 都必须被重新赋值,没有任何节点可以跳过。
4.2 空间复杂度:迭代 O(1),递归 O(n)
迭代法只用了 prev、cur、temp 三个变量,无论链表有多长,额外占用都是常数级别,空间复杂度 O(1)。
递归法不一样。每一层递归调用都会在系统栈里保存参数和返回地址,n 个节点的链表就要递归 n 层,所以空间复杂度是 O(n)。也就是说,递归解法虽然在代码上更简洁,但在空间上是“昂贵”的。
面试官常问的一个场景是:如果现在要处理一条十万节点的超长链表,你会选择哪种写法?答案应该是迭代,因为递归栈很可能被压爆,造成栈溢出。这也是很多公司明确规定“用迭代实现”的原因。
4.3 边界条件逐个过一遍
写算法题,边界条件一定要在写代码之前想清楚。反转链表常见的边界有三个:
- 空链表:head 为 null。迭代法直接不进入循环,返回 prev(null),正确;递归法终止条件直接返回 head(null),也正确。
- 单节点链表:head.next 为 null。迭代法第一轮循环就把这个唯一的节点 next 置空,返回它自己;递归法直接返回 head,反转结果还是它自己,没问题。
- 两个节点的链表:1->2。迭代法第一轮处理 1,第二轮处理 2,返回 2;递归法也会在第二层终止后完成两步接入,最终 2->1,同样没问题。
4.4 反转后会不会留下环
链表的环是很多题目的隐藏考点。这里直接说结论:在正确实现的反转链表里,不会留下环。迭代法靠初始 prev = null 保证原头节点的 next 为 null;递归法靠 head.next = None 保证原头节点作为尾节点时 next 为空。两条路径殊途同归。
值得留意的是,当你做反转部分链表这类变形题时,如果反转段的前后没有正确接管,反而很容易成环。这也是为什么我在后面讲变体时反复强调“先定位边界,再反转,最后接管”的顺序。
4.5 一个反直觉但很常见的现象
反转完成后,你手上原来的 head 变量仍然指向原链表的头节点。但这个节点如今已经是新链表的尾节点了。如果你测试代码里打印 head.val,会发现它还是原始第一个节点的值,这是正常现象,不是代码写错了。
真正的新链表头,是函数返回的那个节点。这个“头尾互换”的直觉转换,是很多初学链表的人踩坑的地方。
5. 反转链表背后长出来的经典变体
5.1 反转部分链表:LeetCode 92
反转整条链表学会之后,下一个自然延伸就是反转局部区间。题目会给你 left 和 right 两个位置,要求只反转 left 到 right 这一段,其余部分保持原样。
核心思路是分三步走:
- 创建一个 dummy 节点,指向 head,这是为了统一处理头节点可能被反转的情况;
- 把指针移动到 left 的前一个节点,记为 pre;
- 从 pre.next 开始,对 right-left+1 个节点做和基础反转一摸一样的迭代,反转完后再把这段链表接回 pre 和 right.next。
你会发现,基础反转里的三个指针搬运逻辑,在这里是原封不动复用的。差别只在于:基础版是从头反转到尾,部分反转版是从中间某个位置反转到另一个位置。所以我把反转链表称为“模板题”,因为它能被反复嵌套进更复杂的场景。
5.2 K 个一组反转链表:LeetCode 25
如果说反转部分链表是“定位 + 反转”,那么 K 个一组反转就是“分组 + 反转 + 拼接”。
题目的要求是每 k 个节点一组做反转,最后一组如果不足 k 个就保持不变。实现时主要有几个动作:
- 用一个指针数一数,从当前位置往后是否还够 k 个节点;
- 够数就反转这 k 个节点,和基础反转的循环逻辑一致;
- 把反转后的这一段和前后链表重新接好;
- 继续处理下一组。
这个题里 dummy 节点的作用更加明显。因为第一组反转后,整个链表的头会变成原来的第 k 个节点,如果没有 dummy 兜底,返回值就得单独处理。用好 dummy 之后,核心逻辑可以全部统一,不需要为“头节点变化”写分支。
5.3 判断回文链表:LeetCode 234
回文链表判断是另一个高频题。经典解法之一就是借助反转链表:
- 用快慢指针找到链表的中点;
- 把中点之后的那一半链表反转;
- 从原链表头部和反转后的链表头部同时出发,逐个比较节点值;
- 全部相等就说明是回文链表。
这道题里,反转链表不是考点本身,而是一个被调用的工具。如果反转部分的代码不过关,整道题的稳定性和速度都会受影响。
5.4 变体之间的共同点
| 题目 | 和基础反转的关系 | 额外注意点 |
|---|---|---|
| LeetCode 206 反转整表 | 模板本身 | 返回 prev |
| LeetCode 92 反转部分 | 加区间定位 | pre 与 right.next 的接管 |
| LeetCode 25 K 组反转 | 加分组判断 | dummy 节点、剩余不足 k 不反转 |
| LeetCode 234 回文链表 | 反转一半链表 | 快慢指针找中点 |
这些变体共同说明了一件事:只要把一段链表的反转写成肌肉记忆,很多看似复杂的链表题都能被拆解成“定位 + 反转 + 拼接”的组合动作。这也是我一直建议初学者把反转链表刷到“闭着眼都能写对”的原因。
6. 实操复盘:三个高频 bug 与面试表达建议
6.1 Bug 1:没保存后继导致断链
这个 bug 在前面已经演示过。日常写代码时最容易出现的情况是:觉得自己记住了逻辑,结果手一快就写成 cur.next = prev,忘了先 temp = cur.next。
后果是链表被拦腰截断。排查方法也很简单:在每次循环开头打印 prev、cur 和 cur.next 的值,你会看到 cur 突然跳回 prev,或者链表的遍历长度急剧变短。修复方案就是在修改 cur.next 之前,先把 cur.next 存到 temp 里。
6.2 Bug 2:循环条件写成 cur.next != null
这个 bug 非常隐蔽。如果链表是 1->2->3,写成 cur.next != null 之后,循环会处理 1 和 2,但在 3 这一步停下。最后返回的 prev 是 2,输出变成了 2->1,节点 3 直接没了。
为什么会犯这个错?因为很多其他链表题的循环条件都是 cur.next != null,比如找倒数第 k 个节点,用的就是这种写法。但反转链表要求每个节点都被处理,所以必须用 cur != null。用只有两个节点的链表做测试,这个 bug 会立刻暴露。
6.3 Bug 3:递归返回了 head 而不是 new_head
递归解法里,如果把最后一行写成 return head,反馈出来的结果会和 Bug 2 类似:返回的是原链表的头节点,但它在反转后已经是尾节点。从它开始遍历,只能看到少数几个节点,看起来就像链表“断了一半”。
排查思路是反问自己:递归调用的返回值有没有被真正使用?如果 new_head 被赋值了但最后没有返回,编译器不会报错,但逻辑就是错的。
6.4 调试这类题目的小技巧
链表题的调试不像数组题那样可以一眼看出结果,我平时调试反转链表主要用三个方法:
- 用三个节点的短链表,在纸上画出每轮指针变化,对照代码走一遍;
- 在关键位置打印指针指向的节点值,比如每次循环开始前打印 prev.val 和 cur.val;
- 反转完成后,从返回的节点重新遍历一遍,检查节点个数是否和原链表一致,同时观察是否有节点出现重复访问。
这三个方法可以覆盖绝大多数链表 bug。特别是第三个方法,能帮你快速发现是否成环。
6.5 面试答题的节奏
一次好的算法面试回答,通常不是“我直接写代码”,而是按四步走:
- 和面试官确认边界:链表可能为空吗?单节点呢?
- 先说思路:我要用一个 prev 记录前驱,用 temp 保存后继,逐个反转节点的 next 指向;
- 写代码,同时主动说明每一行的作用;
- 主动补充复杂度:时间 O(n),空间 O(1),并且说出为什么。
例如,你可以这样说:“我打算用迭代。因为链表是单向的,所以反转每个节点前必须保存它的后继,否则会断链。我让 prev 初始为 null,cur 从头开始,每次循环把 cur.next 指向 prev,然后三指针整体推进。循环结束后 cur 是 null,prev 就是新头,返回 prev。空间上只用了三个指针,所以是 O(1)。”
这段话信息量足够,逻辑也清楚,面试官基本不需要追问就能判断你对这道题的理解程度。如果他还想看你对递归的理解,就能顺势抛出递归写法,并把递归的空间复杂度 O(n) 讲出来,说明你明白为什么在超长链表场景下不太推荐递归。
就我个人经验,反转链表这道题值得你在学习链表阶段反复写,直到能在五分钟内没有任何停顿地写完迭代版和递归版。它看起来小,但它是通往一大类链表题的门钥匙。写错一个地方,也别急着看答案,拿一条三个节点的链表,一点一点追指针,把每一轮的变化在纸上画出来。这个“慢下来追指针”的过程,比无脑刷十道新题都有用。等你把这道题吃透,后面那些反转部分、K 组反转、回文链表的题目都会变得顺理成章。
