在LeetCode上刷链表题的人,十有八九都会在“反转链表”这道题上停一会儿。明明题目描述只有一句话,代码也就十行上下,但真要自己动手写,很多人在白纸上画了半天指针,最后还是绕晕了。我当年面一家做基础架构的团队时,面试官现场让我写这道题,我用了递归交了第一版,他追问了一句“这个递归的空间复杂度是多少”,我愣了一下才反应过来,递归虽然好看,但调用栈会吃掉O(n)的额外空间。那次之后我把这道题的两种解法认认真真推了几遍,才发现以前很多理解都是“背代码背出来的”,不是真懂。
这篇东西就是想把反转链表讲透,覆盖迭代和递归两条路线,会把指针怎么移动、边界条件怎么定、返回值为什么是那样、面试官追问时该怎么应对都讲清楚。无论你是刚接触链表的初学者,还是准备面试刷题的老手,这篇文章都能给你一些不一样的视角。
1. 题面拆解:反转链表到底在反转什么
1.1 链表的存储方式决定了“反转”不是一件直觉的事
数组反转很简单:知道长度,首尾下标一换就行,因为数组在内存里是连续存放的,下标就是地址。但链表不一样,每个节点是一个独立的对象,除了存自己的数值 val,还维护一个指向下一个节点的指针 next。链表在内存里未必连续,节点之间靠指针“串”起来。
所以反转链表的本质,不是把节点里的数值倒过来排,而是要重新编排每个节点 next 指针的指向。原来指向后一个节点,现在要指向前一个节点。换句话说,整条链的“方向”被拧反了。
这个描述听起来简单,但有一个天然瓶颈:单链表节点只存了“下一个节点”在哪,没有存“上一个节点”在哪。你站在当前节点上,只能往后走,不能回头看。要修改当前节点的 next,就必须提前把“前一个节点”记下来。这个“记下来”的动作,就是所有解法的核心起点。
1.2 输入约束和边界情况:空链表、单节点、多节点
力扣的题目一般会给出 ListNode 的定义:
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) {}
};
输入是链表的头节点 head,要返回反转后的新头节点。这里有三个边界情况:
- 空链表(
head == nullptr):没什么可反转的,直接返回空。 - 只有一个节点(
head->next == nullptr):反转前后一样,直接返回head。 - 多个节点:正常走反转逻辑。
这三种情况其实可以在同一个判断里兜住:如果 head == nullptr || head->next == nullptr,直接返回 head。这也是递归解法的 base case。
很多人面试时一上来就写循环,结果输入是空链表时直接段错误,就是因为在最开始没有处理边界。别看这是个细节,我在实际面试别人时,几乎每三份反转链表的代码里就有一份会把边界漏掉。
1.3 一个容易被忽视的问题:为什么要返回“新头节点”
这里想提醒一下:反转之后,原来的尾节点变成了头节点,原来的头节点变成了尾节点。返回值如果还是原来的 head,那返回的链表只是从原尾部开始的单节点或者空链表,整条链从中断开了。所以最终返回值一定是“新的头”,也就是反转前链表的最后一个节点。
这也是迭代法和递归法在代码层面容易出错的地方。迭代法里新头是循环结束时的 prev,递归法里新头是递归最深处返回的那个节点。后面两部分我会分别推到这一步,现在先有一个印象即可。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 迭代解法:三个指针的动态推演
2.1 为什么要三个指针
先想一个最朴素的目标:从头到尾走一趟,每走到一个节点,就把它的 next 指向前一个节点。那“前一个节点”从哪来?只能拿变量存着。
假设有两个变量:prev 存前一个节点,curr 存当前节点。走到 curr 时,如果直接把 curr->next = prev,坏事了,curr->next 本来指向的是原链表的下一个节点,这一改,后面的节点就全丢了,链表等于从中间断掉。
所以要赶在修改 curr->next 之前,先把下一个节点取出来,存到 next 里。这样即使当前节点的 next 被改掉了,我们依然可以通过 next 往下走。
于是就有了经典的“三指针”结构:
prev:指向当前节点的前驱,初始为nullptr。curr:当前要处理反转的节点,初始为head。next:保存当前节点的原始后继,防止断链。
2.2 迭代步骤:每一步都在做什么
用链表 1 -> 2 -> 3 -> nullptr 来走一遍完整过程。
初始状态:
code复制prev = nullptr
curr = 1
第一步循环:
next = curr->next,也就是next = 2。curr->next = prev,也就是1 -> nullptr。prev = curr,prev从nullptr变成了1。curr = next,curr从1变成了2。
此时局部状态是 nullptr <- 1 2 -> 3 -> nullptr。
第二步循环:
next = 3。curr->next = prev,也就是2 -> 1,注意此时1已经指到了nullptr,所以得到2 -> 1 -> nullptr。prev = 2,curr = 3。
局部状态是 nullptr <- 1 <- 2 3 -> nullptr。
第三步循环:
next = nullptr。curr->next = prev,也就是3 -> 2。prev = 3,curr = nullptr。
循环结束时:
code复制prev = 3
curr = nullptr
3 -> 2 -> 1 -> nullptr,反转完成,返回 prev 即可。
从这个过程里能看出每个指针的职责非常清晰:next 是“导航员”,保证你改了当前指针后还能找到路;prev 是“上岸点”,记录已经反转好的那块链表的头部;curr 是“工位”,每次循环只处理这一个节点的指向。
2.3 迭代代码:一版可以直接落地的实现
cpp复制class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr;
ListNode* curr = head;
while (curr != nullptr) {
ListNode* next = curr->next; // 先保存后继
curr->next = prev; // 反转当前节点的指向
prev = curr; // 前驱后移
curr = next; // 当前节点后移
}
return prev;
}
};
循环条件是 curr != nullptr 而不是 curr->next != nullptr,这一点要特别注意。如果写成 curr->next != nullptr,你会发现循环结束时最后一个节点没有被处理,它的 next 还指向原来的方向,结果链表会在最后一个节点和倒数第二个节点之间形成环,或者整体反转不完整。
复杂度方面:遍历了一遍链表,时间复杂度 O(n),其中 n 是链表的节点个数;整个过程只用到了三个指针变量,空间复杂度 O(1)。
2.4 迭代法的常见变形:虚拟头节点和头插法
除了上面这种“就地反转”的写法,还有一个思路叫“头插法”,原理很好理解:每次从原链表中取下当前的头节点,插入到新链表的头部。这里可以引入一个虚拟头节点 dummy,让操作更统一。
头插法的核心代码:
cpp复制class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* dummy = new ListNode(0);
dummy->next = nullptr;
while (head != nullptr) {
ListNode* next = head->next; // 先保存后续链表
head->next = dummy->next; // 当前节点指向新链表的第一个节点
dummy->next = head; // 更新新链表的头
head = next; // 回到原链表
}
return dummy->next;
}
};
这两个版本的实质是一样的,区别只是“视角不同”。三指针版本是“站在当前节点改指向”,头插法版本是“把当前节点搬到新链表的最前面”。我看过不少人的代码,两种写法都有,个人更推荐三指针版本,因为它逻辑上更紧凑,也不需要额外创建虚拟节点。
迭代法写完之后,建议在纸上手动跑一两次 1 -> 2 -> 3 -> 4,把每一步的 prev、curr、next 画出来。这个工夫花得值,因为很多链表题,比如反转部分区间、K个一组反转,都是在三指针或头插法的基础上扩展的。
3. 递归解法:递推公式和回溯过程
3.1 递归的思维模式:先信函数已经帮你解决了子问题
迭代法是从“第一个节点”往“最后一个节点”方向处理的,而递归法恰恰相反——它先一路递归到链表末端,然后从最后一个节点开始,一层层往回“修指针”。
这里最关键的一步是思维切换:你要相信 reverseList(head->next) 执行完之后,以 head->next 为头的那条子链表已经被整个反转好了。 你不需要关心它内部怎么做到的,只需要在此基础上处理 head 和这条子链表之间的关系。
举例来说,链表是 1 -> 2 -> 3 -> 4 -> 5。假设 reverseList(2) 已经把 2 -> 3 -> 4 -> 5 反转成了 5 -> 4 -> 3 -> 2,那当前局面是什么?
code复制1 -> (2 <- 3 <- 4 <- 5)
注意,此时节点 2 的 next 还是指向 nullptr 吗?不是,递归返回后,子链表内部已经调整完毕,整个子链表的头是 5,尾是 2。而 1 的 next 仍然指向 2。我们想得到的最终结果是:
code复制5 -> 4 -> 3 -> 2 -> 1
那 1 应该被接到哪个位置?应该接到 2 的后面,因为 2 现在是子链表的尾部。所以核心操作就是:
cpp复制head->next->next = head; // 让2的next指向1
head->next = nullptr; // 让1的next指向空,因为1变成了新链表的尾部
这行代码是整个递归解法的灵魂。它本质上是说:把当前节点放到它的下一个节点后面,并且把当前节点的 next 斩断,防止成环。
3.2 递归函数逐步展开:以 1 -> 2 -> 3 -> 4 -> 5 为例
用 reverseList(1) 开始调用。
第一层调用 reverseList(1):
head = 1,head->next非空,于是调用reverseList(2)。
第二层调用 reverseList(2):
head = 2,调用reverseList(3)。
第三层调用 reverseList(3):
head = 3,调用reverseList(4)。
第四层调用 reverseList(4):
head = 4,调用reverseList(5)。
第五层调用 reverseList(5):
head = 5,head->next == nullptr,直接返回5。
现在开始回溯。注意此处是层层返回,不是干等。
第四层拿到了 newHead = 5:
- 此时
head = 4,head->next = 5。 - 执行
head->next->next = head,即5 -> 4。 - 执行
head->next = nullptr,即4 -> nullptr。 - 返回
newHead = 5。
第三层拿到了 newHead = 5:
- 此时
head = 3,head->next = 4(注意第四层已经把4指向3了?不是,我先谨慎一点)。
这里有一个特别容易混乱的地方。第四层执行完之后,链表的局部状态是:
code复制5 -> 4 -> nullptr
而第三层调用时,它的 head = 3,head->next = 4。原来 3 的 next 指向 4,这个关系在第三层代码里没有改动过。所以第三层拿到的当前状态其实是:
code复制3 -> 4 -> nullptr(但 4 的 next 已经指向 nullptr?)
不对,我再仔细推。第四层的 head 是 4,它的 next 在进入时是 5,经过 head->next->next = head 和 head->next = nullptr,节点 5 的 next 被改成 4,节点 4 的 next 被改成 nullptr。也就是说第四层调用结束之后,4 和 5 之间形成了 5 -> 4 -> nullptr 这样的局部反转。
第三层调用时的 head = 3,它的 next 还是 4,4 的 next 已经是 nullptr 了,所以第三层看到的链表是:
code复制3 -> 4 -> nullptr(4 是原链表后续部分的尾节点?其实 4 现在是反转后子链表的尾)
注意这个“3 -> 4”,它仍然是原链表的指向关系,还没有被反转。第三层要做的,就是把节点 3 也接到反转后的子链表尾部:
head->next->next = head,也就是4 -> 3。head->next = nullptr,也就是3 -> nullptr。
此时局部状态变成:
code复制5 -> 4 -> 3 -> nullptr
以此类推,第二层把 2 接进去,第一层把 1 接进去,最终得到:
code复制5 -> 4 -> 3 -> 2 -> 1 -> nullptr
整个过程的核心要义是:回溯阶段,每一层只处理两个节点之间的关系——当前节点和它的原始下一个节点。因为下一层递归已经保证“下一个节点后面的所有节点”都反转好了,所以当前层只需要把“下一个节点”的 next 指向自己,然后把自己的 next 置空。
3.3 递归代码:短小精悍但暗藏风险
cpp复制class Solution {
public:
ListNode* reverseList(ListNode* head) {
if (head == nullptr || head->next == nullptr) {
return head;
}
ListNode* newHead = reverseList(head->next);
head->next->next = head;
head->next = nullptr;
return newHead;
}
};
这个代码结构非常紧凑,核心逻辑只有三行。但有一个关键点必须理解:返回值永远是那个最深的递归返回的节点,也就是原链表的尾节点 newHead。每一层返回的时候,newHead 都是同一个节点,从头到尾没有变过。如果你在某一层把返回值写成了 head,那递归返回上去之后,上层拿到的是一个局部反转链表的尾节点,最终返回的结果就不对了。
再谈一个实际中很重要的问题:递归方法的空间复杂度是 O(n),因为每一层调用都会占用一个栈帧,链表越长,调用栈越深。当链表长度达到几万、几十万的时候,递归版可能直接栈溢出。在力扣的测试用例里这个问题不明显,但在实际工程里,处理一个大链表的时候我建议还是优先用迭代法。面试时如果提到递归解法,最好主动说清楚它的空间复杂度,这会让面试官觉得你真的理解解法之间的取舍。
3.4 递归的“断层”疑问:为什么 head->next 在回溯时还“活着”
很多人学递归时最大的困惑是:reverseList(head->next) 返回之后,head->next 会不会已经被修改了?答案是不会。
head->next 是 head 这个节点的成员变量,递归调用时我们把 head->next 作为参数传下去,但在 head 这一层的代码里,直到我们显式执行 head->next->next = head 和 head->next = nullptr 之前,head->next 的值都没有被改动。递归更深层修改的只是 head->next 这一节点的 next 字段,而不是修改 head 的 next 字段。
打个比方:你让下属去把公司各部门的座位重新排一下,下属干完活后,你还是你,你的工位还是你的工位,只是你工位旁边那个人的座位可能变了。你要做的,就是基于变化后的局面,把你自己这一层的关系再理顺。这就是递归每一层“各司其职”的含义。
4. 两种解法对比:时间、空间与面试表达的取舍
4.1 迭代和递归的硬指标对比
从数据结构和算法的角度,两种解法的时间复杂度都是 O(n),区别主要在空间复杂度和代码可读性上。下面这张表可以帮你快速对比:
| 维度 | 迭代法(三指针) | 递归法 |
|---|---|---|
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(1) | O(n),递归调用栈 |
| 代码长度 | 约10行 | 约7行 |
| 理解难度 | 直观,但要自己管理指针 | 理解门槛高,代码极简 |
| 工程适用性 | 高,无栈溢出风险 | 低,长链表可能栈溢出 |
| 面试加分项目 | 展示对指针的掌控力 | 展示对递归的熟练度 |
很多“刷题党”喜欢一上来就写递归,因为代码短,看着优雅。但如果你说不出递归的空间复杂度是 O(n),面试官心里会打一个问号。反过来,如果你写的是迭代法,那在解释过程中自然说出“我只用了三个指针,额外空间 O(1)”,那就是一个很扎实的加分点。
4.2 现场手写时,一个合理的作答顺序
如果是面试现场遇到这道题,我的建议不是直接埋头写代码,而是先花20秒把思路说清楚,再动手。你可以这样开头:
“反转链表本质是把每个节点的 next 指针从前向改成后向。由于单链表没有前驱指针,我可以用一个 prev 变量保存前驱,用 next 变量保存后继,避免修改指针后丢失后续节点。核心循环就是保存 next、反转当前节点、移动 prev 和 curr。另外我还能写一个递归版本,把子问题看成 reverseList(head->next) 已经反转好后半段,再把当前节点接到尾部。”
这样一段话,信息量很大,面试官马上就知道你是真的懂了,而不是背代码。
4.3 高频错误清单:面试和刷题中常见的坑
-
忘记保存后继节点。迭代法中如果直接写
curr->next = prev后才发现curr原来的后继丢了,代码就废了。必须先把next = curr->next存下来。 -
循环条件写错。
while (curr != nullptr)和while (curr->next != nullptr)结果完全不同,前者会处理到最后一个节点,后者会在最后一个节点之前停下。 -
递归中忘记
head->next = nullptr。如果不把当前节点的 next 置空,会导致链表出现环,比如1 -> 2 -> 1 -> 2 ...。力扣会提示“cycle detected”,报错。 -
返回错误的头节点。迭代法返回
prev,递归法返回newHead,这两个都是反转前的尾节点。如果你返回curr,此时curr已经是nullptr,那结果必然是空链表。 -
对空链表的马虎处理。空链表和单节点链表要能直接返回,否则代码会崩溃。
这些坑我基本都踩过。尤其是递归忘记置空那一次,我在本地跑的时候还奇怪为什么输出一直循环,一查才发现漏了一行。链表题最怕“能跑但结果不对”,因为这种 bug 非常隐蔽,肉眼看不出来。
4.4 面试官追问时,你要能答上来的纵深问题
反转链表这道题虽然简单,但面试官通常不会只让你写完就结束。典型的追问包括:
- 递归版本的空间复杂度是多少?和迭代版本比有什么区别?
- 如果链表是双向链表,反转思路会有什么变化?
- 如果只反转链表的第 m 到第 n 个节点,怎么做?
- 如果每 K 个节点一组反转,最后不足 K 个保持原样,怎么做?
这四个追问里,第一个是基础,第二到第四个都是高级扩展题。其中一个关键点在于,206 不仅是独立的题目,还是 92(反转链表 II)和 25(K 个一组翻转链表)的前置基础。你把 206 彻底吃透,后面做扩展题会顺很多。比如反转部分链表,就是在三指针基础上加一个“定位起点和终点”的操作;K 个一组反转,就是把 206 的迭代逻辑封装成函数,按组调用。
所以我一直建议刷链表题的人,不要只求“AC 通过”,要把 206 当原型题研究透。你在这道题上多花半小时,后面刷十道题都能受益。
5. 这道题背后的思考方式:指针操作与抽象分层
5.1 指针操作的本质:先保存、再修改、后移动
其实反转链表不只是一种算法题,它背后反映的是 “用指针操作数据结构的通用方法论” 。
在任何“修改节点指向”的场景里,你有三个动作是不能乱的:
- 保存现场:改任何指针前,先把会被覆盖掉的旧值存下来。
- 执行修改:完成当前步骤的目标操作。
- 移动游标:把工作指针移动到下一个待处理位置。
这套方法论不只适用于链表反转,写树的遍历、图算法、双向链表的增删改查,本质上都是这三个动作的排列组合。所以当你把 206 的指针移动练到肌肉记忆时,其实是为更复杂的数据结构题打地基。
5.2 从空间换时间的角度看待两种解法
迭代法用三个变量就完成了反转,这说明“就地修改”并不需要额外的容器。有些人第一次做链表题时,会想“新建一个链表,遍历原链表,把每个节点头插到新链表里”。这种做法单独看没错,但它额外分配了节点或者至少需要额外的容器空间,面试的时候往往会被追问“能不能原地完成”。
“原地”二字是链表操作的灵魂。数组排序里 O(n) 额外空间的归并排序可以用,但原地快排更优;链表反转同样如此,能用三个指针完成的事,就不应该开一个栈或数组来存节点。理解这一点,你会慢慢培养出“空间敏感度”,这在后续刷更多高级题时很重要。
5.3 用“头插法”串联起一系列链表题
反转链表和头插法之间有一种很深的联系。头插法指的是:每次从原链表摘下一个节点,把它插到结果链表的头部。反转链表的本质,就是从后往前把节点一个个摘下来,再头插到新链表里——只是这个“新链表”我们可以复用原节点,不需要真的 new 出新节点。
理解了这层关系,你会发现反转链表其实就是一个“循环头插”的过程。这个视角对后续解决“两两交换链表中的节点”“重排链表”这类题很有帮助。我刷题时经常在纸上写一句话:“头插法 + 循环 = 反转”。简单,但有用。
5.4 递归和迭代的选择,本质是“自顶向下”和“自底向上”
递归反转链表是典型的“自顶向下”思维:假装子问题已解决,处理当前层。迭代反转链表明明是从第一个节点开始处理,但实际可以理解为“自底向上”地构建反转后的链表:先让第一个节点变成新链表尾部,然后不断把新节点头插到前面。
这两种思维方式其实贯穿在所有算法题里。动态规划里既有自顶向下的递归 + 记忆化,也有自底向上的递推;树的遍历同样分递归版和迭代版。能在反转链表这道题里同时体验这两种思维,并理解它们各自的空间代价,对你后续学习更复杂的算法非常有帮助。
我个人的建议是:平时刷题时,遇到递归能写的题,都再想一想迭代能不能写;遇到迭代很繁琐的题,也试着用递归去表达。这种“解法对称性”训练,能帮你建立更灵活的算法直觉。
最后说点实际的
反转链表这道题,看起来简单,但它是我在面试里遇到次数最多的题之一,也是很多公司考察候选人基本功的“分水岭”。会写代码的人很多,能讲清楚指针为什么这样移动、递归为什么这样返回的人,才是真正理解这道题的人。
如果你正准备面试,建议把迭代和递归两个版本都做到能默写,并且在白板上能把“空链表、单节点、正常链表”三个边界情况主动说出来。这个细节本身就代表你对问题思考的完整性。
最后教大家一个自查的小技巧:写完代码后,拿一个超短链表比如 1 -> 2 -> 3,自己在脑内模拟一遍循环或递归的每一次执行。如果每一步你都能准确说出当前 prev、curr、next(迭代版)或者每层递归返回的 newHead(递归版),那这道题你就真的过关了。如果模拟到一半卡壳,别急着刷下一题,回到上面任意一节再把指针状态图手画一遍。链表这块没有捷径,画图就是最快的学习方式。
