反转链表,LeetCode第206题,代码随想录链表章节的第一道实战题。很多人第一次刷到这里,看到 head.next.next = head 这行代码直接懵掉,或者明明用迭代写了几十行却总在边界条件上翻车。其实这道题表面是“把一个链表倒过来”,背后却浓缩了链表所有核心操作——遍历、指针重定向、头节点变更。刷透这道题,后面遇到反转前N个、K个一组翻转甚至链表排序,你都不会再怕。
这篇文章我不会只贴两版代码就完事,而是把双指针法、递归法的每一步都拆开讲清楚,把我在实际调试中踩过的坑、面试时被追问过的点都写出来。无论你是刚学链表的小白,还是准备校招社招刷题党,这篇都能给你在206这道题上补上最后一块拼图。
1. 看穿这道题:链表反转的本质
1.1 题目到底在问什么
206题的原题描述很简洁:给你单链表的头节点 head,请你反转链表,并返回反转后的链表头节点。
很多初学者把“反转链表”等价于“把链表倒序输出”,这是完全跑偏的。倒序输出只需要遍历时把值放进栈或数组里,再反向打印就行,链表本身的结构一点没动。而反转链表要求的是原地修改每个节点的 next 指针方向,让原来指向下一个的节点反过来指向前一个。举个具体例子:
code复制原链表: 1 -> 2 -> 3 -> 4 -> null
反转后: 4 -> 3 -> 2 -> 1 -> null
这里要特别注意,反转后头节点变成了原链表的尾节点4,而原来一直指向下一个的箭头全部调转方向。最终尾节点的 next 必须是指向 null 的,否则链表就断了。
这道题在代码随想录中被放在链表章节的靠前位置,是有道理的。它不像环形链表那样需要额外的数学推导,也不像删除节点那样需要考虑“虚拟头节点”的套路。它考的是你在链表这个数据结构上最基础、最核心的能力:在遍历中稳定地重排多个节点之间的前后关系。
1.2 反转一个链表的三个关键动作
如果让我把反转链表的过程浓缩成一句话,就是:一边向后遍历,一边把当前节点的 next 箭头掰回前一个节点。但这句人话要落到代码里,需要三个关键动作配合:
第一,提前保存后继节点。当你把当前节点的 next 指向前一节点时,原链表的“后路”就断了。如果没提前记下当前节点原本的后继,循环就没办法继续往后走。很多初版代码跑起来死循环或者只反转了一截,90%都是栽在这个动作上。
第二,更新前驱节点。当前节点处理完之后,它在下一轮中要扮演“前驱节点”的角色,所以要用变量把它接住。
第三,返回新的头节点。整个链表遍历完成后,最后一个节点就是新链表的头,也就是循环终止时 pre 指针指向的那个节点。
我见过不少人习惯在循环结束后找 cur,或者干脆返回原来的 head,结果发现返回的链表只有最后一个节点。原因就是没搞清楚:循环跑完后 cur 已经变成 null,真正能代表新链表入口的,是最后落位的 pre。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双指针法:一步一步手动反转
2.1 为什么要三指针而不是两指针
双指针法听着像两个指针,实际跑起来需要三个变量:pre(前驱节点)、cur(当前节点)、tmp(临时保存后继节点)。核心循环是五步:
code复制初始化:pre = null,cur = head
循环条件:cur != null
循环体:
1. tmp = cur.next // 先拽住后路
2. cur.next = pre // 反转箭头
3. pre = cur // 前驱前移
4. cur = tmp // 当前前移
最后返回 pre
有人会问:能不能不引入 tmp,直接用 cur.next 做当前节点?我们来模拟一下:假如链表是 1 -> 2,初始化 pre = null, cur = 1。如果做 cur.next = pre,那么 1 -> null,原来的 2 就彻底找不到了。链表不像数组有索引,丢失一个节点就是丢失一整段数据。所以 tmp 是必须的,它是整个方法能否继续推进的生命线。
这就像你在拆一堵砖墙,手里抱着一块砖(cur),要把它塞到前面一块砖(pre)的背后。如果不在动这块砖之前,先把后面那块砖(tmp)托住,墙体瞬间就会垮掉,整个施工顺序也就乱了。
2.2 代码实现与执行过程拆解
我用 Python 和 C++ 各写一版,这两版是代码随想录风格的经典写法,面试时直接背下来也没问题。
Python 版本:
python复制class Solution:
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
pre = None
cur = head
while cur is not None:
tmp = cur.next
cur.next = pre
pre = cur
cur = tmp
return pre
C++ 版本:
cpp复制class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* pre = nullptr;
ListNode* cur = head;
while (cur != nullptr) {
ListNode* tmp = cur->next;
cur->next = pre;
pre = cur;
cur = tmp;
}
return pre;
}
};
我们来手动走一遍 1 -> 2 -> 3 -> null 的过程:
- 初始:
pre = null,cur = 1 - 第1轮:
tmp = 2,cur.next = null(1指向null),pre = 1,cur = 2 - 第2轮:
tmp = 3,cur.next = 1(2指向1),pre = 2,cur = 3 - 第3轮:
tmp = null,cur.next = 2(3指向2),pre = 3,cur = null - 循环结束,返回
pre = 3
写成箭头轨迹就是:
code复制原链表状态: 1 -> 2 -> 3 -> null
第1轮之后: null <- 1 2 -> 3 -> null
第2轮之后: null <- 1 <- 2 3 -> null
第3轮之后: null <- 1 <- 2 <- 3
这个过程中,pre 永远指向当前已经反转好的部分的头节点,cur 指向还没处理的节点。最终返回的就是反转完成后的头节点。
2.3 复杂度分析与易错点
时间复杂度是 O(n),因为每个节点恰好被访问一次;空间复杂度是 O(1),因为只用了三个临时变量。这也是双指针法比递归法优越的地方:不管链表多长,额外的内存开销都是常数级的。
围绕这种方法,有三个易错点我在辅导同学和刷题时见得最多:
第一,忘记保存 tmp 就修改 cur.next。我在 2.1 里已经说过了,这是死代码的源头,链越长老实犯错,因为后面的节点全丢。
第二,终止条件写成 cur.next != null。这会导致链表中最后一个节点没有被反转。正确条件必须是 cur != null,因为只有遍历完整条链表,让 cur 变成 null,才能保证尾节点的 next 也被正确修改为 null。
第三,循环结束返回 cur 或 head。这个我在开头就强调过:cur 循环结束后是 null,head 变成了新链表的尾节点。只有 pre 是新链表的头。实在记不住,就记住口诀:谁最后落位,谁是新头。
3. 递归解法:六行代码背后的递推逻辑
3.1 递归的终止条件和返回值
相对于双指针法的“按部就班”,递归解法只有六行,但理解门槛高了不少。很多人背下了代码,被面试官一问 newHead 到底是谁就卡壳。
先看代码:
Python 递归版本:
python复制class Solution:
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
if head is None or head.next is None:
return head
new_head = self.reverseList(head.next)
head.next.next = head
head.next = None
return new_head
C++ 递归版本:
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 == null || head.next == null。这句话有两个含义:空链表直接返回空;链表只有一个节点时,它本身就是反转后的链表,直接返回它。很多初学者只关注第二个含义,忽略了第一个,导致传入空链表时直接空指针异常。
返回值是反转后的新链表头节点。这是递归问题里最容易搞混的点:每一层递归返回的不再是原来的 head,而是原链表尾节点,也就是最深那个递归调用结束时作为 head 传进去的节点。举个例子,链表 1 -> 2 -> 3 -> null,最深一层递归传入的是 3,这一层的返回值就是 3,然后逐层向上,每一层返回的都是 3。
3.2 为什么是 head.next.next = head
这是递归解法里最魔幻的一行。先看整体思路:reverseList(head.next) 这个调用假设“后面的链表已经反转好了”。比如当前在节点1,调用 reverseList(2) 之后,后面 2 -> 3 已经变成了 3 -> 2,返回值是 3。此时链表形态是这样的:
code复制原顺序还没断开时的视角: 1 -> 2 <- 3 (注意2.next已经指向了1?并没有)
准确说,调用完 reverseList(head.next) 后,head.next 依然是 2,但 2 的 next 已经变成了 3 的反转形态。为了把 1 接到这个反转后的链表上,我们需要让 2 的 next 指向 1。因为 head.next 正好是 2,所以 head.next.next = head 这一步就是把 2 的 next 指向 1,完成 1 的接入。
执行完这行后,当前局部形成了一个环:1 -> 2 -> 1 -> 2 -> ...。如果不斩断,最终递归返回的链表中就会带环。所以紧接着 head.next = null 就是把这个环从 1 这个位置切断,让 1 成为新的尾节点。
整个过程用文字模拟是这样的:
- 原链表
1 -> 2 -> 3 -> null - 递归深入到
3,3.next == null,返回3 - 回到节点
2:2.next是3,执行3.next = 2,链表局部变成3 -> 2,2.next置null - 回到节点
1:1.next是2,执行2.next = 1,链表局部变成3 -> 2 -> 1,1.next置null - 返回
3
3.3 双指针与递归的取舍
双指针法和递归法没有谁绝对好,关键在于场景。我自己的习惯是:面试中优先写双指针法,因为它的空间复杂度是 O(1),而且过程完全可控,不用向面试官解释递归栈的调用逻辑。
递归法代码确实精炼,但代价是空间复杂度变成了 O(n),这个 O(n) 不是显式分配数组,而是系统递归栈的开销。链表很长时(比如几万节点),递归有栈溢出的风险,这在工程代码里是很现实的隐患。
我把两者做个对比,方便你记忆:
| 维度 | 双指针法 | 递归法 |
|---|---|---|
| 代码长度 | 约8行 | 约6行 |
| 空间复杂度 | O(1) | O(n) 递归栈 |
| 指针操作可读性 | 直观,每一步清楚 | 抽象,需要理解递推 |
| 面试推荐度 | 高 | 中,追加提问时常用 |
| 工程使用度 | 高,几乎无风险 | 低,长链表有栈溢出风险 |
如果你能一口气把两种写法都写出来,并且解释清楚各自的空间复杂度和缺陷,面试官通常会比较满意。因为这说明你不是背题党,而是真的理解了链表指针操作。
4. 边界条件与踩坑实录
4.1 空链表、单节点和三节点
链表题有个通用诅咒:边界条件出问题,往往是链表规模太小或者太大的时候。反转链表的边界条件主要集中在三种情况:
空链表:head == null。双指针法里循环根本不进入,直接返回 pre,也就是 null,没问题。递归法里第一个判断直接返回 null,也没问题。但如果面试时你从 head.next 开始操作,这里就直接崩了。
单节点链表:head.next == null。双指针法循环走一轮,pre 变成原头节点,返回它,没问题。递归法第二个判断直接返回 head。
三节点以上:这是最容易把整个过程搞混的地方。我建议初学的朋友一定要在白纸上把 1 -> 2 -> 3 的三轮循环画出来,把每个指针的位置、每次 next 修改后的方向都标清楚。画过一次之后,双指针法的循环体基本不会再写错。
4.2 最常见的三个坑
第一个坑是循环引用。递归解法里如果漏了 head.next = null,在节点2这个位置上会形成 2 -> 3 -> 2 的环。判断链表是否成环的最直接方法就是在测试用例里打印每个节点的地址,观察是否出现重复地址;或者写一段循环遍历代码,设置一个计数器,超过节点数就说明成环了。
第二个坑是C++ 手动内存管理。C++ 刷题时用 new 创建的节点一般不用手动 delete,因为 LeetCode 测试框架会自动回收。但在本地或者工程代码中,反转链表会造成原来的头节点变成尾节点,如果代码逻辑里存在“析构时依次 delete next”的习惯,反转后形成的链表每个节点只被一个指针指向,不会重复释放,这一点倒是安全的。真正要小心的是反转过程中绝对不能 delete 任何一个中间节点,一旦释放,后续所有 next 访问都是悬垂指针。
第三个坑是修改链表后忘记更新外部引用。在真实工程里,链表通常被封装在某个类中,头节点可能有多个全局引用。你反转链表返回了新头,但外部如果还持有旧头引用,就可能出现“看起来链表没变”的诡异 Bug。所以反转函数最好做成接收头节点、返回新头节点,让调用方重新赋值。
4.3 调试链表题的实用手法
我调试链表题有一套固定流程,分享出来供你参考:
第一步,打印工具函数。在刷题环境里写一个 printList(head),遍历链表把节点值用 -> 连接输出。别觉得这多此一举,它能帮你快速确认反转前后形态是否正确。第二步,手动模拟小规模用例。每次提交前,先用 []、[1]、[1,2]、[1,2,3] 这四组最基础的数据测本地。第三步,看环。如果代码在本地运行出现死循环,立刻检查是不是有节点在反转后仍然指向了原本的后继,尤其是递归法里是否漏了把 head.next 置空。
我见过有人在调试时打印 cur.next.val,结果 cur.next 是 null,直接抛空指针异常。这种基础错误很影响心态。所以打印时一定先判断当前节点是否为空。
5. 从206到系列进阶:链表题型怎么练
5.1 先打好链表基本操作
热搜词里出现了一大堆链表相关操作:“链表遍历”“链表插入”“单链表的基本操作实验”“b3631 单向链表”。这其实暴露了一个核心问题:很多人还没把链表的基本功练扎实,就急着刷反转,结果卡在指针操作上。反转链表本质上就是链表遍历加指针重排的组合技,所以基础必须打牢。
我建议按照这个顺序练基本功:
- 遍历链表并打印每个节点的值
- 在头部、尾部、指定位置插入节点
- 删除指定值的节点
- 合并两个有序链表(LeetCode 21)
- 求链表长度和中间节点
这些操作每一个都不难,但它们共同构成你操作链表时的“手感”。手感这个东西很玄学,实际上就是你拿到了一个 head 后,能迅速反应出“我现在能不能动 head->next”“动了之后还能不能找到下一个节点”。刷反转链表之前先把这些练熟,效率反而更高。
5.2 206的三种变形题
206反转链表是一个母题,从它身上能派生出一整族题目,每种都在206的基础上加了一点条件:
反转链表前N个节点:只翻转链表的前N个节点,后面的保持原顺序。实现时需要多记录一个“第N+1个节点”,并在翻转完成后把前N个节点的尾部接到这个节点上。
反转部分区间(LeetCode 92):反转从left到right位置的节点。这个可以看成反转前N个节点的推广,核心是定位 left 的前驱节点,然后在这段区间内做标准反转。区间反转完,再把 left 前驱的下一个指向新头,把区间尾部的 next 指向原本的后继。这道题特别适合检验你对206双指针法的掌握程度,因为区间内的反转代码几乎一模一样。
K个一组翻转链表(LeetCode 25):把链表分成长度为K的段,每段内部翻转,段与段之间再连接。这道题是206的终极进阶,因为要把整条链表切成多个206子问题,还要处理段间连接和不足K个的尾巴。很多候选人能把206默写出来,但25题一上手就乱,核心原因就是对206里的指针交接过程没有形成肌肉记忆。
所以我的建议很明确:206必须练到闭眼能写双指针法的程度,再往92和25题推进。不要一上来就挑战25题,那样大概率是浪费时间。
5.3 下一站:环形链表和有序链表
题干里还出现了“循环单链表”“合并两个有序的单链表”“基于链表的两个集合的差集”这些词,说明你可能已经检索过周边题型。我顺带点一下这些题与206的关系:
环形链表(LeetCode 141、142)考察快慢指针,它和206的共同点是都用指针遍历,但环形链表不需要修改节点方向,本质上是数学上的相遇问题。合并两个有序链表(LeetCode 21)则是递归法和迭代法的经典练习,我在21题里也会用到“哨兵节点”这个小技巧。基于链表的集合差集在笔试中很少直接出现,更多是C语言数据结构课设里的内容,它考察的反而是最纯粹的“遍历+比较”能力。
从整体看,链表刷题有一个很清晰的套路:先刷遍历和基本操作,再刷206和它的变形题,然后刷环形和相交类题目,最后才是复杂场景下的链表操作。我见过不少人反着来,先做难题被打击,再回头刷基础,效率低不说,还容易产生畏难情绪。
6. 常见问题速查表与面试心法
6.1 常见问题速查表
我整理了一个速查表,都是平时答疑时高频出现的问题,你可以直接收藏:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 反转后只返回了一个节点 | 最后返回了 cur 而非 pre |
循环结束后 cur 必为 null,返回 pre |
| 反转后链表出现循环 | 递归法漏写 head.next = null |
手动把尾节点指向空 |
| 反转前几项后后面的节点丢失 | 修改 cur.next 前未保存后继 |
循环体内先执行 tmp = cur.next |
| 空链表传入直接崩溃 | 没有判断 head == null |
双指针法天然安全;递归法需要显式判断 |
| C++ 中内存泄漏或悬垂指针 | 反转过程中误删节点 | 反转过程中不要释放任何节点 |
| 链表很长时递归栈溢出 | 递归深度等于链表长度 | 改用双指针法迭代实现 |
还有一个很多人忽略的点:LeetCode 的链表节点定义在不同题目中可能字段不同。有的叫 next,有的叫 next 但节点类中构造函数不同。刷题时先看题目定义的 ListNode 结构,再写代码,避免在返回值类型上花无谓的时间。
6.2 面试现场怎么稳
反链链表作为高频手写题,面试时我建议按照“沟通思路 -> 手写 -> 自测边界 -> 复杂度分析”四步走。
先说沟通思路。面试官出题后,不要立刻低头写代码,先用一两句话告诉他:“我准备用双指针法,维护前驱节点和当前节点,在遍历过程中逐个反转箭头方向,时间复杂度O(n),空间O(1)。”这能展现出你审题和规划的能力。如果你觉得递归法也可以展开,可以补一句:“还有一种递归写法,不过空间复杂度会到O(n)。”
手写时要注意代码风格,别把临时变量起名 a、b、c。pre、cur、tmp 这三名字在链表题里是约定俗成的,面试官一看就懂。写完立刻用空链表和单节点链表自测,然后主动说出“这里我判断了 head == null,所以空链表安全”。
最后回答复杂度时,重点讲清楚“为什么空间复杂度是O(1)”——因为不管链表多长,额外变量始终只有三个。这一句话就能把你和只会背代码的人区分开。
我个人在实际辅导中有一个很深的体会:反转链表这道题,刷一遍绝对不够。每周抽五分钟再手写一遍,连续三到四周,你会发现自己在指针操作上的自信度完全不同。这种自信不是靠背出来的,是你在一次次 cur.next = pre 中真正理解了“链表里没有魔法,只有指针”这句话之后长出来的。
