自己刷了快三年题,回过头来看,如果让选一道最能代表链表基本功的题目,我大概率会投给LeetCode 206反转链表。题目短、描述简单,就是一个单链表反转,但它在面试里出现的频率高得离谱,而且几乎所有链表相关的复杂题目,最后都能拆解成这道题的操作。不管是刚入门准备校招的初学者,还是想巩固基础的社招选手,这道题都值得花时间吃透,不是背代码,而是把指针操作的思路彻底想明白。
今天这篇就围绕206反转链表来聊,包含题目解析、迭代递归两种核心解法的完整推导、代码里容易踩的坑,以及从这道题延伸出去的一串变体题。内容比较长,但保证每个细节都能直接用在你的刷题和面试里。
1. 题目与解题思路拆解
1.1 题目到底在说什么
原题描述其实很简单:给你单链表的头节点 head,请你反转链表,并返回反转后的链表头节点。
举个具体的例子。假设链表是 1 -> 2 -> 3 -> 4 -> 5,反转后应该变成 5 -> 4 -> 3 -> 2 -> 1。那这里的“反转”本质上是什么?不是把节点的值倒过来,而是要改变每个节点的 next 指针方向。原来 1 的 next 指向 2,反转后 1 的 next 应该指向 null,原来 2 的 next 指向 3,反转后应该指向 1,以此类推。
我刚刷这道题的时候,第一个直觉是遍历一遍链表,把节点的值都存到数组里,然后倒序重新赋值。这种做法能过测试,也能通过,但它完全违背了这道题的考察目的。因为面试官真正想看的,是你对链表这种数据结构本身的理解——指针怎么移动、节点怎么连接、怎么在不额外开辟大块内存的情况下完成反转。存数组的方式时间复杂度 O(n)、空间复杂度 O(n),如果面试官追问一句“能不能用 O(1) 空间完成”,基本就露馅了。
所以这道题最正统、最值得掌握的解法就两条路:迭代法和递归法。两种方法的时间复杂度都是 O(n),区别在于空间复杂度,迭代法是 O(1),递归法因为涉及调用栈,是 O(n)。核心逻辑都是要改变每个节点的 next 方向,只是实现手法不同。
1.2 为什么反转链表值得反复练
很多人可能会想,这道题这么简单,会写不就行了?但我的实际体会是,反转链表几乎是所有链表题的地基。二叉树的翻转、局部链表反转、K 个一组翻转、回文链表判断,这些题的核心操作里都能看到 206 题的影子。
举个例子,LeetCode 234 回文链表,判断一个链表是否回文,一个标准解法就是先找到中点,然后把后半段反转,再和前半段比较。这里的“后半段反转”就是 206 题的操作。LeetCode 25 K 个一组翻转链表,本质上就是在一个链表里反复做小规模的反转操作,每次反转 K 个节点,考的依然是 206 题那套指针逻辑。
所以我的建议是,不要只满足于把这道题写对,而是要做到三件事:第一,迭代法能流畅地写出来,并且能清楚解释每一步指针的变化;第二,递归法也能独立推导出来,知道递归的返回值是什么、边界条件为什么这么写;第三,当面试官提出各种追问——比如“如果是双向链表呢”“如果只让你反转前三个节点呢”时,你能基于最核心的指针操作思路现场推演。把这些吃透了,后面刷链表相关的困难题会顺很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 迭代法:最直观、最实用的解法
2.1 从走路的例子理解指针变换
迭代法的核心思路其实特别像“排队掉头”。想象一列人排着队,每个人手里举着一根绳子,绳子的另一端连着下一个人。现在要让整列队伍掉头,那每个人就要把手里的绳子松开,转而连到原来自己前面那个人身上。但问题是,如果所有人同时动手,绳子就会乱掉。所以只能一个一个来,从左到右依次调整。
仔细想这个过程,当你站在当前节点 cur,想把它原来指向下一个节点的指针改成指向上一个节点时,有个关键麻烦:你一旦改了 next,就找不到原来的下一个节点了。所以必须先提前把下一个节点存下来,再动手改当前节点的指针。这就是为什么迭代法里要有一个临时变量。
整个过程里涉及三个指针:prev(当前节点的前一个节点)、cur(当前正在处理的节点)、nextTemp(当前节点的下一个节点)。每轮操作就三件事:先保存 next,再让 cur.next 指向 prev,然后 prev 和 cur 同时向前移动一步,继续处理下一个节点。
这份代码是迭代解法的 Python 实现:
python复制def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
prev = None
cur = head
while cur:
next_temp = cur.next # 先保存下一个节点
cur.next = prev # 反转指针
prev = cur # prev 前移
cur = next_temp # cur 前移
return prev # 循环结束时 prev 就是新链表的头
我用一组具体数据走一遍流程来帮助理解。初始链表为 1 -> 2 -> 3 -> null,prev = null,cur = 1。
第一轮循环:next_temp = 2,然后让 1.next = null,这时链表从 1 这里断开了,变成 1 -> null,同时后面 2 -> 3 还连着。接着 prev = 1,cur = 2。
第二轮循环:next_temp = 3,让 2.next = 1,2 的指针就指向了 1。接着 prev = 2,cur = 3。
第三轮循环:next_temp = null,让 3.next = 2,prev = 3,cur = null。
循环结束,返回 prev,也就是节点 3,此时链表的顺序已经变成了 3 -> 2 -> 1 -> null,反转完成。
这里最关键的一点是,每一步的指针变换都依赖上次循环的 prev,而不是一个固定的“头节点”。所以 prev 这个变量必须在循环开始前初始化为 null,原因很简单,原始链表的头节点反转之后,它的 next 必须指向 null。
2.2 循环终止条件和返回值为什么是 prev
写这道题的时候,很多人会纠结循环条件写成 while cur 还是 while cur.next,返回值写 head 还是 prev。这两个地方恰恰是最容易出错、最值得展开讲的。
循环条件 while cur 意味着当 cur 走到 null 时停止。上一轮的操作已经让最后一个非空节点完成了指针反转,所以停止的时机刚好是所有节点都处理完。如果写成 while cur.next,那么当 cur 指向最后一个节点时,你还能进循环体,但是 cur.next 已经是 null 了,你需要在循环体里额外判断,而且容易把逻辑绕晕。
返回值为什么是 prev?因为循环结束时,cur 已经变成 null,而 prev 恰好停在了原链表的最后一个节点上。原链表的最后一个节点,反转之后就是新链表的头节点。所以返回 prev 是理所当然的。如果返回 head,那 head 还是原来的头节点,反转后它已经变成了新链表的尾节点,返回它结果就错了。
很多代码模板会把这套逻辑写成一个 for 循环配合 p、q 两个指针,但我觉得不管是 while 还是 for,理解变量在每轮循环结束后的落点才是关键。只要你能在一张草稿纸上,把每一轮循环结束后 prev、cur、next_temp 的位置画出来,这道题就永远不会写错。
2.3 带哨兵节点的头插法变种
除了上面这种“三指针原地反转”,迭代法还有一种变体,叫头插法。思路是新建一个虚拟头节点 dummy,然后遍历原链表,每遇到一个节点,就把它插入到 dummy 的后面。因为每次都插入到最前面,所以遍历结束后,dummy.next 指向的就是反转后的链表头。
这是 C++ 的头插法写法:
cpp复制ListNode* reverseList(ListNode* head) {
ListNode* dummy = new ListNode(0);
dummy->next = nullptr;
ListNode* cur = head;
while (cur) {
ListNode* nextTemp = cur->next;
cur->next = dummy->next;
dummy->next = cur;
cur = nextTemp;
}
return dummy->next;
}
头插法的好处是思路更直观,尤其适合扩展到“反转链表前 N 个节点”这类题目,因为 dummy 节点天然帮你处理了头节点变化的问题。缺点是多用了一个虚拟节点,而且插入操作比直接改指针稍微绕一点,新手容易搞混 dummy->next 和 cur->next 的赋值顺序。
两种迭代写法我建议都练一练,因为在不同题目里,有时三指针写法更顺手,有时头插法更好理解。平时刷题不是背模板,而是要理解每种写法的设计动机,才能真正做到灵活运用。
3. 递归法:另一种理解链表的视角
3.1 递归的拆解思路
递归法第一次接触时确实有点难以理解,但只要抓住关键点,你会发现它比迭代更有对称美感。递归的核心思想是:假设链表只有两层,我要怎么反转?再看清楚,这个“两层处理”可不可以一直套用到更长链表上?
假设我们有链表 1 -> 2 -> 3 -> 4 -> 5。递归函数 reverseList(head) 的任务是“传入一个头节点,返回反转后新的头节点”。如果我们把问题缩小一层,先不考虑第一个节点 1,而是让递归去处理 2 -> 3 -> 4 -> 5 这一段,那递归返回的结果应该是 5 -> 4 -> 3 -> 2,新的头是 5。
现在的问题是,怎么把这个结果和节点 1 拼接起来?注意,递归处理完 2 到 5 这一段后,原来的 2 节点成了这段链表的尾巴,而 2 是 head.next,所以在递归返回后,我们可以直接通过 head.next 找到 2 这个节点。要想让 1 变成这段反转链表的最后一个节点,只需要执行 head.next.next = head,把 2 的 next 指向 1,同时让 head.next = null,因为 1 是新的尾巴,它的 next 必须是空。这样就完成了整条链的反转。
递归终止条件是什么?当 head 为 null 或者 head.next 为 null 时,不需要再反转了,直接返回 head。节点数少于两个,反转结果就是它自己,比如只有 1 个节点,反转之后还是它自己。
3.2 递归代码与调用过程模拟
以下是递归解的 Python 实现:
python复制def reverseList(self, head: Optional[ListNode]) -> Optional[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
这段代码真正核心的只有三行:递归调用、改指针、断开原链接。我强烈建议初学者在纸上把调用栈画一遍,因为递归的本质就是“先一路走到黑,再一路回头改指针”。
拿 1 -> 2 -> 3 -> null 举例。调用 reverseList(1),发现 1.next 不为空,于是调用 reverseList(2)。调用 reverseList(2) 又调用 reverseList(3)。调用 reverseList(3) 时,3.next 为 null,满足终止条件,直接返回 3。这是递归最底层的一层。
回到调用 reverseList(2) 这一层,new_head = 3,执行 head.next.next = head,也就是 3.next = 2,然后 head.next = null,即 2.next = null。此时链表局部的状态是 3 -> 2,返回 new_head = 3。
回到调用 reverseList(1) 这一层,new_head = 3,执行 head.next.next = head,也就是 2.next = 1,然后 head.next = null,即 1.next = null。此时链表整体状态是 3 -> 2 -> 1,返回 new_head = 3。反转完成。
画完这个调用过程会有一个很深的体会:递归在“归”的过程中,每一层都只做一件事——把当前层的 head 节点放到已经反转好的子链表后面,同时断开原来向前的链。每一层的操作完全一样,所以复杂度非常清晰。
3.3 迭代和递归怎么选
这两者的空间复杂度差异是面试里一定会被问到的问题。迭代法只用了常数个指针变量,空间复杂度 O(1),而递归法每一层调用都会占用栈空间,空间复杂度是 O(n)。当链表非常长时,递归法可能因为栈溢出直接崩溃,所以如果生产代码里真的要反转链表,我肯定优先考虑迭代法。
但递归也不是没有价值。在一些更复杂的场景中,比如反转链表的一部分、二叉树相关问题,递归的“先处理子问题,再拼接当前节点”的思路特别自然,反而迭代要维护很多状态。所以实际面试中,如果面试官要求两种方法都写,先写迭代,再补充递归;如果只让写一种,优先迭代,因为空间更优,逻辑也更直接,不容易在细节上翻车。
从时间角度看,两者都是 O(n),每个节点都要访问一次,区别只在常数大小。所以不必纠结“递归一定慢”,在某些语言里现代编译器对尾递归有优化,但链表反转的递归不一定是尾递归,所以别指望这个。
4. 常见问题与调试技巧实录
4.1 空指针和悬空指针问题
链表题写多了你会发现,绝大多数 bug 都出在空指针上,尤其反转链表这种频繁改 next 的题,最容易出现两类问题。
第一类:忘了处理空链表。如果 head 是 null,迭代法里 while cur 循环根本不会进去,直接返回 prev,也就是 null,这没问题。但如果你在循环前就访问 head.next,那就会直接报错。所以如果你的代码里出现了 head.next 这种表达式,一定先确认 head 不为空。
第二类:悬空指针。这个问题出在递归法里,如果不给 head.next 赋 null,那么反转之后,新的尾节点的 next 仍然指向原来的节点,可能形成环。比如 1 -> 2 -> 3 反转后,如果不打断 1.next,1 的 next 还是 2,而 2 的 next 已经变成 1 了,两个节点互相指向,形成一个环,遍历的时候就会死循环。这是 206 题最常见的“看起来对了但跑起来卡死”的坑。
我调试这类问题的习惯是,写完之后手动模拟一遍长度为 2 和长度为 3 的链表,不用画得太细,只要看一轮循环或一层递归结束后 next 的指向合不合理。很多隐藏 bug 就是在手推两个节点时暴露的。
4.2 测试用例怎么设计才算完整
不要觉得反转链表简单就只测一个用例。面试现场写代码,测试用例的设计往往能体现一个人的工程素养。我平时做这道题,至少会跑五类用例:
| 场景 | 输入 | 预期输出 | 作用 |
|---|---|---|---|
| 普通链表 | 1->2->3->4->5 | 5->4->3->2->1 | 验证基本功能 |
| 空链表 | null | null | 验证空指针保护 |
| 单节点链表 | 1 | 1 | 验证最小规模 |
| 双节点链表 | 1->2 | 2->1 | 验证两节点反转 |
| 带重复值的链表 | 1->2->2->3 | 3->2->2->1 | 验证与值无关,只动指针 |
空链表和单节点这两个用例看似简单,但我见过不少人在面试时一上来就写 while cur.next,导致空链表直接崩。把这两个边界情况提前想清楚,代码自然会更稳。
4.3 现场写出正确代码的小技巧
如果面试时压力大导致思路混乱,我有个习惯,先在注释里把每个变量的含义写清楚,再写代码。比如迭代法里,我肯定会先写三行注释:prev 是已反转部分的头节点,cur 是当前待处理节点,nextTemp 是下一个待处理节点。
还有一点,写完后建议口头走一遍测试用例,不是心里默念,而是真的说出来。因为说出来的时候你的逻辑会更清晰,面试官也能看到你的思路。比如可以说“初始 prev 是空,cur 指向头节点,第一轮循环保存 next,然后反转指针……”,这种表达方式本身就是加分项。
5. 从206题延伸出去的一串变体
5.1 反转链表 II:只反转指定区间
LeetCode 92题“反转链表 II”是206题最常见的变体,要求反转链表从 left 到 right 的区间。题目的结构是:找到 left 的前一个节点,然后对 left 到 right 这一段做反转,最后把反转后的区间重新接回原链表。
做法其实是在迭代法的基础上加一个定位过程。先用一个指针走到 left 的前一个节点,记作 pre,然后让 cur 指向 left,接下来做 right - left 次反转操作,操作逻辑和 206 完全一样。最后把 pre.next 指向反转后的头节点,把反转区间的尾节点指向原来的 right.next。
我在做这道题时最深的体会是,206 题的三指针操作并不是只有“从头开始”才能用,你可以把“已经反转好的部分”看作 prev,把“当前待处理部分”看作 cur,把“还没处理的部分”看作 nextTemp,在任何一段链表中都能复用这套逻辑,这也是 206 作为基础题的核心价值。
5.2 K 个一组翻转链表
LeetCode 25题“K 个一组翻转链表”,要求每 K 个节点一组反转,不足 K 个的不反转。这道题比 92 题又复杂了一层,因为你需要先找出当前这一组的头尾,反转整组,然后把组与组之间接起来。
实现上一般用一个 dummy 节点作为整个链表的虚拟头,每次循环定位 K 个节点,如果不够 K 个就退出。反转一组就可以复用 206 的迭代逻辑,只不过 206 的终止条件是 cur 走到 null,这里变成了 cur 走到这一组的结尾。
如果你觉得 25 题太难,我建议先把 206 迭代法练到可以闭眼写,再去尝试。等你能独立写出 25 题后,你对链表的理解基本就合格了。
5.3 用栈、递归其他思路做反转
除了迭代和递归,还有一些非主流但很有意思的解法。比如用栈:遍历链表,把每个节点入栈,然后依次出栈,重新连接 next 指针。这个方法空间复杂度是 O(n),不推荐在面试里当主解法,但作为扩展思路,能帮你理解“栈的先进后出天然适合反转”。
也可以用数组先存节点,再倒序重建链表,但注意这样重建的链表节点和原节点不同,如果题目要求“原地反转”,这种解法就完全不适用了。
6. 刷题与面试中的经验心得
反转链表这道题,我从第一次接触到现在写了不下 20 遍。每次写都有新的体会,比如递归解法在刷题初期怎么看都不理解,刷了几十道二叉树递归题后再回头看,突然就通透了。这其实暴露了一个真相:很多链表题的难点不在指针操作本身,而在于有没有建立“递归处理子问题”的思维模式。
如果你也是那种“看题解秒懂,合上书就写不出来”的人,我的建议是不要等看懂了再写,而是先合上书,凭记忆和推理在纸上把代码写出来,卡住了再翻。卡住的地方就是你的知识盲区,多卡几次,进步会非常快。
最后分享一个实际操作中的小技巧:在做 206 题的时候,把链表节点用一个自定义的类在本地打印出来,每执行一轮循环就打印一次链表的顺序。这样你能直观看到每轮循环后链表的变化,远比只看最终结果有用。比如初始 1 -> 2 -> 3,第一轮后变成 1 -> null,同时 2 -> 3 还没处理,打印出来你会发现链表其实从中间断开了,然后再慢慢接起来。理解了中间态,你对指针操作的理解会上一个台阶。
这道题真的不难,但把它吃透的价值,远比刷几十道简单题更大。希望这篇能帮你把 206 反转链表从“会背代码”变成“真的懂”。
