LeetCode 92题是我在刷链表题时觉得最值得反复琢磨的一道。如果你刷过LeetCode 206题(反转整个链表),再来看这道“反转链表II”,会发现它其实是把反转动作限定在一个区间内,难度直接从“入门”跳到了“必须熟练”。面试里这道题出现频率相当高,因为它精准地考察了三个东西:链表节点的指针操作、边界条件的处理、以及代码实现时的手感。很多人在白板上写这道题,思路能说清楚,一动手就崩,崩的地方基本都集中在指针更新顺序和虚拟头节点的处理上。
这篇文章我不打算只贴一个答案了事,而是把这道题彻底拆开,从题目到底在考什么、两种主流解法的推导过程、代码实现细节,到常见错误和扩展变体,一次性讲透。无论你是刚开始刷LeetCode,还是准备面试前突击链表专题,这篇都值得收藏下来反复看。
1. 题目到底在考什么:先看懂LeetCode 92题
1.1 题目描述与输入输出格式
先过一遍原题。给定单链表的头节点head和两个整数left、right,要求反转从位置left到位置right的链表节点,返回反转后的链表。注意这里的left和right是从 1 开始计数的。
举个最经典的例子:
输入:head = [1,2,3,4,5], left = 2, right = 4 输出:[1,4,3,2,5]也就是说,原始链表是 1->2->3->4->5,把第 2 个节点到第 4 个节点这一小段(2->3->4)反转成 4->3->2,再接回原链表,得到 1->4->3->2->5。
还有一个先决条件:1 <= left <= right <= n,n 是链表长度。这意味着输入不用考虑区间越界的问题。
这道题常见的进阶要求是:一次遍历完成。也就是说你不能先把链表转成数组、反转区间再建链,那样虽然也能过,但面试官基本会追问“能不能一次遍历,空间复杂度 O(1)”,所以直接按最优解来写才是正路。
1.2 这道题为什么是面试高频:考点拆解
很多人刷题喜欢按题号顺序刷,刷到92题时往往已经被前面的链表题折磨得够呛。但我想说的是,92题在整个链表题型里是一个“分水岭”。
它会同时考察你以下这几个能力:
- 指针操作的精确性。链表反转本质上就是不断修改节点的
next指向。全量反转只需要维护两个指针,但区间反转要额外记录区间的“前驱节点”和“后继节点”,相当于在一串珍珠项链里精准地挑出一段,翻转后再接回去,手一抖就散架。 - 边界条件的敏感度。
left = 1时,反转区间包含头节点,这时候如果没有虚拟头节点,处理起来会非常别扭;right = n时,区间后面没有节点了,也要保证代码不出错。面试官非常喜欢把这两个边界条件单独拿出来考你。 - 代码的简洁性和健壮性。这道题解法不止一种,但最优雅的“头插法”只需要一个 for 循环就能完成反转。能在白板上写出简洁且不容易出错的版本,是面试官判断你代码能力的重要依据。
换句话说,刷透这道题,你等于同时复习了 206 题(反转链表)、需要额外处理边界场景的区间操作,也为后面刷 25 题(K 个一组翻转链表)打下了基础。
2. 解题前的关键认知:链表反转的三种形态
在动手写代码之前,有几个底层认知必须先建立起来,不然你只是背代码,换个题目就废。
2.1 从206题到92题:全量反转 vs 区间反转
先回顾一下 206 题的反转逻辑。全量反转链表的经典迭代写法是:
prev = None curr = head while curr: nxt = curr.next curr.next = prev prev = curr curr = nxt return prev这段代码的核心思路是:每次把当前节点的next指向前一个节点,然后三个指针整体向后移动。循环结束后,prev就是新链表的头节点。
92 题可以看成是 206 题的“局部版本”。差别在于,206 题从头到尾都在反转,而 92 题只反转中间某一段。这就引出了两个额外需求:
- 需要找到反转区间的“前驱节点”
pre,也就是left位置的前一个节点; - 反转完区间后,需要把区间的头部接回
pre,区间的尾部接回原来的后继节点succ。
所以整体思路是:定位 -> 反转区间 -> 重新连接。听起来不复杂,但实现细节决定成败。
2.2 虚拟头节点 dummy 为什么必不可少
关于虚拟头节点(dummy node),我见过太多人一开始不理解它的意义,直到在left = 1时把自己卡死。
当left = 1时,反转区间从链表头节点开始。此时区间前面没有任何节点,执行“定位 pre”这步时根本没有pre可找。如果强行特判,代码就会变得非常啰嗦。
解决办法就是人为造一个哨兵节点:
dummy = ListNode(-1) dummy.next = head这个dummy节点不存储有效数据,只用来占位置。这样一来,无论left = 1还是left > 1,pre永远都存在。最终返回结果时,只需要返回dummy.next,它一定指向处理完之后链表的真实头节点。
记住一个套路:只要链表操作可能涉及头节点的变更,就无脑加虚拟头节点。这是一个可以帮你省掉大量边界讨论的习惯。
2.3 一次遍历的核心:三指针的接力
92 题的高频进阶要求是一次遍历完成。这意味着你不能先遍历找到right位置再回头处理,而是在从头走到尾的过程中就把反转做掉。
这里要引入一个核心的三指针模型:pre、cur、nxt。
pre永远指向反转区间前一个节点,也就是反转区间的“锚点”,它不会移动;cur始终指向当前待处理区间内的第一个节点(最初是left位置的节点),注意这里说的是“始终”,它不会在循环里推进;nxt是cur.next,也就是每次要被“摘走”并移动到区间前端的那个节点。
理解了这个模型,头插法的代码就只剩一个动作:不断把nxt从cur后面摘下来,插到pre的后面。这个过程重复right - left次,区间就反转完了。
很多同学死活看不懂头插法的 for 循环,就是没意识到cur在整个过程中一直在向右“后退”——不对,应该说cur的位置没变,变的是它后面挂的节点被逐个摘走。用例子走一遍,立刻就会明白。
3. 两种主流通解:穿针引线与头插法
这道题网上常见的有两种解法:一种是“穿针引线法”,也叫“先定位再断开反转”;另一种是“头插法”。两种思路都值得掌握,因为它们分别对应了不同的思考角度,而且面试中你很可能会被要求说出多种解法。
3.1 方法一:先定位再断链(穿针引线法)
穿针引线法的核心思想比较直白:把反转区间单独拆下来,反转完再接回去。
具体步骤是:
- 用
pre指针走到left前一个节点; - 用两个指针
left_node和right_node锁定反转区间的左右端点; - 记录区间后面的节点
succ; - 把区间从原链表上“剪”下来:让
pre.next = None,right_node.next = None; - 对区间链表调用 206 题的反转函数,返回新的头节点;
- 重新缝合:
pre.next = right_node(反转后右端点变成了新区间头),left_node.next = succ。
这个方法思路清晰,每一步都对应一个直观的操作,尤其适合在讲解时让别人听懂。但它的缺点是代码较长,需要额外实现一个反转函数,而且断链和重连的过程要非常小心,否则容易丢节点。
3.2 方法二:头插法(推荐解法)
头插法是我个人最推荐的做法,也是面试时写起来最快的版本。它不需要把区间拆下来,而是在一次遍历中,不断把当前节点后面的节点“挪”到pre的后面,效果上和区间反转完全一致。
模拟一下经典用例left = 2, right = 4,链表初始状态是dummy -> 1 -> 2 -> 3 -> 4 -> 5:
第一步:pre移动到 1,cur指向 2。此时要把 3 插到 1 的后面,得到dummy -> 1 -> 3 -> 2 -> 4 -> 5。
第二步:继续把 4 插到 1 的后面,得到dummy -> 1 -> 4 -> 3 -> 2 -> 5。
此时区间2 -> 3 -> 4已经被原地反转成了4 -> 3 -> 2,整个过程只遍历了两个节点,代码里唯一的指针移动就在这几次“摘下和插入”中。
头插法的好处是空间复杂度 O(1),且只做了一次遍历(pre走到左侧端点的那一轮不算额外遍历,因为链表头部操作本身就需要走到定位点)。代码量也不大,后面我会给出完整实现。
3.3 复杂度分析与对比
| 维度 | 穿针引线法 | 头插法 |
|---|---|---|
| 时间复杂度 | O(n),需要遍历链表定位 | O(n),一次遍历完成 |
| 空间复杂度 | O(1),不计递归栈 | O(1) |
| 代码量 | 较长,需额外反转函数 | 较短,单函数完成 |
| 边界处理 | 需要切断再缝合,易漏指针 | 只需要正确管理 pre.next 和 cur.next |
| 面试表现 | 思路直观,但实现易错 | 简洁高效,推荐优先写这个 |
如果你在 LeetCode 上做题,两个方法都能通过。但如果是在面试现场给你 10 分钟手写,我会毫不犹豫选头插法。
4. 实操过程与代码实现:一步步手写核心解法
光讲思路不行,代码得能跑。这一节我给出头插法的完整实现,并逐步解析每一行的含义。老规矩,以 Python 为主,再给一个 Java 版本做对照。
4.1 Python 版完整代码与逐行解析
# Definition for singly-linked list. # class ListNode: # def __init__(self, val=0, next=None): # self.val = val # self.next = next class Solution: def reverseBetween(self, head: ListNode, left: int, right: int) -> ListNode: dummy = ListNode(-1) dummy.next = head pre = dummy # 第 1 步:让 pre 走到 left 的前一个节点 for _ in range(left - 1): pre = pre.next # cur 指向反转区间的第一个节点,它只负责“向后看” cur = pre.next # 第 2 步:头插法,执行 right - left 次 for _ in range(right - left): nxt = cur.next # 1. 先把 cur 后面的节点摘出来 cur.next = nxt.next # 2. 让 cur 跨过 nxt,直接连到 nxt 后面 nxt.next = pre.next # 3. 让 nxt 指向 pre 后面的第一个节点 pre.next = nxt # 4. 再把 nxt 接到 pre 的后面 return dummy.next逐行拆解一下这里的重点:
dummy节点的作用是统一处理left = 1的情况,不解释太多,直接当固定套路记住。for _ in range(left - 1)让pre从dummy开始移动到第left - 1个节点。比如left = 2,pre移动 1 步,正好指向节点 1。cur指向pre.next,也就是left位置的那个节点。这个节点在整个过程中不向前移动,每次循环只是把它后面的节点摘走。- 循环次数是
right - left,这很关键。比如区间长度是 3(left=2, right=4),只需要反转两次,因为第一次把第 3 个节点挪到前面,第二次把第 4 个节点挪到前面,原本第 2 个节点自然就被挤到最后了。
4.2 Java 版本对照实现
/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode reverseBetween(ListNode head, int left, int right) { ListNode dummy = new ListNode(-1); dummy.next = 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; } return dummy.next; } }Java 版和 Python 版逻辑完全一致。面试时如果允许选语言,我一般优先写 Python,代码短、不容易写错;但有些公司要求用 Java,那也不慌,逻辑是一样的,只是语法换了皮。
4.3 边界用例验证:left=1 和 right=链表末尾
这段一定要自己推一遍,尤其是left = 1时的执行过程。比如head = [3,5], left = 1, right = 2:
- 初始化:
dummy -> 3 -> 5,pre = dummy; pre移动left - 1 = 0步,所以pre仍然指向dummy;cur = pre.next,也就是节点 3;- 循环
right - left = 1次:nxt = cur.next,即节点 5;cur.next = nxt.next,即节点 5 的 next,为 None,所以3.next = None;nxt.next = pre.next,即5.next = 3;pre.next = nxt,即dummy.next = 5。
最终链表是5 -> 3,返回dummy.next,正确。
再看right = 链表末尾的情况,比如head = [1,2,3], left = 1, right = 3。此时效果等价于整个链表反转,代码同样能正确处理。因为nxt每次摘取的都是cur.next,当区间到达末尾时cur.next会变成 None,nxt.next = pre.next也照样执行,不会产生空指针异常。
5. 常见问题与排查技巧实录
不管我写多少遍这道题,总能在评论区看到几乎相同的几个问题。这里集中整理一下,每个问题都是真实的踩坑经历。
5.1 循环终止条件写错:到底执行几次?
我看到的最常见错误就是把第二个 for 循环的次数写成right - left + 1甚至right - left + 2。
记住:反转区间left到right,本质上只需要把区间内除了第一个节点以外的right - left个节点逐个提到最前面。比如 2 到 4,除了第 2 个节点 2 本身,还需要把节点 3 和节点 4 各提一次,所以是 2 次,也就是4 - 2 = 2。
如果写成+1,第一次循环后链表就已经多处理了一个节点,后面再循环下去就会把已经反转好的节点又挪动一次,最终结果完全乱掉。
5.2 指针更新顺序记不住:先取 nxt,再动 cur.next
头插法四行代码里,最核心也是最容易出错的一点是:先保存nxt,再修改nxt.next或cur.next。一旦你先改了cur.next,你后面就找不到nxt了,链表直接断掉。
我用一句话记住这个顺序:“先摘后接,先记后改”。具体到代码里就是:
- 先记下
nxt = cur.next; - 让
cur跳过nxt:cur.next = nxt.next; - 让
nxt指向pre后面的节点; - 把
nxt挂在pre后面。
第 3、4 步的顺序也不能颠倒。如果先执行pre.next = nxt,那pre.next就变成了nxt,此时再执行nxt.next = pre.next,就让nxt.next指向了它自己,形成环。这一步我至少看到五六个人在评论区问过为什么“链表变成环了”,基本都是这个原因。
5.3 易错点速查表
| 易错点 | 正确姿势 |
|---|---|
left = 1时没有虚拟头节点 | 一律先建dummy,返回dummy.next |
第二个循环写right-left+1次 | 严格写right-left次 |
先改cur.next再取nxt | 先nxt = cur.next,再改指针 |
nxt.next和pre.next的赋值顺序颠倒 | 必须先nxt.next = pre.next,再pre.next = nxt |
结束时cur有没有前进? | 不需要,cur位置不变,变的是它后面连接的节点 |
返回head还是dummy.next | 统一返回dummy.next,防止头节点被换掉 |
6. 从一道题刷穿一类题:链表题的通用套路
刷题最忌讳的就是一题一题地背答案,而是要形成“题型意识”。92 题一旦吃透,你会发现很多题目都是它的变体。
6.1 与25题 K 个一组翻转链表的关联
LeetCode 25 题要求每 K 个节点一组进行翻转。这个题本质上就是反复调用“区间反转”的能力。只需要把链表按 K 个一组划分,对每一组执行区间反转,然后处理好组与组之间的连接即可。
如果 92 题你能裸写出来,25 题你就只需要额外考虑两个点:怎么确定一组的起始位置,以及怎么把上一组的尾巴接到下一组的头上。反过来说,如果 92 题还卡着,25 题基本不会顺畅。
另外,LeetCode 24 题(两两交换链表中的节点)可以看成是 K=2 的 25 题,也是区间反转的一种特殊形态。
6.2 面试中如何快速给出手写代码
面试手写链表题,我有一个固定的节奏:
- 先画图,把
pre、cur、nxt标出来,确定循环步骤; - 写
dummy节点,直接避开头节点变更问题; - 写出定位
pre的循环; - 写出头插法的四行核心交换逻辑;
- 测两个边界用例:
left = 1和right = n; - 最后再口头跟面试官确认一下时间复杂度和空间复杂度。
这套流程走下来,基本不会翻车。尤其是第 5 步,很多人写出了代码就觉得万事大吉,结果面试官随手改一个参数,代码就崩了。养成“写完就测边界”的习惯,能让你在面试里显得非常专业。
6.3 扩展思考:递归写法与原地算法
92 题除了迭代写法,也可以用递归实现区间反转。递归的核心思想是“递归到 right 位置后开始逐个返回并调整指针”,代码很简洁,但理解成本会高一些。我个人建议优先掌握迭代写法,递归作为进阶内容,面试时如果不问就不主动写。
另外,这道题完全可以纯原地完成,不需要额外开辟数组或者其他数据结构。在 LeetCode 上,这道题的官方题解和热门题解也都强调 O(1) 空间复杂度。遇到任何说“这个题必须用额外空间才能做”的说法,都要打个问号。
把 92 题刷透之后,建议再花一天时间刷掉这几个关联题:206 题(反转整个链表)、24 题(两两交换节点)、25 题(K 个一组翻转)。你会发现这些题之间有一个非常清晰的递进关系,而 92 题正是整个链条里承上启下的关键一环。
我个人在实际练习中的体会是,链表题最考验的就是“指针修改顺序”的肌肉记忆。一道 92 题如果能在不看答案的情况下,15 分钟内手写出来并一次性通过所有测试用例,那么链表这一关你基本算是站稳了。别急着追求刷题数量,先把这道题吃透,后面的路会顺畅很多。