Files
leetcode-go/链表/34-合并 K 个升序链表.md
T

21 KiB
Raw Blame History

tags, create time
tags create time
LeetCode
链表
堆
分治
优先队列
困难
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⌉ 轮,每轮所有节点恰好被访问一次。

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]] 为例的分治过程:

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 条链表:

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),不会像退化版本那样出现栈溢出。

实现方式二:迭代式逐轮合并

不依赖调用栈,显式地逐轮减少链表数量:

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]] 为例的迭代过程:

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,退出

复杂度分析

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 个链表的头节点中选出最小的那个,接入结果链表,然后把该链表的下一个节点放回堆中:

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 模板:

// 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
}

完整代码

/**
 * 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 个节点。


两种最优方法对比

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 的函数栈帧很小,多一个切片头参数毫无影响,无需借助闭包增加认知负担:

/**
 * 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)——递归栈深度等于分治树高度


迭代实现

不依赖调用栈,显式地逐轮减少链表数量(原地合并):

/**
 * 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 数组中的指针,不使用额外空间。


方法二:最小堆 / 优先队列

/**
 * 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] 🔧 本地测试辅助函数

// 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] 📎 相关题目串联