1. 为什么反转链表能成为必考题
每次打开力扣热题100,前二十题里几乎都有反转链表这道题。我面试过的候选人、带过的新人、甚至我自己刚起步刷题那会儿,都在链表的指针操作上栽过跟头。刷题攻略里经常把反转链表列为“链表类题型第一课”,这个定位不是没有道理。
反转链表本身并不难,难的是它背后那一整套指针操作的思维方式。如果你直接背答案,五分钟就能把迭代解法写出来,但这道题真正的价值在于:它逼你搞清楚“指针指向的是什么”“什么时候需要保存中间状态”“为什么原来的链表没有丢”。这些问题一旦想透,后面遇到合并两个有序链表、环形链表检测、K个一组翻转链表,理解成本都会直线下降。所以不管是纯面试准备,还是想扎实打好算法基础,这道题都值得反复琢磨。
这篇文章不打算只贴一个标准答案。我会把题目拆开,从“为什么需要三个指针”讲起,手写一遍完整推导过程,再加上我实际刷题和辅导别人时踩过的坑、总结出的经验。如果你是刚开始刷链表题的新手,耐心看完,应该能彻底弄懂反转链表;如果你已经会做了,也可以重点看后面变种题的思路衔接,尤其是K个一组翻转那部分,面试中很多人会在这里掉链子。
2. 题目定位与核心难点拆解
2.1 这道题到底在考什么
先说题面:给你单链表的头节点 head,反转链表,返回反转后的链表头节点。输入是 1->2->3->4->5,输出是 5->4->3->2->1。
很多人第一次看到这个题,第一反应是“把链表的值取出来,倒着放回去”。比如遍历一遍塞进数组,reverse 一下,再遍历一遍覆盖原链表的值。这个思路确实能通过测试,但面试官一看就知道你没有理解链表的本质。
链表的核心操作是“改指针”,而不是“动数据”。你明明可以在不新建任何节点的情况下,把每个节点的 next 指向前一个节点,整个链表就“反向”了,那为什么还要额外开一个数组?空间复杂度从 O(1) 变成 O(n),属于典型的思路正确、方案不合格。这道题真正想考察的,就是你是否具备“通过调整指针关系来改变数据结构形态”的直觉。
2.2 链表的“断链”风险
理解反转链表之前,先得对单链表的结构有肌肉记忆。每个节点有一个 val 和一个 next 指针,next 指向下一个节点。头节点是入口,尾节点的 next 是 null。
反转的直观操作是:把 1 的 next 指向 null,2 的 next 指向 1,3 的 next 指向 2,依此类推。思路听起来特别简单,但动手写代码时很多人会卡在一个问题上:当我把 2 的 next 指向 1 之后,原来 2->3 的那条线就断了,3 就找不到了。
这就好比你排队排到一半,突然转身往后走,后面的队友如果不提前拉住,整个队伍就散了。链表也是同样的道理:在你改变一个节点的 next 之前,必须先把它原本指向的下一个节点保存下来。这就是反转链表所有解法的核心前提——先保存后继,再修改指向。
2.3 空间复杂度的隐藏要求
很多题解会说反转链表“需要三个指针”,分别是 prev、curr、next。这是对的,但你要理解为什么是三个,而不是两个。prev 用来记录已经反转好的部分,curr 是当前正在处理的位置,next 则是为了“保存后继”而必须存在的临时变量。
这三个指针的存在,本质上是在用 O(1) 的额外空间完成整个反转过程。如果你理解了这一点,就能明白为什么这道题被归类为“链表基础题”里的经典代表——它考察的是你用有限几个变量完成数据重组的能力,这在实际工程里也很常见:有限内存环境下调整数据结构,不能靠拷贝一份数据来解决。
3. 解法一:迭代法,面试中最稳的写法
3.1 三个指针的完整推导过程
迭代法的代码只有几行,但是每一步都要想清楚。我建议你按照下面的过程,在纸上画一遍:
初始状态:
prev = null,curr = head。此时 prev 指向空,curr 指向第一个节点,表示“前面没有已经反转好的节点”。
第一步:
nxt = curr.next,先把 2 保存下来。然后把 curr.next 指向 prev,即 1.next = null。此时 1 变成了新链表的尾巴,prev 移到 1,curr 移到 nxt 也就是 2。
第二步:
nxt = curr.next,保存 3。curr.next = prev,即 2.next = 1。prev 移到 2,curr 移到 3。
重复这个过程,直到 curr 变成 null,也就是遍历完了整个链表。此时 prev 指向原来的最后一个节点 5,这个节点就是反转后新链表的头节点,返回 prev 即可。
写成代码是这样:
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next def reverseList(head: ListNode) -> ListNode: prev = None curr = head while curr is not None: nxt = curr.next curr.next = prev prev = curr curr = nxt return prev3.2 为什么每个操作都是必须的
很多新手会问:最后为什么要返回 prev,不能返回 curr 吗?你看循环结束条件——curr 已经是 null 了,空节点当然不是链表头。prev 指向的是“最后一个被处理过的节点”,也就是原链表的尾节点,反转之后它就是新链表的头节点。
还有人会问:为什么 nxt = curr.next 必须在 curr.next = prev 之前?因为一旦你执行了 curr.next = prev,当前节点就不知道原本下一个节点是谁了。先保存,再修改,这是指针操作里永远不变的铁律。
这道题的循环内部就三件事:保存后继、反转指针、整体后移。每一个操作都对应一次指针移动,不多不少。你要是发现自己写出来的解法里有“多余的判断条件”或者“额外的临时变量”,那很可能思路还没理顺。
3.3 时间复杂度与空间复杂度分析
时间复杂度是 O(n),n 是链表长度。因为每个节点只会被访问一次,进行一次指针重指向操作。空间复杂度是 O(1),只用了 prev、curr、nxt 三个指针变量,和链表长度无关。
这里提一个面试加分细节:极端情况下,空链表和只有一个节点的链表都只需要直接返回 head。上面这份代码天然处理了这两种情况,因为空链表不会进入循环,单节点链表在第一轮循环里就把 1.next 改成了 null,prev 指向 1,返回正确。
4. 解法二:递归法,理解“从后往前”的思维跃迁
4.1 递归的递推公式
迭代法讲清楚了,递归法就是另一种脑回路。递归解法的代码更短,但理解门槛更高。我第一次学递归版本时,盯着代码看了一个多小时才彻底反应过来它到底在做什么。
递归思路是这样的:假设你已经把 head 之后的链表全部反转好了,那你要做的就只剩一件事——让 head 的下一个节点反过来指向 head,同时让 head 指向 null。
写成递归公式:
def reverseList(head: ListNode) -> ListNode: if head is None or head.next is None: return head new_head = reverseList(head.next) head.next.next = head head.next = None return new_head4.2 递归过程逐步还原
我来模拟一个三个节点的链表 1->2->3,递归的执行过程:
第一层调用 reverseList(1),head 是 1,不满足终止条件,进入递归调用 reverseList(2)。
第二层调用 reverseList(2),head 是 2,不满足终止条件,进入递归调用 reverseList(3)。
第三层调用 reverseList(3),head 是 3,3.next 是 null,满足终止条件,直接返回 3。此时整个递归开始“归”的过程。
回到第二层,head 是 2,拿到 new_head = 3。执行 head.next.next = head,也就是 3.next = 2。然后 head.next = None,即 2.next = None。返回 new_head = 3。
回到第一层,head 是 1,new_head = 3。执行 head.next.next = head,此时 head.next 是 2,2.next = 1。然后 head.next = None,即 1.next = None。返回 new_head = 3。
到这一步,链表变成了 3->2->1,反转完成。
4.3 递归解法的两个易错点
第一个易错点是终止条件。很多新手写成 if head is None: return head,这会导致只有空链表时正常,遇到一个节点的链表就会报错。因为单节点链表不需要反转,head.next 是 None,递归调用 reverseList(None) 会返回 None,传回来之后 head.next.next 就会因 None.next 报错。所以终止条件必须是 head is None or head.next is None。
第二个易错点是 head.next = None 这一行。递归回溯过程中,每一层都要把原来的后继清掉,否则会出现环。以五个节点的链表为例,如果不加这一行,递归完成后只有第一个节点的 next 被正确设置为 None,其他节点之间可能出现回环,结果直接超时或者报错。
4.4 递归解法的时间与空间复杂度
时间复杂度同样是 O(n),每个节点被访问一次。但空间复杂度是 O(n),因为递归调用栈的深度和链表长度成正比。如果链表有一万个节点,递归解法就可能在栈上出问题。
所以我的建议是:面试中默认用迭代法,因为空间占用更优。但递归法也要会讲,面试官有时候会追问“能不能用递归实现”,目的就是考察你是否理解递归的本质——把大问题分解成结构相同的子问题。两种解法都掌握,比只会一种要稳妥得多。
5. 变种题:从反转整个链表到局部反转
5.1 反转链表的前 N 个节点
热题100里,反转链表常常不是单独出现的,而是作为一批变种题的基础。你在力扣上会看到“反转链表 II”“K个一组翻转链表”,这些题的核心思路做完反转链表之后就能自然衔接。
先看一个相对温和的变种:反转链表的前 N 个节点。比如链表是 1->2->3->4->5,要求只反转前 3 个节点,结果是 3->2->1->4->5。
迭代思路跟反转整个链表几乎一样,唯一区别是循环次数从“遍历到末尾”变成“只走 N 次”,并且最后要记得让第一个被反转的节点指向原链表的第 N+1 个节点。你可能已经发现,这正是“反转整个链表”加一个“提前停车”的条件。
def reverseN(head: ListNode, n: int) -> ListNode: prev = None curr = head for _ in range(n): nxt = curr.next curr.next = prev prev = curr curr = nxt head.next = curr return prev这里有个容易忽略的点:循环结束后,curr 指向的是第 N+1 个节点,head 是原来的第一个节点,也就是反转后成为新链表尾巴的节点。所以必须执行 head.next = curr,把尾巴接回剩余链表。
5.2 反转链表区间 [m, n]
力扣第 92 题就是这种题:反转从位置 m 到 n 的链表。你需要先找到第 m-1 个节点,把它的后继换成反转后的头,再把第 n 个节点的后继接到第 n+1 个节点上。这个思路本质上就是在反转整个链表的基础上,加一个“锁定区间”的操作。
处理这种题有一个常用技巧:添加一个哑节点 dummy,dummy.next = head。为什么要加哑节点?因为 m 有可能等于 1,而 m=1 时,“第 m-1 个节点”根本不存在。用一个 dummy 节点做统一处理,就不用单独写 if 判断了。这道题的完整代码如下:
def reverseBetween(head: ListNode, m: int, n: int) -> ListNode: dummy = ListNode(0) dummy.next = head prev = dummy for _ in range(m - 1): prev = prev.next curr = prev.next for _ in range(n - m): nxt = curr.next curr.next = nxt.next nxt.next = prev.next prev.next = nxt return dummy.next我辅导过的很多人第一次看到这段代码会觉得绕,核心在于第二段循环里的“头插法”逻辑:每次把 nxt 摘出来,插到 prev 的后面。反复做 n-m 次,原来区间里的节点就整体逆序了。这个过程建议在纸上画一下,比光读代码要直观得多。
5.3 K个一组翻转链表
这是反转链表家族里最复杂的一道。力扣第 25 题:每 K 个节点一组翻转,不足 K 个的组保持不变。核心难点是“分组”和“衔接”。
我的处理步骤是这样的:先确定剩余链表长度够不够一组。如果不够,直接返回。如果够,就对这一组的 K 个节点执行一次迭代反转,反转完把尾部接到下一组的头部。这个“下一组的头部”是哪来的呢?递归调用自己的返回值。
def reverseKGroup(head: ListNode, k: int) -> ListNode: cur = head count = 0 while cur and count < k: cur = cur.next count += 1 if count < k: return head prev = None curr = head for _ in range(k): nxt = curr.next curr.next = prev prev = curr curr = nxt head.next = reverseKGroup(curr, k) return prev递归在这里承担了一个很重要的职责:处理“下一组”的逻辑。反转完当前组后,原来的头节点变成了组的尾巴,它必须指向下一组反转后的头节点,而下一组的头节点是一个结构相同、规模更小的子问题,所以递归调用自己最合适。
5.4 面试中的实际考察方式
在我接触过的算法面试中,反转链表本身很少作为唯一的考法,面试官更常把它当作一道“开胃菜”,检验你能否准确、快速、清晰地写出代码。通常接下来会有一个变种题:有的考反转区间,有的考回文链表,有的考两两交换链表中的节点。这些题的可变角色都离不开同一个基本功:稳准狠地处理链表指针。
如果你把反转链表真正理解了,这类题基本就是条件判断加指针重连的组合。如果没有理解,刷一道忘一道,下次看到还是一脸懵。所以我一直建议刷题的朋友:做一道链表题,别急着看下一个题,先把这道题涉及的指针操作画清楚,再对照着做两三个变种,记忆会牢固很多。
6. 实操过程:手写一遍才能感觉到的东西
6.1 从零开始在编辑器里写反转链表
很多看题解觉得自己懂的人,真正在编辑器里手写时会卡壳。所以我强烈建议,看完这篇文章后立刻关掉文章,自己打开力扣的编辑器,把迭代解法从头写一遍,不加任何提示。
我在这里还原一次我手写代码时的心路历程:
先定义 prev = None,表示当前节点的前一个节点最初不存在。然后 curr = head,从链表头开始。进入循环后,第一步永远是 nxt = curr.next,哪怕这个 nxt 一会儿就是 null。第二步是 curr.next = prev,这个操作是“改方向”。第三步是 prev = curr,让 prev 追上 curr。第四步是 curr = nxt,让 curr 走向原先保存的后继。
第一次写的时候很容易把第三步和第四步的顺序弄反。如果先 curr = nxt,再 prev = curr,那 prev 和 curr 就会指向同一个节点,整个链表就乱了。这个顺序问题的根源在于没有意识到:prev 的更新必须在 curr 移动之前,因为一旦 curr 移动了,原来的节点信息就丢了。
6.2 一道例题的完整推演
假设输入链表是 1->2->3->4->5,我推演一下迭代的全过程:
初始:prev=None,curr=1。
第一轮循环:nxt=2,1.next=None,prev=1,curr=2。
第二轮循环:nxt=3,2.next=1,prev=2,curr=3。
第三轮循环:nxt=4,3.next=2,prev=3,curr=4。
第四轮循环:nxt=5,4.next=3,prev=4,curr=5。
第五轮循环:nxt=None,5.next=4,prev=5,curr=None。
循环结束,返回 prev=5。结果就是 5->4->3->2->1。
这个过程每轮都在做一模一样的事,没有任何例外分支。链表题的代码往往很短,但每一行都对应一个明确的指针动作。新手最容易犯的错就是“凭感觉写代码”,觉得逻辑对了就提交,结果 LeetCode 报错后开始一点点试。正确的做法是先在纸上画一遍,确认指针每一步的状态,再敲代码。
6.3 常见报错信息与排查思路
我在辅导新人时遇到频率最高的报错有这么几类:
第一类是 AttributeError: 'NoneType' object has no attribute 'next'。这个错误几乎总是出现在你对空指针调用了属性。回溯一下代码,通常是在循环里没有判断当前节点是否为空,或者终止条件写晚了一步。
第二类是超时。这个大概率是链表变成了环。比如递归解法里忘了 head.next = None,或者迭代解法里 prev 和 curr 的更新顺序错了。链表一旦成环,循环就永远不会结束,就会一直转下去直到超时。
第三类是结果不对。比如输入 1->2->3,输出变成 1 或者 2->1。这个一般是返回值写错了。循环结束后,新链表的头节点是 prev,不是 head 也不是 curr。如果你在循环结束后 return head,head 早就被改成了新链表的尾巴。
这三种错误只要理解了指针逻辑都能快速定位。特别是超时问题,你可以给链表加一个计数器,如果循环次数超过链表长度,就能确认出现了环。
7. 力扣刷题攻略:这道题在热题100里的位置
7.1 为什么它排在链表题的前列
打开力扣热题100,你会发现链表类的题目数量不算多,但反转链表几乎总是被放在最前面。这是有原因的:它是整个链表题型的“基本动作”,和数组里的“二分查找”、字符串里的“反转字符串”一样,承担着奠基的角色。
力扣刷题攻略中有一个比较普遍的建议:按模块刷题,而不是随机刷。链表模块的启动姿势就是反转链表,因为它需要你掌握最核心的指针操作。做完它,你就能顺畅地理解两数相加、合并K个有序链表、删除链表倒数第N个节点这些题。
我把热题100里链表类的题大致梳理了一遍,按依赖关系排一下:
- 反转链表:基础指针操作,几乎其他链表题都依赖它
- 反转链表 II:反转链表上的区间限制
- K个一组翻转链表:反转链表的分组扩展
- 环形链表与循环链表:快慢指针与环检测
- 合并两个有序链表:链表的连接操作
- 删除链表的倒数第 N 个节点:双指针技巧
如果你把反转链表刷透了,再去看这些题,会发现很多题的核心都是“找到合适的位置,然后改指针”。差别只是在怎么找位置、怎么处理边界条件。
7.2 一道题反复刷的正确姿势
我在带新人时推荐一套三遍法:第一遍看题解,把代码默写出来,确保能通过。第二遍隔天再写,不参考任何资料,看看自己能不能独立把思路推出来。第三遍一周后,用纸笔手写核心逻辑,只写思路,不写完整代码,然后对照题解的复杂度分析,看能不能说出为什么空间复杂度是 O(1)。
这三遍下来,反转链表的基本功就扎实了。然后可以进入变种题环节,从反转链表 II 开始,逐步过渡到 K个一组翻转链表。这个循序渐进的过程,比一次性把十道链链表题刷完效率要高得多,因为每一步都在用已经理解的知识解决新的问题。
7.3 刷题时的心态建设
反转链表这道题看起来很基础,但我在做算法辅导时发现一个规律:很多人第一遍能看懂题解,第二遍写不出来,就觉得自己“不是算法这块料”。这完全是误区。链表题本质上是一类需要空间想象力和指针操作经验的题,刚开始写不出来太正常了。
我自己的经历也差不多。第一次看完题解,觉得自己懂了。合上代码开始写,卡在 nxt = curr.next 到底放哪个位置,纠结了很久。等到我把指针画图做多了,才真正形成“肌肉记忆”。这个过程没有捷径,只能靠多写多画。如果你现在写不出来,别急,画图、模拟、再写,练上十遍,一定有质变。
8. 常见问题与排查技巧实录
8.1 问题速查表
| 问题 | 可能原因 | 排查方法 |
|---|---|---|
| 空指针异常 | 循环终止条件不正确,或对空节点取属性 | 打印 curr 和 prev 的状态,检查边界值 |
| 程序超时 | 链表成环 | 添加循环计数器,超限后输出链表状态 |
| 输出结果顺序没变 | 循环没有进入或 return 写错 | 检查 head 是否为 None,检查 while 条件 |
| 输出结果只有第一个节点 | prev 更新位置不对 | 确认 prev = curr 在 curr = nxt 之前 |
| 递归解法报错 | head.next 为 None 时没有终止条件保护 | 在终止条件中同时判断 head 和 head.next |
| 反转区间题结果不对 | 哑节点 dummy 的衔接没有处理好 | 在返回前打印从 dummy 开始的整个链表 |
每次排查问题的过程,都是对链表指针理解的加深。我特别推荐一个调试技巧:在关键位置添加打印语句,把每轮循环结束后的当前链表状态打印出来。比如在迭代法则中,每轮循环结束后打印 prev.val 和 curr 的值,你能直观看到指针移动的轨迹。
8.2 几个我反复踩过的坑
第一个坑:递归解法里漏掉 head.next = None。这个问题非常隐蔽。如果你在递归里只写了 head.next.next = head,而没有最后的 head.next = None,那么当链表较长时,某些节点之间会出现回环。LeetCode 的判题系统会超时,你会一头雾水地认为是复杂度问题,实际上就是指针没有清干净。
第二个坑:链表的变量名混淆。算法题代码本身就短,变量名一旦取得像 a、b、c 这种,五分钟后你自己都分不清谁是谁。我在实际刷题时会把变量名取得具有语义,比如 prev 表示前一个节点,curr 表示当前节点,nxt 表示后继节点。看起来多敲了几个字母,但 debug 效率会高很多。
第三个坑:在纸上推演时没有考虑空链表和单节点链表。真实的测试用例中,空链表和单节点链表是必测的。很多题解的代码能通过这种情况是因为边界条件天然正确,但你写的时候必须有意识地检查。面试的时候,如果面试官给出了一个空链表的测试用例,你直接在循环前加一个判断返回 head,会显得你考虑问题很全面。
9. 递归、迭代、实战:反转链表背后的方法论
9.1 从这道题学到的一种思维模型
反转链表最有价值的不是答案本身,而是“保存后继、修改指向、推进指针”这个思维模型。你可以把这个模型套用到很多链表操作上,只要涉及“改变节点之间相对位置”的操作,基本都会用到类似的模式。
比如删除链表中的一个节点,你需要保存前驱节点,然后把前驱的 next 指向被删除节点的后继。再比如交换链表相邻的两个节点,你同样需要保存后继,然后调整两组指针关系。这些都是同一个思维模型的变体:在改变结构之前,先把后续路径保存下来,防止“断链”。
这种思维不仅适用于算法题。你去看操作系统里的链表操作、嵌入式系统里的任务队列管理、甚至是数据库里的 B+ 树节点分裂,做指针调整的时候都是同一个套路。这道题看着小,背后是一种通用工程能力。
9.2 为什么说反转链表是“练内功”的题
算法题刷得多的人会有一种感觉:有些题是在考你“见没见过这个套路”,有些题是在考你“基础功扎不扎实”。反转链表属于后者。它没有任何需要记忆的模板,也没有复杂的数学公式,纯粹看你能否在几个指针之间保持清晰思维。
换句话说,这道题就是一个“内功题”。你能把每一行代码的执行过程都说得清清楚楚,你就有了面对更复杂链表问题的底气。反之,如果你只会照抄代码,遇到稍微变化一点的场景就会露馅。
我去面试候选人的时候,经常从反转链表入手,再层层深入。如果候选人对这道题的解读能到“为什么要先保存后继”这个层次,我会很确定他对链表的理解是到位的。如果只背了代码,回答不了这一层,那后面更深的问题基本不用问了。
9.3 从这道题延伸出的其他算法思想
反转链表还能帮你建立“递归思维”的直观感受。如果你能把递归解法讲清楚,你就理解了“子问题”和“递归基”这两个概念。这对后续做二叉树遍历、回溯算法、动态规划都有帮助。因为那些题的代码结构,本质上都是一层一层地调用自己,然后在某个时刻开始回溯。
反过来,迭代解法培养的是“状态机思维”:每一轮循环开始时,系统处于一个明确的状态,循环体执行一组动作,结束时进入下一个明确状态,直到触发终止条件。这种思维在任何需要手写状态流转代码的工程场景中都非常有用。
所以你看,一个小小的反转链表,横跨了指针操作、递归思想、状态机思维三个层面。它在热题100里承担的角色远不止一道题那么简单,值得你花时间把它真正吃透。
10. 最后分享一点我做这道题的心得
我在不同阶段写了三次反转链表的完整题解,每次感受都不一样。第一次是刚学算法,抄代码都费劲。第二次是准备面试,已经能熟练画图讲解迭代法和递归法。第三次是辅导别人写这道题,我发现真正的难点不是代码,而是“为什么这么写能够保证不断链”。
如果你现在正在力扣热题100里刷到这一题,我的建议很直接:不要满足于提交通过。试着把这道题讲给一个完全不懂链表的人听,如果他能听懂你在讲什么,你才是真的理解了。讲不清楚的地方,就是你需要再补的地方。
反转链表这道题,刷十遍都不嫌多。每刷一遍,你对指针的理解都会深一层。这比急着往前赶进度做新题,重要得多。