📖 第99课:合并K个升序链表

3 阅读16分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第99课:合并K个升序链表

模块:堆与优先队列 | 难度:Hard ⭐⭐⭐ LeetCode 链接:leetcode.cn/problems/me… 前置知识:第25课(合并两个有序链表)、第97课(前K个高频元素) 预计学习时间:30分钟


🎯 题目描述

给你一个链表数组,每个链表都已经按升序排列。请将所有链表合并到一个升序链表中,返回合并后的链表。

示例:

输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
解释:
  链表1: 145
  链表2: 134
  链表3: 26
  合并后: 11234456

约束条件:

  • k == lists.length
  • 0 <= k <= 10⁴
  • 0 <= lists[i].length <= 500
  • -10⁴ <= lists[i][j] <= 10⁴
  • lists[i]按升序排列

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
空数组lists = []None空输入处理
单链表lists = [[1,2,3]]1→2→3基本功能
含空链表lists = [[],[1]]1空链表过滤
全空链表lists = [[],[],[]]None全空处理
两链表lists = [[1,2],[3,4]]1→2→3→4退化为合并2个
大规模k=10⁴,每个链表500节点性能边界

💡 思路引导

生活化比喻

想象你是一个图书管理员,桌上有K堆书,每堆都已经按书名字母排好序。你要把这K堆书合并成一堆,仍保持有序。

🐌 笨办法1:每次从K堆书中找出书名最小的那本,放到新堆里。重复这个过程,每次都要看K堆的顶部,太慢了!

🐌 笨办法2:先把第1、2堆合并,再和第3堆合并,再和第4堆合并...后面的堆要等很久!

🚀 聪明办法:用一个"优先级书架",每堆书的当前最小值都放在书架上,书架会自动按书名排序。每次从书架上拿走最小的那本,然后把这本书所在那堆的下一本放上书架。这就是"最小堆"思想!

关键洞察

每次只需要找K个链表的"当前最小值",用最小堆能在O(log K)时间找到,远快于O(K)遍历!


🧠 解题思维链

这一节模拟你在面试中"从零开始思考"的过程。

Step 1:理解题目 → 锁定输入输出

  • 输入:K个升序链表的数组,每个链表节点值在[-10⁴, 10⁴]
  • 输出:一个合并后的升序链表
  • 限制:K可能很大(10⁴),需要高效的合并策略

Step 2:先想笨办法(暴力法)

最直接的想法1:每次从K个链表中找出当前最小值,加入结果链表,重复直到所有链表为空。

  • 时间复杂度:假设总节点数N,每次找最小值需要O(K),总共O(N*K)
  • 瓶颈在哪:每次都要遍历K个链表头找最小值

另一个想法2:逐个合并链表,先合并lists[0]和lists[1],再和lists[2]合并...

  • 时间复杂度:第i次合并的链表长度约为iavg_len,总复杂度O(K² * avg_len) = O(KN)
  • 瓶颈在哪:前面的链表被重复遍历多次

Step 3:瓶颈分析 → 优化方向

  • 核心问题:如何快速找到K个链表的当前最小值?
  • 优化思路:如果能用O(log K)时间找最小值,总复杂度就能降到O(N log K)

Step 4:选择武器

  • 选用:最小堆(优先队列)
  • 理由:
    • 堆能在O(log K)时间插入和弹出最小值
    • 每次弹出最小值后,只需插入该链表的下一个节点
    • 总复杂度O(N log K),在K很大时远优于O(N*K)

🔑 模式识别提示:当题目涉及"合并K个有序序列"或"多路归并",优先考虑"最小堆"


🔑 解法一:暴力比较(直觉法)

思路

每次遍历K个链表的头节点,找出值最小的节点,加入结果链表,然后移动该链表的指针。

图解过程

初始:
  lists[0]: 1 → 4 → 5
  lists[1]: 1 → 3 → 4
  lists[2]: 2 → 6

Step 1: 比较[1, 1, 2],最小值1(来自lists[0])
  结果: 1
  lists[0]: 4 → 5
  lists[1]: 1 → 3 → 4
  lists[2]: 2 → 6

