bigywater.tech
算法与数据结构 · LeetCode 92

LeetCode 92. 反转链表 II

给定一个链表和两个位置 left、right(从 1 开始计数,left ≤ right),只翻转从第 left 个到第 right 个结点这一段,其余部分保持原样。这是"反转链表"的区间版本。有一个很干净的做法叫头插法:用哑结点 dummy 处理 left 等于 1 的情形;让 prev 走到第 left 个结点的前一个;cur 是原来的第 left 个结点,它在整个过程中始终不动,反复把 cur 后面的那个结点摘下来,插到 prev 的后面(也就是区间的最前面),重复 right - left 次。这样只需要一遍扫描、没有额外的空间。本文用真实数据展示没有哑结点的写法会错在哪里,给出五种语言的实现,并把头插的过程做成分步演示。

20,000
组随机对拍,与暴力解的结果全部一致
4 步
分步演示,每一帧都来自解法的真实执行,另附 5 种语言实现

题意与示例

完整题目见官方页面(链接在文末)。链表在测试里用整数数组表示。基准解在数组上切片翻转,是与指针操作完全不同的写法。一共 12 组用例,覆盖:官方两个示例、只有一个结点、翻转整个链表、翻转头部(left = 1)、翻转尾部(right = 长度)、left 等于 right(不变)、结点值全相同:

真实实测 · 本机执行输出
输入 [[1,2,3,4,5],2,4]
输出 [1,4,3,2,5]
输入 [[5],1,1]
输出 [5]
输入 [[1,2],1,2]
输出 [2,1]
输入 [[1,2,3],1,3]
输出 [3,2,1]
输入 [[1,2,3,4,5],1,5]
输出 [5,4,3,2,1]
输入 [[1,2,3,4,5],3,3]
输出 [1,2,3,4,5]

陷阱:left = 1 时,头结点会变

翻转的区间包含头结点时(left = 1),翻转之后新的头结点变成了原来的第 right 个结点。如果没有哑结点,很容易写成"总是返回原来的头结点",而原来的头结点现在已经被翻转到了区间的末尾,返回它得到的是一条缩短的链表。下面是这种写法的真实结果:

真实实测 · 本机执行输出
# 没有哑结点、总是返回原头结点的写法
[1, 2, 3, 4, 5], left=2, right=4: 该写法 -> [1, 4, 3, 2, 5]    solve -> [1, 4, 3, 2, 5]
[1, 2, 3, 4, 5], left=1, right=3: 该写法 -> [1, 4, 5]    solve -> [3, 2, 1, 4, 5]
[1, 2], left=1, right=2: 该写法 -> [1]    solve -> [2, 1]
[1, 2, 3], left=1, right=3: 该写法 -> [1]    solve -> [3, 2, 1]
随机 20000 组:该写法出错的有 2807 组(14.0%)

[1, 2, 3, 4, 5] 翻转第 2 到 4 个时(不包含头结点),两种写法一致;翻转第 1 到 3 个时,错误写法返回 [1, 4, 5],正确答案是 [3, 2, 1, 4, 5];[1, 2] 翻转整条链,错误写法只剩 [1]。在随机的 20000 组数据里(left 在 1 到长度之间随机),错误写法出错的有 2807 组,占 14.0%,几乎全部集中在 left = 1 的情形。哑结点让"区间前面的结点"永远存在,返回 dummy.next 就总是正确的新头结点。

头插法:五种语言的实现

dummy 指向头结点,prev 从 dummy 出发走 left - 1 步,到第 left 个结点的前一个。cur = prev.next,也就是原来的第 left 个结点。然后重复 right - left 次:取 nxt = cur.next(cur 后面的结点),把它从原位置摘下(cur.next = nxt.next),插到 prev 后面(nxt.next = prev.next; prev.next = nxt)。每次循环,区间最前面多了一个结点,cur 被越挤越靠后,最后到了区间末尾,并且它的 next 已经自然指向了 right 后面的第一个结点。返回 dummy.next。Python、Java、Go、JavaScript、C++ 都是真实编译运行的。

