我第一次做LeetCode 92这道题用的是最朴素的思路:把要反转的区间整段遍历出来,存成一个数组,然后逆序把数组里的值填回节点。OJ很快给了个Accepted,我甚至觉得这题也不过如此。直到后来面试被追问了一句“如果禁止改val,只能动指针呢”,我才意识到自己绕开了这道题真正要练的核心能力。
LeetCode 92,反转链表II,表面上是206题的加强版,实际上它比206题难了不止一点:206题让你反转整条链表,你只需要一个持续往前挪的指针;92题只反转区间,要求你在正确的位置停下、精准地操作一段子链表、再接回去,任何一个指针提前或延后一步,结果都是环或者丢节点。这篇文章面向正在刷链表专题的人、刚学到单链表的数据结构初学者,以及准备面试但一写链表就心虚的读者。我会从原理讲到代码,再讲几个我实际调试时踩过的边界坑,最后说说这道题在面试和工程里到底在考什么。
1. 先搞懂一件事:这题为什么比206难,难在哪
1.1 会做206,却总是差几行到不了Accepted
206题的核心操作是三指针翻转:pre、cur、next依次挪动,把每个节点的next指向它前面一个节点。这个套路刷过几次之后基本是肌肉记忆,代码量也很短。于是很多人做92时抱着同样的心态,结果一跑就发现不对:边界一多,代码就开始加if-else,加到最后自己都看不懂在干嘛。
打个比方,206题像是把一列火车整列掉头,你只需要考虑车头和车尾;92题像是把这列火车的中间三节车厢摘下来,掉头,再装回去,前后两截车厢还不能动。你需要同时盯住四个位置:区间左边界的前一个节点、左边界节点、右边界节点、右边界后一个节点。任何一个位置的保存时机出错,拼接回去的时候要么形成环,要么把链表拆成两段。
另外一个隐性难点是“返回值”的处理:整链反转时,新的头节点就是原链表的尾节点,很好确定;区间反转时,如果left等于1,头节点本身就是被反转的节点,反转后原来的第二个节点会变成新的头节点,如果代码里还在老老实实返回传入的head,就会输出一段丢了开头的链表。
1.2 头节点身份不稳定,虚拟头节点是必经之路
前面提到,left等于1时,头节点会被卷进反转区间,原来的头节点反转后不再是链表的头。这其实是92题和206题最大的差别之一。206题里,反转完成后的head其实是原来的tail,代码里直接用它做返回值即可;92题则不行,你的返回值可能是原head,也可能是原head之后的某个节点,这取决于反转区间从哪里开始。
为了解决这个问题,最稳的做法是引入虚拟头节点(dummy node):
cpp复制ListNode* dummy = new ListNode(-1);
dummy->next = head;
所有查找和反转操作都从dummy出发,而不是从head出发。最后统一返回dummy->next。这样left等于1时,dummy就充当了原head的“前驱”,让反转逻辑和其他情况完全一致,不需要特判。
我见过不少人排斥虚拟头节点,觉得它是多余的节点,自己多写几个if也能搞定。但我的经验是:在链表题里,虚拟头节点不是“优雅技巧”,而是“保命手段”。它能把你从大量边界条件里解放出来,让你的核心逻辑只有一套,而不是left=1一套、left>1另一套。写过工程代码的人都明白,分支越多,出错概率越高。
1.3 题目在考什么:引用传递和边界控制
很多教程会把92题归为“链表基础题”,但我认为它考的东西相当深:第一,你是否理解单链表的next指针本质上是一条条“引用”,修改它会影响链表整体的结构;第二,你是否能在多指针同时变化时保持逻辑清晰,不弄丢节点;第三,你是否具备边界意识,知道left=1、right=n这类极端情况和普通情况的统一处理方式。
这三点在工程中同样重要。任何涉及到“把某个节点从一条链上摘下来,再插到另一个位置”的操作——比如任务队列重新排序、内存块回收、页面节点重排——底层逻辑都和这道题一模一样。面试官喜欢考它,不是因为它难,而是因为它能在很短的时间里看出一个人对引用操作的熟练程度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 区域反转的三种实现思路,哪种才值得背
2.1 先断开子区间再整体反转:最直觉但最坑
一种很容易想到的做法是:先找到left节点和right节点,把这一段子链表从原链表中“剪”下来,整体反转,然后再拼接回去。思路清晰,符合直觉,很多人第一反应就是这个。这个方案的方向没错,但它要求你同时保存四个关键指针:left的前一个节点pre、left节点、right节点、right的后一个节点after,缺一个都拼不回去。
实际写起来你会发现几个头疼的地方:
- 断开时机不好把握。如果先把子区间剪下来,pre和after指向的位置虽然不变,但子链表内部的指针已经反转,此时的left和right身份已经互换了,拼接时容易搞混。
- 边界条件特别多。right如果恰好是链表尾节点,after是nullptr,反转后子链表的最后一个节点要指向nullptr;left如果是头节点,pre就是nullptr,这时新的头节点要更新。这些分支写下来,代码量直接翻倍。
- 调试困难。一步断开、一步反转、一步拼接,中间任何一步出错,链表的结构就变了,你很难一眼看出是哪个指针的问题。
我并不是说这种思路完全不能用,事实上只要细心它是能写对的。但从复习效率和面试表现来看,它远不如头插法干净。面试时代码越短、逻辑越集中,你留给自己的检查时间越多。
2.2 边遍历边头插:最优解的核心不变量
我现在推荐的做法是“边遍历边头插”,它在很多题解里也被称为一次遍历法。核心思想是:不把子区间剪下来,而是在遍历的过程中,把区间内right-left个节点逐个“摘下来”,插到区间左边界前驱节点pre的后面。每插一个,被插入的节点就到pre后面了,整体效果等价于反转。
先给出完整的C++代码:
cpp复制class Solution {
public:
ListNode* reverseBetween(ListNode* head, int left, int right) {
ListNode* dummy = new ListNode(-1);
dummy->next = head;
ListNode* pre = dummy;
// 移动left-1步,让pre指向区间左边界的前一个节点
for (int i = 0; i < left - 1; i++) {
pre = pre->next;
}
ListNode* cur = pre->next; // cur指向反转区间的第一个节点
// 迭代right-left次,每次把cur后面的节点插到pre后面
for (int i = 0; i < right - left; i++) {
ListNode* next = cur->next; // 保存cur的下一个节点
cur->next = next->next; // 把next从链上摘下来
next->next = pre->next; // next指向pre后面的节点(也就是当前区间的第一个节点)
pre->next = next; // pre的下一个节点更新为next
}
return dummy->next;
}
};
代码只有十几行,但每一步都不能乱。这里有一个关键点需要特别理解:为什么循环结束后,区间就反转了?我拿一个具体的例子走一遍。
链表初始为:1 -> 2 -> 3 -> 4 -> 5,left=2,right=4,目标是把2、3、4反转为4、3、2,最终结果应该是1 -> 4 -> 3 -> 2 -> 5。
- 初始:pre指向1,cur指向2,cur后面的节点是3。
- 第一次循环(把3插到pre后面):cur->next指向4,3->next指向2(也就是pre->next原来的值),pre->next指向3。链表变成 1 -> 3 -> 2 -> 4 -> 5。此时cur仍然指向2,cur后面的节点是4。
- 第二次循环(把4插到pre后面):cur->next指向5,4->next指向3(也就是pre->next当前的值),pre->next指向4。链表变成 1 -> 4 -> 3 -> 2 -> 5。循环结束。
注意一个很容易让人困惑的点:在这个过程中,cur始终没有移动,它一直指向原区间的第一个节点2,但它的位置在链表里被一步步往后“顶”。这是因为我们每次都在pre后面插入新节点,而pre一直不动,所以新插入的节点总是往pre后面挤,cur自然就一步步让位了。循环次数是right-left,也就是要处理的“需要往前插”的节点个数。链表长度是5,left=2,right=4,right-left=2,所以循环两次正好把3和4都处理完。
这个方法的精髓在于:你不需要记住right节点的位置,也不需要处理after指针。循环次数天然保证了处理范围,边界条件被虚拟头节点和循环次数两个机制同时消化掉,代码自然简洁。
2.3 递归兜底:另一种思路,以及它的代价
除了迭代,还有一套很有启发性的递归写法。它把“反转区间”拆成“先走到left节点,再反转以它为头的前right-left+1个节点”。为此需要先实现一个反转前n个节点的辅助函数:
cpp复制ListNode* successor = nullptr; // 记录第n个节点的下一个节点
ListNode* reverseN(ListNode* head, int n) {
if (n == 1) {
successor = head->next;
return head;
}
ListNode* newHead = reverseN(head->next, n - 1);
head->next->next = head;
head->next = successor;
return newHead;
}
ListNode* reverseBetween(ListNode* head, int left, int right) {
if (left == 1) {
return reverseN(head, right);
}
head->next = reverseBetween(head->next, left - 1, right - 1);
return head;
}
这套递归非常优雅,尤其是reverseBetween这个函数通过递归天然“走到”了left位置,然后复用reverseN完成剩余操作,不需要手动找pre节点。但代价也很明显:递归深度和链表长度相关。如果链表有十万个节点,递归栈就有十万层,在工程环境里很容易栈溢出。LeetCode的测试用例规模不大,所以能通过,但如果你去生产环境处理超长链表,这种写法就不太合适了。
我个人的态度是:递归写法值得用来锻炼思维,但面试和实际开发中首选迭代头插法。空间复杂度O(1)是真的可以在任何场景里放心用的。
3. 调试现场:那些能让你卡到凌晨的边界Bug
3.1 经典Bug复盘:循环次数多一次、少一次
我最初写头插法的时候,循环范围写成了for (int i = left; i <= right; i++),结果程序直接超时或者输出乱链。原因是多跑了一次循环,把区间之外的一个节点也卷了进来。
这里要记住一个硬规则:如果你用的是上面那种“cur指向区间第一个节点”的写法,循环次数就是right-left,而不是right-left+1。为什么?因为cur本身已经在区间内了,它不需要自己插自己,只需要把它后面的节点逐个插到pre后面。区间内有right-left+1个节点,cur占据了其中第一个,剩下的right-left个节点才是需要被处理的。
另一个更隐蔽的Bug是更新顺序错乱。有人会写:
cpp复制pre->next = next; // 先把pre连到next
next->next = pre->next; // 但此时pre->next已经变了
cur->next = next->next; // 这里的next->next也被污染了
结果链表直接变成环,程序卡死。正确顺序必须严格按照“先保存、再断开、再接上”的原则:先保存cur->next,让当前cur先断开与它的联系,然后把这个被摘下来的节点的next指向pre->next,最后才更新pre->next。这三步的顺序互换任何一个,都会出问题。我建议写完后在脑子里过一遍:你保存的每一个节点,在使用它之前,有没有可能已经因为前一步操作而变成了别人?
3.2 边界用例逐一跑:left=1、right=n、双节点、相邻区间
写链表题最忌讳只过了示例就提交。我每次写完92题都会强制自己跑一遍下面的测试矩阵:
| 用例 | 输入 | 预期输出 | 考察点 |
|---|---|---|---|
| 普通用例 | 1->2->3->4->5, left=2, right=4 | 1->4->3->2->5 | 基本反转逻辑 |
| left=1 | 1->2->3->4->5, left=1, right=3 | 3->2->1->4->5 | 头节点被反转 |
| right=n | 1->2->3->4->5, left=2, right=5 | 1->5->4->3->2 | 尾节点参与反转 |
| 单节点 | 1, left=1, right=1 | 1 | 极端最小规模 |
| 双节点全反转 | 1->2, left=1, right=2 | 2->1 | 两节点反转 |
| 相邻区间 | 1->2->3, left=1, right=2 | 2->1->3 | 区间长度为2 |
| 反转区间长度为1 | 1->2->3->4, left=2, right=2 | 1->2->3->4 | 相当于无操作 |
前四个用例能暴露绝大多数实现问题。比如left=1的用例,如果你没有用虚拟头节点,返回的节点很可能不是新的头节点,而是反转前的老头节点,输出就变成了1->4->3->2->5这样的错误结果。右端到链尾的用例则能检查你在处理结尾时,最后一个节点的next是否正确指向了nullptr。
我建议你把这几个用例画在草纸上,手动走走指针,再对着代码检查。很多问题不是看你代码“写得对不对”,而是看你“有没有想过这个位置会发生什么”。
3.3 一个实用的调试习惯:先写printList再写核心逻辑
链表调试和数组不同,数组打日志能看到完整内容,链表中一个节点丢了,后面的所有节点都跟着丢。你在IDEA或VS Code里debug断点看指针,其实不如直接打链表输出来得直观。
所以我的习惯是:写核心逻辑之前,先写一个辅助函数:
cpp复制void printList(ListNode* head) {
while (head) {
cout << head->val << " -> ";
head = head->next;
}
cout << "null" << endl;
}
然后在头插法的每次循环结束后,调用printList(dummy->next),观察每轮链表结构的变化。比如上面那个1->2->3->4->5的例子,你会在控制台看到:
code复制1 -> 2 -> 3 -> 4 -> 5 -> null
1 -> 3 -> 2 -> 4 -> 5 -> null
1 -> 4 -> 3 -> 2 -> 5 -> null
这三行输出比任何debug都直观:第一轮后3到了2前面,第二轮后4到了3前面,整个反转过程一目了然。如果某一次输出突然变成1 -> 4 -> 3 -> 4 -> 3,说明出现环了,你立刻就知道是pre->next的那次更新出了问题,可以往这个方向查。这个习惯我用了很久,几乎每次链表题出错都能靠它快速定位。
另外,写完代码后不要急着提交,先自己跑三个用例:left等于1、right等于链表长度、反转区间长度为1。这三个用例覆盖了大部分边界,跑通之后再提交,通过率会高很多。
4. 从这题往后走:工程价值与进阶路线
4.1 面试官为什么偏爱这道题
92题在面试题单里出现的频率非常高,我觉得原因是它同时考察了好几种能力:代码简洁性、边界意识、对引用的理解和耐心。这题的解法虽然不难,但能在五分钟内一遍写对的人并不多。面试官能通过这道题判断一个候选人写代码是“靠肌肉记忆背题”,还是“真正理解数据结构的运转方式”。
一个过来人的建议:面试时如果遇到这道题,不要急着写代码,先把思路用一两句话说清楚——虚拟头节点、pre定位、头插法循环,把right-left次循环的原因讲明白。这比直接闷头写代码得分高。面试官看的不只是答案,更是你发现问题、拆解问题的方式。
4.2 真实工程里和“指针重排”类似的场景
你可能觉得工作中谁会闲得蛋疼去反转一个单向链表?确实,直接让你写链表反转的需求很少。但链表指针重排的底层思想到处都是。比如任务队列按照优先级重新整理,你把一个节点从队里摘出来,插到另一个位置,这本质就是“摘除+头插”。再比如实现一个LRU缓存,你要在O(1)时间内把某个节点从双向链表中摘下来,再插到头部,这个“摘+插”的操作和本题的pre、cur、next三指针配合如出一辙。
更进一步的例子是内存池或者文件系统的空闲块管理,很多底层实现会把内存块串成链表,回收和分配的过程就是链表的摘除和插入。你不会直接写“反转链表”,但你写的每一行链表操作,都是在跟类似本题的指针逻辑打交道。所以我说,92题练的不是“反转”本身,而是你操作链式结构时的空间感和手稳程度。
4.3 进阶:K个一组反转、回文链表、再反转后N个
把92题吃透之后,建议趁热打铁做几道关联题,它们都是在不同方向上的延伸:
| 题目 | 关联点 |
|---|---|
| LeetCode 25 K个一组翻转链表 | 把区间反转的思路按组重复执行,难点在分组和组间衔接 |
| LeetCode 24 两两交换链表中的节点 | 相当于K=2的特例,加深对pre和cur的理解 |
| LeetCode 234 回文链表 | 快慢指针找到中点,反转后半段后逐一比对 |
| LeetCode 206 反转链表 | 92题是它的区间版,206是基础,必须滚瓜烂熟 |
其中25题特别值得一写。它的思路是把链表按K个一组分段,每一段内部做反转,再把段与段之间用前驱连起来,很多人的“段间连接”就是在这一步翻车的。如果你能把92题的pre、cur、next关系理解透彻,25题的段间连接对你来说就是同一个逻辑的重复使用,手会顺很多。
最后再分享一个我在实际刷题中的习惯:每道链表题写完之后,我会把链表长度改成100000,本地跑一下。迭代写法瞬间完成,递归写法直接栈溢出。这个测试帮我养成了对递归栈深度的敏感,也让我在真正写工程代码时,会下意识避免用递归去处理不确定长度的数据。算法题刷多了你会发现,判断一个方案是不是工程可用的,不只是看它在OJ上能不能AC,还要看它在极端数据下会不会爆栈、会不会超时。92题的头插法,就是那种在任何场景下都能放心用的方案。
