拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

链表指定区间反转:从指针定位到头插法,彻底掌握LeetCode 92题

链表指定区间反转:从指针定位到头插法,彻底掌握LeetCode 92题

链表内指定区间反转,是LeetCode第92题,也是各大厂面试中出现频率相当高的一道基础算法题。题目本身并不难,但很能拉开差距:能把单链表整体反转背下来的人不少,能一气呵成写对区间反转的却不多。原因在于它同时考察三件事——指针定位是否精准、局部反转是否熟练、边界处理是否敏感。这篇文章我会从题目拆解、思路推演、多语言实现、边界测试到面试追问,把这道题完整讲透。适合准备算法面试的读者,也适合刚学完链表、想找一道综合题练手的初学者。

1. 题目拆解与核心考点

1.1 题目到底在考什么

题目描述很简洁:给定单链表头节点head和两个整数left、right,反转从位置left到位置right的链表节点,返回反转后的链表。位置从 1 开始计数,left和right一定满足1 <= left <= right <= 链表长度。

这道题表面上是“反转”,实际上考的是三件事。

第一,指针定位。你得先准确找到区间的前一个节点。很多人习惯从head开始往前走left步,结果走到的是left节点本身,而不是它前面的节点。这里只需要走left - 1步,差的这一步就是整道题的第一个分水岭。

第二,局部反转。区间内的反转和整体反转逻辑上是一致的,但多了“接回去”这一步。你在反转中间节点的同时,要保证区间前端和区间后端不断链、不错位。

第三,边界处理。left == 1时没有前驱,right == 链表长度时区间直接连到尾部,这两种情况是最容易写出 bug 的。

说实话,这些考点每一个单独拎出来都不难,但合在一起,对基本功的要求就上来了。这也是为什么这道题被归入“leecode必刷基础算法题”的常客。

1.2 最常见的三个误区

我在帮别人 review 代码和看面经的时候,见过太多人在这道题上踩坑,总结下来有三个误区出现频率特别高。

误区一:先把整个链表反转,再截取区间。这个思路听起来可行,实际做起来会非常痛苦。因为整体反转之后,所有节点的相对位置都变了,你还得想办法找回原来的left和right位置,再切出区间,最后又要考虑拼接顺序。操作量翻倍,出错概率成倍上升,面试官也不会觉得你有巧思,只会觉得你没抓住问题的本质。

误区二:通过交换节点值实现反转。有些人觉得,反正链表里存的是整数,那我把left到right之间的节点值拿出来倒序放回去不就行了?这样确实能过部分测试用例,但面试官考察链表题的核心目的是看你如何处理指针和引用关系,而不是看你如何操作数组。如果节点里包含的不止一个值,比如带随机指针的复杂链表,值交换就彻底失效了。

误区三:循环边界用错。链表题的位置默认是 1-based 的,但很多人写循环时下意识按 0-based 来,定位pre的时候多走或少走一步,结果整体错位。这种错误很难肉眼排查,因为链表的链接关系不打印出来根本看不出来。

1.3 为什么说它是“试金石”题

判定一道题是不是好题,要看它能不能区分出不同水平的候选者。区间反转就是这样的题。

新手可能见过整体反转的写法,但面对区间反转会犹豫:区间外的节点怎么保住?区间内的节点怎么处理?老手则会在十秒内构建出“虚拟头节点 + 头插法”的框架。这种思维差距,正是面试官想看到的。

更重要的是,这道题是很多复杂链表问题的基础模块。你以后会遇到的“K 个一组反转链表”“回文链表判断”“重排链表”等题目,核心思想都能回溯到区间反转。把这道题练透,等于给后面的进阶题打好地基。

2. 思路推演:把反转拆成三步

2.1 区间反转的本质:摘、转、接

把链表想成一列火车车厢,每节车厢用挂钩连接,车厢只能往一个方向走。整体反转就像是把这列火车掉头,而区间反转是把中间几节车厢单独摘下来、掉头、再重新挂回去。

这个过程拆开就是三件事:

  1. 找到区间的前一个节点pre,相当于找到摘钩的位置。
  2. 把区间内的子链表反转,相当于把摘下来的车厢掉头。
  3. 把反转后的子链表接回原来的前后节点之间,相当于重新挂好钩子。