Python · 解法代码class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next def reverse_between(head, left, right): dummy = ListNode(0, head) # 哑结点:left = 1 时头结点也要被翻转 prev = dummy for _ in range(left - 1): # prev 走到「第 left 个结点的前一个」 prev = prev.next cur = prev.next # cur 始终是原来的第 left 个结点,它会慢慢被"挤"到区间末尾 for _ in range(right - left): # 头插法:每次把 cur 后面的结点摘下来,插到区间的最前面(prev 之后) nxt = cur.next cur.next = nxt.next nxt.next = prev.next prev.next = nxt return dummy.next
真实实测 · Python · 前几组用例的输出
$ python3 solution.py   # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON
[[1,2,3,4,5],2,4]  →  [1,4,3,2,5]
[[5],1,1]  →  [5]
[[1,2],1,2]  →  [2,1]
[[1,2,3],1,3]  →  [3,2,1]
[[1,2,3,4,5],1,5]  →  [5,4,3,2,1]
[[1,2,3,4,5],3,3]  →  [1,2,3,4,5]
Java · 解法代码class ListNode { int val; ListNode next; ListNode(int v) { val = v; } } class Solution { public ListNode reverseBetween(ListNode head, int left, int right) { ListNode dummy = new ListNode(0); // 哑结点:left = 1 时头结点也要被翻转 dummy.next = head; ListNode prev = dummy; for (int i = 0; i < left - 1; i++) prev = prev.next; // prev 走到「第 left 个结点的前一个」 ListNode cur = prev.next; // cur 始终是原来的第 left 个结点,它会慢慢被"挤"到区间末尾 for (int i = 0; i < right - left; i++) { // 头插法:每次把 cur 后面的结点摘下来,插到区间的最前面(prev 之后) ListNode nxt = cur.next; cur.next = nxt.next; nxt.next = prev.next; prev.next = nxt; } return dummy.next; } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl
[[1,2,3,4,5],2,4]  →  [1,4,3,2,5]
[[5],1,1]  →  [5]
[[1,2],1,2]  →  [2,1]
[[1,2,3],1,3]  →  [3,2,1]
[[1,2,3,4,5],1,5]  →  [5,4,3,2,1]
[[1,2,3,4,5],3,3]  →  [1,2,3,4,5]
Go · 解法代码type ListNode struct { Val int Next *ListNode } func reverseBetween(head *ListNode, left int, right int) *ListNode { dummy := &ListNode{Next: head} // 哑结点:left = 1 时头结点也要被翻转 prev := dummy for i := 0; i < left-1; i++ { // prev 走到「第 left 个结点的前一个」 prev = prev.Next } cur := prev.Next // cur 始终是原来的第 left 个结点,它会慢慢被"挤"到区间末尾 for i := 0; i < right-left; i++ { // 头插法:每次把 cur 后面的结点摘下来,插到区间的最前面(prev 之后) nxt := cur.Next cur.Next = nxt.Next nxt.Next = prev.Next prev.Next = nxt } return dummy.Next }
真实实测 · Go · 前几组用例的输出
$ go build -o bin . && ./bin < cases.jsonl
[[1,2,3,4,5],2,4]  →  [1,4,3,2,5]
[[5],1,1]  →  [5]
[[1,2],1,2]  →  [2,1]
[[1,2,3],1,3]  →  [3,2,1]
[[1,2,3,4,5],1,5]  →  [5,4,3,2,1]
[[1,2,3,4,5],3,3]  →  [1,2,3,4,5]
JavaScript · 解法代码class ListNode { constructor(val = 0, next = null) { this.val = val; this.next = next; } } function reverseBetween(head, left, right) { const dummy = new ListNode(0, head); // 哑结点:left = 1 时头结点也要被翻转 let prev = dummy; for (let i = 0; i < left - 1; i++) prev = prev.next; // prev 走到「第 left 个结点的前一个」 const cur = prev.next; // cur 始终是原来的第 left 个结点,它会慢慢被"挤"到区间末尾 for (let i = 0; i < right - left; i++) { // 头插法:每次把 cur 后面的结点摘下来,插到区间的最前面(prev 之后) const nxt = cur.next; cur.next = nxt.next; nxt.next = prev.next; prev.next = nxt; } return dummy.next; }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js < cases.jsonl
[[1,2,3,4,5],2,4]  →  [1,4,3,2,5]
[[5],1,1]  →  [5]
[[1,2],1,2]  →  [2,1]
[[1,2,3],1,3]  →  [3,2,1]
[[1,2,3,4,5],1,5]  →  [5,4,3,2,1]
[[1,2,3,4,5],3,3]  →  [1,2,3,4,5]
C++ · 解法代码struct ListNode { int val; ListNode* next; ListNode(int v) : val(v), next(nullptr) {} }; class Solution { public: ListNode* reverseBetween(ListNode* head, int left, int right) { ListNode dummy(0); // 哑结点:left = 1 时头结点也要被翻转 dummy.next = head; ListNode* prev = &dummy; for (int i = 0; i < left - 1; i++) prev = prev->next; // prev 走到「第 left 个结点的前一个」 ListNode* cur = prev->next; // cur 始终是原来的第 left 个结点,它会慢慢被"挤"到区间末尾 for (int i = 0; i < right - left; i++) { // 头插法:每次把 cur 后面的结点摘下来,插到区间的最前面(prev 之后) ListNode* nxt = cur->next; cur->next = nxt->next; nxt->next = prev->next; prev->next = nxt; } return dummy.next; } };
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl
[[1,2,3,4,5],2,4]  →  [1,4,3,2,5]
[[5],1,1]  →  [5]
[[1,2],1,2]  →  [2,1]
[[1,2,3],1,3]  →  [3,2,1]
[[1,2,3,4,5],1,5]  →  [5,4,3,2,1]
[[1,2,3,4,5],3,3]  →  [1,2,3,4,5]
真实实测 · 本机执行输出
共 12 组用例(含边界),以暴力解的结果为基准逐组比对:
  Python      通过 12/12
  Java        通过 12/12
  Go          通过 12/12
  JavaScript  通过 12/12
  C++         通过 12/12

随机对拍

Python 版本另外和数组切片翻转做了随机对拍:链表长度 1 到 10,1 ≤ left ≤ right ≤ 长度(包含翻转头部的情形):

真实实测 · 本机执行输出
随机对拍 20000 组(长度 1 到 10 的随机链表,1 ≤ left ≤ right ≤ 长度(包含 left = 1 翻转头结点的情形)),solve 与 brute 的结果全部一致。

另外四种语言只在上面 12 组固定用例上验证过,没有做大规模随机对拍。这道题只扫描链表一遍,是线性的,所以没有做规模对比实验。

分步演示:[1, 2, 3, 4, 5] 翻转第 2 到第 4 个

下面的演示中,链表最前面的 dummy 是哑结点。prev 走到第 1 个结点(值 1),cur 是它的下一个(值 2)。第一次头插:把 cur 后面的 3 摘下来插到 prev 后面,链表变成 [1, 3, 2, 4, 5];第二次:把 cur 后面的 4 摘下来插到 prev 后面,变成 [1, 4, 3, 2, 5]。重复 right - left = 2 次,区间翻转完成。每一帧都是把上面的解法在这组输入上真实执行记录下来的,运行器还检查了演示的最终结果与 solve 的返回值一致。

反转链表 II:[1, 2, 3, 4, 5],翻转第 2 到第 4 个结点
prev(区间前一个结点)cur(原第 left 个结点)刚被插到区间最前面的结点

    参考与说明

    • 官方题目页:LeetCode 92. 反转链表 II。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚。
    • 没有覆盖:先断开区间、单独翻转再接回去的四指针写法;递归写法。
    京ICP备2026067502号-1京公网安备11010502063487号