Step 2: 比较[4, 1, 2],最小值1(来自lists[1])
  结果: 1 → 1
  lists[0]: 4 → 5
  lists[1]: 3 → 4
  lists[2]: 2 → 6

Step 3: 比较[4, 3, 2],最小值2(来自lists[2])
  结果: 1 → 1 → 2
  ...依此类推

Python代码

class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next


def mergeKLists(lists: list[ListNode]) -> ListNode:
    """
    解法一:暴力比较
    思路:每次从K个链表头中找最小值
    """
    if not lists:
        return None

    dummy = ListNode(0)
    current = dummy

    while True:
        # 找出当前K个链表头的最小值
        min_idx = -1
        min_val = float('inf')

        for i in range(len(lists)):
            if lists[i] and lists[i].val < min_val:
                min_val = lists[i].val
                min_idx = i

        # 所有链表都空了,退出
        if min_idx == -1:
            break

        # 将最小节点加入结果
        current.next = lists[min_idx]
        current = current.next
        lists[min_idx] = lists[min_idx].next

    return dummy.next


# ✅ 测试
def build_list(arr):
    dummy = ListNode(0)
    cur = dummy
    for val in arr:
        cur.next = ListNode(val)
        cur = cur.next
    return dummy.next

def print_list(head):
    vals = []
    while head:
        vals.append(head.val)
        head = head.next
    return vals

lists = [build_list([1,4,5]), build_list([1,3,4]), build_list([2,6])]
result = mergeKLists(lists)
print(print_list(result))  # 期望输出:[1,1,2,3,4,4,5,6]

复杂度分析

  • 时间复杂度:O(N * K) — N是总节点数,每次找最小值需要O(K)
    • 如果K=1000,N=5000,大约需要500万次操作
  • 空间复杂度:O(1) — 只用了常数额外空间(不含输出链表)

优缺点

  • ✅ 代码简单,空间占用少
  • ❌ 时间复杂度O(N*K),在K很大时非常慢
  • ❌ 每次都要遍历K个链表,重复比较太多

⚡ 解法二:分治合并(优化)

优化思路

借鉴归并排序的思想:每次将K个链表两两配对合并,K个变成K/2个,再继续合并,直到只剩1个。这样每层合并的总节点数是N,总共log K层。

💡 关键想法:分治法将"合并K个"转化为多次"合并2个",复杂度从O(K²)降到O(K log K)!

图解过程

初始:
  [L1, L2, L3, L4]

第1轮:两两合并
  L1 ← merge(L1, L2)
  L3 ← merge(L3, L4)
  剩余: [L1, L3]

第2轮:两两合并
  L1 ← merge(L1, L3)
  剩余: [L1]

返回 L1

时间复杂度分析:
- 第1轮:合并K/2对,每对平均2N/K个节点,总O(N)
- 第2轮:合并K/4对,每对平均4N/K个节点,总O(N)
- ...共log K轮,总O(N log K)

Python代码

def mergeKLists_divide(lists: list[ListNode]) -> ListNode:
    """
    解法二:分治合并
    思路:两两合并,类似归并排序
    """
    if not lists:
        return None

    def merge_two(l1: ListNode, l2: ListNode) -> ListNode:
        """合并两个有序链表(第25课的经典题)"""
        dummy = ListNode(0)
        current = dummy

        while l1 and l2:
            if l1.val < l2.val:
                current.next = l1
                l1 = l1.next
            else:
                current.next = l2
                l2 = l2.next
            current = current.next

        current.next = l1 if l1 else l2
        return dummy.next

    # 分治合并
    interval = 1
    while interval < len(lists):
        for i in range(0, len(lists) - interval, interval * 2):
            lists[i] = merge_two(lists[i], lists[i + interval])
        interval *= 2

    return lists[0] if lists else None


# ✅ 测试
lists = [build_list([1,4,5]), build_list([1,3,4]), build_list([2,6])]
result = mergeKLists_divide(lists)
print(print_list(result))  # 期望输出:[1,1,2,3,4,4,5,6]

