1. 先聊聊这道题的含金量
反转链表这道题,在LeetCode上是第206题,标签是“简单”,但它绝对是被低估的一道题。我在刷题和实际面试过程中,见过太多人在这个“简单题”上翻车——不是写不出来,而是一写就错,边界条件处理不干净,递归版本更是绕不明白。说句实在话,这道题是链表类问题的“地基”,后面你刷反转链表II、K个一组翻转链表、回文链表、重排链表,全部建立在对这道题的透彻理解之上。如果这道题你只是背了个答案,那后面的题你会刷得很痛苦。
我自己刷这道题经历了好几个阶段:一开始照着题解抄,抄完就忘;后来自己推了一遍迭代法的指针变化,才算真正掌握;再后来为了应付面试,把递归版本也彻底搞懂了。现在回头看,这道题值得写一篇完整的拆解文章,把迭代、递归、头插法、栈辅助几种思路全部讲透,顺便把调试经验也分享出来,帮大家少踩几个坑。
这篇文章适合谁?准备面试的、刚开始刷LeetCode的、以及刷了好几遍还是记不住链表操作的。不管你是哪种情况,我建议你把文章里的代码亲手动笔推导一遍,尤其是指针的指向变化,画图比看代码有用得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心思路拆解:反转链表到底在反转什么
2.1 问题本质:改变指针方向,而不是移动节点
反转链表,输入是一个单链表的头节点,输出是反转后的新头节点。比如 1 -> 2 -> 3 -> 4 -> 5,反转后变成 5 -> 4 -> 3 -> 2 -> 1。
很多新手第一反应是“把节点倒过来放”,于是想着新建一个数组,遍历链表存值,再倒序创建新链表。这种思路能AC,但完全没有get到这道题考察的点。
简单说:链表的节点在内存里并没有“倒过来”这一说,你能改的只是每个节点的 next 指针。反转操作的本质是:把每个节点的 next 指针从指向下一个节点,改为指向前一个节点。原本链表是单向的,从前指向后;反转后,方向全部掉头,每个节点的“下一个”变成了原来的“前一个”。
这个认知非常重要。很多题解里画了一堆箭头图,核心就是追踪这三样东西:
- 当前节点 cur:你现在正在处理谁
- 前驱节点 prev:cur 应该指向谁
- 后继节点 nxt:cur 原来的下一个是谁(不保存的话,改完指针就找不到了)
你只要抓住这三个角色,迭代法的代码就是顺理成章的事。
2.2 为什么反转链表是面试高频题
从面试官的角度看,这道题考察的核心能力是:指针操作的精确性和边界条件的敏感度。链表操作不像数组那样有下标可以随意访问,所有操作都得靠指针一个一个挪,稍微不留神就会出现空指针异常、死循环、丢节点的问题。
从技术体系的角度看,反转链表是很多高级算法的基础操作。比如:
- 回文链表的判断:先找到中点,然后把后半段反转,再逐一比较
- 两数相加(链表存储):反转后从低位开始加更直观
- K个一组翻转链表:本质上就是多次调用反转链表,只是要处理好每一组的边界
- 反转链表II:只反转指定区间的节点,是对这道题能力的直接延伸
所以这道题虽然标着“简单”,但它是链表这个知识板块里“练内功”的题。你内功扎实了,后面那些花哨的招式才使得出来。
3. 迭代法:最推荐掌握的解法
3.1 指针设计的核心逻辑
迭代法的代码非常短,但背后每一步都有讲究。我们先看完整的实现,然后再一步步拆解。
python复制class Solution:
def reverseList(self, head: ListNode) -> ListNode:
prev = None
cur = head
while cur is not None:
nxt = cur.next # 先把下一个节点存起来
cur.next = prev # 反转指针
prev = cur # 前驱节点右移
cur = nxt # 当前节点右移
return prev
代码只有七行,但每一行都在做一件关键的事。
第一步:nxt = cur.next。这一步是必须的。因为一旦你执行了 cur.next = prev,cur 原来的下一个节点就丢了,后面再也找不回来。这就像你搬家前先把箱子打包好、贴上标签,不然搬家公司一来,东西就乱了。
第二步:cur.next = prev。这一步是核心操作,把当前节点的指针掉头,指向它的前一个节点。注意,对于第一个节点来说,prev 是 None,所以反转后它变成了尾节点,next 指向空。
第三步:prev = cur。当前节点处理完了,它变成了下一个节点的“前驱”。
第四步:cur = nxt。继续处理链表中下一个节点。
循环结束后,cur 指向 None(链表遍历完了),prev 指向原来的尾节点,也就是反转后的头节点。所以函数返回 prev。
3.2 手绘推演:看指针怎么走的
我强烈建议你拿张纸,跟着我下面这个推演画一遍。假设链表是 1 -> 2 -> 3 -> null。
初始状态:
- prev = null
- cur = 1
第一轮循环:
- nxt = 2(保存 1 的下一个)
- cur.next = prev,即 1.next = null,此时链表逻辑上变成了
1 -> null,2 和 3 还等着被处理 - prev = 1
- cur = 2
第二轮循环:
- nxt = 3
- cur.next = prev,即 2.next = 1,此时
2 -> 1 -> null - prev = 2
- cur = 3
第三轮循环:
- nxt = null(3 是最后一个节点)
- cur.next = prev,即 3.next = 2,此时
3 -> 2 -> 1 -> null - prev = 3
- cur = null
循环结束,返回 prev,即节点 3。反转完成。
这四行代码在循环里转了三圈,就把整个链表掉了个头。关键在于,每次循环开始时,cur 都是当前待处理的节点,prev 都是它前一个已经处理好的节点,两者像两个指针在链表上同步滑行。
3.3 迭代法的时间与空间复杂度
时间复杂度是 O(n),因为你需要遍历整个链表,每个节点恰好访问一次。空间复杂度是 O(1),因为你只用了 prev、cur、nxt 三个额外指针,不随链表长度增长。
这应该是面试时你能给出的最优解了——时间上必须遍历全部节点,这是无可避免的下界;空间上已经压缩到了常数级别。面试官问“能不能再优化”,正常来说是没有优化空间的。
4. 递归法:理解栈的妙用
4.1 递归的思考方式
递归版本的代码更短,但理解门槛更高。先看代码:
python复制class Solution:
def reverseList(self, head: ListNode) -> 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
递归的核心思想是:假设你已经有能力反转一条链表,那么反转 head -> [已经反转好的子链表] 时,只需要处理 head 这一个节点和子链表的关系。
我们来拆解一下:
递归出口:如果 head 是空,或者 head.next 是空,说明链表为空或只有一个节点,反转结果就是它自己,直接返回。
递归体:先调用 self.reverseList(head.next),假设它能返回 head.next 开头的那段链表反转后的新头节点。这段代码会一路递归到链表的尾节点,然后开始逐层返回。
这里的难点是理解 head.next.next = head 这一句。假设当前链是 1 -> 2 -> 3 -> 4 -> null,递归到最深层时,我们拿到了以 4 为头、已经反转好的链表:4 -> 3 -> 2 -> null。此时在外层,head 是 1,head.next 是 2。我们要做的是让 2 的 next 指向 1,也就是 head.next.next = head,然后让 1 的 next 指向 null,即 head.next = None。
你会发现,递归过程中每个节点都只处理自己“和下一个节点”的关系,但因为这个递归是从尾到头执行的,所以处理每个节点时,它后边的链表已经全部反转完毕了。
4.2 递归和栈的关系:一副生动的画面
我第一次学递归版反转链表时,脑子里始终绕不过弯。后来我用一个朴素的比喻帮助自己理解:递归就像你排队往后传话。
你站在队首,想知道队伍反转后谁是新队首,于是你问第二个人:“你后面的队伍反转后,新队首是谁?”第二个人又问第三个人,一直传到队尾。队尾发现自己后面没人了,就回复:“我就是新队首。”然后消息一层层往回传,每个人在收到回复的同时,把自己和身后那个人的关系翻转过来——原来是“我指向你”,现在变成“你指向我”。
这个过程在计算机里就是函数调用栈:每一层递归调用都保存了当前层的信息,等最深层的递归返回后,再一层层向外恢复执行。你理解了图像,就理解了代码。
4.3 递归的时间与空间复杂度
时间复杂度同样是 O(n),每个节点访问一次。但空间复杂度是 O(n),因为递归调用会使用系统栈,栈的深度就是链表长度。对于特别长的链表,递归版本有栈溢出的风险。所以如果面试没有特别要求,我一般优先写迭代版本。但递归版本也必须掌握,因为面试官经常会让你“再写一个递归版本”来考察你对递归的理解深度。
5. 其他思路与做法横向对比
5.1 头插法:另一种迭代实现
头插法的思路是:新建一个虚拟头节点 dummy,然后遍历原链表,每拿到一个节点,就把它插到 dummy 的后面。这样先插入的节点不断被后插入的节点推向链表尾部,最终所有节点的顺序就反过来了。
python复制class Solution:
def reverseList(self, head: ListNode) -> ListNode:
dummy = ListNode(0)
cur = head
while cur is not None:
nxt = cur.next
cur.next = dummy.next
dummy.next = cur
cur = nxt
return dummy.next
这个方法的代码结构和迭代法很像,区别在于它引入了一个虚拟头节点。虚拟头节点的作用是简化插入逻辑,避免处理“第一个节点插入时链表为空”的特殊情况。实际刷题时,这招也很常见,尤其是在“反转链表II”这类区间反转题目里,虚拟头节点能大幅简化边界处理。
5.2 栈辅助法:最直观但非最优
栈的思路最简单:遍历链表,把所有节点压入栈中,然后弹出并重新连接。
python复制class Solution:
def reverseList(self, head: ListNode) -> ListNode:
if head is None:
return None
stack = []
cur = head
while cur is not None:
stack.append(cur)
cur = cur.next
new_head = stack.pop()
cur = new_head
while stack:
node = stack.pop()
cur.next = node
cur = node
cur.next = None
return new_head
这个方法时间复杂度是 O(n),空间复杂度是 O(n),因为你需要额外的栈空间来存储所有节点。虽然能 AC,但面试时如果只给出这个解法,通常会被追问“能不能优化到 O(1) 空间”。所以这个思路适合作为理解和对比的切入点,不适合作为最终答案。
5.3 四种思路对比总结
| 方法 | 时间复杂度 | 空间复杂度 | 实现难度 | 适用场景 |
|---|---|---|---|---|
| 迭代法 | O(n) | O(1) | 低 | 最推荐,面试首选 |
| 递归法 | O(n) | O(n) | 中 | 考察递归理解,链表不宜过长 |
| 头插法 | O(n) | O(1) | 中 | 区间反转类题目更常用 |
| 栈辅助法 | O(n) | O(n) | 低 | 快速实现,不适合当最终方案 |
6. 边界条件与常见错误排查
6.1 三种典型的边界输入
链表操作题,边界条件是重灾区。反转链表主要考三种情况:
空链表:head = null。迭代法直接返回 prev(null),递归法通过递归出口返回 null,都不会报错。但如果你在代码里无脑访问 head.next,空链表就会触发空指针异常。
单节点链表:head = 1。迭代法执行一轮循环就结束,返回节点 1,没问题。递归法通过 head.next is None 的出口直接返回,也没问题。单节点反转的结果就是它自身,这是很多人容易忽略的边界。
两个节点的链表:1 -> 2。这是最小规模的非平凡反转。迭代法循环两轮,递归法递归两层。我建议你把这个 case 手推一遍,能帮你验证对指针操作的理解。
6.2 常见错误:丢失节点、死循环、返回值错误
我在刷题群和评论区里看过很多次别人贴的错误代码,自己也踩过不少坑,整理一下最典型的几类:
错误一,没有保存后继节点。 直接 cur.next = prev,然后 cur = cur.next。此时 cur 已经指向 prev 了,链表后半段彻底丢失,程序陷入死循环或者遍历不完整。解决办法就是记住那行 nxt = cur.next,把它放在修改指针之前。
错误二,返回值搞错。 有人最后返回了 cur,但循环结束时 cur 已经是 null,返回了一个空指针。正确的返回对象是 prev,因为循环结束时 prev 指向原链表尾节点,也就是反转后的头节点。
错误三,递归版本忘记把 head.next 置空。 如果不加 head.next = None,反转后的尾节点仍然指向原来的下一个节点,链表里会出现环。LeetCode 的判题系统会检测环,直接报错。
错误四,误用虚拟头节点但没处理好 dummy.next。 头插法里,如果忘记在末尾把最后一个节点的 next 置空,同样会形成环。
我把这些错误归纳成一个排查清单:
- 反转后原始头节点(新尾节点)的 next 是否为 null?
- 返回值是否指向反转后的新头节点?
- 整个链表是否还存在环?
- 空链表和单节点链表是否能正确处理?
6.3 调试技巧:把链表变成字符串输出
链表题调试起来很痛苦,因为默认的打印结果只是内存地址,看不出结构。我自己的习惯是写一个辅助函数,把链表转成字符串:
python复制def list_to_string(head: ListNode) -> str:
result = []
cur = head
visited = set()
while cur and id(cur) not in visited:
result.append(str(cur.val))
visited.add(id(cur))
cur = cur.next
return ' -> '.join(result)
注意这里我加了一个 visited 集合,用 id 判断节点是否重复访问,这样万一链表里出现了环,打印函数不会死循环,还能帮你快速定位问题。调试的时候,在反转前和反转后各打印一次,一眼就能看出结果对不对。
7. 实战经验:这道题还能延伸出什么
7.1 反转指定区间:核心能力的直接应用
LeetCode 92题“反转链表II”是这道题最直接的升级版,要求只反转从 left 到 right 之间的节点。求解它需要先定位 left 的前一个节点,然后对区间内的节点执行和本题一样的指针反转操作,最后把反转后的子链表接回原链表。如果你把本题的迭代法吃透了,92题就是一个“定位 + 拼接”的功夫。
7.2 回文链表判断:反转后半段
LeetCode 234题“回文链表”,常见解法是先通过快慢指针找到链表中点,然后把后半段反转,再和前半段逐一比较。如果不熟练反转链表,这道题你会卡在“反转后半段”这步。而一旦你会了反转链表,这道题就变成“找中点 + 反转 + 比较”的组装题。
7.3 K个一组翻转链表:组合拳
LeetCode 25题“K个一组翻转链表”,要求每 K 个节点一组进行反转。这道题的核心依然是本题的反转逻辑,但额外增加了分组、剩余不足 K 个不反转、组与组之间的拼接等复杂边界。刷到这道题时,你会发现本题的基础性有多强。
这些延伸题有一个共同规律:把反转链表当作一个独立的“函数能力”抽出来,然后在更复杂的场景里调用它。所以你现在花时间把基础打牢,后面刷扩展题会顺畅很多。
8. 面试答题的节奏建议
面试时如果遇到这道题,我建议按下面的节奏来,既展示代码能力,又展示思考深度:
第一,先确认题意。问清楚:是单链表吗?能不能修改原链表?返回值是什么?虽然题目说得很明确,但这个习惯能让面试官觉得你严谨。
第二,先说思路。比如:“我会用迭代法,维护 prev 和 cur 两个指针,每次先把 cur.next 存下来,再把 cur.next 指向 prev,然后整体右移。时间 O(n),空间 O(1)。”一句话就能让面试官知道你不是在背答案,而是真的理解。
第三,再写代码。写完代码后不要急着说“写完了”,自己快速检查一下边界:空链表、单节点、返回值是不是 prev。
第四,如果面试官追问“还有别的写法吗”,可以补一个递归版本,顺便说说递归的空间复杂度是 O(n),以及为什么迭代更优。
这套节奏下来,面试官对你的评价基本不会差。
9. 我踩过的坑和总结
最后分享一点个人体会。我在刷这道题的过程中,最大的一个教训是:不要太早看题解。我第一次刷这道题时,看了两眼题解觉得“就这?”,然后自己写,结果一跑就错。后来我强迫自己先画图,把 1 -> 2 -> 3 -> 4 -> 5 这个例子从头到尾推一遍指针变化,推完再写代码,才真正记住了。现在每次讲到链表反转,我都会建议别人先画图,不要急着码代码。
还有一点,多语言刷题的话可以对比一下实现差异。C++ 版本里指针操作更明显,Python 版本里代码更简洁,Java 版本里类型声明更严谨。不管用哪门语言,指针的移动逻辑都是一样的,底层理解到位了,语言只是表达方式的问题。
如果你正在刷题,看到这篇文章,我希望你至少能收获两件事:一是把迭代法彻底练熟,做到闭着眼睛也能写对;二是理解递归版的栈调用过程,而不是死记代码。这两件事做到,后面的链表题你会觉得轻松很多。
