LeetCode-92这道题我断断续续刷了三遍才彻底搞懂。说它难吧,解法代码也就十来行;说它简单吧,第一次写的时候不是断链就是死循环,边界上一堆小坑等着你踩。它卡住的大部分人,其实不是不懂反转,而是不懂“只反转一段”时,该怎么把整条链子接回去。这篇文章就把这道题拆开揉碎了讲清楚:题面到底在问什么、两种主流解法的每一步在干什么、边界条件怎么处理,以及我拿它和同家族题目对比后的一些实操体会。无论你是刚接触链表数据结构的新手,还是准备面试想快速过一遍题解思路,这篇都值得看完再上手敲。
1. 题目到底在考什么:区域反转的设计难点
1.1 把题面翻译成人话
题目本身不复杂:给你单链表的头节点 head 和两个整数 left、right,要求反转从位置 left 到位置 right 的链表节点,最后返回反转后的链表头节点。注意这里的位置是从 1 开始数的,不是从 0 开始。
举个例子。链表是 1 -> 2 -> 3 -> 4 -> 5,left = 2,right = 4。那反转第 2 个到第 4 个节点,也就是反转 2 -> 3 -> 4 这一小段,变成 4 -> 3 -> 2。完整结果就是 1 -> 4 -> 3 -> 2 -> 5。
很多人在这一步就栽了,因为他们以为反转之后是 1 -> 3 -> 2 -> 4 -> 5,把“交换”和“反转”搞混了。反转不是把首尾对调,而是把整段的方向倒过来。这道题和第一节最经典的“反转整个链表”最大的区别,就藏在“区域”两个字里。
1.2 为什么“区域反转”比“全链反转”难
先说说 LeetCode-206“反转整个链表”。那道题只需要三个指针 prev、cur、nxt 一路走到底,最后把原头节点的 next 指向 null,返回新的头节点就行。整个过程没有外部断点,因为首尾都是开口的,随便怎么转都不会影响其他节点。
但区域反转不一样。你只反转中间一小段,意味着:
- 反转区间的前面有节点,后面也有节点。反转之后,前驱节点的
next必须指向反转后的新头;反转后的旧头节点,它的next又必须指向原来的后继节点。 - 这两个“接缝”如果没处理好,链表就会断成两截,或者形成环。
- 如果
left = 1,那还要考虑头节点本身被反转,返回的头节点会变化,处理不好整个答案就错了。
我习惯把这道题理解成“在高速公路上拆一段护栏”:护栏里面你想怎么调头都行,但两端必须稳稳接回原位,否则整条公路就断了。所以解这道题的关键不是“会不会反转”,而是“怎么把反转后的段塞回原链表的正确位置”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心解法一:头插法,一次遍历优雅搞定
2.1 虚拟头节点:所有边界问题的答案
写这道题之前,先问自己一个问题:如果 left = 1,那么反转区间的“前驱节点”是谁?答案是没有前驱,头节点本身就是要反转的第一个节点。这时候如果你直接拿 head 操作,代码里就得单独分情况讨论,麻烦不说,还容易漏。
解决这个问题最干净的方式是加一个虚拟头节点(dummy node)。不管题目给的链表长什么样,我先造一个 dummy,让 dummy.next = head。这样一来,原来的 head 变成了“第二个节点”,所有节点都有前驱了,处理逻辑完全统一。
提示:虚拟头节点在链表类题目里几乎是个万能工具。凡是涉及“头节点可能被修改”的题,比如删除倒数第 N 个节点、两两交换节点、反转链表 II,先加一个 dummy,能省掉一半的边界判断。
最终返回的时候,注意要返回 dummy.next,而不是 head。因为头节点可能已经被翻到后面去了,只有 dummy.next 才是新链表的真正头部。
2.2 从“游标后插”理解头插法
头插法的核心思想一句话:每次把 cur 后面的那个节点摘下来,插到 pre 的后面。
更具体一点。先让 pre 走到 left - 1 的位置,也就是反转区间的前一个节点。然后让 cur = pre.next,这是反转区间的第一个节点。接下来反复执行这样的操作:
- 记录
cur的下一个节点为nxt。 - 把
cur.next指向nxt.next,相当于把nxt从链表中摘出来。 - 让
nxt.next指向pre.next,这一步是把摘出来的节点插到pre后面。 - 更新
pre.next = nxt,让pre的 next 指向这个新插进来的节点。
这个过程执行 right - left 次。为什么是这个次数?因为区间里有 right - left + 1 个节点,第一个节点 cur 作为“基准”不动,后面 right - left 个节点依次被摘下来并插到 pre 后面,每插一个,它在 final 链表里的相对位置就越靠前。循环结束,区间自然就被反转了。
我画个图辅助记忆。初始状态:
code复制pre -> cur -> nxt -> ... (区间内剩余节点)
插入 nxt 到 pre 后面之后:
code复制pre -> nxt -> cur -> ... (区间剩余节点)
第一次循环后,cur 和 nxt 的相对位置已经交换。继续循环,原来更靠后的节点会一个个被顶到 pre 后面,直到区间内所有节点都反转过。整个过程 pre 一直没动,它就像个“缝合点”,把新头一节一节接起来。
2.3 Python 和 C++ 代码实现
Python 版本:
python复制class Solution:
def reverseBetween(self, head: Optional[ListNode], left: int, right: int) -> Optional[ListNode]:
dummy = ListNode(0, head)
pre = dummy
# 1. 走到 left 的前一个节点
for _ in range(left - 1):
pre = pre.next
# 2. 头插法反转区间
cur = pre.next
for _ in range(right - left):
nxt = cur.next
cur.next = nxt.next
nxt.next = pre.next
pre.next = nxt
return dummy.next
C++ 版本:
cpp复制class Solution {
public:
ListNode* reverseBetween(ListNode* head, int left, int right) {
ListNode* dummy = new ListNode(0, head);
ListNode* pre = dummy;
for (int i = 0; i < left - 1; i++) {
pre = pre->next;
}
ListNode* cur = pre->next;
for (int i = 0; i < right - left; i++) {
ListNode* nxt = cur->next;
cur->next = nxt->next;
nxt->next = pre->next;
pre->next = nxt;
}
ListNode* ans = dummy->next;
delete dummy;
return ans;
}
};
注意 C++ 自己 new 的节点要记得 delete,虽然 LeetCode 的判题环境不查内存泄漏,但好习惯还是得有。
这段代码里最容易被写错的地方,就是第三步 nxt.next = pre.next。有人会写成 nxt.next = cur,这在第一次循环没问题,因为此时 pre.next 就是 cur。但从第二次循环开始,pre.next 已经变成了之前插进来的节点,你要是写成 nxt.next = cur,链表就彻底乱套了。记住:在头插法里,pre.next 永远指向当前已插入的“最新头”。
2.4 为什么头插法不容易断链
我第一次写这道题用的是最自然的“局部反转再拼接”,也就是下一章要讲的解法二。写完之后虽然也能过,但总觉得接缝处理很绕。后来看了题解里的头插法,才意识到它的精妙之处。
头插法的关键设计在于:cur 的位置在整个过程中从来不变,它始终是原区间的第一个节点。每次操作只是把 cur 后面的节点挪到前面来,cur 的 next 始终指向“当前还没处理完的剩余节点”。这保证了循环不变量始终成立——你永远不会撞上“空指针”或者“重复节点”。链表中最怕的“断链”问题,在头插法里几乎不存在,因为每一步都只改三个指针:cur.next、nxt.next、pre.next,这三个节点在操作前都是确定的。
3. 核心解法二:定位+整段反转,理解起来更直接
3.1 关键节点定位:pre、leftNode、rightNode、succ
头插法虽然代码短,但对新手来说稍微有点“反直觉”。如果你更倾向于先理解再写代码,我建议你掌握第二种解法:先把反转区间从原链表里“切”出来,反转后再“接”回去。思路大白话,每一步都有明确目标。
核心要定位四个节点:
pre:第left - 1个节点,反转区间的直接前驱。leftNode:第left个节点,反转后的区间末尾。rightNode:第right个节点,反转后的区间头部。succ:第right + 1个节点,反转区间的直接后继。
定位方式很简单,用循环走到对应位置。从 dummy 开始走 left - 1 步到 pre,再从 pre 继续走 right - left + 1 步到 rightNode,此时 succ = rightNode.next。
3.2 反转子区间的接缝处理
切出区间之后,我们可以暂时让区间“脱离”原链表:把 rightNode.next 置为 None,然后对以 leftNode 为头的这段子链表做一次标准的反转链表操作。
这里给一个技巧:反转子链表时,prev 初始值不要用 None,而是用 succ。这样反转完之后,子链表的新头(也就是原来的 rightNode)的 next 天然指向 succ,省去一步手动拼接。反转完成之后,只需要执行 pre.next = rightNode,整条链表就接回去了。
代码实现:
python复制class Solution:
def reverseBetween(self, head: Optional[ListNode], left: int, right: int) -> Optional[ListNode]:
dummy = ListNode(0, head)
pre = dummy
# 定位 pre
for _ in range(left - 1):
pre = pre.next
# 定位 rightNode 和 succ
right_node = pre
for _ in range(right - left + 1):
right_node = right_node.next
succ = right_node.next
# 反转子区间
prev = succ
cur = pre.next
while cur != succ:
nxt = cur.next
cur.next = prev
prev = cur
cur = nxt
# 拼接回原链表
pre.next = prev
return dummy.next
这里还藏着一个细节:while cur != succ 这个条件的巧妙之处。它既保证了循环在反转完 leftNode 到 rightNode 之后正好停止,又保证了 cur 不会越界跑到 succ 后面去。如果你用 for _ in range(right - left + 1) 来控制次数,本质上没有区别,但 while 循环配合 pre.next 的尾部连接,逻辑上更直观,少一个计数器变量。
3.3 两种解法横向对比
两种解法我都在 LeetCode 上提交过,性能没有本质差别。给你列个对比表,方便你选择:
| 对比维度 | 头插法(解法一) | 定位+整段反转(解法二) |
|---|---|---|
| 遍历次数 | 基本一次 | 定位两段,加起来也是一次 |
| 代码量 | 短,约10行核心逻辑 | 略长,但每步含义清晰 |
| 断链风险 | 低,循环不变量稳定 | 中,需要小心保存四个关键节点 |
| 理解难度 | 初学时有点抽象 | 更贴合“切出来-反转-接回去”的直觉 |
| 面试推荐度 | 高,代码简洁是加分项 | 高,讲思路时容易让对方跟上 |
我的建议是:先用解法二理解题目本质,再写出解法一提升代码表达能力。面试时两种解法都能过,但如果你能主动说出“这里用虚拟头节点是为了统一处理 left=1 的情况”,面试官通常会很满意。
4. 边界情况与常见问题排查实录
4.1 特殊输入的边界验证
刷题最怕的不是主流程,而是边界条件。这道题几个典型的边界案例,我一个个过一遍:
第一个是 left = right。这时候区间只有一个节点,反转一个节点等于没反转,链表原样返回。两种解法里循环次数都是 0,代码天然处理这种情况,不需要额外 if。
第二个是 left = 1。这是最容易出错的点,但加了 dummy 之后,pre 就指向 dummy,leftNode 是原来的 head,所有逻辑照常走,返回值统一用 dummy.next,稳得很。
第三个是 right = 链表长度。此时 succ 要么是 None,要么不存在。解法一根本不需要 succ,解法二里 succ = None,prev 初始化为 None 也没问题,反转完区间尾部自然指向 None。
第四个是链表只有一个节点,且 left = right = 1。dummy.next 还是那个节点,返回它,正确。
第五个是区间长度为 2,比如 1 -> 2 -> 3 -> 4,left = 2,right = 3。头插法只循环一次,摘掉 3 插到 pre 后面,变成 1 -> 3 -> 2 -> 4。这个案例我建议你在草稿纸上亲自画一遍,画完就彻底理解了头插法的动态过程。
4.2 我踩过的几个典型 bug
说实话,这道题的 bug 类型比较集中,我来把常见的坑一次说清楚。
| 错误类型 | 错误写法 | 后果 | 正确做法 |
|---|---|---|---|
| 返回值写错 | return head |
left=1 时返回的还是旧头,答案必然错 | 返回 dummy.next |
| 循环次数多一次 | for _ in range(right - left + 1) |
多摘了一个区间外的节点,破坏链表 | 次数是 right - left |
| 头插时指针顺序错 | nxt.next = cur |
第二次循环后链表结构错乱 | 写 nxt.next = pre.next |
| 忘记保存 nxt | 改了 cur.next 但没留备份 |
直接断了,后面全部丢失 | 动 cur.next 前一定要 nxt = cur.next |
| 没处理 left=1 | 直接判断 pre == None |
头节点反转时 pre 不存在,空指针 | 用 dummy 统一处理 |
反转完没接 pre.next |
只反转了子区间 | 原链表断开,返回也是错 | 必须让 pre.next 指向子区间新头 |
其中最阴间的坑是第二种,循环次数多一次。这个 bug 你在小数据样本上不太容易发现,因为链表长度够长的时候,多摘一个节点也只是把区间后面的第一个节点也反转了,输出结果大部分测试用例是能过的,但某些特定场景下就会挂。我有一次在本地调试了十分钟才发现是这里写错了,教训深刻。
另一个比较隐蔽的问题是头插法第三步到底该指向谁。很多人会下意识写 nxt.next = cur,因为“接下来应该是这个顺序啊”。但其实只有第一次循环时 cur 等于 pre.next,第二次开始就不对了。我建议你在写的时候,把四个指针的当前指向画在纸上,每轮循环结束标一遍,写完再对照检查。
4.3 时间与空间复杂度分析
这道题的时间复杂度是 O(n),因为不管是头插法还是定位法,每个节点最多被访问常数次,整体只需要遍历一次链表。空间复杂度是 O(1),因为只用了几个指针变量,没有额外的数据结构。
确切地说,头插法从 dummy 出发走了 left - 1 步定位 pre,然后循环 right - left 次操作节点,总步数大概是 right - 2,远小于 n,但最坏情况下 left=1、right=n,就是完整遍历一次。定位法也是类似的,定位 pre 和 rightNode 加起来也是遍历一次,再加上反转子区间,总操作次数还是 O(n) 级别。
5. 从这题延伸:反转链表家族与刷题节奏
5.1 和 LeetCode-206、LeetCode-25 的关系
刷完了这道题,建议趁热打铁把同家族的题目一起过一遍,它们之间是层层递进的关系。
LeetCode-206“反转链表”是 92 的特殊情况:left = 1,right = n。如果你用 92 的解法代入,dummy 加上去之后直接反转整条链表,完全正确。也就是说,能把 92 拿下,206 等于白送。
LeetCode-25“K 个一组翻转链表”则是 92 的加强版:先按 K 个一组分组,每组内部反转,然后组与组之间接起来。这道题的难点在于,你需要不断地定位每组的前驱和后继,不停地重复 92 的“切-转-接”流程。如果你 92 的接缝处理很熟练,25 题的核心框架就只是“如何确认一组有 K 个节点”和“如何用循环控制多组操作”这两个新问题。
另外还有 LeetCode-24“两两交换链表中的节点”,本质上也是区域反转的特例:K=2 的局部反转。不过这题用 92 的头插法也能解,只是稍微杀鸡用牛刀。
这几道题放在一起刷,你会发现一个共性套路:所谓反转,核心就是搞清“谁的前驱被谁接管、谁的后继指向谁”。一旦这个套路熟练,你在链表类题目里的速度会有一个明显的提升。
5.2 我的刷题建议与实操心得
最后分享几个我在刷这题过程中的实操体会,希望能帮你少走弯路。
第一,务必画图。链表题最忌空想。我一开始刷 92 时,直接盯着代码想“这个节点应该指向哪”,越想越乱。后来换了笨办法,拿纸笔画一个 5 个节点的链表,把每次操作后的指针关系都画出来。画完两遍,头插法的过程就像肌肉记忆一样刻在脑子里,后面再写其他反转题基本不卡壳。
第二,本地调试时多用几个特殊输入。LeetCode 自带的测试用例通常比较温和,建议你手动多试几个:left=1、right=n、left=right、两个节点、区间长度为 2,这些边界案例能帮你提前暴露问题,比靠提交再报错效率高得多。
第三,面试时先说思路再写代码。如果你用解法二,跟面试官讲清楚四个节点 pre、leftNode、rightNode、succ 分别是什么,再说“断开之后反转,反转完再拼接”,对方很容易跟上。如果你用解法一,要重点解释 pre.next 始终指向新头部这个关键点。两种都不是套路话,而是真的想清楚逻辑之后自然说出来的话。
第四,不要追求一次写对直接提交。我在 LeetCode-92 上第一次独立写的时候,前三次提交分别犯了返回值错误、循环次数错误和指针顺序错误。承认自己会错,然后按部就班检查每一步,反而是最快的方式。
个人体会是,链表反转这一类题目,本质上考察的是你是不是真的理解“通过改变指针指向来操作节点”这个核心思维,而不是“背模板”。92 题作为一个承上启下的题目,把最基本的整链反转和更复杂的区间操作连接了起来,值得在上面多花点时间,把它吃透。等你回过头再看的时候,会发现在链表这条路上,很多看似新题的题目其实都在重复同一个底层逻辑:找到正确的节点,断开,反转,接回去。
