刷算法题这种事儿,说实话大部分题目做完一周不看基本就忘光了,可 206 反转链表偏偏是那类你忘了解法也能自己推出来的题。正因为它足够基础、解法足够多,每次重刷都能看出自己之前的理解停留在哪个层次。这次我重刷 206,不光是默写一遍代码,而是把迭代、递归、头插几种思路认真捋了一遍,顺便把边界条件和常见变体也过了一下。这篇文章就当是我自己的二刷笔记,同时也适合准备面试或者刚开始刷链表专题的朋友参考。
1. 整体设计与思路拆解:为什么 206 值得一刷再刷
1.1 重刷到底在刷什么
很多人对重刷的理解就是“再做一遍,把代码背熟”。我刷了这么多题之后发现,重刷的真正价值在于做三件事:第一,检验自己能不能在完全不看题解的前提下,从问题描述推导出解法;第二,对比不同解法的代码量和运行效率,养成从多个角度思考的习惯;第三,把一道简单题当成跳板,串联起它背后的一整类链表操作问题。
206 反转链表恰好能承担以上所有任务。链表的反转操作是后续很多题目的公共底座,比如反转链表的前 N 个节点、区间反转、每 K 个一组反转,甚至回文链表的判断,本质上都是在反转局部链表。能把这个底座打得越扎实,后面做中难题就越省力。
1.2 解法全貌与核心矛盾
反转链表的主流解法有三种:迭代法、递归法、头插法(借助新链表头)。另外还有人用栈来做,但是栈做的话空间复杂度会退化成 O(n),所以在面试中优先级不高。
这三种解法表面上看是“不同实现”,实际反映的是同一个核心矛盾:单向链表只能从 head 往后走,不能回头。而反转相当于要让每个节点都回头指向自己的前一个节点。怎么在“不能回头”的物理限制下实现“回头”的动作?迭代用三个指针轮流替换,递归用系统调用栈帮你记住前驱节点,头插法则是把每个节点一个一个摘下来挂到新头前面。理解了这一层,你写的就不是代码,而是思路本身。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点:链表的指针操作三坎
2.1 迭代法的 prev、curr、next 三指针是怎么动起来的
迭代法是反转链表的首选方案,代码短、空间 O(1)、思路直观。它的核心是维护两个指针:prev 和 curr。初始时 prev 指向 nullptr,curr 指向 head。每轮循环做四件事:
- 用 next 暂存 curr 的下一个节点(防止断链);
- 把 curr 的下一个节点指向 prev(完成一次反转);
- 把 prev 移动到 curr(prev 前进);
- 把 curr 移动到 next(curr 前进)。
这里最容易被忽略的其实是第一步。很多人第一次写反转链表,会把 curr->next = prev 当成唯一核心操作,忘了提前保存原来的 curr->next,造成链表在第一个节点处就断掉了。这是因为一旦 curr->next 被改写,原本的链表后续部分就没人能访问到了。
我个人的经验是,在写链表操作之前,心里默默过一遍“这个指针被改之后,还有没有其他变量能访问到它原来指向的节点”——如果答案是没有,那你必须先用一个临时变量把它保存下来。这是链表指针操作的一条通用规则,206 不是唯一用到它的地方。
2.2 递归法的递推关系是什么
递归法的代码看起来更短,但理解门槛反而更高。它的核心不是“循环反转”,而是“把问题缩小”。
反转整个链表,可以拆成两步:先反转从第二个节点开始的子链表,再把原来位于 head 的节点接到反转后子链表的末尾。注意这里的关键:子链表反转完成后,它的尾节点恰好就是原来 head 的下一个节点,也就是 head->next。所以我们要做的操作是:
cpp复制ListNode* newHead = reverseList(head->next);
head->next->next = head;
head->next = nullptr;
解释下这两行:head->next->next = head 相当于让原来 head 的下一个节点反转后指向 head,完成一次“回头”;head->next = nullptr 则是为了让反转后的 head 成为新的尾节点,如果不置空,链表末尾会形成环或者保留旧的指向,导致输出结果错误。
很多讲解会把递归法解释成“先递后归”,我觉得更直观的理解是:不需要关心子链表内部是怎么反转的,你只需要无条件信任递归函数能把子链表反转好,然后把当前层和子结果之间的连接补上。写递归时最怕的就是陷入细节,一层层去模拟调用栈,模拟到第三层就晕了。正确的姿势是假设子问题已经解决,只关心当前层要做什么。
2.3 头插法的本质还是“拆”和“挂”
头插法常见于借助哨兵节点的场景,比如反转链表 II 这类指定区间反转的题目。它的思路是:把节点从原链表中一个一个“拆”下来,然后插到新链表头部的后面。
用哨兵节点 dummy 可以避免单独处理 head 为空的特殊情况,统一按普通节点处理。每轮循环做三步:记住当前节点的下一个节点、当前节点插入到 dummy 和它下一个节点之间、移动到原链表的下一个节点。
不过说实话,这道 206 题我个人不推荐头插法,因为完全可以用迭代法原地反转,不需要额外的新链表头。但如果你是为了练手、为了后续区间反转题打基础,头插法值得写一遍。
3. 实操过程与核心环节实现:三种解法的完整落地
3.1 方法一:迭代实现,原地反转链表
先上代码,语言我用 C++,后面会给出对应的 Python 实现思路:
cpp复制struct ListNode {
int val;
ListNode *next;
ListNode(int x) : val(x), next(nullptr) {}
};
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;
}
};
这段代码的返回值是 prev,因为循环结束时 curr 已经变成 nullptr,prev 正好指向原来的尾节点,也就是反转后的新头。如果链表为空,循环压根不会进去,prev 还是 nullptr,直接返回即可。
我用一个常见例子走一遍,链表为 1 -> 2 -> 3,初始 prev = null,curr = 1:
- 第一轮:next = 2;1.next = null;prev = 1;curr = 2;
- 第二轮:next = 3;2.next = 1;prev = 2;curr = 3;
- 第三轮:next = null;3.next = 2;prev = 3;curr = null;
- 循环结束,返回 prev = 3,链表变为 3 -> 2 -> 1。
Python 版本的代码逻辑完全一致,只是不需要显式声明指针类型:
python复制class Solution:
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
prev, curr = None, head
while curr:
nxt = curr.next
curr.next = prev
prev = curr
curr = nxt
return prev
3.2 方法二:递归实现,子问题视角反转
用递归来写 206,代码短到只有六行:
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;
}
};
这里要注意递归结束条件写的是 head == nullptr || head->next == nullptr。如果链表是空链表,返回空;如果链表只有一个节点或当前已经递归到最后一个节点,那么它反转后还是自己,直接返回。
以 1 -> 2 -> 3 为例拆解递归过程:调用 reverseList(1),它会先调用 reverseList(2),然后 reverseList(2) 会调用 reverseList(3)。reverseList(3) 此时满足返回条件,直接返回节点 3。回到 reverseList(2),这时候 head->next 是 3,3->next 原本是 nullptr,我们设置 3->next = 2,再把 2->next 置空,返回 newHead = 3。回到 reverseList(1),此时 head->next 是 2,2->next 已经被设成 nullptr,我们设置 2->next = 1,再把 1->next 置空,返回 newHead = 3。最终链表是 3 -> 2 -> 1。
不少初学者写递归时容易漏掉 head->next->next = head 这行,或者只写了这行没写 head->next = nullptr,结果返回的链表带环,一打印就死循环。踩过一次坑你就会记住:反转链表后,原来的头节点必须变成尾节点,而尾节点的 next 必须是空指针,否则链表不是“链”,是“环”。
3.3 方法三:头插法配合哨兵节点
头插法的核心是用一个虚拟头节点 dummy,初始的 dummy->next 为 nullptr。遍历原链表,每拿到一个节点,就先把这个节点的 next 存下来,然后让它的 next 指向 dummy 的 next,再把 dummy 的 next 更新为当前节点。
cpp复制class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* dummy = new ListNode(0);
dummy->next = nullptr;
ListNode* curr = head;
while (curr != nullptr) {
ListNode* next = curr->next;
curr->next = dummy->next;
dummy->next = curr;
curr = next;
}
return dummy->next;
}
};
这个方法的好处在于思路统一:不管反转整个链表,还是反转局部区间,头插的逻辑不用变。缺点在于需要额外维护一个哨兵节点,代码上多了一点无关的分配。刷题和面试场景中,我还是优先推荐迭代法,但头插法作为备选方案必须写得出来。
3.4 时间与空间复杂度对照
| 解法 | 时间复杂度 | 空间复杂度 | 是否原地 | 优点 | 缺点 |
|---|---|---|---|---|---|
| 迭代法 | O(n) | O(1) | 是 | 空间最优、不易栈溢出 | 指针操作需仔细 |
| 递归法 | O(n) | O(n) | 否(递归栈) | 代码简洁、思路优雅 | 链表过长可能栈溢出 |
| 头插法 | O(n) | O(1) | 是(借助虚拟头) | 易于迁移到区间反转 | 有额外节点分配 |
| 栈实现 | O(n) | O(n) | 否 | 最符合直觉 | 空间差、不推荐面试写 |
实际做题时,空间复杂度往往被忽视。迭代和递归都能完成同样的任务,但递归在链表长度极大时会有递归深度过深的风险,这在生产环境的代码里尤其要注意。LeetCode 上通常测试链路不长看不出问题,实际工程中反转一个几十万节点的链表,递归直接栈溢出也不是不可能。
4. 常见问题与排查技巧实录:动手写之前先看这几个坑
4.1 新手必踩的三个大坑
坑一:忘了保存下一个节点。这是迭代法最常见的错误。一旦我们把 curr->next 指向 prev,原链表后续节点就全部丢失。解决方法是任何修改 next 指针的操作之前,先判断“还有没有别人记住原来的 next”。如果只有 curr 自己知道,那必须先把 next 存进临时变量。
坑二:递归反转后没把链表尾部置空。很多人写出 head->next->next = head 就以为完成任务,结果一运行直接超时。原因是原来的 head 现在变成链表的尾节点,但它的 next 还指向原来的第二个节点,形成环路。调试链表环问题,用快慢指针检测或者打印每个节点的地址都能发现。
坑三:边界条件没处理。常见的是空链表、单节点链表。迭代法因为 while 条件天然处理了这两种情况,不容易出错;递归法如果忘了 head == nullptr || head->next == nullptr 的返回条件,空链表直接触发空指针异常。写完后一定要用空链表和单节点链表跑一遍。
4.2 自测用例清单
无论哪种解法,写完以后我都建议按下面的清单自测一遍,尤其是准备面试的话,能自己主动列出测试用例是加分项:
- 输入
[](空链表),期望输出[]; - 输入
[1](单节点),期望输出[1]; - 输入
[1,2](双节点),期望输出[2,1]; - 输入
[1,2,3,4,5](常规多节点),期望输出[5,4,3,2,1]; - 输入长链表(比如 10000 个节点),验证递归是否爆栈、迭代是否正常工作。
自测单节点和双节点这两个用例,能快速暴露“返回了错误的头节点”和“链表成环”这两类问题。我自己刷题时还养成了一个习惯:把返回的头节点的地址打印出来,如果地址和原链表尾节点一致,说明反转找对了头。
4.3 面试追问的应对口径
面试官在 206 之后最常见的追问方向有三个:
第一个追问是“能不能用递归实现”,聊的是你是否理解函数调用栈在替你保存中间状态。面试官想看的是你能不能在代码层面讲清楚递推关系,而不是背出递归模板。
第二个追问是“递归空间复杂度是多少”,答案是 O(n),因为递归调用会占用系统栈,每次递归一层就存一个现场。这个追问通常会引出“如果链表有十万个节点会怎样”的现实场景问题,回答思路是:递归深度过大会导致栈溢出,所以生产代码通常选迭代法。
第三个追问是“能否只反转前 k 个节点”或者“能否反转区间 [left, right]”,这直接衔接到 92 题。如果你能顺势说出区间反转需要同时记录 pre 和区间尾节点,面试官会认为你对链表局部操作理解得比较扎实。
5. 一题带出一片:反向延伸的高频变体题
5.1 反转链表前 N 个节点
以 206 为基础可以轻松扩展到“只反转前 N 个节点”。思路是设一个计数器,递归到第 N+1 个节点时,把它作为后继节点保存下来,然后反转前 N 个节点,最后把反转后的尾节点指向这个后继。
这类问题的代码和 206 的核心差异在于:206 返回时,反转后的尾节点指向 null;反转前 N 个时,反转后的尾节点要指向第 N+1 个节点,否则链表就断了。
5.2 反转链表的 II(LeetCode 92)
92 题要求反转从 left 到 right 的区间。拿 206 做底子,思路就清楚很多:第一步,找到 left 的前一个节点 pre;第二步,从 left 开始做长度为 right-left+1 的反转;第三步,把 pre 指向反转后的新头,同时让反转后的尾节点接上原来的 right+1 节点。
如果你只会背 206 的迭代法而不知道头插法,92 题写起来会有一点绕。反过来,如果你已经熟练掌握了带哨兵节点的头插法,92 题基本就是在固定区间内重复“拆节点、挂到头部”的动作,一趟遍历就能完成。
5.3 回文链表(LeetCode 234)与 K 个一组翻转链表(LeetCode 25)
234 回文链表的经典解法之一是找到链表中点,然后反转后半段链表,再与前半段逐节点比较。这里的“反转后半段”实际就是在调用 206 的反转能力。
25 题的难度比 206 高一个档次,要求每 K 个节点一组翻转,最后不足 K 个的部分保持原样。实现思路是在外层循环里分段调用 206 的反转逻辑,同时维护好上一组的尾节点和下一组的头节点。如果在刷 25 题之前能把 206 的迭代法写得滚瓜烂熟,那么 25 题的难点就不在“反转”本身,而在怎么分组和怎么拼接。
所以我的建议很直接:先把 206 的三种解法全部写熟,再去碰 92 和 25。不要小看简单题,很多中难题的代码框架都是简单题慢慢长出来的。
6. 额外想分享的刷题笔记:206 背后还藏了这些点
6.1 哑节点(dummy node)到底什么时候用
206 本身不需要哑节点,因为头指针可以在遍历中被自由替换。但在很多链表题里,哑节点能显著减少对“头节点是否为空”和“是否要单独处理第一个节点”的判断。
判断要不要用哑节点,一个朴素的检验标准是:删除节点或插入节点时,如果头节点可能被移除,或新链表的头节点还不确定,用哑节点最省心。206 是反转类操作,头节点必然变成尾节点,用哑节点也只是为了头插的便利,并不是必须的。
6.2 链表类题目的通用调试技巧
链表题报错最让人头疼的通常是“链接过程出现环”和“输出结果少了一截”。我的调试经验是:先用最简单的 1->2 用例跑一遍,如果结果对了,再逐步增加节点。复杂度上,如果出现超时,优先怀疑是不是有环;如果输出不完整,优先怀疑是不是没接上应该接的节点。
另外,不要只盯代码想逻辑,画图是最快的排查方式。在草稿纸上画三个节点的小链表,然后手动模拟一遍迭代过程,通常一眼就能看出哪一步指针操作顺序不对。我刷了这么多链表题,遇到想不通的时候,很少是因为代码语法错,更多是对指针之间的相对关系没画面感。
6.3 这道题给工程师带来了什么
从工程角度看,反转链表的实际价值未必是让你在工作中反一个链表,而是帮你建立“操作指针/引用时,如何保证不丢失信息”的思维。前端操作 DOM 节点顺序、后端操作数据库游标、底层操作内存缓冲区,都会遇到类似的“先保存现场,再修改指向”的问题。
所以我一直觉得像 206 这种“一眼看穿”的题目,比很多靠背模板的难题更能反映一个开发者的基本功。它能逼你直视几个最核心的问题:当前要修改哪个指针、修改之后原来指向的节点还能不能找到、边界条件是否完备。这三个问题想明白了,链表题基本就通了一半。
如果你现在正处于刷题初期,这道题值得反复写三次:第一次按自己的直觉写,第二次用递归写,第三次尝试不看任何资料,在 5 分钟内把迭代法和递归法都写对。然后你再去推进 92 题、25 题,会发现它们的难度并没有想象中那么吓人。