复杂度分析

  • 时间复杂度:O(N log K) — N是总节点数,分治有log K层,每层合并O(N)
    • 如果K=1000,N=5000,约5000 * 10 = 5万次操作,比暴力法快100倍
  • 空间复杂度:O(log K) — 递归调用栈(如果用迭代则是O(1))

🏆 解法三:最小堆(最优解)

优化思路

维护一个大小为K的最小堆,堆中存储每个链表的当前头节点。每次弹出堆顶(最小值),然后将该节点的next压入堆。这样每次操作只需O(log K),总复杂度O(N log K)。

💡 关键想法:最小堆自动维护K个候选值的最小值,比暴力遍历快K/(log K)倍!

图解过程

初始:
  L1: 1  4  5
  L2: 1  3  4
  L3: 2  6

堆初始化:[(1,L1), (1,L2), (2,L3)]  (按值排序)

Step 1: 弹出(1,L1),加入结果,压入(4,L1)
  堆:[(1,L2), (2,L3), (4,L1)]
  结果: 1

Step 2: 弹出(1,L2),加入结果,压入(3,L2)
  堆:[(2,L3), (3,L2), (4,L1)]
  结果: 1  1

Step 3: 弹出(2,L3),加入结果,压入(6,L3)
  堆:[(3,L2), (4,L1), (6,L3)]
  结果: 1  1  2

...依此类推,直到堆为空

关键:堆的大小始终 <= K,每次操作O(log K)

Python代码

import heapq


def mergeKLists_heap(lists: list[ListNode]) -> ListNode:
    """
    解法三:最小堆
    思路:维护K个链表头的最小堆
    """
    if not lists:
        return None

    # 初始化堆:存(节点值, 链表索引, 节点)
    # 注意:Python的heapq不能直接比较ListNode,需要加索引
    heap = []
    for i, head in enumerate(lists):
        if head:
            heapq.heappush(heap, (head.val, i, head))

    dummy = ListNode(0)
    current = dummy

    while heap:
        # 弹出最小值
        val, idx, node = heapq.heappop(heap)
        current.next = node
        current = current.next

        # 如果该链表还有后续节点,压入堆
        if node.next:
            heapq.heappush(heap, (node.next.val, idx, node.next))

    return dummy.next


# ✅ 测试
lists = [build_list([1,4,5]), build_list([1,3,4]), build_list([2,6])]
result = mergeKLists_heap(lists)
print(print_list(result))  # 期望输出:[1,1,2,3,4,4,5,6]

# 边界测试
print(print_list(mergeKLists_heap([])))  # 期望输出:[]
print(print_list(mergeKLists_heap([None, build_list([1])])))  # 期望输出:[1]

复杂度分析

  • 时间复杂度:O(N log K) — N个节点,每次堆操作O(log K)
    • 与分治法相同的复杂度,但堆方法常数更小,实际更快
  • 空间复杂度:O(K) — 堆中最多存K个节点

🐍 Pythonic 写法

利用Python 3.10+的dataclass和heapq,可以让代码更简洁:

from dataclasses import dataclass, field
import heapq


@dataclass(order=True)
class HeapNode:
    val: int
    idx: int = field(compare=False)
    node: ListNode = field(compare=False)


def mergeKLists_pythonic(lists: list[ListNode]) -> ListNode:
    heap = [HeapNode(head.val, i, head) for i, head in enumerate(lists) if head]
    heapq.heapify(heap)

    dummy = current = ListNode(0)

    while heap:
        item = heapq.heappop(heap)
        current.next = item.node
        current = current.next

        if item.node.next:
            heapq.heappush(heap, HeapNode(item.node.next.val, item.idx, item.node.next))

    return dummy.next

这个写法用dataclass自动实现了比较逻辑,避免了手动元组打包。

⚠️ 面试建议:先写清晰版本展示思路,再提Pythonic写法展示语言功底。 面试官更看重你的堆应用的理解,而非语法糖的使用。


📊 解法对比

维度解法一:暴力比较解法二:分治合并🏆 解法三:最小堆(最优)
时间复杂度O(N*K)O(N log K)O(N log K) ← 同样最优
空间复杂度O(1)O(log K)O(K) ← 可接受
代码难度简单中等中等
面试推荐⭐⭐⭐⭐⭐ ← 首选
适用场景K很小(<10)通用K个有序流的标准解法

