近期在梳理 Hot100 刷题路线时,翻转链表是我记录里来回打磨比较多的一道。很多人觉得链表反转太基础、看一眼就会,可一旦连续写几道变体,或者在面试白板上从零开始推,常常会发现中间有几个细节需要真正想清楚。这篇就围绕 Hot100 里这道高频基础题,把迭代与递归的来龙去脉、容易踩的坑、还有它背后能延伸出的区间翻转和 K 个一组翻转全部串起来,分享一些我用代码反复试错之后沉淀下来的思考方式。
1. 翻转链表是Hot100里的高频题,更是一系列进阶题的“神经元”
如果只看题目本身,翻转链表确实算不上复杂。但它频繁出现在各种热题索引里,原因并不是因为它考背诵,而是因为它是很多链表结构的“神经单元”。如果你把它变成单纯记住代码片段,那后续碰上回文链表、两两交换节点、K 个一组翻转、甚至链表排序,大概率又得从头死记一套。相反,如果在翻转链表这个题上把指针的走向、边界条件、递归返回值的语义吃透,上面那些题就会自然变成同一个模板的微调。
1.1 链表题的第一课:用指针关系代替“物理位置”
数组和链表最大的不同,在于我们对“位置”的直觉。数组按下标访问,给人感觉是一个连续排列的格子;而链表所谓的位置全靠 next 指针串联,没有物理连续。正因如此,“翻转链表”并不是把元素拷贝到新空间,而是调整每条 next 指向的源头与去向。
第一次写翻转链表时,我自己最明显的思想误区是试图画一条新链表出来:比如构造一个 dummy 节点去遍历原链表,把每个节点想办法塞到 dummy 后面。这种做法最终也能工作,但实现的往往是“头插法”的不自觉变种,而且容易引入不必要的内存分配。实际在 Hot100 的标准解法里,我们要做的事更纯粹:不新建节点,只把原链表中每条边的方向“调头”。
这就带来一个编码上的关键习惯:在动手修改 cur.next 之前,必须先把 cur.next 原本指向的下一个节点保存下来,否则修改之后,后面的节点就找不到了。这个“先锁后继”的做法贯穿整个链表操作,可以说是后续各种链表题里最通用的底层操作之一。
1.2 翻转链表为什么值得彻底弄懂而不是背熟
在做 Hot100 的过程中,我发现不少人有“背题惯性”:看到一个链表反转的答案,代码很短,就先背下来。可一旦题目多一些限制条件,比如“只翻转第 4 个节点到第 7 个节点”,或者“每两个一组翻转”,背过的代码立刻就不够用了。
翻转链表真正有价值的地方,在于它是一种“模式”:先分清当前节点、前驱节点和后继节点,然后在遍历中依次完成 保存后继 → 修改当前节点指向 → 前驱右移 → 当前右移。这个模式推演一遍之后,很多题能套用到上面。与其说它是代码模板,不如说它是你脑子里对链表操作的具象建模。只有当你能在图中把指针一行一行画出来,才算真的把这类题吃透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 迭代翻转:靠三个指针把整条链“拆开又接上”
迭代法应该是大家最先接触的解法,也是很多面试官默认要求你能流畅写出来的方案。逻辑上,它用 pre、cur、nxt 三个指针协同工作。
2.1 核心过程:保存后继,再掰弯当前节点
给定一个单链表头节点 head,我们要把它彻底反转。一开始,链表的头节点会成为新链表的尾节点,所以它的 next 最终指向 null。我们可以用 pre 表示已经翻转好的链表段的头节点,cur 表示当前待处理的节点,nxt 用于暂存 cur 原本的下一个节点。
标准的迭代代码如下:
python复制def reverseList(head):
pre = None
cur = head
while cur is not None:
nxt = cur.next # 先把下一个节点保存下来
cur.next = pre # 反转箭头:当前节点指向前一个节点
pre = cur # 前驱移动到当前节点
cur = nxt # 当前节点移动到原本的下一个节点
return pre # 循环结束时,pre 正好指向翻转后的头节点
这套代码极短,但它在每一步都有一个关键保护:nxt = cur.next 必须发生在 cur.next = pre 之前。这句话看着稀松平常,实际写代码时经常被漏掉,尤其是从递归写法切换回迭代时,容易被返回值影响。
2.2 用一条长度为 5 的链表来追踪指针
拿一个具体例子走一遍。初始链表为 1 -> 2 -> 3 -> 4 -> 5 -> null。
| 步骤 | 循环前的核心状态 | 循环后的链表大致形态 |
|---|---|---|
| 初始 | pre=None, cur=1 | 1 -> 2 -> 3 -> 4 -> 5 |
| 第 1 轮 | nxt=2,cur.next=None | 1 -> null,pre=1,cur=2 |
| 第 2 轮 | nxt=3,cur.next=1 | 2 -> 1 -> null,pre=2,cur=3 |
| 第 3 轮 | nxt=4,cur.next=2 | 3 -> 2 -> 1 -> null,pre=3,cur=4 |
| 第 4 轮 | nxt=5,cur.next=3 | 4 -> 3 -> 2 -> 1 -> null,pre=4,cur=5 |
| 第 5 轮 | nxt=None,cur.next=4 | 5 -> 4 -> 3 -> 2 -> 1 -> null,pre=5,cur=None |
循环结束后 cur 已经是 None,说明已经走到原链表结尾之后。此时 pre 指向节点 5,而节点 5 正好就是翻转后新链表的头节点。之所以返回 pre,而不是返回 head,是因为 head 这个变量从始至终都没有移动,它仍然指向原始头节点 1,而节点 1 现在变成了尾节点。
2.3 迭代解法的注意事项与时空复杂度
迭代方案的时间复杂度是 O(n)。每个节点只被访问一次,每条 next 指针只在循环里被重新赋值一次,整个过程没有嵌套循环,所以性能是线性的。空间复杂度为 O(1),只用了 pre、cur、nxt 三个辅助指针变量,没有随链表长度增长的额外存储。
这个解法里最值得养成的习惯,是从 for 循环思维切换成 while 循环思维。很多人写数组翻转时会下意识用下标索引,但链表的遍历没有随机访问能力,必须一步一步通过 next 往下传递。所以条件的判断会变成 while cur is not None,而不是 while i < n。这种思维方式本身,就是学习链表训练的核心目标。
3. 递归翻转:利用函数调用栈让回溯过程自动完成
递归写法代码更短,但理解成本通常更高。它在 Hot100 题解里很常见,是衡量你是否真正理解“递归返回值”的试金石。
3.1 递归的第一步:想清楚每一层在等什么
采用翻转链表的递归思路是:如果我可以先拿到“从当前节点的下一个节点开始、已经翻转好的子链表”,那么我只需要把当前节点接到这个子链表的尾部即可。如何把它接到尾部?对于当前节点 head 来说,它的下一个节点是 head.next,而翻转之后 head.next 会成为子链表的尾节点。所以要设置 head.next.next = head,也就是说,让原本的下一个节点反过来指向当前节点。
很多人在这一步卡住,是因为把“已知子链表的新头节点”和“子链表的尾节点”搞混。递归函数返回的是翻转后的新头,但当前这一层要做的是访问 head.next.next,利用的是 head.next 子链表的尾节点。换句话说,head.next 这个对象在子链表翻转后恰好变成了尾节点,这是链表递归问题里最神奇的既有关系。
递归入口的代码一般写成这样:
python复制def reverseList(head):
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_head
以 1 -> 2 -> 3 为例:
- reverseList(1) 调用 reverseList(2)
- reverseList(2) 调用 reverseList(3)
- reverseList(3) 发现 head.next 为 None,直接返回节点 3
- 回到 reverseList(2),拿到 new_head = 3,执行 head.next.next = head,即让 3 的下一个节点指向 2,再执行 head.next = None,即把 2 对 3 的原箭头断开,返回 3
- 回到 reverseList(1),拿到 new_head = 3,执行 head.next.next = head,即 2 的下一个节点指向 1,再执行 head.next = None,即把 1 对 2 的原箭头断开,返回 3
最终 new_head 会一路传回最外层,结果就是 3 -> 2 -> 1。
3.2 为什么递归里那句 head.next = None 不能省
看到 head.next = None 时,有人会疑惑:这会不会把链表截断?实际上,这一句的核心作用是把当前节点和它原本的后继节点之间的关系清除,避免生成环。上一层的 head.next.next = head 已经让原后继节点指回了当前节点,所以原方向上的指针如果不置空,就会形成双向引用。如果不处理,最终翻转完成后,原来的头节点和第二个节点之间会保留下一个环,遍历新链表时极可能死循环。
更准确的说法是:每一层把 head.next 置空,是为了保证当整条链表翻转完成时,新链表的尾节点确实指向 null。这个尾节点就是最原始的头节点,如果没有这个置空动作,原始头节点的 next 仍指向原始第二个节点,而原始第二个节点已经指了回来,形成一个环。
3.3 递归隐藏的空间代价与栈溢出风险
递归写法的时间复杂度同样是 O(n),但空间复杂度是 O(n),因为每次递归调用会占用一层函数调用栈。链表长度为 100 时问题不大,但当输入规模较大,比如几万、几十万节点时,Python 这类语言会触发默认递归深度限制,直接导致 RecursionError。Java 中对递归深度也没有严格的下限,但深层递归依然会增加栈内存压力。
我在实际练习 Hot100 时,会刻意训练自己对于递归代码的两层理解:一是能写出并解释逻辑;二是能意识到 O(n) 的栈空间在工程里意味着什么。递归在算法题里很美,但真实服务中处理超长链表时,迭代仍然是更稳妥的默认选择。
4. 从基础翻转推导出区间翻转和 K 个一组翻转
翻转链表的真正价值,体现在 Hot100 进阶题里。如果面试官只考一道 206,你快速写完可能并不能展示多少功力。真正的热门组合题通常在翻转基础上加一些边界条件,比如“只翻转链表的一部分”。
4.1 区间翻转:先拆出待反转子链,再复用主反转逻辑
LeetCode 92 是一道常见的翻转链表变体:反转从第 left 个节点到第 right 个节点的区间。处理办法并不需要发明新算法,只需要四步:
- 先找到第 left 个节点的前驱节点 pre_left。
- 把从 left 到 right 这段子链表“切”出来,通常做法是用一个指针遍历到 right。
- 对这段子链表调用标准的链表反转逻辑。
- 把反转后的子链表重新接回原来的链表中。
关键点是:切断和拼接的时候要特别小心边界。如果 left 是头节点,就没有 pre_left,此时需要单独处理;更稳妥的方法是统一加一个哑节点 dummy,让 dummy.next 指向 head,这样即使从第一个节点开始翻转,也有一个统一的前驱指针可用。
我在实际编码时,会先定义两个辅助变量:left_node 表示第 left 个节点,right_node 表示第 right 个节点。通过从 dummy 出发遍历,分别记录它们的准确位置。然后保存 right_node.next 为后面的链表部分,再令 right_node.next = None,这样就把待翻转区间从原链表上“剪”了下来。调用 reverseList(left_node) 后,得到新的区间头节点。最后把 pre_left.next 指向新头,再让 left_node.next 指向之前保存的尾部即可。
这种方法有一个好处,是让主逻辑保持纯净:先裁剪,再翻转,最后拼接。你不用在一个 while 循环里同时处理区间判定和指针翻转,代码的可读性和正确率都会明显提高。
4.2 K 个一组翻转:把链表切成长度固定的片段,逐段处理
LeetCode 25 是翻转链表的进阶代表,也是 Hot100 里让人印象深刻的题目之一。它要求在链表中每 K 个节点一组进行翻转,如果最后一组不足 K 个则保持不变。这道题最直观的做法,是先遍历一遍获取链表总长度,再从头按 K 个一组处理,每一组内部用翻转链表的逻辑。
每次翻转一组时,需要明确的变量是:
- 该组的前驱节点 pre
- 该组的起始节点 start
- 该组结束后的后续节点 tail_next
在区间内部,你要通过 K 次循环找到该段的末尾节点 end。然后先把 end.next 保存下来,再把这个段从主链表上断开并执行内部的翻转,最后把翻转后的段接回去。这里的复杂度边界在于嵌套调用:外层遍历组,内层对每组执行翻转,整体时间复杂度是 O(n),因为每个节点只经历常数次扫描。
我自己在实现 K 个一组翻转时,最常用的代码骨架是这样的:
python复制def reverseKGroup(head, k):
dummy = ListNode(0)
dummy.next = head
pre = dummy
end = dummy
while end.next is not None:
for _ in range(k):
end = end.next
if end is None:
return dummy.next
start = pre.next
next_part = end.next
end.next = None
pre.next = reverseList(start)
start.next = next_part
pre = start
end = start
return dummy.next
这里一个比较容易被忽略的细节是:每完成一组的拼接后,pre 要更新为该组的起始节点,也就是翻转后的尾节点。如果不更新,下一次翻转的位置就会错乱。这个更新逻辑,本质上是从基础翻转代码里“当前节点变成下一轮的前驱”延伸出来的,能够看到同一套指针习惯在更大范围内的复用。
4.3 回文链表、两两交换:同一个核心操作的不同包装
Hot100 中另有几道题,也会大量用到翻转链表思想。比如“回文链表”,常见做法是先找到链表的中点,然后把后半段翻转,再和前半段逐一比较。这个过程不需要额外的 O(n) 辅助数组,属于用空间换时间思维的反面。实现时,“找中点”用快慢指针,“翻转后半段”用迭代法,两道经典操作组合起来。
“两两交换链表中的节点”同样也能用子链表翻转的思路去处理,只是每组长度固定为 2。当你看过 K 个一组翻转以后,两两交换会显得特别顺:把 k 换成 2,就得到了一组节点数为 2 的完整解法。从一道基础题出发,就能自然延伸到这么多热门问题,这也解释了为什么翻转链表是 Hot100 里练习优先级很高的题目。
5. 我在练习翻转链表时踩过且很多人会踩的坑
刷题不是看会,而是写会。这里整理几个我在反复提交与测试中真正踩过的坑。它们单个看起来都很小,但叠加起来足够让看似完美的代码整体崩溃。
5.1 对 None 的 next 属性发起访问:空指针异常
一开始处理空链表时,我曾写出类似这样的代码:
python复制def reverseList(head):
cur = head
while cur.next:
nxt = cur.next
cur.next = pre
...
如果 head 本身是 None,循环条件里访问 cur.next 会立刻报错。严格来说,这行代码有两个问题:一是没有处理空链表输入;二是边界判断应该用 while cur is not None,而不是 while cur.next is not None。因为当 cur 是尾节点时,cur.next 固然为 None,但在设置 pre 和移动 cur 之后,我们需要下一轮进入循环去处理新的 cur;如果改成 cur.next 判断,尾节点这一轮操作就无法完成。
推荐的姿势是在函数开头就处理空链表:
python复制if not head or not head.next:
return head
不要小看这一行,很多系统里的链表问题都因为边界输入是空链而翻车,先养成习惯能少花大量调试时间。
5.2 保存后继节点后没有移动前驱指针
迭代里有个特别容易犯的错:只改了 cur.next,却忘记把 pre 推进到当前节点。比如初学时我写过:
python复制while cur:
nxt = cur.next
cur.next = prev
# 忘了 prev = cur
cur = nxt
这种代码跑起来不会立刻报错,而是会出现诡异的错乱:每轮 cur.next 虽然指向了上一个节点,但 prev 一直停留在 None,导致每轮翻转结果都是 1 -> null,最终只返回最后一个被处理节点,还弄丢了大半条链表。
为了避免这种问题,可以在写 while 循环时按四个动作逐一排练:保存后继、修改当前、旧前驱变成新前驱、当前移动到后继。四个动作缺一不可。顺序也很重要:先保存 next、再翻转 next、再更新 pre、最后移动 cur。如果先移动 cur 再更新 pre,一定会出现引用错位。
5.3 递归时把 new_head 和 head.next.next 的关系混淆
写出递归版以后,我曾试图把 “子问题返回的新头” 赋值给当前节点的某个局部变量,然后直接用这个局部变量去重新连接当前节点。例如:
python复制new_head = reverseList(head.next)
head.next.next = head
head.next = None
return new_head
本身这段代码是对的,但很多人在理解它时,会误以为 head.next.next 里的 head.next 已经在调用 reverseList 后被修改成了新头部。这是一个概念误区:head.next 仍然是原来的下一个节点,只是这个节点此时已经变成子链表翻转后的尾节点,所以通过它再往下走一步,才能把当前节点接到底部。搞清楚这一点,递归题的思路就会顺很多。
5.4 反转整个链表和反转前 K 个节点的写法混淆
部分题解为了优化,会把“翻转整个链表”和“翻转前 N 个节点”分开写成一个带计数参数的 inner 函数。如果没理清两者边界,容易把递归终止条件写错。整体反转的终止条件是 head is None or head.next is None,而翻转前 N 个节点时,需要额外标记一个 successor 节点来保存第 N 个节点之后的链表。不少人在练习时把这两个 base case 混在一起,结果适用范围就变了。
我的建议是初始阶段先彻底掌握整体翻转,再单独用一个小例子推导区间翻转。不要一开始就试图背一套通用模板去通吃所有题,不然当题目的细节改变时,会非常难以排查错误。
6. 把翻转链表拆解成一种“条件反射”以后,Hot100 里很多链表题都会显得顺畅
很多一开始觉得难的问题,最终都可以拆成模式组合。比如环形链表用快慢指针,合并两个有序链表用哨兵节点加双指针,删除倒数第 N 个节点用快慢指针拉开距离,而翻转链表是用三指针对方向进行改变。刷到后面我发现,链表的题目本质上是在考察几个动作的熟练组合:遍历、插入、删除、翻转。翻转作为其中较难的一个动作,值得通过多道题去刻意训练。
6.1 面对 Hot100 题解,不要直接背轮数,而是把每次循环的目标讲清楚
看别人的题解时,很多人容易陷入“对着代码一步步走一遍,然后觉得自己懂了”的状态。但题目变化后,同样的细节立刻变成坑。训练时可以尝试一个方法:不看代码,只对着题目标题,在空白处写出每个变量的语义。比如:
- pre 代表已翻转部分的头节点
- cur 代表当前待处理节点
- nxt 代表原来的后继,防止断链
把这些语义写在纸上,再动手写代码。这样即使递归调用较复杂,也不容易在看不清图的情况下死记硬背。遇到 H100 题解的递归版本时,也可以试着把“子问题返回的 new_head”当作核心支柱,反复画调用栈,时间久了会形成一种很自然的推导能力。
6.2 链表题多测几组边界数据:空链、单节点、两个节点、长链
写完后不要急于提交,先用几组输入测试自己的代码。以前我在本地测试时,总是只测一条 3 个节点的链表,结果通过后以为自己已经掌握了。后来在大型链表中才发现,递归会栈溢出,迭代也偶尔在尾节点处理上出问题。
推荐建立一份固定的测试清单:
- 空链表:输入 None,应返回 None
- 单节点链表:输入
1 -> null,应返回本身 - 两个节点链表:输入
1 -> 2 -> null,应返回2 -> 1 -> null - 包含重复值的链表:输入
1 -> 1 -> 2 -> 2,验证操作稳定性,虽然这里没有排序要求,但用来追踪指针也有帮助
这份清单能挡住至少 30% 的提交错误,因为很多题解跑通示例,却没跑过临界情况。
6.3 实际编码前的画图步骤,值得多花一分钟
我注意到不少初学者在见到链表题后第一反应是写代码,而不是画图。链表操作只要画好一行 [pre] [cur] [nxt] 的关系,代码就自然浮现了。画图不是浪费时间,反而是一种快速理清逻辑的方式。特别是翻转链表这种涉及“断开箭头”的操作,如果不在纸上画清下一跳,出错后排查成本会更高。
我个人的实践顺序是:
- 画清原链表,并用颜色标出三个指针。
- 手动演示一轮循环中指针的变化。
- 找出循环结束条件。
- 把文字描述翻译成代码。
这个方法同样适用于递归版本,只是将图的结构改成函数调用栈视图。每当遇到新的链表 Hot100 题,我都会先做一遍这个过程,效果比自己直接阅读题解要稳定得多。
6.4 从一次 Hot100 刷题顺序聊到刻意练习的收益
在 Hot100 这座庞大的题库里,翻转链表所在的位置往往靠前,但它不是一次跑完就能彻底放下的题。我做过多次回炉,间隔一两周重新默写一次,每次都有新的体会。第一次是在了解迭代的三个指针后觉得恍然大悟;第二次开始思考为什么这样可以保证不会断链;第三次则开始和区间翻转组合使用。
有这种“回炉增益”的题不多,而翻转链表恰好是其中之一。它像一个清晰的锚点,帮你把链表题中零散的经验串成系统。如果刷题时间有限,把这类题真正学透,比多刷十道重复题更有价值。
最后再分享一条实践中的体会:翻转链表不是那种让你产生“我懂了”幻觉就能掌握的题,它需要你真正动笔,需要你关闭 IDE 提示,从一个空函数开始把它们完整写出来。建议你马上拿一条链表手动追踪一轮迭代。追完之后你会发现,Hot100 里那些看似更复杂的链表题,其实都在一遍又一遍地使用同一个操作:让当前节点的 next 改变方向之前,先用手把后继节点抓住。
