刷了这么多道链表题,我越来越觉得反转链表就是整个链表题型的“地基中的地基”,hot100里把它排在第18位,看着简单,但真到了面试手写环节,能一次性写对、讲清楚的人其实不多。这篇就拆一拆这道题,把我自己从“只会背代码”到“真正搞懂指针流动”的过程和经验全部倒出来。
1. 题目到底在考什么:为什么一道反转链表能被所有大厂翻来覆去地考
刷题圈子有句话叫“得链表者得面试”,虽然夸张,但也有一定道理。反转链表(力扣206题)的题干一句话就说完:给你单链表的头节点head,请你反转链表,并返回反转后的链表。
但就是这么一道人畜无害的题,常年出现在hot100、剑指Offer、各公司题库里。我一开始也想不通:这题长得也太基础了,凭什么这么高频?后来面了几轮、帮别人讲了几天题才明白,它考察的东西远比题目本身深。
第一层,考察你对指针操作的理解是不是“真懂”。很多人数组题刷得很溜,一到链表就露馅,因为数组靠下标访问,脑子里想的是“位置”;链表靠引用串联,脑子里必须想的是“节点连接关系”。反转链表恰恰是把这种连接关系玩到极致的题:你要不断改变next指针的方向,还不能丢失节点引用。
第二层,考察你对边界情况的条件反射。空链表怎么处理?只有一个节点怎么办?两个节点呢?很多人在IDE里写跑得通,一换到白板或在线编辑器,漏掉一个head为空就直接空指针异常。这题把这些边界条件全部压缩在短短几行代码里,是天然的“边界感体检机”。
第三层,它是一堆进阶题的前置技能。力扣25题K个一组翻转链表本质上就是“分组反转链表”;力扣92题反转链表II是“反转指定区间的链表”;力扣234题回文链表需要先找中点再反转后半段做比较。这些题你回头看,内核全都是反转链表的变形。
所以我的建议是:不要因为题目简单就跳过去,至少要把这题的迭代、递归、头插法三种写法都练到“手比脑子快”的程度,进阶题遇到瓶颈时再回来重新审视它。
2. 迭代反转:三指针步进法,把每一条next指针的方向掰过来
迭代法的思路最难的部分不是代码,而是怎么让新手理解“我到底在反转什么”。很多讲解直接甩出pre、cur、temp三个指针的循环,看得人云里雾里。我先用自己的话把过程捋一遍。
假设链表是1 -> 2 -> 3 -> 4 -> 5,目标是5 -> 4 -> 3 -> 2 -> 1。我们想象有一个“已反转区域”和“待反转区域”中间被一条线隔开。最开始,1已经是反转区域的第一个节点,但我们希望1变成最后一个节点,所以它最终要指向None。于是我们用一个prev指针表示“当前节点应该指向谁”,初始是None;用cur表示“当前正在处理的节点”,初始是head;还需要一个nextTemp记录“下一个要处理的节点”,因为一旦改了cur.next,原链表后半段就断了,不提前保存就找不回来了。
2.1 逐步推演:手动模拟一次循环
我从最简单的三节点链表1 -> 2 -> 3开始,把每一轮循环结束后指针的状态写下来。
初始状态:
- prev = None
- cur = 1
- nextTemp = 1.next = 2
第一轮循环:
- 保存nextTemp = 2
- 执行cur.next = prev,也就是把1.next指向None,此时1和2断开
- prev = cur(prev变为1)
- cur = nextTemp(cur变为2)
第二轮循环:
- 保存nextTemp = 3
- 执行cur.next = prev,也就是把2.next指向1
- prev = cur(prev变为2)
- cur = nextTemp(cur变为3)
第三轮循环:
- 保存nextTemp = None(因为3.next是None)
- 执行cur.next = prev,也就是把3.next指向2
- prev = cur(prev变为3)
- cur = nextTemp(cur变为None)
循环结束,返回prev,也就是3。看到没有,整个链表被我们从头部开始一节一节地“掰”掉了方向,最后原来的tail变成了head。
2.2 迭代法标准代码
用Python写就是下面这样,我建议把这段代码练到闭上眼睛都能写出来:
class Solution: def reverseList(self, head: ListNode) -> ListNode: prev = None cur = head while cur: next_temp = cur.next # 第一步:先保存后继,不然改完指针就断了 cur.next = prev # 第二步:把当前节点指向前面 prev = cur # 第三步:prev前移 cur = next_temp # 第四步:cur前移 return prev # 循环结束时prev正好是原链表的尾节点,也就是新头这段代码里最容易错的地方有两处。一是忘了保存next_temp就直接改cur.next,导致链表后半段丢失;二是循环结束之后不知道返回谁,有人会返回cur,但此时cur已经变成None了,应该返回prev。这两个错误我在初学阶段各犯过不下五次。
2.3 复杂度与边界条件
迭代法的时间复杂度是O(n),只需要遍历链表一次;空间复杂度是O(1),只用了三个指针,没有任何额外的存储。这也是面试中最推荐的写法,因为空间表现最好。
边界条件其实被while cur悄悄处理掉了:链表为空的时候,prev保持None,返回None,正确;链表只有一个节点的时候,循环执行一次后返回那个节点本身,正确。所以迭代法天然对空链表和单节点链表免疫,写的时候反而不需要额外判断。
3. 递归反转:先递进到终点,再逐层把方向倒带回来
如果说迭代法是“顺着箭头走”,那递归法的核心就是**“先走到头,再回头处理”**。这玩意儿我第一次看的时候大脑直接宕机,尤其是head.next.next = head这行代码,一度让我觉得是在写什么魔法。后来我换了个理解角度,才算真正看透。
递归法的base case很直白:当链表为空或者只有一个节点时,反转就是它自己,直接返回。这一步谁都看得懂,难点在递归之后那两行代码。
3.1 递归法标准代码
class Solution: def reverseList(self, head: ListNode) -> ListNode: # 递归终止条件:空链表或单节点链表 if not head or not head.next: return head # 等子链表反转完成,拿到新头 new_head = self.reverseList(head.next) # 把当前节点的后继节点的next指向当前节点 head.next.next = head # 当前节点原来的next置空,防止成环 head.next = None return new_head慢慢拆解,假如链表是1 -> 2 -> 3 -> 4 -> 5:
reverseList(1)被调用,由于1.next不是None,进入递归,调用reverseList(2); reverseList(2)同理调用reverseList(3); reverseList(3)调用reverseList(4); reverseList(4)调用reverseList(5); reverseList(5)满足5.next为None,直接返回5。
到这里,递进过程结束,开始“归”。每一层归的时候,做的都是同一件事:把当前节点后继的next指向当前节点。比如回到reverseList(4)这一层,head是4,head.next是5,所以head.next.next = head就是把5.next指向4,此时4和5这段变成4 <- 5。然后head.next = None,把4原来的next断掉。这步不写会有什么后果?等我们回到reverseList(3)时,3.next是4,而4.next已经是None了,如果3不把自己和4的连接后续处理,链表最终会变成5 -> 4 -> 3 -> 2 -> 1 -> ?,1还指着2,2还指着3,3指着4,4指着5,5指着4……就成环了。所以每次递归归来必须把当前节点的next置空,靠着更高一层递归去重新建立正确连接。
3.2 递归的隐患与权衡
递归法的时间复杂度是O(n),空间复杂度是O(n)。n是链表的长度,因为每一层递归都占用一个栈帧。链表特别长的时候(比如几十万节点),递归可能直接栈溢出。这一点在面试时必须主动提出来,能说完“空间复杂度是O(n),主要消耗在递归栈上,如果链表很长会有栈溢出风险”,往往比闷头写完加分更多。
但也有一个面试中容易忽略的细节:递归法代码量少、逻辑抽象,但对理解要求高。你如果只背下来而讲不清楚那两行head.next.next的道理,面试官一追问就露馅。所以我给很多人的建议是:迭代法必须滚瓜烂熟,递归法要能讲清楚原理,两个都要会,但先确保迭代是条件反射级的熟练。
3.3 配合“递归含义”来理解
还有一个特别有用的视角,是我后来从某位前辈那里学来的:不要去脑补每一层递归的完整执行过程,只要信任函数的定义就行。reverseList(node)这个函数,定义了就是“把以node为头节点的链表反转,并返回新头”。那么调用reverseList(head.next)之后,我不用管它内部怎么执行,只需要知道“从head.next开始的那条子链表已经被反转好了”,它的最后一位是head.next(反转前是“子链表的第一个节点”,反转后成了“子链表的最后一个节点”),而它的新头我们已经拿到了,叫new_head。现在要做的就只剩把head接到这个反转后的子链表末尾,再把head的next置空。用这个信任模型去思考,递归瞬间就没那么玄了。
4. 头插法:另一种思维路径,反转链表和“重建链表”的关系
几乎每个人学反转链表都先接触迭代三指针法,但很多人不知道还有头插法这一路数。头插法的核心思路是:遍历原链表,把每个节点摘下,插入到一个新链表的头部。因为每次都是插在头部,所以先来的节点反而被后来的节点“顶”到最后,整体顺序自然就反了。
我第一次听人讲“头插法”时觉得是多此一举:明明有标准三指针啊,干嘛还要再造一个新链表?后来在理解链表排序、链表插入删除等问题时才发现,头插法其实是一种更通用的思维模型,它把“反转”从一个特定动作变成了“不停地往头部插节点”这一通用操作的自然结果。
4.1 头插法实现
class Solution: def reverseList(self, head: ListNode) -> ListNode: # 虚拟头节点,简化插入逻辑 dummy = ListNode(0) cur = head while cur: next_temp = cur.next # 先保存下一个节点 cur.next = dummy.next # 当前节点指向新链表头部之后的那个节点 dummy.next = cur # 虚拟头节点的next指向当前节点 cur = next_temp # 移动指针 return dummy.next这里的dummy是一个虚拟头节点,主要作用是让插入操作不需要处理“链表为空”的特殊情况。每次循环,cur就是当前要插入的节点,先把它的next指向dummy.next(也就是目前新链表最前面的节点),再把dummy.next更新为cur。这样cur像个“压栈”一样一只往前顶,最后的链表顺序就是完全反过来的。
4.2 和迭代法的对比
| 对比维度 | 三指针迭代法 | 头插法 | | 时间复杂度 | O(n) | O(n) | | 空间复杂度 | O(1) | O(1) | | 是否需要额外节点 | 不需要 | 需要一个虚拟头节点 | | 思维起点 | 直接改变原有指针方向 | 遍历摘节点并重建链表 | | 适用场景 | 反转整体链表 | 反转部分链表、插入类操作、需要维护“新头”的场景 |
我个人觉得,三指针法更符合“反转”这个动作本身,头插法更适合作为理解链表插入操作的温补。实际做题时,两种方式我偶尔都会用,面试时我会优先选三指针法,因为它不需要额外节点,参数也更少,代码看起来更“干净”。
5. 易错点与自测方法:踩过的坑和让代码一次写对的技巧
刷题和写业务代码有个共同的底层逻辑:越是看起来简单的题,越容易在细节上翻船。反转链表这道题,我已经见过太多人在同一个坑里翻来覆去地栽跟头,这里把我踩过和见过的易错点集中整理一下。
5.1 高频错误清单
**错误一:没有提前保存后继节点。**很多新手一上来就写cur.next = prev,结果链表从中间就被截断了,cur根本走不到下一轮。链表和其他数据结构最大的区别就是它只能单向走,断了就是真丢了。所以在修改next之前,一条铁律是先把cur.next存到临时变量里。
**错误二:返回了cur而不是prev。**循环结束时cur已经是None,如果返回cur,等于返回了一个空链表。这个错误特别迷惑人,因为代码在IDE里不报错,只有测试的时候结果永远是[]。我建议写完之后立刻问自己一句:循环退出时,cur是什么?prev是什么?想清楚这两个,就不会返回错了。
**错误三:递归里忘了head.next = None。**前面分析过,这会导致链表成环。具体表现是反转后的链表能正常输出,但一旦有人试图遍历两次,程序就陷入死循环。这也是为什么很多在线判题系统能过,但一旦自己写测试用例就会卡死。
**错误四:把head当成了尾节点来改。**有些人写递归时会在base case返回head,然后归来时又想给head.next.next赋值,结果发现之前已经递归到最深处了,当前层的head早就不是原来的head了。这说明递归的根本逻辑没理顺,建议回到上一步,先搞懂“每一层递归的head到底是谁”。
5.2 自测的几条实用经验
写完之后怎么验证?我自己的习惯是先在脑子里跑三组测试用例:空链表、单节点链表、两个节点的链表。这三组能过,基本逻辑没有大问题。然后再拿一个5节点的普通链表手动走一遍循环,重点盯每次循环结束后prev和cur的位置。
还有一个很好用的技巧:写一个辅助的打印函数,把链表从头到尾的值打印出来。很多语言的链表类没有自带字符串表示,导致反转后只能看“对不对”而不知道“差在哪”。打印函数能帮你快速定位是哪一步断了。
def print_list(head): result = [] while head: result.append(str(head.val)) head = head.next print(" -> ".join(result))如果测试环境支持画图,手动画箭头比看代码调试更快。链表题本质上就是箭头题,纸上画清楚三个指针之间的变化,代码是怎么写的自然就清楚了。
5.3 面试时的表达节奏
还有一个在面试中容易忽视的点:怎么把思路讲出来。很多人上来就埋头写代码,写完才解释,这是比较吃亏的。我的建议是先花30秒把思路提纲讲清楚:先说迭代法,遍历链表,用prev和cur两个指针,每次把cur.next指向prev,同时用临时变量保存原后继,最后返回prev。让面试官在你看代码之前就知道你要干什么,不仅显得更有条理,也能在迷路时得到提示。然后写代码,写完后主动跑一遍那三个边界用例。这一套流程走完,给人的整体印象会比“闷声写代码然后说写完了”好不少。
6. 从反转链表延伸出去:hot100里那些同源的进阶题
反转链表之所以值得深挖,还有个原因是它像一块积木,很多题都是在它上面搭出来的。我刷到哪些题的时候尤其有这种感觉,这里列几个代表性的,如果你也想系统性过关,建议按这个顺序往下推。
6.1 反转链表II:只翻转指定区间
这道题要求翻转从left到right之间的节点,其它部分保持不变。核心思路是先找到left的前驱节点,然后对这个区间内的节点做和反转链表一模一样的操作,最后再把区间的头尾跟外部接上。难点在于返回值的拼接:要先记下区间的prev和tail,反转后再接回原链表。如果用头插法来做,逻辑会清晰很多,因为虚拟头节点避开了“区间从头部开始”这种特判。
6.2 K个一组翻转链表
这是把“分组”和“反转”糅在一起的题。整体思路是先数出K个节点作为一组,对这一组做反转,然后用上一组的尾去接这一组的新头。实现时需要两个函数:一个负责反转单组,另一个负责遍历分组。很多解法会写成递归形式,因为“处理完一组再处理下一组”这个结构和递归天然贴合,但迭代写法同样可以。这道题比反转链表难在“组与组之间的衔接”,你会反复用到prevTail和nextGroupHead这类的指针。
6.3 回文链表
判断链表是不是回文,面试中经常出现的变形。最tricky的解法是:先通过快慢指针找到中点,然后把后半段反转,再从前半段和后半段同步比较。这个解法里,找中点和反转后半段都是基本功,拆开看没有任何一个环节超纲。也就是说,反转链表做不溜,这道题连门都进不去。我当时做完这题,回头再去刷反转链表,明显感觉理解上了一个台阶,因为脑子里已经清楚“反转一个子链表”和“反转整条链表”只是控制范围不同,核心动作完全一样。
6.4 两两交换链表中的节点
看名字以为跟反转没关系,但其实每一对节点就是一次局部反转。可以用递归写,也可以用迭代维护三个指针。它和反转链表II的思路有不少重叠,区别是这一段区间只有两个节点。所以练完反转链表,这道题几乎算“白给”。
这几道题串下来,你会发现“反转链表”根本不是一道孤立的题,而是所有链表指针操作的地基。地基牢固了,上面盖什么楼都有底。如果现在你再回看hot100第18题,我想你看到的应该不只是那两行代码,而是一整套关于链表操作的思考方式。