为什么最小堆是最优解:

  • 时间复杂度O(N log K)已达理论最优(必须访问所有N个节点)
  • 空间O(K)远小于O(N),在K<<N时非常高效
  • 代码简洁清晰,heapq模块开箱即用
  • 在K很大时,堆方法的常数因子比分治更小

面试建议:

  1. 先用30秒口述暴力法思路(O(N*K)),表明你能想到基本解法
  2. 立即优化到🏆最小堆(O(N log K)),展示对堆的掌握
  3. 重点讲解堆的应用:"K个有序流,堆维护当前K个最小值候选,每次O(log K)找最小"
  4. 强调为什么这是最优:时间已达O(N log K)理论最优,空间O(K)远优于暴力法
  5. 手动模拟添加3个链表[1,4,5],[1,3,4],[2,6]的堆变化过程

🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你合并K个升序链表。

:(审题30秒)好的,这道题要求合并K个有序链表成一个有序链表。让我先想一下...

我的第一个想法是每次从K个链表头中找最小值,时间复杂度是O(N*K),N是总节点数。这在K很大时会很慢。

我可以优化到O(N log K),用最小堆维护K个链表的当前头节点:

  • 初始化时将K个链表头都压入最小堆
  • 每次弹出堆顶(最小值),加入结果链表
  • 将该节点的next压入堆,保持堆中始终有K个候选值
  • 重复直到堆为空

面试官:很好,请写一下代码。

:(边写边说关键步骤)

# 关键点:
# 1. 堆中存(val, idx, node)三元组,因为heapq不能直接比较ListNode
# 2. idx用于区分值相同的节点,保证堆的稳定性
# 3. 每次弹出后立即压入next,保持堆大小 <= K

面试官:测试一下?

:用示例[[1,4,5],[1,3,4],[2,6]]走一遍:

  1. 初始堆:[(1,0,L1), (1,1,L2), (2,2,L3)]
  2. 弹出(1,0,L1),压入(4,0,L1.next) → 结果:1
  3. 弹出(1,1,L2),压入(3,1,L2.next) → 结果:1→1
  4. 弹出(2,2,L3),压入(6,2,L3.next) → 结果:1→1→2
  5. ...依此类推 → 最终:1→1→2→3→4→4→5→6

结果正确!再测边界情况,空数组[] → None,也正确。

高频追问

追问应答策略
"为什么不用分治法?""分治法时间复杂度也是O(N log K),但在K很大时,堆方法的常数因子更小,且代码更简洁"
"如果链表数量K非常大(百万级)?""可以考虑分批处理:每次取1000个链表用堆合并,得到1000个结果链表,再递归合并这1000个"
"能否支持链表动态添加?""可以将新链表的头节点直接压入堆,堆会自动调整。时间复杂度仍是O(log K)"
"为什么堆中要存idx?""因为heapq在比较元组时,如果第一个元素相同会比较第二个。如果第二个是ListNode对象,Python无法比较,会报错。idx保证了稳定性"

🎓 知识点总结

Python技巧卡片 🐍

# 技巧1:heapq处理自定义对象 — 用元组包装
import heapq
heap = []
heapq.heappush(heap, (priority, unique_id, obj))  # unique_id避免obj比较

# 技巧2:链表转数组辅助调试 — 快速验证结果
def to_list(head):
    return [node.val for node in iter(lambda: head if (head := head.next if head else None) else None, None)]

# 技巧3:批量初始化堆 — 用heapify比逐个push快
nums = [3, 1, 4, 1, 5]
heapq.heapify(nums)  # O(n),比n次push的O(n log n)快

💡 底层原理(选读)

为什么堆的插入/弹出是O(log K)而不是O(K)?

堆是完全二叉树,K个元素的堆高度是log K。

  • 插入:先放到末尾(数组最后),然后"上浮"到正确位置,最多上浮log K次
  • 弹出:取出堆顶后,用末尾元素替代,然后"下沉"到正确位置,最多下沉log K次