这里有个重要认知:反转链表并不是“物理上把整段搬走再搬回来”,而是通过不断改变节点的next指针指向来实现的。每次改一个指针,链接关系就变化一次。区间反转只是在局部反复做这件事。

所以,与其说这道题考反转,不如说它在考“链表插入”和“链表遍历”的组合运用。你每轮操作的本质,都是把区间后面的一个节点插到pre的后面。这也是为什么有人说这道题练的是头插法。

2.2 为什么必须加虚拟头节点

先看一个场景:left == 1,要从头节点开始反转。此时区间的前一个节点不存在,你没法用统一的逻辑去处理“定位前驱”这一步。

想绕开这个问题,最优雅的做法就是加一个虚拟头节点,也叫哨兵节点、dummy node。做法很简单:在真正的head前面再建一个节点dummy,让dummy.next = head。这样无论left是几,都能统一从dummy开始走left - 1步,找到区间前驱。

很多教材会强调“不带头结点的单链表”,意思是链表本身没有额外的哨兵节点。但这和我们在解题时加一个局部虚拟头节点并不冲突——dummy只是函数内部的一个局部变量,不是链表本身的一部分,它存在的意义纯粹是为了统一边界逻辑、避免空指针判断。

如果不用虚拟头节点,也能写。你需要在left == 1时单独处理,把pre当作空节点,代码会多几个分支,看起来像是能跑,但逻辑可读性差很多,出 bug 的概率也更高。面试中我强烈建议直接上虚拟头节点,简单、稳妥、不容易翻车。

2.3 迭代法核心操作:逐步拆解

以1 -> 2 -> 3 -> 4 -> 5为例,反转第 2 到第 4 个节点,目标当然是1 -> 4 -> 3 -> 2 -> 5。

先定义两个关键指针。pre指向区间前驱,也就是节点1;cur指向区间的第一个节点,也就是节点2。注意,cur在整个过程中始终指向节点2,它不会变,我们反复操作的是cur.next。

然后进入循环,循环次数是right - left次,也就是 2 次。

第一轮:

  • next = cur.next,next指向节点3。
  • cur.next = next.next,把节点2的下一个指向节点4,此时链表变成1 -> 2 -> 4 -> 5,节点3被“摘”出来了。
  • next.next = pre.next,节点3的下一个指向pre现在的下一个节点(此时是节点2),把3挂在2前面。
  • pre.next = next,让pre的下一个指向节点3,链表变成1 -> 3 -> 2 -> 4 -> 5。

第二轮:

  • next = cur.next,此时cur还是节点2,cur.next已经是节点4了,所以next指向节点4。
  • cur.next = next.next,节点2的下一个指向节点5。
  • next.next = pre.next,节点4的下一个指向节点3。
  • pre.next = next,链表变成1 -> 4 -> 3 -> 2 -> 5。

循环结束,结果正确。

为什么循环次数是right - left,而不是right - left + 1?因为区间内有right - left + 1个节点,第一个节点已经在最前面了,只需要把后面right - left个节点依次“摘”到区间头部。每摘一次,区间头就多一个节点,摘完最后一个节点就是反转完成。

还有一个细节容易写错:三条赋值语句的顺序。很多人会把next.next = pre.next和cur.next = next.next调换位置,结果一跑就断链。原因是必须先断开cur和next的链接,让cur提前指向next的后继节点;如果先改next.next,cur.next里存的指向就丢了,后面再取next.next时会取到已经被改过的指针,链表就乱了。

3. 代码实现:C、Java、Python 对照

3.1 C 语言实现:不带头结点的经典解法

很多嵌入式开发者和 C 语言学习者会特别强调“不带头结点的单链表”场景,因为真实的低级数据结构往往就是这种形态。但解题时我们仍然可以用一个局部虚拟头节点来兜底。

struct ListNode* reverseBetween(struct ListNode* head, int left, int right) { struct ListNode dummy; dummy.next = head; struct ListNode* pre = &dummy; for (int i = 1; i < left; i++) { pre = pre->next; } struct ListNode* cur = pre->next; for (int i = 0; i < right - left; i++) { struct ListNode* next = cur->next; cur->next = next->next; next->next = pre->next; pre->next = next; } return dummy.next; }

这里我用的是栈上分配的dummy变量,而不是malloc。好处很明显:不用手动free,也不用担心内存泄漏,函数结束自动失效。如果你用了malloc,一定要在返回前free掉,否则面试官可能会追问内存管理问题。

