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` 指针
|