Python的heapq用列表实现堆,利用下标关系:

  • 父节点i的左子节点:2i+1,右子节点:2i+2
  • 子节点i的父节点:(i-1)//2

算法模式卡片 📐

  • 模式名称:多路归并(K路归并)
  • 适用条件:合并K个有序序列(链表/数组/数据流)
  • 识别关键词:"合并K个"+"有序"、"多路归并"
  • 模板代码:
import heapq

def merge_k_sorted(lists):
    heap = []
    for i, lst in enumerate(lists):
        if lst:
            heapq.heappush(heap, (lst[0], i, 0))  # (值, 链表索引, 元素索引)

    result = []
    while heap:
        val, list_idx, elem_idx = heapq.heappop(heap)
        result.append(val)
        if elem_idx + 1 < len(lists[list_idx]):
            next_val = lists[list_idx][elem_idx + 1]
            heapq.heappush(heap, (next_val, list_idx, elem_idx + 1))

    return result

易错点 ⚠️

  1. 忘记处理空链表:初始化堆时要跳过None链表。正确做法:if head: heapq.heappush(heap, ...)
  2. heapq比较ListNode报错:直接push ListNode对象会因无法比较而报错。正确做法:用元组(val, idx, node)
  3. 堆中重复压入节点:弹出节点后忘记检查next是否为空,会导致None入堆。正确做法:if node.next: heapq.heappush(...)
  4. 索引越界:在分治法中,lists[i + interval]可能越界。正确做法:for i in range(0, len(lists) - interval, interval * 2)

🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:分布式系统中合并多个数据分片的排序结果(如Hadoop MapReduce的Reduce阶段)
  • 场景2:日志系统中合并多个服务器的时间戳有序日志文件
  • 场景3:数据库中合并多个索引扫描的结果集(Multi-Index Merge)
  • 场景4:搜索引擎中合并多个倒排索引的查询结果(按相关性排序)

🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 21. 合并两个有序链表Easy链表归并本题的基础版,递归或迭代
LeetCode 148. 排序链表Medium链表归并排序分治+合并两个链表
LeetCode 378. 有序矩阵中第K小的元素Medium堆/二分二维有序,用堆合并K行
LeetCode 373. 查找和最小的K对数字Medium类似K路归并,但合并的是数对

📝 课后小测

试试这道变体题,不要看答案,自己先想5分钟!

题目:如果K个链表的总节点数N已知,且K很大(>1000),如何进一步优化空间?

💡 提示(实在想不出来再点开)

可以不一次性把K个链表头都放入堆,而是先放前100个,弹出一个就从后续链表中补充一个。这样堆大小始终保持在100,空间从O(K)降到O(100)=O(1)。

✅ 参考答案
import heapq


def mergeKLists_space_optimized(lists: list[ListNode], max_heap_size: int = 100) -> ListNode:
    """
    空间优化版:堆大小限制在max_heap_size
    思路:先放前max_heap_size个链表,后续按需补充
    """
    if not lists:
        return None

    heap = []
    next_list_idx = 0

    # 初始化:先放前max_heap_size个非空链表
    for i in range(min(max_heap_size, len(lists))):
        if lists[i]:
            heapq.heappush(heap, (lists[i].val, i, lists[i]))
            next_list_idx = i + 1

    dummy = current = ListNode(0)

    while heap:
        val, idx, node = heapq.heappop(heap)
        current.next = node
        current = current.next

        # 如果该链表还有后续节点,压入堆
        if node.next:
            heapq.heappush(heap, (node.next.val, idx, node.next))
        # 如果该链表已空,且还有未处理的链表,补充一个新链表
        elif next_list_idx < len(lists):
            while next_list_idx < len(lists) and not lists[next_list_idx]:
                next_list_idx += 1
            if next_list_idx < len(lists):
                head = lists[next_list_idx]
                heapq.heappush(heap, (head.val, next_list_idx, head))
                next_list_idx += 1

    return dummy.next

核心思路:堆大小从O(K)降到O(min(K, max_heap_size)),在K>>max_heap_size时显著节省空间。时间复杂度仍是O(N log min(K, max_heap_size))。


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。