C 语言版本最容易错的点有两个。一个是返回值,一定要返回dummy.next,因为当left == 1时原来的head已经不是新链表的头节点了。另一个是pre的定位循环,从 1 到left - 1,不是 0 到left - 1,也不是 1 到left。

3.2 Java 实现:虚拟头节点简化边界

Java 的写法和 C 语言几乎一模一样,区别只是语法和对象引用的处理方式。

class Solution { public ListNode reverseBetween(ListNode head, int left, int right) { ListNode dummy = new ListNode(0); dummy.next = head; ListNode pre = dummy; for (int i = 1; i < left; i++) { pre = pre.next; } ListNode cur = pre.next; for (int i = 0; i < right - left; i++) { ListNode next = cur.next; cur.next = next.next; next.next = pre.next; pre.next = next; } return dummy.next; } }

Java 里的ListNode是一个引用类型,赋值操作传递的是引用,本质上和 C 的指针操作是一回事。唯一的区别是,dummy = new ListNode(0)在堆上创建对象,不需要手动释放,垃圾回收器会处理。

我在写 Java 版本时有一个习惯:给虚拟头节点的值随便赋个 0,因为反正不会用到。但要注意,如果你打印链表调试,dummy的值会显示出来,别被它误导了,误以为是链表的一部分。

3.3 Python 实现:引用操作与易错点

Python 的链表题写起来最接近伪代码,逻辑也最清晰。

class Solution: def reverseBetween(self, head: Optional[ListNode], left: int, right: int) -> Optional[ListNode]: dummy = ListNode(0, head) pre = dummy for _ in range(left - 1): pre = pre.next 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

Python 没有指针概念,但每个变量名都是对对象的引用,pre.next = next本质上是在修改对象属性。这个模式下,变量名就格外重要。我把临时节点命名为nxt,就是为了避免和内置的next()函数混淆,也让自己在头脑中明确“这是当前节点的后继”这个语义。

Python 版本常见的坑是把for _ in range(left - 1)写成range(left),或者把range(right - left)写成range(right - left + 1)。我建议你把示例用例代入走一遍,逐轮打印出cur.val和nxt.val,很快就能发现边界错在哪。

3.4 三个版本实现对比

对比维度C 语言JavaPython
虚拟头节点栈上结构体变量堆上对象前端带参构造
内存回收手动(最好不用 malloc)垃圾回收垃圾回收
核心循环完全一致完全一致完全一致
易错点指针运算与整体考虑引用赋值变量命名与边界

三个版本的核心逻辑完全一致,都是一套“先定位前驱,再头插法逐轮反转”的流程。语言差异只影响语法写法,不影响算法思想。所以我一直建议,学这道题时不要局限于某一种语言,先用最熟悉的语言把逻辑跑通,再对照翻译成其他语言,这样理解最深刻。

4. 边界测试与排查实录

4.1 边界条件与测试用例设计

面试写代码,一定要主动构造边界用例,这能体现你的细致程度。我建议至少跑下面这几个用例:

用例说明期望输出
[]空链表[]
[1],left=1, right=1单节点[1]
[1,2,3],left=1, right=1区间只有一个节点[1,2,3]
[1,2,3,4,5],left=2, right=4常规区间反转[1,4,3,2,5]
[1,2,3,4,5],left=1, right=5整体反转退化为区间[5,4,3,2,1]

用[1,2,3,4,5]、left=2、right=4这个用例,你可以把每一步的链表状态打印出来:

  • 初始:1 -> 2 -> 3 -> 4 -> 5
  • 第一轮后:1 -> 3 -> 2 -> 4 -> 5
  • 第二轮后:1 -> 4 -> 3 -> 2 -> 5

这组输出能帮你快速确认逻辑是否正确。如果你发现第二轮结果变成1 -> 4 -> 2 -> 3 -> 5,恭喜你,最典型的“断链”bug出现了。

4.2 调试时如何快速定位断链

链表调试最直观的手段就是打印。写一个简单的打印函数,每轮循环末尾输出当前链表状态,问题立刻原形毕露。

def print_list(head): cur = head while cur is not None: print(cur.val, end=" -> ") cur = cur.next print("None")

