“这道题我昨晚刚背完,今天一紧张还是写错了”——这是我在几次模拟面试里真实见过的反转链表翻车现场。
你搜“算法面试必刷”时,206. 反转链表绝对是被列在榜首的那一档。它是LeetCode上入门级的链表题,却成了无数人挂在面试第一轮的高频题。说穿了,是因为它考察的不只是“你会不会翻转链表”,而是在看你有没有把链表操作的底层逻辑吃透:指针的移动顺序、边界条件的处理、递归与迭代的切换能力。
这篇文章我会直接进入实战,先讲清楚链表的存储结构为什么让“反转”这件事变得特殊,再分别拆解迭代和递归两条主流解法的完整推理过程,附带代码、自测用例、易错点,以及从这道题延伸出去的几个面试变种。无论你是正在刷题准备秋招,还是想夯实数据结构的底子,这篇都可以直接用。
1. 题目速览与核心思路拆解
1.1 题目本身到底在问什么
题目描述很短:给你单链表的头节点 head,请你反转链表,并返回反转后的链表头节点。输入 1->2->3->4->5,输出 5->4->3->2->1。
很多人第一次看到这道题觉得很简单,但一动手就懵,核心原因是:数组反转是有下标可以直接“交换首尾”的,链表不行——每个节点只保存了下一个节点的地址,没有前驱指针。你没办法通过下标随机访问任何一个节点,只能从 head 开始一个一个往后走。
这就是反转链表的本质难点:你要在“只能向前走”的数据结构里,把每个节点的 next 指针掉转方向,让它指向前一个节点。相当于你在一条单行道上开车,想把整条路的方向倒过来,但你没有倒车档,也不认识上一站的路口,你只能边开边记路标。
1.2 为什么这是面试必刷题
这道题出现频率极高的原因,不是因为它难,而是因为它“小而全”。一个考官的潜台词往往是:我知道你刷过这道题,我不需要你背答案,我需要你在白板上把它写对、讲清楚。
它能在五分钟之内考察出你的几项基本功:
- 你是否理解链表节点结构(一个 value 加一个 next 指针);
- 你是否能做到多指针协同操作时不乱(prev、curr、next 三指针的配合);
- 你是否清楚边界条件(空链表、单节点、两个节点);
- 你是否两种解法(迭代 + 递归)都能写,并能说出各自的空间复杂度差异。
很多人在 LeetCode 上提交通过,就以为会了,但面试状态下手写白板,会暴露不少问题:循环里指针覆盖顺序错了、没有保存临时 next、递归出口写错,等等。这篇文章后续会专门把这些问题列出来。
1.3 两条主流路线:迭代和递归
反转链表有两种主流实现方式:
- 迭代法(推荐最先掌握):利用三个指针 prev、curr、next 遍历链表,每次把当前节点的 next 指向 prev,再整体移动指针。空间复杂度 O(1),时间复杂度 O(n)。
- 递归法:递归函数返回以当前节点为头的链表反转后的新头节点,核心递推式是 head.next.next = head 和 head.next = null。空间复杂度 O(n)(递归栈开销),时间复杂度 O(n)。
两条路线各有适用场景。迭代法适合面试首选,因为它稳定、不依赖函数调用栈深度;递归法代码更简洁,但需要你把递归的“信任”建立起来,并且在链表很长时有栈溢出的风险(实际工程中一般不用递归反转链表)。
2. 迭代解法:三指针的完整推理过程
2.1 为什么是三指针,而不是两指针
先来看一下最容易踩的坑:只用一个指针能不能反转?假设链表是 1->2->3,你站在节点 1,想把它的 next 指向 null,这一步没问题。但接下来你要处理节点 2,发现你已经找不到 2 了——因为从 1 出发的唯一线索 next,已经被你改成了 null。这就是链表操作里最常见的“丢节点”事故。
所以反转时至少要三个指针协同:prev 记录当前节点的前驱,curr 指向当前要操作节点,next 提前保存当前节点的后继。顺序必须严格遵守:先把 next 保存下来,再改 curr.next 指向 prev,然后整体右移。
我来用一个生活化类比帮你理解:你在一条只能单向通行的队伍里,想让大家挨个转身面向后方。你现在站在队伍里,左手拉着你身后的人,右手拉着你前面的人(虽然正常情况下你只认识身后那个)。每处理一个人,你要做的动作是:先记住你身后站的是谁(防止以后找不到),再让你身后的人转身面向你(把 next 指向 prev),搞定之后,你和刚才被你处理的人一起往前走一步,去处理下一个。
这个“先保存、再改向、后移动”的顺序不能乱。一旦你先改向再保存,原来的后继节点就找不到了,整个链表就断了。
2.2 迭代代码的逐步拆解
以 C++ 为例,标准解法如下:
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) {} }; class Solution { public: ListNode* reverseList(ListNode* head) { ListNode* prev = nullptr; ListNode* curr = head; while (curr != nullptr) { ListNode* next = curr->next; // 步骤1:先保存后继 curr->next = prev; // 步骤2:掉转指针方向 prev = curr; // 步骤3:prev 右移 curr = next; // 步骤4:curr 右移 } return prev; // 循环结束时,prev 指向新头 } };Python 版本也很常见:
class Solution: def reverseList(self, head: ListNode) -> ListNode: prev = None curr = head while curr: next_node = curr.next curr.next = prev prev = curr curr = next_node return prev循环终止的条件是 curr 走到链表末尾的 null,此时 prev 正好指向原链表的最后一个节点,也就是反转后的新头节点。这也是为什么最后返回 prev 而不是 curr——curr 已经是 null 了,返回它什么都没用。
2.3 为什么空间复杂度是 O(1)
迭代法只用到了 prev、curr、next 三个固定指针,不管链表多长,额外开辟的内存都是常数级别,所以空间复杂度是 O(1)。
这也是它相比递归最大的优势。我在实际面试中,如果面试官不问递归,我通常会先写迭代解法,并且主动指出这一点:只需要固定三个指针,没有额外空间开销。这句话本身就是加分项。
2.4 手写时需要向面试官讲清楚的细节
白板写代码时,光写对还不够,你需要在写的时候同步讲出你的思路。通常我会边写边说这几句话:
- “prev 初始化为 nullptr,因为原链表头节点反转后要指向空。”
- “每次进入循环先把 curr->next 保存到 next,避免改指针后丢失后继。”
- “循环结束条件是 curr 为空,为什么?因为空节点没有可以反转的 next,同时也标志着我们已经走完了整条链表。”
这几句话不需要背,但如果你能把“为什么 prev 初始化为空”和“为什么最后返回 prev”讲清楚,面试官基本就能确认你是真的懂,而不是背答案。
3. 递归解法:从链表末尾反向往回退
3.1 递归的核心思考方式
递归解法的代码比迭代更短,但理解门槛更高。很多教程直接抛给你代码,然后说“就是这样的”,结果读者一脸懵。这里我不这样讲,而是把递归的思维过程完整走一遍。
递归的出发点不是“从前往后循环”,而是“假设我们已经反转好了当前节点之后的那一段链表”。这个假设就是递归里的“信任链”:我们先信任一个函数,它能把自己收到的子链表反转好,返回新头节点。
举个例子:链表 1->2->3->4->5。我们调用 reverseList(head),信任它会返回反转后的 5->4->3->2->1。重要的是中间这个过程:当函数处理到节点 1 时,我们不去管 1 后面的 2->3->4->5 是怎么被反转的,只当它已经完成,结果是 5->4->3->2。那么此时链表的样子其实是 1->2<-3<-4<-5(2 的 next 还是指向 3 的,但从 3 往后的指针都已经反转过来了)。
我们要做的只剩两件事:让 2 的 next 指回 1,再让 1 的 next 指向 null。这样整条链表就全部反转过来了。
3.2 递归代码与那句最关键的代码
class Solution { public: ListNode* reverseList(ListNode* head) { // 递归出口:空链表或只有一个节点 if (head == nullptr || head->next == nullptr) { return head; } ListNode* newHead = reverseList(head->next); // 关键操作:让下一个节点的 next 指回自己 head->next->next = head; // 断开自己与下一个节点的正向连接 head->next = nullptr; return newHead; } };Python 版本:
class Solution: def reverseList(self, head: ListNode) -> ListNode: if not head or not head.next: return head new_head = self.reverseList(head.next) head.next.next = head head.next = None return new_head那句 head->next->next = head 是整个递归的精髓:它做的事情是“让当前节点的后继节点反过来指向当前节点”。head->next 原本指向后继,这个后继的 next 原本指向更后面,现在我们把它改成指向 head,就完成了一组相邻节点的反转。head->next = nullptr 则是为了让原本的链头成为新链表末尾时指向空。
你不需要去追踪每一层递归的具体状态,只需要信任:每一层都会把自己的后继节点的 next 指回自己,并把自己和后继的连线断开。当递归返回时,整条链表就已经反转好了。
3.3 递归的调用栈到底发生了什么
为了让你彻底放心,我用一个三层链表 1->2->3 走一遍完整流程:
- reverseList(1) 调用 reverseList(2),等待返回;
- reverseList(2) 调用 reverseList(3),等待返回;
- reverseList(3) 因为 head->next 为 null,直接返回节点 3;
- 回到 reverseList(2) 这一层:newHead = 3,此时执行 2->next->next = 2,也就是 3->next = 2,再执行 2->next = null。链表变成 3->2,返回 newHead(节点 3);
- 回到 reverseList(1) 这一层:newHead = 3,此时执行 1->next->next = 1,也就是 2->next = 1,再执行 1->next = null。链表变成 3->2->1,返回节点 3。
整个过程中,每个节点只被处理一次,所以时间复杂度 O(n)。但每一层递归都会占用一份函数调用栈空间,所以空间复杂度是 O(n)。
3.4 工程上为什么不建议用递归反转链表
面试中写递归没问题,但一定要清楚它的实际代价。一条几万节点甚至更长的链表,如果用递归反转,函数调用深度会随着链表长度线性增长,很容易导致栈溢出。C++ 默认的调用栈大小在几 MB 级别,每层递归光函数帧就有几十字节开销,几万层就可能有明显压力。
我在真实项目里处理链表反转,几乎不会用递归版本。工程上追求的是可控的内存占用和可预期的性能,迭代法用固定三个指针搞定所有情况,明显更稳妥。这也是面试官可能会顺着问的知识点:两种解法的时间和空间复杂度分别是什么?哪种更适合在生产环境使用?你如果能把上面这层道理讲出来,说明你不只是会刷题。
4. 边界条件、常见错误与自测用例
4.1 边界条件:空链表和单节点
边界条件几乎是这道题唯一的“暗坑”。很多人迭代法主体写得很顺,但没处理空链表的情况:如果 head 本身为 nullptr,代码直接进入循环,prev 为 nullptr,返回 prev,结果是 null,这看起来好像没问题。
但是递归版本里如果没有加上 head->next 的判断,只写 if (head == nullptr) return head;,当链表只有一个节点时,reverseList(head->next) 传进去的是 nullptr,虽然下一层能正确返回 nullptr,但回到上层执行 head->next->next 时就会发生空指针解引用,直接崩溃。所以递归的出口必须是两个条件:head 为空,或者 head->next 为空。
这道题我刷了不止一遍,每次写完都会顺手测试下面四种输入,确保边界正确:
- 空链表:输入 nullptr,期待输出 nullptr;
- 单节点:输入 1,期待输出 1;
- 双节点:输入 1->2,期待输出 2->1;
- 多节点:输入 1->2->3->4->5,期待输出 5->4->3->2->1。
4.2 最常见的三个写错场景
第一个场景:没有提前保存 next 就直接修改 curr->next。比如你写 curr->next = prev,然后想移动 curr,发现原来的后继已经找不到了。这是新手最典型的问题,也是面试官看代码时最先盯的位置。
第二个场景:移动指针的顺序不对。正确的是 prev = curr,curr = next。有人写成 curr = next,prev = curr,结果 prev 和 curr 指向了同一个节点,链表反转失败。这里可以这么记:先把 prev 移动到 curr 的位置,再让 curr 走向 next,两人是“先后脚式”前进,不是“同步跳”。
第三个场景:循环结束后返回了错误节点。最后应该返回 prev,因为循环结束时 prev 是原链表最后一个节点,也就是新链表的头。如果你返回 head,那只会在原链表长度大于 1 时得到错误的答案。
4.3 用调试和日志验证你的指针移动
我自己刷题时有一个习惯:写完代码先不急着提交,而是在草稿纸上演算一轮小的链表,或者加几个临时输出看每个循环里 prev、curr、next 的变化。比如输入 1->2->3,在循环第一轮结束时,预期的状态是:next 指向节点 3,curr 指向节点 3,prev 指向节点 2,链表结构变为 1->null、2->1、3->2(还没执行完)。第二轮结束,prev 指向 3,curr 变为 null,循环退出,返回 3。
纸上演算几轮,比盯着代码空想要直观得多。你甚至可以自己画一个三行表格:prev、curr、next 各是什么,每次循环后更新成什么,一眼就能发现逻辑里的问题。面试时如果时间允许,用一个长度为三的输入在白板上演算一遍再写最终代码,也是很好的习惯。
5. 面试实战与变种扩展
5.1 面试官会怎么追问这道题
反转链表本身不难,但面试官往往会在你做完之后立刻追加问题,用来判断你是“背题型”还是“理解型”。常见追问包括:
- “你能用递归写一遍吗?”——考察两种解法思维的切换能力。
- “如果链表很长,递归会有什么问题?”——考察你对系统栈的理解。
- “你能说说时间复杂度为什么是 O(n) 吗?空间复杂度呢?”——考察复杂度分析基本功。
- “如果只让反转链表的前 k 个节点,怎么写?”——考察举一反三的能力。
每次被追问,不要急着写代码,先把思路聊清楚。面试官想看到的是“先分析、再动手”的过程,而不是一把梭把代码写完。
5.2 从反转链表延伸到其他高频题
弄清反转链表之后,你会突然发现很多中等题和困难题的基础都是它。这里列几个经典的延伸方向:
- 反转链表 II:给定区间 [left, right],只反转这一段的节点。解法是:先定位到 left 的前一个节点,再对该区间做一次反转,然后接回原来的链表。核心操作仍然是那道三指针反转,只是多了“从哪里开始”和“在哪里停下”的处理。
- K 个一组翻转链表:每 k 个节点为一组进行反转,最后一组不足 k 个则保持原样。这道题会递归地处理每一组,每一组内部用的还是反转链表的标准逻辑。复杂度明显上了一个台阶,算是反转链表题型的“顶配”代表。
- 回文链表:判断链表是否是对称的。常见解法之一就是用快慢指针找到中点,然后反转后半段,再和前半段逐节点比较。也就是说,反转链表在这里变成了一个“子步骤”。
这几道题都指向同一个结论:反转链表不是孤立的知识点,而是一个方法库。你越扎实,后面解题就越顺手。我自己准备面试时的顺序是:先从 206 题把迭代和递归都练熟,再去做 92、25、234 这三道变种题,循序渐进地巩固同一个核心操作。
5.3 除了算法题,反转链表在哪还有用
很多人觉得链表反转只是面试专属,实际工程中它的精神也无处不在。比如缓存淘汰策略里的 LRU 链表、数据库底层日志在某些场景下的逆序遍历、图形学里双向链表的多指针维护,这些场景不一定是在“反转”链表,但它们都是在做同一件事:高效地调整节点之间的指向关系。
换句话说,你把这道题吃透了,掌握的是一种“在受限访问方式下重组关系”的思维方式。这种能力会迁移到其他数据结构问题上,比如树的镜像翻转(二叉树的左右子树交换)、图的邻接表转换等等。
刷完题之后,建议你亲自把迭代和递归两种写法各写三遍,分别验证空链表、单节点、双节点和五个节点的用例。写完之后,再把 head 为 nullptr 的极端情况单独测一次,因为这是最容易被忽略却最常出现在测试集里的情况。
最后分享一个我自己的习惯:每次面试前,我会花五分钟在白纸上默写一遍这道题的迭代解法,不看书,不看笔记。能完整默写出来,并且能边说边写出指针每一步的移动意图,这道题的准备才算真正过关。别小看这个动作,它帮我在几次面试里稳定拿到了“基础题稳过”的加分印象。