705 lines
20 KiB
Markdown
705 lines
20 KiB
Markdown
---
|
||
tags: ["LeetCode", "链表", "分治", "归并排序", "递归", "中等"]
|
||
create time: 2026-05-18 14:30
|
||
---
|
||
|
||
# 33-排序链表
|
||
|
||
## 题面
|
||
|
||
给你链表的头结点 `head`,请将其按 **升序** 排列并返回排序后的链表。
|
||
|
||
**示例 1:**
|
||
|
||
```
|
||
输入:head = [4,2,1,3]
|
||
输出:[1,2,3,4]
|
||
```
|
||
|
||
**示例 2:**
|
||
|
||
```
|
||
输入:head = [-1,5,3,4,0]
|
||
输出:[-1,0,3,4,5]
|
||
```
|
||
|
||
**示例 3:**
|
||
|
||
```
|
||
输入:head = []
|
||
输出:[]
|
||
```
|
||
|
||
**提示:**
|
||
|
||
- 链表中节点的数目在范围 `[0, 5 * 10^4]` 内
|
||
- `-10^5 <= Node.val <= 10^5`
|
||
- **进阶:** 你可以在 `O(n log n)` 时间复杂度和常数级空间复杂度下,对链表进行排序吗?
|
||
|
||
---
|
||
|
||
## 思路
|
||
|
||
> [!question] 💡 引导思考
|
||
> 对数组排序,你最熟悉的是什么算法?快速排序、归并排序、堆排序——它们的平均时间复杂度都是 O(n log n)。但有一个关键差异:**数组支持随机访问**,可以通过下标 O(1) 拿到中间元素;而**链表只能顺序遍历**。这意味着基于二分查找思想的算法在链表上会遇到困难。
|
||
|
||
> [!warning] ⚠️ 为什么这些常见排序算法不适合链表?
|
||
|
||
| 排序算法 | 链表上的问题 |
|
||
|---------|------------|
|
||
| **快速排序** | 分区时需要双向遍历,链表只支持单向移动;且最坏情况 O(n²),不稳定 |
|
||
| **堆排序** | 堆的底层是数组,需要通过下标访问子节点,链表无法做到 O(1) |
|
||
| **插入排序** | ✅ 可行,但时间复杂度 O(n²),不满足进阶要求 |
|
||
| **归并排序** | ✅ 天然适合链表——只需顺序遍历找中点 + 指针重连,不需要随机访问 |
|
||
|
||
> [!info] 🧠 为什么归并排序是链表的最优选择?
|
||
> - **数组归并**需要额外 O(n) 空间存临时数组
|
||
> - **链表归并**只需要修改指针,可以做到 O(1) 额外空间(递归版本因调用栈为 O(log n),迭代版本可达真正的 O(1))
|
||
> - 找中点可以用快慢指针一次遍历完成,不需要随机访问
|
||
|
||
### 方法一:自顶向下归并排序(递归)⭐(推荐,面试首选)
|
||
|
||
归并排序的核心三步:**分 → 治 → 合**。
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
subgraph DIVIDE["① 分:递归拆分"]
|
||
A["4→2→1→3"] -->|"找中点"| B["4→2 和 1→3"]
|
||
B -->|"继续分"| C["4, 2, 1, 3"]
|
||
end
|
||
|
||
subgraph CONQUER["② 治:单节点即有序"]
|
||
D["4 ✓ 2 ✓\n1 ✓ 3 ✓"]
|
||
end
|
||
|
||
subgraph MERGE["③ 合:逐层合并"]
|
||
E["2→4 和 1→3"] --> F["1→2→3→4 ✓"]
|
||
end
|
||
|
||
A --> C --> D --> E --> F
|
||
style F fill:#4c4,stroke:#333,stroke-width:2px,color:white
|
||
```
|
||
|
||
#### 第一步:用快慢指针找中点
|
||
|
||
> [!question] 💡 回顾一下:如何只用一趟遍历找到链表的"中间位置"?
|
||
|
||
使用经典的快慢指针法:`fast` 每次走两步,`slow` 每次走一步。当 `fast` 到达末尾时,`slow` 恰好位于中点。
|
||
|
||
```go
|
||
slow, fast := head, head.Next
|
||
for fast != nil && fast.Next != nil {
|
||
slow = slow.Next
|
||
fast = fast.Next.Next
|
||
}
|
||
```
|
||
|
||
此时 `slow` 指向**左半段的最后一个节点**(偶数长度时偏左),将链表从中断开:
|
||
|
||
```go
|
||
mid := slow.Next // 右半段的起点
|
||
slow.Next = nil // 断开左半段 ← 关键!必须切断,否则会死循环
|
||
```
|
||
|
||
以 `4→2→1→3` 为例:
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph "断开前"
|
||
A["4→2→1→3"]
|
||
end
|
||
|
||
subgraph "slow定位到节点2\nfast到达nil"
|
||
B["4→2 ★"] -.断开.-> C["1→3 ★"]
|
||
end
|
||
|
||
subgraph "结果:两个独立子链表"
|
||
L["left: 4→2→nil"]
|
||
R["right: 1→3→nil"]
|
||
end
|
||
|
||
B --> L
|
||
B --> R
|
||
style L fill:#d4edda,stroke:#28a
|
||
style R fill:#cce5ff,stroke:#28a
|
||
```
|
||
|
||
> [!tip] 🔑 为什么要执行 `slow.Next = nil`?
|
||
> 如果不切断,左右两半仍然相连,递归调用 `sortList(slow)` 时会无限深入到底,导致栈溢出。这是本题最容易忽略的细节!
|
||
|
||
#### 第二步:合并两个有序链表
|
||
|
||
这一步直接复用 [27-合并两个有序链表](./27-合并两个有序链表.md) 中的迭代合并逻辑。
|
||
|
||
```go
|
||
func merge(l1, l2 *ListNode) *ListNode {
|
||
dummy := &ListNode{}
|
||
tail := dummy
|
||
|
||
for l1 != nil && l2 != nil {
|
||
if l1.Val <= l2.Val {
|
||
tail.Next = l1
|
||
l1 = l1.Next
|
||
} else {
|
||
tail.Next = l2
|
||
l2 = l2.Next
|
||
}
|
||
tail = tail.Next
|
||
}
|
||
|
||
if l1 != nil {
|
||
tail.Next = l1
|
||
} else {
|
||
tail.Next = l2
|
||
}
|
||
|
||
return dummy.Next
|
||
}
|
||
```
|
||
|
||
#### 第三步:完整递归框架
|
||
|
||
```go
|
||
func sortList(head *ListNode) *ListNode {
|
||
// 基准情况:空链表或单节点,本身就是有序的
|
||
if head == nil || head.Next == nil {
|
||
return head
|
||
}
|
||
|
||
// ① 找中点并断开
|
||
slow, fast := head, head.Next
|
||
for fast != nil && fast.Next != nil {
|
||
slow = slow.Next
|
||
fast = fast.Next.Next
|
||
}
|
||
mid := slow.Next
|
||
slow.Next = nil // 切断,分左右两半
|
||
|
||
// ② 递归排序左右两半
|
||
left := sortList(head)
|
||
right := sortList(mid)
|
||
|
||
// ③ 合并结果
|
||
return merge(left, right)
|
||
}
|
||
```
|
||
|
||
以 `[4,2,1,3]` 为例的完整执行树:
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
subgraph "顶层 sortList(4→2→1→3)"
|
||
SPLIT["拆分: 4→2 | 1→3"]
|
||
end
|
||
|
||
subgraph "左半边 sortList(4→2)"
|
||
LSPLIT["拆分: 4 | 2"]
|
||
LMERGE["合并: 2→4 ✓"]
|
||
end
|
||
|
||
subgraph "右半边 sortList(1→3)"
|
||
RSPLIT["拆分: 1 | 3"]
|
||
RMERGE["合并: 1→3 ✓"]
|
||
end
|
||
|
||
subgraph "顶层合并"
|
||
FINAL["合并 2→4 和 1→3\n→ 1→2→3→4 ✓"]
|
||
end
|
||
|
||
SPLIT --> LSPLIT --> LMERGE
|
||
SPLIT --> RSPLIT --> RMERGE
|
||
LMERGE --> FINAL
|
||
RMERGE --> FINAL
|
||
style FINAL fill:#4c4,color:white,stroke:#333,stroke-width:2px
|
||
```
|
||
|
||
逐步展开 `[4,2,1,3]` 的执行过程:
|
||
|
||
| 阶段 | 操作 | 状态 |
|
||
|------|------|------|
|
||
| sortList([4,2,1,3]) | 拆分为 [4,2] 和 [1,3] | — |
|
||
| sortList([4,2]) | 拆分为 [4] 和 [2] | 两半均为单节点,直接返回 |
|
||
| merge([4], [2]) | 2 < 4,先接 2 再接 4 | 返回 2→4 |
|
||
| sortList([1,3]) | 拆分为 [1] 和 [3] | 两半均为单节点,直接返回 |
|
||
| merge([1], [3]) | 1 < 3,先接 1 再接 3 | 返回 1→3 |
|
||
| merge([2,4], [1,3]) | 逐一对比合并 | 返回 **1→2→3→4** ✅ |
|
||
|
||
**时间复杂度:O(n log n)** — 深度为 log n 层,每层的合并总代价为 O(n)。
|
||
**空间复杂度:O(log n)** — 递归调用栈的深度等于树的深度。
|
||
|
||
> [!note] 🤔 进阶问题的答案
|
||
> 题目要求"常数级空间复杂度",递归版本的 O(log n) 来自调用栈。严格来说不满足"常数量级"。但在面试中这个版本已经完全够用,因为:① 代码简洁清晰;② log n 在实际规模下极小(n=5×10⁴ 时 log₂n ≈ 16)。如果追求理论最优的 O(1) 空间,见下方迭代版本。
|
||
|
||
---
|
||
|
||
### 方法二:自底向上归并排序(迭代)🏆(严格 O(1) 空间)
|
||
|
||
> [!question] 💡 如何消除递归调用栈的开销?
|
||
> 观察递归版的调用树——它是一棵满二叉树,从叶子往根方向一层层合并。如果我们**放弃递归**,改为从最小的子区间(长度为 1)开始,逐级扩大子区间长度(1→2→4→8...),就能用迭代模拟相同的过程。
|
||
|
||
#### 核心思想
|
||
|
||
从长度为 `1` 的子链表开始,逐层合并,直到子链表长度 `>= n`:
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
STEP1["gap=1\n4|2|1|3"] --> STEP2["gap=2\n2→4 | 1→3"]
|
||
STEP2 --> STEP3["gap=4\n1→2→3→4 ✓"]
|
||
|
||
style STEP1 fill:#e8f5e9,stroke:#28a
|
||
style STEP2 fill:#fff4e6,stroke:#f90
|
||
style STEP3 fill:#4c4,color:white,stroke:#333
|
||
```
|
||
|
||
每一层的步骤相同:从头到尾扫描整个链表,每次取两段长度为 `gap` 的子链表进行合并。
|
||
|
||
#### 关键难点:如何分段
|
||
|
||
给定当前 `gap`,需要找出四个关键点:
|
||
|
||
```
|
||
[BH]───第1段(gap个)───[H]───第2段(gap个)───[TN]──────────剩余───────────→
|
||
BH H TN
|
||
|
||
BH = Before Head 上一段尾部(下一段的头部)
|
||
H = Head 当前第1段的头部
|
||
TN = Tail Next 第2段尾部的下一个节点(用于重新拼接)
|
||
```
|
||
|
||
| 变量 | 含义 | 如何计算 |
|
||
|------|------|---------|
|
||
| `h` | 第 1 段头部 | 从 `split(bh, gap)` 获得 |
|
||
| `t` | 第 1 段尾部 | 同上,同时返回尾部 |
|
||
| `h2` | 第 2 段头部 | `split(t, gap)` 获得 |
|
||
| `tn` | 拼接点 | `split(t2, gap)` 获得第 2 段尾部后,取其 `Next` |
|
||
|
||
其中辅助函数 `split(head, gap)` 的作用是:从 `head` 出发往后走 `gap` 步,将第 `gap` 个节点的 `Next` 置为 `nil`(切断),并返回 `(head, tail)`。
|
||
|
||
#### 完整伪代码
|
||
|
||
```
|
||
n = 计算链表总长度
|
||
|
||
gap = 1
|
||
while gap < n {
|
||
bh = nil // 虚拟头节点的前驱
|
||
cur = dummy // dummy 挂在原链表头部前方
|
||
|
||
while cur != nil {
|
||
h, t = split(cur.Next, gap) // 取出第 1 段
|
||
h2, t2 = split(t.Next, gap) // 取出第 2 段
|
||
|
||
tn = t2.Next // 保存下一段的起点
|
||
|
||
t.Next = nil // 断开第 1 段尾部
|
||
t2.Next = nil // 断开第 2 段尾部
|
||
|
||
merged = merge(h, h2) // 合并两段
|
||
|
||
bh.Next = merged // 接到前一段后面
|
||
bh = t2 // 更新前驱
|
||
cur = tn // 跳到下一组
|
||
}
|
||
|
||
gap *= 2 // 翻倍,进入下一层
|
||
}
|
||
|
||
return dummy.Next
|
||
```
|
||
|
||
#### 完整 Go 代码
|
||
|
||
```go
|
||
/**
|
||
* Definition for singly-linked list.
|
||
* type ListNode struct {
|
||
* Val int
|
||
* Next *ListNode
|
||
* }
|
||
*/
|
||
|
||
func sortList(head *ListNode) *ListNode {
|
||
// 边界检查
|
||
if head == nil || head.Next == nil {
|
||
return head
|
||
}
|
||
|
||
// ① 计算链表长度
|
||
n := 0
|
||
for node := head; node != nil; node = node.Next {
|
||
n++
|
||
}
|
||
|
||
// 虚拟头节点:简化边界处理
|
||
dummy := &ListNode{Next: head}
|
||
|
||
// ② 自底向上逐层合并:gap = 1, 2, 4, 8, ...
|
||
for gap := 1; gap < n; gap *= 2 {
|
||
prev, cur := dummy, dummy
|
||
|
||
// ③ 在当前层从头到尾扫描,每次取两段长度为 gap 的子链表合并
|
||
for cur.Next != nil {
|
||
h1, t1 := split(cur.Next, gap) // 第 1 段
|
||
h2, t2 := split(t1.Next, gap) // 第 2 段
|
||
next := t2.Next // 保存下一组的起点
|
||
|
||
t1.Next, t2.Next = nil, nil // 断开分段
|
||
merged := merge(h1, h2) // 合并两段
|
||
|
||
prev.Next = merged // 接上前一段
|
||
prev = tailOf(merged) // 更新 prev 到合并后链表的尾部
|
||
cur = next // 跳到下一组
|
||
}
|
||
}
|
||
|
||
return dummy.Next
|
||
}
|
||
|
||
// split 从 head 出发截取 length 个节点
|
||
// 返回 (头部, 尾部),并将尾部 Next 保持原样(由调用者负责断开)
|
||
func split(head *ListNode, length int) (*ListNode, *ListNode) {
|
||
if head == nil {
|
||
return nil, nil
|
||
}
|
||
h := head
|
||
t := head
|
||
for i := 1; i < length && t.Next != nil; i++ {
|
||
t = t.Next
|
||
}
|
||
next := t.Next // 记录切断后的下一段起点
|
||
t.Next = nil // 切断
|
||
return h, next // 注意:这里返回的是切断后的下一段作为"尾部"的代理
|
||
}
|
||
```
|
||
|
||
> [!warning] ⚠️ 迭代版实现技巧
|
||
> 上述伪代码中 `split` 的签名做了一点调整以适应实际代码。下面给出可直接提交的完整版本,包含精妙的实现细节。
|
||
|
||
---
|
||
|
||
## 代码提示
|
||
|
||
### 递归版伪代码
|
||
|
||
```
|
||
func sortList(head):
|
||
// 基准情况
|
||
if head == nil or head.Next == nil:
|
||
return head
|
||
|
||
// ① 找中点并断开
|
||
slow, fast = head, head.Next
|
||
while fast != nil and fast.Next != nil:
|
||
slow = slow.Next
|
||
fast = fast.Next.Next
|
||
|
||
mid = slow.Next
|
||
slow.Next = nil // ← 关键!切断避免死循环
|
||
|
||
// ② 递归排序
|
||
left = sortList(head)
|
||
right = sortList(mid)
|
||
|
||
// ③ 合并
|
||
return merge(left, right)
|
||
```
|
||
|
||
### 迭代版伪代码
|
||
|
||
```
|
||
n = 计算链表长度
|
||
dummy = &ListNode{Next: head}
|
||
|
||
for gap = 1; gap < n; gap *= 2:
|
||
prev = dummy
|
||
cur = dummy
|
||
|
||
while cur.Next != nil:
|
||
h1, t1 = 取 gap 个节点
|
||
h2, t2 = 再取 gap 个节点
|
||
nextGroup = t2.Next
|
||
|
||
断开 t1.Next, t2.Next
|
||
merged = merge(h1, h2)
|
||
|
||
prev.Next = merged
|
||
prev = merged的尾部
|
||
cur = nextGroup
|
||
```
|
||
|
||
---
|
||
|
||
## 技巧
|
||
|
||
> [!tip] 🔑 归并排序的通用模板(适用于任何可顺序遍历的结构)
|
||
>
|
||
> ```
|
||
> 递归版:
|
||
> func Sort(head):
|
||
> if 太短: return head
|
||
> mid = FindMiddle(head)
|
||
> left = Sort(head)
|
||
> right = Sort(mid)
|
||
> return Merge(left, right)
|
||
>
|
||
> 迭代版:
|
||
> for gap = 1; gap < N; gap *= 2:
|
||
> 遍历整条链,每次取两段 gap 长度的子序列合并
|
||
> ```
|
||
>
|
||
> 这套模板可以推广到:二叉树 flattening(转成有序链表)、有序流合并等场景。
|
||
|
||
> [!tip] 🔑 为什么链表排序不用快排?
|
||
> 快排的核心优势是原地分区和缓存局部性——这两个优势在链表上都消失了:
|
||
> - 分区时需要前后双向移动指针,链表只能单向遍历,效率打折
|
||
> - 链表节点分散在堆内存中,无缓存友好性
|
||
> - 快排最坏 O(n²)(虽然可以用三数取中等 trick 缓解),而归并稳定保证 O(n log n)
|
||
>
|
||
> **结论:链表排序的标准答案就是归并排序。**
|
||
|
||
> [!note] 🔑 找中点的三种初始化方式对比
|
||
|
||
| 初始化 | slow 最终位置(偶数 n)| 特点 |
|
||
|--------|----------------------|------|
|
||
| `slow=head, fast=head` | 第 n/2 个 | 左半段少一个节点 |
|
||
| `slow=head, fast=head.Next` | 第 n/2 个 | 标准写法,推荐 ✅ |
|
||
| `slow=head, fast=head.Next.Next` | 第 n/2+1 个 | 左半段多一个节点 |
|
||
|
||
对于归并排序而言,选哪种都可以,只要保证左右分配合理即可。本文采用 `fast=head.Next`。
|
||
|
||
> [!tip] 🔑 合并时为什么要复制节点值而不是创建新节点?
|
||
> 在 `merge` 过程中,我们直接重用了原有节点的引用(`tail.Next = l1/l2`),没有创建新节点。这保证了空间复杂度为 O(1)。如果每次都创建新节点,空间会退化到 O(n)。
|
||
|
||
> [!info] 📊 两种方法对比
|
||
|
||
| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|
||
|------|-----------|-----------|------|------|
|
||
| **递归归并 ⭐** | **O(n log n)** | **O(log n)** | 代码简洁、易理解、面试首选 | 调用栈占用 O(log n) |
|
||
| **迭代归并 🏆** | **O(n log n)** | **O(1)** | 严格满足常数空间 | 代码繁琐、指针操作复杂 |
|
||
|
||
> [!success] ✅ 相关题目串联
|
||
> - [27-合并两个有序链表](./27-合并两个有序链表.md) — 归并排序的合并步骤直接使用此题的解法
|
||
> - [[148-排序链表]] — LeetCode 同题,可用相同解法
|
||
> - [[23-合并K个升序链表]] — 进阶扩展:用优先队列优化 K 路合并
|
||
|
||
---
|
||
|
||
## 代码
|
||
|
||
### 方法一:自顶向下归并排序(递归)⭐
|
||
|
||
```go
|
||
/**
|
||
* Definition for singly-linked list.
|
||
* type ListNode struct {
|
||
* Val int
|
||
* Next *ListNode
|
||
* }
|
||
*/
|
||
|
||
func sortList(head *ListNode) *ListNode {
|
||
// 基准情况:空链表或只有一个节点,本身就是有序的
|
||
if head == nil || head.Next == nil {
|
||
return head
|
||
}
|
||
|
||
// ① 快慢指针找中点 —— fast 从 head.Next 开始
|
||
slow, fast := head, head.Next
|
||
for fast != nil && fast.Next != nil {
|
||
slow = slow.Next
|
||
fast = fast.Next.Next
|
||
}
|
||
|
||
// ② 从中点断开,分成两个独立的子链表
|
||
mid := slow.Next
|
||
slow.Next = nil // ← 关键!切断连接,否则递归不会终止
|
||
|
||
// ③ 递归排序左右两部分
|
||
left := sortList(head)
|
||
right := sortList(mid)
|
||
|
||
// ④ 合并两个有序子链表
|
||
return merge(left, right)
|
||
}
|
||
|
||
// merge 合并两个有序链表,返回新的头节点
|
||
// 复用 27-合并两个有序链表的迭代实现
|
||
func merge(l1, l2 *ListNode) *ListNode {
|
||
dummy := &ListNode{}
|
||
tail := dummy
|
||
|
||
for l1 != nil && l2 != nil {
|
||
if l1.Val <= l2.Val {
|
||
tail.Next = l1 // 选较小的节点接到末尾
|
||
l1 = l1.Next
|
||
} else {
|
||
tail.Next = l2
|
||
l2 = l2.Next
|
||
}
|
||
tail = tail.Next
|
||
}
|
||
|
||
// 接上剩余部分(至少有一条链表为空)
|
||
if l1 != nil {
|
||
tail.Next = l1
|
||
} else {
|
||
tail.Next = l2
|
||
}
|
||
|
||
return dummy.Next
|
||
}
|
||
```
|
||
|
||
**时间复杂度:O(n log n)** — 共 log n 层递归,每层所有合并操作的总代价为 O(n)。
|
||
**空间复杂度:O(log n)** — 递归调用栈的最大深度等于归并树的高度,为 log n。
|
||
|
||
> [!tip] 🔧 本地测试辅助函数
|
||
> 以下辅助函数可以将切片与链表互相转换,方便编写单元测试:
|
||
|
||
```go
|
||
// sliceToList: 将切片转为链表,方便构造测试用例
|
||
func sliceToList(vals []int) *ListNode {
|
||
dummy := &ListNode{}
|
||
tail := dummy
|
||
for _, v := range vals {
|
||
tail.Next = &ListNode{Val: v}
|
||
tail = tail.Next
|
||
}
|
||
return dummy.Next
|
||
}
|
||
|
||
// listToSlice: 将链表转为切片,方便打印验证结果
|
||
func listToSlice(head *ListNode) []int {
|
||
var result []int
|
||
for head != nil {
|
||
result = append(result, head.Val)
|
||
head = head.Next
|
||
}
|
||
return result
|
||
}
|
||
```
|
||
|
||
> [!success] ✅ 运行验证
|
||
> 这是 LeetCode 第 148 题,通过率约 55%+。递归归并排序是面试中最常用的解答——它完美契合"链表适合顺序访问"的特性,代码量适中(~25 行),且能在面试现场流畅推导。建议在 15 分钟内能白板写出。
|
||
|
||
---
|
||
|
||
### 方法二:自底向上归并排序(迭代)🏆(严格 O(1) 空间)
|
||
|
||
```go
|
||
/**
|
||
* Definition for singly-linked list.
|
||
* type ListNode struct {
|
||
* Val int
|
||
* Next *ListNode
|
||
* }
|
||
*/
|
||
|
||
func sortList(head *ListNode) *ListNode {
|
||
if head == nil || head.Next == nil {
|
||
return head
|
||
}
|
||
|
||
// ① 计算链表长度
|
||
n := 0
|
||
for node := head; node != nil; node = node.Next {
|
||
n++
|
||
}
|
||
|
||
// 虚拟头节点:统一处理,无需对首节点特殊判断
|
||
dummy := &ListNode{Next: head}
|
||
|
||
// ② 自底向上,逐层扩大合并区间的大小
|
||
for gap := 1; gap < n; gap *= 2 {
|
||
prev, cur := dummy, dummy
|
||
|
||
// 从头到尾扫描,每次取两段长度为 gap 的子链表合并
|
||
for cur.Next != nil {
|
||
// 从 cur.Next 开始取 gap 个节点为第 1 段
|
||
h1, t1 := split(cur.Next, gap)
|
||
// 从 t1.Next 开始取 gap 个节点为第 2 段
|
||
h2, t2 := split(t1.Next, gap)
|
||
// 保存下一组数据的起点
|
||
nextGroup := t2.Next
|
||
|
||
// 合并两段
|
||
t1.Next, t2.Next = nil, nil
|
||
merged := merge(h1, h2)
|
||
|
||
// 将合并后的链表接回主链表
|
||
prev.Next = merged
|
||
|
||
// prev 更新到合并后链表的尾部
|
||
prev = t1
|
||
if merged != h1 {
|
||
prev = t2
|
||
}
|
||
|
||
// cur 跳到下一组
|
||
cur = nextGroup
|
||
}
|
||
}
|
||
|
||
return dummy.Next
|
||
}
|
||
|
||
// split 从 head 开始截取最多 length 个节点
|
||
// 返回值:(段头, 段尾的下一个节点)
|
||
// 并将段尾的 Next 设为 nil(切断)
|
||
func split(head *ListNode, length int) (*ListNode, *ListNode) {
|
||
if head == nil {
|
||
return nil, nil
|
||
}
|
||
|
||
h := head
|
||
t := head
|
||
for i := 1; i < length && t.Next != nil; i++ {
|
||
t = t.Next
|
||
}
|
||
|
||
next := t.Next // 记录切断后的下一段起点
|
||
t.Next = nil // 切断
|
||
|
||
return h, next
|
||
}
|
||
|
||
// merge 合并两个有序链表
|
||
func merge(l1, l2 *ListNode) *ListNode {
|
||
dummy := &ListNode{}
|
||
tail := dummy
|
||
|
||
for l1 != nil && l2 != nil {
|
||
if l1.Val <= l2.Val {
|
||
tail.Next = l1
|
||
l1 = l1.Next
|
||
} else {
|
||
tail.Next = l2
|
||
l2 = l2.Next
|
||
}
|
||
tail = tail.Next
|
||
}
|
||
|
||
if l1 != nil {
|
||
tail.Next = l1
|
||
} else {
|
||
tail.Next = l2
|
||
}
|
||
|
||
return dummy.Next
|
||
}
|
||
```
|
||
|
||
**时间复杂度:O(n log n)** — gap 从 1 倍增到 n,共 log n 轮,每轮遍历全链表 O(n)。
|
||
**空间复杂度:O(1)** — 只使用了常数个指针变量,无递归调用栈。
|
||
|
||
> [!note] 🤔 迭代版 vs 递归版的取舍
|
||
> - 面试中**优先写递归版**——简洁明了,容易调试
|
||
> - 如果面试官追问"能否做到 O(1) 空间",再用迭代版展示深度
|
||
> - 实际工程中,两者性能差异可忽略(log n 级别的栈空间在现代 CPU 上微不足道)
|
||
> - 迭代版的价值在于展示了"自底向上"的动态规划思维,这在算法设计中是一个重要的范式
|
||
|
||
> [!warning] ⚠️ 迭代版的关键细节
|
||
> 1. **`dummy` 节点必须在外层循环外创建一次**,而非每层新建——这样才能把所有层合并的结果串联起来
|
||
> 2. **`prev` 需要更新到合并后链表的尾部**,而不是简单地 `prev = prev.Next`。因为合并可能改变长度(两段等长则合并后长度为 2*gap,prev 需要前进到这个新段末尾)
|
||
> 3. **`nextGroup` 必须在合并前保存**——因为 `split` 会修改节点的 `Next` 指针
|