vault backup: 2026-05-18 12:24:21

This commit is contained in:
2026-05-18 12:24:21 +08:00
parent f0c7163858
commit 74cae35e51
6 changed files with 3211 additions and 0 deletions
+777
View File
@@ -0,0 +1,777 @@
---
tags: ["LeetCode", "链表", "堆", "分治", "优先队列", "困难"]
create time: 2026-05-18 15:30
---
# 34-合并 K 个升序链表
## 题面
给你一个链表数组,每个链表都已经按升序排列。
请你将所有链表合并到一个升序链表中,返回合并后的链表。
**示例 1:**
```
输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
解释:链表数组如下:
[
1->4->5,
1->3->4,
2->6
]
将它们合并到一个有序链表中得到。
1->1->2->3->4->4->5->6
```
**示例 2:**
```
输入:lists = []
输出:[]
```
**示例 3:**
```
输入:lists = [[]]
输出:[]
```
**提示:**
- k == lists.length
- 0 <= k <= 10^4
- 0 <= lists[i].length <= 500
- -10^4 <= lists[i][j] <= 10^4
- lists[i] 按 升序 排列
- lists[i].length 的总和不超过 10^4
---
## 思路
> [!question] 💡 引导思考
> 假设你已经会合并两个有序链表——现在让你合并 **K** 个有序链表,你会怎么做?——最直白的想法是:**先合并前两个,再用结果和第三个合并,依次类推**。这个思路可行吗?我们来分析一下。
### 方法零:暴力法 — 收集后排序 🐌(不推荐)
把所有链表中的所有节点值收集到一个切片中,排序后再重建链表。
| 步骤 | 操作 | 代价 |
|------|------|------|
| ① 遍历所有节点 | 把 Val 存入 `[]int` | O(N) |
| ② 排序 | Go 1.21+ 的 `slices.Sort()`(TimSort) | O(N log N) |
| ③ 重建链表 | 顺序创建节点 | O(N) |
其中 N 为所有链表的节点总数。
**时间复杂度:O(N log N)** — 排序主导。
**空间复杂度:O(N)** — 存储所有节点值 + 新链表。
> [!note] 💬 虽然能 AC(总节点数 ≤ 10⁴),但完全没有利用"每个子链表已经有序"这一关键条件。面试中写出这个方案只能证明你理解题目,无法展示算法功底。接下来我们看更好的方案。
---
### 方法一:分治Merge(递归/迭代两阶段)⭐(推荐,面试首选)
> [!info] 🧠 核心洞察
> 合并两个有序链表是 O(m + n)。合并 K 个链表可以看作 **K-路归并**——就像归并排序的 merge 阶段,只不过同时参与 merge 的不是 2 个而是 K 个子序列。
#### 分治策略
如果我们每次只取两条链表来合并,那总共需要合并多少次呢?
| 阶段 | 剩余链表数 | 合并操作数 | 说明 |
|------|-----------|----------|------|
| 初始 | K | — | K 条独立链表 |
| 第 1 轮 | K/2 | K/2 | 两两配对合并 |
| 第 2 轮 | K/4 | K/4 | 上一步的结果继续两两合并 |
| ... | ... | ... | — |
| 最后 | 1 | 1 | 只剩一条,完成 ✅ |
共需 ⌈log₂K⌉ 轮,每轮所有节点恰好被访问一次。
```mermaid
flowchart TB
subgraph Round1["🔄 第 1 轮:K → K/2"]
A1["L1: 1→4→5"]
A2["L2: 1→3→4"]
A3["L3: 2→6"]
M1["merge(L1,L2)\n→ 1→1→3→4→5"]
B1["L4: 2→6"]
A1 -->|"配对"| M1
A2 -->|"配对"| M1
A3 -->|"落单,直接保留"| B1
end
subgraph Round2["🔄 第 2 轮:K/2 → 1"]
M2["merge(M1,B1)\n→ 1→1→2→3→4→4→5→6"]
end
M1 --> Round2
B1 --> Round2
style M2 fill:#4c4,color:white,stroke:#333,stroke-width:2px
style M1 fill:#fff4e6,stroke:#f90
style B1 fill:#e8f5e9,stroke:#28a
```
以 `[[1,4,5],[1,3,4],[2,6]]` 为例的分治过程:
```mermaid
flowchart LR
subgraph "初始 K=3"
L1["1→4→5"]
L2["1→3→4"]
L3["2→6"]
end
subgraph "第 1 轮: K→2"
P1["merge(L1,L2)\n→ 1→1→3→4→5"]
KEEP["L3 保留\n→ 2→6"]
end
subgraph "第 2 轮: K=1"
FINAL["merge(P1,KEEP)\n→ 1→1→2→3→4→4→5→6 ✅"]
end
L1 --> P1
L2 --> P1
L3 --> KEEP
P1 --> FINAL
KEEP --> FINAL
style FINAL fill:#4c4,color:white,stroke:#333,stroke-width:2px
```
#### 实现方式一:自顶向下递归(Divide & Conquer)
将问题不断二分,直到只剩 1 条或 0 条链表:
```go
func mergeKLists(lists []*ListNode) *ListNode {
if len(lists) == 0 {
return nil
}
return divideAndConquer(lists, 0, len(lists)-1)
}
func divideAndConquer(lists []*ListNode, left, right int) *ListNode {
// 基准情况:只有一个链表,直接返回
if left == right {
return lists[left]
}
mid := (left + right) / 2
leftHalf := divideAndConquer(lists, left, mid) // 合并左半部分
rightHalf := divideAndConquer(lists, mid+1, right) // 合并右半部分
return mergeTwoLists(leftHalf, rightHalf) // 合并两个有序链表
}
```
> [!tip] 🔑 这棵递归树的结构
>
> ```
> [0,2]
> / \
> [0,1] [2,2]
> / \ |
> [0,0] [1,1] [2]
> | | |
> L1 L2 L3
> ```
>
> 这是一棵平衡二叉树——深度为 O(log K),不会像退化版本那样出现栈溢出。
#### 实现方式二:迭代式逐轮合并
不依赖调用栈,显式地逐轮减少链表数量:
```go
func mergeKLists(lists []*ListNode) *ListNode {
interval := 1
for interval < len(lists) {
for i := 0; i + interval < len(lists); i += 2 * interval {
lists[i] = mergeTwoLists(lists[i], lists[i+interval])
}
interval *= 2
}
if len(lists) > 0 {
return lists[0]
}
return nil
}
```
以 `[[1,4,5],[1,3,4],[2,6]]` 为例的迭代过程:
```mermaid
flowchart LR
subgraph "初始状态"
A["[1,4,5]"]
B["[1,3,4]"]
C["[2,6]"]
end
subgraph "interval=1: i+=2"
R1["A=merge(A,B)\n→ [1,1,3,4,5]"]
KEEP["C 不变\n→ [2,6]"]
end
subgraph "interval=2: i+=4"
FIN["A=merge(R1,C)\n→ [1,1,2,3,4,4,5,6] ✅"]
end
A --> R1
B --> R1
C --> KEEP
R1 --> FIN
KEEP --> FIN
style FIN fill:#4c4,color:white,stroke:#333,stroke-width:2px
```
逐步展开 `[interval=1, i=0]`:
- `lists[0] = merge(lists[0], lists[1])` → `merge([1,4,5], [1,3,4]) = [1,1,3,4,5]`
- `i += 2` → `i = 2`,`2 + 1 = 3 ≱ 3`,内层循环结束
- `interval *= 2` → `interval = 2`
接着 `[interval=2, i=0]`:
- `lists[0] = merge(lists[0], lists[2])` → `merge([1,1,3,4,5], [2,6]) = [1,1,2,3,4,4,5,6]`
- `i += 4` → `i = 4`,退出
#### 复杂度分析
```mermaid
flowchart TD
subgraph "分治合并的分析"
DEPTH["递归深度: log K 层"]
subgraph "每层工作量"
PER_LAYER["每层合并所有 N 个节点<br/>代价: O(N)"]
end
TOTAL["总代价: O(N) × log K = O(N log K)"]
end
DEPTH --> PER_LAYER --> TOTAL
style TOTAL fill:#d4edda,stroke:#28a,stroke-width:2px
```
- **每层代价:O(N)** —— 无论有多少条链表在参与合并,所有节点的 total value 在每个层级只被访问一次。
- **层数:O(log K)** —— 每轮配对使链表数量减半。
- **总时间复杂度:O(N log K)** ✅
> [!info] 📊 K=10^4 时:log₂(10⁴) ≈ 13.3,即最多约 14 轮合并。
> N ≤ 10⁴ 时:总操作量约 1.4 × 10⁵ 次比较,远优于暴力的 O(N²)。
**空间复杂度:O(log K)**(递归版)或 **O(1)**(迭代版)。
---
### 方法二:最小堆 / 优先队列 🏗️
> [!question] 💡 如果你需要维护一个集合中的"最小元素",并且这个集合会动态增删,什么数据结构最高效?
答案是 **堆(Heap)**。它的插入和删除都是 O(log M),查询最小值是 O(1)——正是本题的需求!
#### 核心思想
每一步都从 K 个链表的头节点中选出最小的那个,接入结果链表,然后把该链表的下一个节点放回堆中:
```mermaid
flowchart TD
PUSH["将所有非空链表的头节点推入最小堆"]
subgraph Loop["循环:直到堆为空"]
EXTRACT["弹出堆顶<br/>(当前最小节点)"]
APPEND["接入结果链表"]
CHECK{"该节点有 Next 吗?"}
INSERT["将 Next 推入堆"]
EXTRACT --> APPEND --> CHECK
CHECK -- "是" --> INSERT --> PUSH
CHECK -- "否" --> EMPTY["堆中少了一个元素"]
EMPTY --> CHECK
end
subgraph Done["终止"]
RESULT["返回结果链表 ✅"]
end
PUSH --> Loop
CHECK -. "堆空" .-> Done
style RESULT fill:#4c4,color:white,stroke:#333,stroke-width:2px
```
以 `[[1,4,5],[1,3,4],[2,6]]` 为例:
| 步骤 | 堆内容(Val) | 弹出的节点 | 结果链表 |
|------|-------------|----------|---------|
| 初始化 | `[1, 1, 2]` | — | `dummy → ?` |
| 第 1 步 | `[1, 2, 4]` | ①(来自 L1) | `1` |
| 第 2 步 | `[1, 2, 3]` | ①(来自 L2) | `1→1` |
| 第 3 步 | `[2, 3, 4]` | ②(来自 L3) | `1→1→2` |
| 第 4 步 | `[3, 4, 4]` | ③(来自 L2) | `1→1→2→3` |
| 第 5 步 | `[4, 4, 6]` | ④(来自 L1) | `1→1→2→3→4` |
| 第 6 步 | `[4, 6]` | ④(来自 L2) | `1→1→2→3→4→4` |
| 第 7 步 | `[6, 5]` | ⑤(来自 L1) | `1→1→2→3→4→4→5` |
| 第 8 步 | `[6]` | ⑥(来自 L3) | `1→1→2→3→4→4→5→6` ✅ |
堆的操作次数 = 总节点数 N,每次 O(log K)。
#### Go 语言的 min-heap 实现
Go 标准库 `container/heap` 提供了通用的堆基础设施。你需要实现三个接口方法:`Len`、`Less`、`Swap`,以及自行封装 `Push` 和 `Pop`。
> [!warning] ⚠️ Go 的 interface{} vs 泛型
>
> Go 1.18+ 引入了泛型,但 `container/heap` 仍基于 `interface{}`。对于 LeetCode Go 环境(通常是较新版本),可以使用以下通用 heap 模板:
```go
// IntHeap 是最小堆,存储链表指针,按 Val 排序
type IntHeap []*ListNode
func (h IntHeap) Len() int { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i].Val < h[j].Val } // 最小堆
func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *IntHeap) Push(x any) {
*h = append(*h, x.(*ListNode))
}
func (h *IntHeap) Pop() any {
old := *h
n := len(old)
item := old[n-1]
old[n-1] = nil // 避免内存泄漏
*h = old[:n-1]
return item
}
```
#### 完整代码
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
import (
"container/heap"
)
// IntHeap 最小堆,按节点 Val 排序
type IntHeap []*ListNode
func (h IntHeap) Len() int { return len(h) }
func (h IntHeap) Less(i, j int) bool {
return h[i].Val < h[j].Val // 越小优先级越高
}
func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *IntHeap) Push(x any) {
*h = append(*h, x.(*ListNode))
}
func (h *IntHeap) Pop() any {
old := *h
n := len(old)
item := old[n-1]
old[n-1] = nil
*h = old[:n-1]
return item
}
func mergeKLists(lists []*ListNode) *ListNode {
h := &IntHeap{}
heap.Init(h)
// ① 初始化:将所有非空链表的头节点推入堆
for _, node := range lists {
if node != nil {
heap.Push(h, node)
}
}
// 虚拟头节点
dummy := &ListNode{}
tail := dummy
// ② 逐个弹出最小节点,并入结果链表
for h.Len() > 0 {
node := heap.Pop(h).(*ListNode) // 取出当前最小节点
tail.Next = node
tail = tail.Next
// 如果该节点还有后继,将其推入堆
if node.Next != nil {
heap.Push(h, node.Next)
}
}
return dummy.Next
}
```
**时间复杂度:O(N log K)** — 对 N 个节点各做一次 push/pop,每次 O(log K)。
**空间复杂度:O(K)** — 堆中最多同时存储 K 个节点。
---
### 两种最优方法对比
```mermaid
flowchart LR
subgraph DC["方法一:分治"]
TIME1["O(N log K)"]
SPACE1["O(1) 迭代 / O(log K) 递归"]
CODE1["代码简洁"]
PERF1["常数因子小"]
end
subgraph HP["方法二:最小堆"]
TIME2["O(N log K)"]
SPACE2["O(K)"]
CODE2["需实现 heap 接口"]
PERF2["适合动态流式输入"]
end
DC -->|"面试首选 ✅"| ANSWER["最优解"]
HP -->|"工程适用 | 扩展性强"| ANSWER
style ANSWER fill:#d4edda,stroke:#28a
```
| 维度 | 分治法(递归)⭐ | 最小堆法 🏗️ |
|------|---------------|------------|
| **时间复杂度** | O(N log K) | O(N log K) |
|**空间复杂度**| O(log K) 递归 / O(1) 迭代 | O(K) |
| **代码难度** | 低(复用两两合并) | 中(需实现 heap 接口) |
| **常数开销** | 更小(无 heap 操作) | 更大(push/pop 调度) |
| **适用场景** | 链表数组已知 | 链表逐个到来(流式数据) |
| **面试推荐度** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
> [!success] ✅ 面试建议
> - **优先写分治法**:代码极简(仅需一行 merge 调用),容易推导,面试官满意
> - **作为加分项**提及堆做法:展示你对优先队列的理解及在流式场景的应用
> - 如果面试官追问"K 极大时怎么办",可以讨论桶优化等更深层的思路
---
## 代码提示
### 分治迭代伪代码
```
interval = 1
while interval < K:
for i = 0; i + interval < K; i += 2 * interval:
lists[i] = merge(lists[i], lists[i + interval])
interval *= 2
return lists[0] if K > 0 else nil
```
### 最小堆伪代码
```
h = 新建最小堆()
// 初始化:所有非空头入堆
for each node in lists:
if node != nil:
h.push(node)
dummy = new ListNode()
tail = dummy
while h is not empty:
node = h.pop() // 取出最小节点
tail.Next = node
tail = tail.Next
if node.Next != nil:
h.push(node.Next) // 后继补入堆
return dummy.Next
```
---
## 技巧
> [!tip] 🔑 为什么 O(N log K) 优于 O(N log N)?
>
> 暴力法排序的本质忽略了数据内在结构——每个子链表本身已是有序的。堆/分治法利用了这一点:只需要在 K 个候选者之间做决策(log K 级比较),而非对所有 N 个元素做全排序(log N 级比较)。当 K << N 时,差距巨大。
>
> 举例:N = 10⁴, K = 100 → log K ≈ 6.6,log N ≈ 13.3——堆做法约为暴力的一半操作量;但当 N = 10⁶, K = 100 时,差距变为 10 倍以上。
> [!tip] 🔑 虚拟头节点(Dummy Node)万能公式
>
> 遇到"构造链表"类题目,模板化的起手式:
>
> ```
> dummy = new ListNode()
> tail = dummy
> // ... 循环中 tail.Next = newNode; tail = tail.Next ...
> return dummy.Next
> ```
>
> 这条模式出现在:27-合并两个有序链表、此题、21-两数相加、83-删除排序链表中的重复元素、等等。**背下来,考场上一行就搞定边界处理。**
> [!note] 🔑 分治的迭代写法比递归写法精妙在哪?
>
> 它直接在原数组 `lists` 上原地合并,不分配额外数组。关键在于 `i += 2 * interval`——每次跳过已合并的那一对,防止重复处理。这种"间隔倍增"的技巧在很多分治场景中都有应用。
> [!warning] ⚠️ Go 中 container/heap 的常见坑
>
> 1. **Less 必须严格小于 `<`**,不能用 `<=`——否则相同值的节点顺序不确定,可能导致逻辑错误
> 2. **Pop 后要置 nil**:`old[n-1] = nil`,避免 Go GC 无法回收被弹出的旧引用造成内存泄漏
> 3. **Push/Pop 接收者是指针**:函数签名是 `(h *IntHeap)`,不是值类型
> [!tip] 🔑 边界情况的三种极端
>
> | 输入 | 预期输出 | 处理要点 |
> |------|---------|---------|
> | `[]`(K = 0) | `nil` | 检查 `len(lists) == 0` |
> | `[[]]`(K = 1,空链表) | `nil` | 堆初始化时跳过 nil 节点 |
> | `[[], [], []]`(全空) | `nil` | 堆始终为空,直接返回 `dummy.Next` |
> [!info] 📊 三者的直观感受
| 方法 | 直觉 | 代码行数(含注释) | 面试白板友好度 |
|------|------|------------------|-------------|
| **暴力排序** | 最容易想到 | ~10 | ★★★ |
| **分治合并** | 自然推广两两合并 | ~15 | ★★★★★ |
| **最小堆** | 需要熟悉 heap 接口 | ~25 | ★★★★ |
---
## 代码
### 方法一:分治合并 ⭐
#### 递归实现
将问题不断二分,直到子问题只剩 0 或 1 条链表,回溯时逐层合并。由于 `lists` 在递归过程中是**只读的**,直接作为额外参数传递即可——Go 的函数栈帧很小,多一个切片头参数毫无影响,无需借助闭包增加认知负担:
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func mergeKLists(lists []*ListNode) *ListNode {
if len(lists) == 0 {
return nil
}
return divideAndConquer(lists, 0, len(lists)-1)
}
// divideAndConquer 二分合并 lists[left..right]
func divideAndConquer(lists []*ListNode, left, right int) *ListNode {
if left == right { // 基准情况:只剩一条
return lists[left]
}
mid := left + (right-left)/2
leftHalf := divideAndConquer(lists, left, mid) // 左半 [left, mid]
rightHalf := divideAndConquer(lists, mid+1, right) // 右半 [mid+1, right]
return mergeTwoLists(leftHalf, rightHalf) // 两半回溯合并
}
// mergeTwoLists 合并两个有序链表(复用 27-合并两个有序链表的解法)
func mergeTwoLists(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 K)
**空间复杂度:** O(log K)——递归栈深度等于分治树高度
---
#### 迭代实现
不依赖调用栈,显式地逐轮减少链表数量(原地合并):
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func mergeKLists(lists []*ListNode) *ListNode {
// 边界情况:没有链表需要合并
if len(lists) == 0 {
return nil
}
interval := 1
for interval < len(lists) {
// 本轮:每两个相距 interval 的链表互相合并
for i := 0; i+interval < len(lists); i += 2 * interval {
lists[i] = mergeTwoLists(lists[i], lists[i+interval])
}
interval *= 2 // 间隔翻倍,进入下一轮
}
// 最终 lists[0] 就是合并结果
if len(lists) > 0 {
return lists[0]
}
return nil
}
```
**时间复杂度:** O(N log K) — log K 轮合并,每轮遍历所有 N 个节点。
**空间复杂度:** O(1) — 原地修改 lists 数组中的指针,不使用额外空间。
---
### 方法二:最小堆 / 优先队列
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
import (
"container/heap"
)
// IntHeap 实现 container/heap 接口的最小堆,按节点 Val 排序
type IntHeap []*ListNode
func (h IntHeap) Len() int { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i].Val < h[j].Val }
func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *IntHeap) Push(x any) {
*h = append(*h, x.(*ListNode))
}
func (h *IntHeap) Pop() any {
old := *h
n := len(old)
item := old[n-1]
old[n-1] = nil // 避免内存泄漏
*h = old[:n-1]
return item
}
func mergeKLists(lists []*ListNode) *ListNode {
h := &IntHeap{}
heap.Init(h)
// ① 初始化:将所有非空链表的头节点推入堆
for _, node := range lists {
if node != nil {
heap.Push(h, node)
}
}
// 虚拟头节点
dummy := &ListNode{}
tail := dummy
// ② 逐个弹出最小节点,并入结果链表
for h.Len() > 0 {
node := heap.Pop(h).(*ListNode) // 取出当前 Val 最小的节点
tail.Next = node
tail = tail.Next
// 将该节点的后继推入堆,维持堆大小为 ≤ K
if node.Next != nil {
heap.Push(h, node.Next)
}
}
return dummy.Next
}
```
**时间复杂度:O(N log K)** — 对 N 个节点各执行一次 heap.Push / heap.Pop,单次 O(log K)。
**空间复杂度:O(K)** — 堆中同时存储最多 K 个节点指针。
---
> [!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 第 23 题,通过率约 45%+。作为链表难度的代表之作,它综合考察了两个重要范式:① **分治法**——通过递归/迭代两两合并降低问题规模;② **优先队列**——用堆动态维护多路有序流的"下一步"。两道解法都值得熟练掌握,面试中建议优先展示分治法,再补充堆方法作为拓展。
> [!info] 📎 相关题目串联
> - [27-合并两个有序链表](./27-合并两个有序链表.md) — 本问题的基础组件,合并两步直接复用
> - [28-两数相加](./28-两数相加.md) — 同样是链表加法思维,注意进位处理的差异
> - [33-排序链表](./33-排序链表.md) — 归并排序思想,与本问题的分治框架高度相似