反转链表是数据结构里最经典的一道题,也是一个绕不开的面试高频考点。题目本身很简单:给你一个单链表,让你把整条链表反转,返回新链表的头节点。但正是这道看似基础的题,能同时看出你对链表结构的理解、对指针引用的掌握,以及边界条件的敏感度。我见过不少同学刷到几十道题后,写反转链表还是会丢节点,或者把链表弄成环。这篇文章我想从问题定义、迭代解法、递归解法、进阶变形到排坑实录,完整拆一遍反转链表。无论你是刚接触算法的初学者,还是准备面试想再巩固一遍,这份梳理都很值得看。
1. 反转链表是什么:把“单向箭头”换成“倒着指”
1.1 问题定义与示例
反转链表的标准描述是:给定单链表的头节点head,反转整个链表,并返回新链表的头节点。例如输入1 -> 2 -> 3 -> 4 -> 5,输出5 -> 4 -> 3 -> 2 -> 1。这里的箭头就是每个节点的next指针,链表的最后一个节点指向null。
需要注意,题目要求的是原地修改链表结构,一般不希望你新建一个链表再重新拷贝。真正的考点是你能不能在不额外申请链表空间的情况下,只通过调整节点之间的指针方向,完成整条链表的逆序。很多刚接触的同学会尝试用一个数组把节点存下来,再倒序串联,这种方法虽然能过,但面试时显然不是最优解。
1.2 单链表的结构定义
要理解反转,先得清楚链表节点长什么样。以最常见的单链表为例,每个节点一般包含两个字段:存储数据的val,以及指向下一个节点的next。用 Python 定义大概是这样:
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = nextJava 版本的节点类也类似,只是字段前需要声明类型。链表本身并没有数组那样的“下标”概念,你拿到一个head其实只是一个引用,指向第一个节点。想访问某个位置的节点,只能从这个head开始,通过next一路走。所以反转链表的核心操作对象是“指针”或者“引用”,而不是节点的值。
1.3 为什么不能像数组一样直接交换
数组反转可以两端交换,因为数组支持随机访问,通过下标就能拿到任意元素。链表没有这个能力,如果我想让尾节点变成头节点,我必须从头遍历到尾;如果我又想让原来的倒数第二个节点变成新的第二个节点,又得从头遍历一遍。这样做的总时间复杂度会达到 O(n²),在链表长度稍大时性能非常糟糕。
更麻烦的是,如果只交换节点的val,而不是调整next,那么对于存储复杂对象或大对象的链表,会带来额外的赋值开销,而且逻辑上也绕。最干净的做法是:只遍历一遍,每经过一个节点,就把它的next指针从“指向后一个节点”改成“指向前一个节点”。这样整条链表的箭头方向全部反过来,反转就完成了。
1.4 核心思路:三指针模型
用一个比喻来理解:想象一排人前后排列,每个人只牵着后面人的手。现在要让这一排人全部转身,变成每个人都牵着前面人的手。你不能让所有人同时转身,因为一转身就看不到原来身后的人是谁了。正确做法是每次只操作一个人,并且在他转身之前,先记住他身后那个人是谁。
对应到链表里,就是三个指针:prev指向当前节点的前一个节点,cur指向当前正在处理的节点,nxt用来临时保存cur原本的后继节点。每一步的操作顺序固定:先保存后继,再改变当前节点的指向,最后把prev和cur同时向后移动。这个三指针模型是迭代法的灵魂,理解了它,反转链表的基本盘就已经拿下了。
2. 迭代法:面试优先选它,代码短且空间为 O(1)
2.1 三个指针的分工与初始值
迭代法需要三个指针:prev(前驱节点)、cur(当前节点)、nxt(后继节点)。初始时,prev必须是null,cur指向head。为什么prev不能是别的值?因为反转之后,原来的头节点会变成新链表的尾节点,而尾节点的next必须是null。所以一开始就要让head.next指向null,而这个“空”就是由初始的prev提供的。
循环体内部一共四步操作:保存nxt = cur.next;让cur.next = prev;把prev移动到cur;再把cur移动到nxt。循环结束条件是cur == null,此时prev正好停在原链表的尾节点上,也就是新链表的头节点,直接返回prev即可。
2.2 Python 代码实现与逐行解释
这里给出标准 Python 实现:
def reverseList(self, head: ListNode) -> ListNode: prev = None cur = head while cur: nxt = cur.next # 1. 先保存下一个节点 cur.next = prev # 2. 当前节点指向前驱 prev = cur # 3. 前驱后移 cur = nxt # 4. 当前节点后移 return prev用手工推演一个三节点例子:链表1 -> 2 -> 3。初始prev=None,cur=1。第一次循环:nxt=2,把1.next指向null,prev变成1,cur变成2。此时局部链表已经是1 -> null,2和3还没处理。第二次循环:nxt=3,把2.next指向1,prev变成2,cur变成3。第三次循环:nxt=null,把3.next指向2,prev变成3,cur变成null。循环结束,返回prev也就是3。整个过程刚好把所有指针方向调转。
2.3 Java 版代码参考
如果面试用 Java,核心逻辑完全一样,只是类型声明不同:
public ListNode reverseList(ListNode head) { ListNode prev = null; ListNode cur = head; while (cur != null) { ListNode nxt = cur.next; cur.next = prev; prev = cur; cur = nxt; } return prev; }对比两份代码可以发现,反转链表跟语言关系不大,关键是你能否把三指针的移动顺序写对。尤其要注意nxt的赋值必须放在修改cur.next之前,否则一旦cur.next被改写,原来的后继节点就再也找不到了。这个顺序错一步,整道题全错。
2.4 复杂度分析与原地修改的意义
迭代法只遍历链表一次,所以时间复杂度是 O(n),其中 n 是节点数量。空间上只额外使用了三个指针,无论链表多长,占用空间都是常数级别,因此空间复杂度是 O(1)。这也是工程上更推荐迭代法的原因:不需要担心递归调用栈过深,内存开销完全可控。
“原地修改”意味着我们没有开辟任何新的节点,只是在原有节点之间改指针。这样做的好处是省内存,同时保持了节点本身的地址不变。面试官问你“为什么空间复杂度是 O(1)”,你要能答出来:因为三个指针都是固定大小的局部变量,不会随输入规模增长。
2.5 边界条件一个都不能漏
这里单独强调边界条件,因为我见过太多人在这些简单情况上翻车。最基础的测试用例有两个:空链表和单节点链表。
空链表时head是null,cur初始为null,while循环不会进入,直接返回prev也就是null,结果正确。单节点链表时,cur指向唯一节点,nxt是null,执行完一次循环后cur.next指向null,prev变为这个节点,cur变为null,返回prev,结果也正确。
另一个容易错的地方是循环条件。有人会写成while (cur.next != null),这会导致最后一个节点没有被反转,原链表最后一个节点不会变成新链表的头节点。所以循环条件一定要是“当前节点不为空”,而不是“下一个节点不为空”。
3. 递归法:代码更简洁,但必须想清楚“从后往前”的路线
3.1 递归思路:先反转后面的链表,再接上头节点
递归法和迭代法的思考方向完全相反。迭代法是从头开始,像推土机一样一步步把指针扭过来;递归法则是假设“当前节点后面的链表已经全部反转好了”,只需要再处理当前节点和它原本下一个节点之间的关系。
核心逻辑可以写成三步:第一,如果当前节点是空,或者当前节点的下一个节点是空,直接返回当前节点,这是递归出口;第二,递归调用reverseList(head.next),得到反转后的新头节点new_head;第三,让head.next.next = head,也就是让原本的下一个节点指回当前节点,然后让head.next = null,断开原来的正向链接。最后返回new_head。
3.2 代码实现与手工推演
递归代码非常简单:
def reverseList(self, head: ListNode) -> 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推演一遍:调用reverseList(1),进入1.next=2,于是递归reverseList(2);进入2.next=3,再递归reverseList(3);此时3.next是null,所以直接返回3。回到上一层,head=2,new_head=3,执行2.next.next = 2,也就是3 -> 2,再让2.next = null,返回3。回到最外层,head=1,new_head=3,执行1.next.next = 1,也就是2 -> 1,再让1.next = null,返回3。最终链表3 -> 2 -> 1。
注意这个过程的巧妙之处:递归到最底层后,每一层返回的都是同一个新头节点3。而每一层做的操作,都是让自己原本的后继反过来指向自己。这正好符合反转的定义。
3.3 递归法的复杂度与栈溢出风险
递归法的时间复杂度同样是 O(n),因为每个节点都会被访问一次。但空间复杂度是 O(n),而且这个空间来自调用栈的深度——每一层递归调用都会占用一个栈帧,链表长度为 n 时,递归深度就是 n。如果链表特别长,比如几十万个节点,就可能直接触发递归深度限制,程序抛出栈溢出异常。
Python 默认递归深度大约在 1000 左右,所以非常长的链表并不适合用递归。面试时如果你写了递归,最好主动补充一句:“如果链表特别长,递归可能栈溢出,迭代法是更稳定的选择。”这一句话就能体现你对算法复杂度有完整的认识。
3.4 递归 vs 迭代:怎么选择
用一张表把两种方法的关键差异整理清楚:
| 维度 | 迭代法 | 递归法 |
|---|---|---|
| 思考方向 | 从前到后 | 从后到前 |
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(1) | O(n) |
| 代码行数 | 略多但直观 | 很短但抽象 |
| 超长链表风险 | 安全 | 可能栈溢出 |
| 面试加分点 | 空间优势明显 | 展现递归思维 |
我的建议是:面试中优先写迭代法,因为 O(1) 空间是非常稳妥的答案;如果面试官追问“还能怎么写”,再补充递归法,并顺带说明递归的空间代价。千万不要只背递归代码却解释不清每一步在做什么,面试官很容易用“你大声讲一遍递归调用过程”来检验你是否真的理解。
4. 进阶变体:从逆序整个链表到逆序一小段
4.1 反转部分链表:核心是先定位前驱
LeetCode 92 题要求反转从left到right之间的节点。例如链表1 -> 2 -> 3 -> 4 -> 5,反转第 2 到第 4 个节点,得到1 -> 4 -> 3 -> 2 -> 5。
这道题不能直接把整条链表反转,也不能用数组把区间存下来再倒序。正确的做法是先找到left位置的前一个节点pre,以及left位置的节点leftNode。然后对以leftNode为头部的子链表做一次普通的反转,但只反转right - left步。最后把pre.next接到反转后的头部,把反转后的尾部接到原right后面的节点上。过程需要仔细画图,尤其注意几个连接点不能接错。
4.2 每 K 个一组反转:分组反转与剩余处理
LeetCode 25 题是另一个高频变体:给你一个链表和一个整数 k,每 k 个节点一组反转,如果剩余节点不足 k 个,就保持原顺序。比如链表1 -> 2 -> 3 -> 4 -> 5,k=2 时得到2 -> 1 -> 4 -> 3 -> 5。
这道题可以递归求解:先找到一个长度为 k 的区块,如果找不到第 k 个节点,说明剩余不足 k,直接返回当前头;如果找到了,就把这一组 k 个节点用迭代法反转,反转后原来的头变成这一组的尾部,然后让它指向下一组递归反转后的结果。这样一层层做下去,核心代码实际上就是“普通反转链表 + 递归连接”。
4.3 双向链表的反转:多一个指针,多一步交换
双向链表和单链表不同,每个节点除了next还有一个prev指针。反转双向链表,需要同时交换每个节点的prev和next。可以理解成:原来head.next指向第二个节点,反转后第二个节点的next应该回过头指向head;原来head.prev是空,反转后头节点的prev应该指向原来的第二个节点。
实现时用一个cur指针遍历,每次交换cur.next和cur.prev,然后把cur移动到交换前的next节点。最后返回原链表的尾节点。这个知识点不是面试主流,但如果你在简历里写了“熟悉链表”,偶尔会被问到,提前了解没坏处。
4.4 反转链表还能引出哪些题
反转链表经常作为其他题目的前置工具。比如判断回文链表时,可以先找到链表中点,反转后半部分,再和前半部分比较;比如两数相加时,如果链表低位在前,可能需要先反转链表;再比如对链表做某种逆序合并时,也绕不开反转这个操作。
把这些相关题刷熟练后,你会发现反转链表不是孤立的知识点,而是一把通用钥匙。我建议学习顺序是:先掌握完整反转,再做部分反转,然后做 K 个一组反转,最后配合回文链表、两数相加这类题目巩固,形成自己的链表解题框架。
5. 实战排坑:我在反转链表中踩过的几个坑
5.1 空指针异常:最常见的 Bug
空指针异常通常出现在没判断当前节点是否为null就访问next的场景。比如递归法里,有人会写成if not head.next: return head,却漏了head为null的情况;比如迭代法里,采用while cur.next作为循环条件,可能导致最后一个节点没被处理,或者在循环体内误访问cur.next.next。
解决思路很简单:在每个方法入口先统一处理“空节点”判断,再进入主逻辑。迭代法里坚持用while cur,不要用while cur.next;递归法里一定要写if not head or not head.next: return head。这样能挡住大部分空指针问题。
5.2 链表成环问题
链表成环是最恐怖的 Bug,因为它不会马上报错,而是让程序在打印链表时陷入死循环。常见的成环原因有两个:一个是迭代时没有先保存nxt就直接修改cur.next,导致后续节点丢失,节点之间的关系混乱;另一个是递归法里没有把head.next最终置为null,使得原头节点仍然指向第二个节点,而第二个节点经过反转后又指向第一个节点,形成环。
排查成环时,可以写一个辅助函数,打印每个节点地址以及它的next地址,观察是否出现重复地址。如果打印到某个节点后又回到之前访问过的节点,说明链表已经成环,需要检查上述两处代码。
5.3 边界测试用例清单
刷反转链表时,我习惯用下面这组测试用例来验证代码:
| 用例类型 | 输入 | 期望输出 |
|---|---|---|
| 空链表 | null | null |
| 单节点 | 1 -> null | 1 -> null |
| 两个节点 | 1 -> 2 | 2 -> 1 |
| 多个节点 | 1 -> 2 -> 3 -> 4 -> 5 | 5 -> 4 -> 3 -> 2 -> 1 |
| 带重复节点 | 1 -> 2 -> 1 -> 2 | 2 -> 1 -> 2 -> 1 |
不要觉得这些用例简单就不测。越是简单的边界,越容易被面试官追问。比如“你的递归对空链表会不会有问题”,如果你提前准备好了,回答就会很从容。
5.4 写一个辅助打印函数,调试效率翻倍
在本地 IDE 或白板上调试时,写一个打印链表的辅助函数非常有用。我分享一个简洁版本:
def print_list(node, limit=10): count = 0 while node and count < limit: print(node.val, end=" -> ") node = node.next count += 1 print("None")这个函数加了一个limit限制,目的是防止代码有 Bug 成环时无限打印,最多打印limit个节点就停止,避免调试工具被卡死。实测下来,调试反转链表时这个函数帮我快速看清每一步结果,比单纯用断点还直观。
5.5 典型错误版本速查
| 错误代码特征 | 后果 | 修复方式 |
|---|---|---|
循环里没有保存nxt | 后继节点丢失 | 先把nxt = cur.next |
用while cur.next作为循环条件 | 最后一个节点未反转 | 改成while cur |
递归出口漏了not head | 空链表报错 | 改成if not head or not head.next |
递归结束没有设置head.next = None | 链表成环 | 一定要把head.next置空 |
把这些错误类型记在心里,写代码的时候主动规避,比自己闷头调试十几次效率高得多。
6. 几点过来人的练习建议
反转链表这个题,真的值得多写几遍。我自己的练习经验是:第一天先背迭代法,理解三指针移动;第二天尝试不看代码自己默写;第三天再练递归法,并且对着纸模拟调用过程;第四天开始做 92 题和 25 题这样的变体。这样循序渐进,一周内基本能把这个知识体系彻底掌握。
面试的时候,记得先跟面试官说清楚思路再动笔。说清楚“我要用prev、cur、nxt三个指针,每次让cur指向prev”,比直接闷头写代码要加分得多。最后再分享一个小技巧:反转链表之前,先画一个 3 个节点的链表图,把每一步指针变化都标出来,这个过程会帮你避免掉至少一半的边界 Bug。这是我刷过上百道链表题后最真实的感觉,希望这个方法也能帮你少走弯路。