这段小工具我几乎用在所有链表题上,成本极低,收益极高。遇到诡异 bug,先打印,再对照手画的图,绝大多数问题都能肉眼定位。

还有一种情况是你最后打印时发现死循环,大概率是某个节点的next指回了前面的节点,形成环。如果怀疑有环,可以用快慢指针检测,但更快的办法是回过头检查那三条赋值语句的顺序。我总结过一个自查口诀:先存后继、再断旧链、再搭新链、最后接入头部。每一轮操作都按这个顺序执行,基本不会出环。

4.3 面试追问与相关变体题

这道题在面试中经常附带追问。最常见的追问是:如果不用虚拟头节点,你怎么处理left == 1的情况?这就需要分类讨论,代码会多一些分支,但核心逻辑不变。你可以主动先写虚拟头版本的解法,然后补充说明:“如果不用 dummy,我还得对left == 1单独处理,代码会比较冗余。”

更进阶的追问会把这道题扩展成变体:

  • 反转整个链表:直接调用reverseBetween(head, 1, 链表长度)。
  • K 个一组反转:LeetCode 第 25 题,核心就是反复做区间反转,只是每组区间长度固定为 K。
  • 回文链表判断:先找中点,再反转后半段,本质上是区间反转的一个应用。
  • 重排链表:涉及“找中点、反转后半段、合并两条链表”,同样会用到局部反转思想。

能把这些变体和区间反转联系起来,面试官会觉得你是真的理解了,而不是背题。

5. 实操心得与进阶思路

5.1 迭代和递归怎么选

除了迭代法,这道题还有一种递归写法,核心是“反转前 N 个节点”。我在面试时偶尔会作为加分项抛出。

struct ListNode* successor = NULL; struct ListNode* reverseN(struct ListNode* head, int n) { if (n == 1) { successor = head->next; return head; } struct ListNode* last = reverseN(head->next, n - 1); head->next->next = head; head->next = successor; return last; } struct ListNode* reverseBetweenRecur(struct ListNode* head, int left, int right) { if (left == 1) { return reverseN(head, right); } head->next = reverseBetweenRecur(head->next, left - 1, right - 1); return head; }

递归版本代码更短,但有两个明显短板:一是额外使用了递归栈空间,复杂度 O(N);二是successor需要使用外部变量或包装类保存,写起来不够“纯净”。所以在面试中,我会先给迭代法,再提一句递归法作为补充,而不是反过来。

5.2 从这道题延伸出去的通用技巧

刷链表题这么多年,我发现有两条通用技巧是放之四海皆准的。

第一条,画图。任何链表题,先画几条线、几个方框,理清操作前后的链接关系再动手写代码。你在图上画清楚,代码就是照着图翻译。我在指导别人刷题时,几乎每次都要求他们先把图按步骤画完,再允许打开编辑器。

第二条,检查断链窗口。修改链表指针时,最多同时涉及三个节点。我在每次写赋值语句前都会问自己:被覆盖的那个引用,还有没有别的变量指向它?如果没有,它就会断链。有了这个意识,很多链表 bug 在写代码阶段就能避免。

5.3 一道题的多种解法:递归版本

递归版本虽然不推荐在正式面试中作为首选,但它值得你花时间理解,因为递归的视角和迭代完全不同。迭代法是“从前往后逐个摘节点再插到前面”,递归法是“递归到区间尾部,再一层层把头节点挪到后面”。

理解递归版本能加深你对“反转”本质的认知:反转任意一段链表,可以分解成“反转去掉第一个节点后的子段,再把头节点放到子段末尾”。这种分解思想不仅适用于链表,也适用于很多递归结构的问题。

我个人的建议是:这道题先画图走三遍迭代逻辑,再手写一遍递归逻辑,最后把两种方法的时空复杂度写出来对比。这个过程比单纯刷十道简单链表题都有用。

这道题我刷过很多遍,也反复给别人讲。每次讲完我都会强调,链表的操作核心永远是指针和引用管理,背模板没有出路,真正理解每一步在做什么才是关键。等你把区间反转嚼透了,再回头看 K 个一组反转和重排链表,会发现它们没那么可怕。最后分享一个小技巧:面试时写完迭代解法,可以主动提一句“我还知道递归写法,空间复杂度高一些,但可以展开聊聊”,这往往是个不错的加分点,亲测有效。

返回列表