LeetCode 23(合并 K 个升序链表)是「合并两个有序链表」(第 21 题)的成年版:把 k 个升序链表合并成一个升序链表。它是 Hard,却不像 LC4 那样考验视角跃迁——它的难点朴素得多:朴素做法会慢,快的做法要在堆与分治之间选一个。这道题真正的价值在于它是一类行业问题的原型:日志归并、多路搜索结果排序、外部排序的归并段合并——「K 路归并」的四个字,在面试题与生产系统里是同一个问题。本文把两种 O(N log k) 解法讲透,代码本地验证(含 30000 组随机对拍),最后说说这道题在真实系统里的样子。开始前的一个阅读约定:文中「k」始终指链表条数、「N」始终指全部节点总数——这两个量的混淆是讨论复杂度时最常见的口误。

一、题目与复杂度的账
题面:给定 k 个升序链表,合并为一个升序链表。约束(标准 LeetCode 口径):链表个数 0 <= k <= 10⁴,所有链表节点总数 0 <= N <= 10⁴(注意约束的是总和而不是单个链表长度),节点值范围 [-10⁴, 10⁴];允许空数组与空链表。约束里真正影响写法的是三处:k 可以为 0(直接返回 None)、数组里可以混有空链表(入堆时跳过)、以及 N 是总和——这意味着「一条超长链表加一堆空链表」是完全合法的输入,实现必须能优雅处理这种极端不均衡。
先算朴素做法的账,才能看清优化方向。方案零:依次合并——先把第 1、2 个合并成结果,再与第 3 个合并……每次「合并两个有序链表」是 O(两串长度和),最坏情况下(每条链表一样长)第 i 轮的合并代价是 O(i·L),合计 O(N·k)。问题出在「已经排好的元素被反复重新比较」:先合并完的链表会一遍遍参与后续比较,而它本来的有序性早就在第一次合并时用掉了。用具体数字感受一下量级:k=1000 条链表、每条 10 个节点(N=10⁴),朴素方案最坏要做约 500 万次比较;而 O(N log k) 的两种优化解法只要 10⁴ × log₂1000 ≈ 13 万次——四十倍的差距,这就是这道题被设为 Hard 的理由。两个优化方向恰好对应两种经典解法:要么让每次取最小值这件事变快(最小堆),要么让合并的次序变均匀(分治)。
顺手复习一下两种解法都要用到的地基——「合并两个有序链表」(第 21 题):双指针分别指向两条链表头部,每次取较小者接到结果尾部、对应指针后移,一条耗尽就把另一条整体接上。它是 O(m+n)、O(1) 空间,也是本题分治解法的原子操作。这道地基题本身有个经典技巧值得复述:用一个哑头(dummy)节点起步,省掉「结果链表第一个节点」的特判——本文代码里的 dummy = tail = ListNode() 正是这个技巧。
二、解法一:最小堆的 K 路归并
堆解法把问题抽象成「K 路归并」的标准模型:k 条有序流,每次要从 k 个「当前头部」中取最小者接到结果。用一个最小堆维护 k 个当前节点:初始把所有链表的头节点入堆;每次弹出堆顶(全局最小),接到结果尾部,把它的 next 推回堆;堆空即完成。堆的大小恒为 k,每次操作 O(log k),总共 N 次弹出——O(N log k),空间 O(k)。
为什么堆解法是「显然正确」的?不变量一句话:堆里的节点,恰好是所有「尚未进入结果链表」的节点中、每条链表各自的当前头部——全局最小值必然在其中。因为每条链表升序,某条链表还没进结果的剩余部分全都大于等于它的当前头部;所以全局最小只可能出现在堆里,弹出堆顶绝不会漏掉更小的元素。这个不变量同时保证了堆解法对「链表长度极不均衡」的鲁棒性:某条链表特别长也不要紧,它只是长期占着堆里的一个位置,其他链表耗尽后它自然接管。把堆解法与分治解法再列一张对照表,选型就一目了然:
| 维度 | 最小堆 | 分治两两归并 |
|---|---|---|
| 时间 | O(N log k) | O(N log k) |
| 额外空间 | O(k) 堆 | O(1)(迭代版) |
| 流式友好 | 极高(边到边出) | 需全部在手 |
| 实现复杂度 | 需处理平局比较 | 只需复用 LC21 |
为什么堆解法是「显然正确」的?不变量一句话:堆里的 k 个节点,恰好是所有「尚未进入结果链表」的节点中、每条链表各自的当前头部——全局最小值必然在其中。因为每条链表升序,某条链表还没进结果的剩余部分全都大于等于它的当前头部;所以全局最小只可能出现在 k 个头部之中,弹出堆顶绝不会漏掉更小的元素。这个不变量同时保证了堆解法对「链表长度极不均衡」的鲁棒性:某条链表特别长也不要紧,它只是长期占着堆里的一个位置,其他链表耗尽后它自然接管。
import heapq
class Solution:
def mergeKLists(self, lists):
dummy = tail = ListNode() # 结果链表的哑头
heap = []
for idx, node in enumerate(lists): # 堆条目: (值, 唯一序号, 节点)
if node:
heapq.heappush(heap, (node.val, idx, node))
while heap:
val, idx, node = heapq.heappop(heap)
tail.next = node
tail = tail.next
if node.next:
heapq.heappush(heap, (node.next.val, idx, node.next))
return dummy.next
两个实现细节值得点破。堆条目必须带一个「唯一序号」:Python 的 heapq 比较元组时,若两节点值相等,会继续比较下一个元素——如果下一个直接是链表节点,节点对象不支持 < 比较就会 TypeError,序号(链表的数组下标)充当了永不相同的平局裁决者。这是 Python 写堆题的高频坑,也是「为什么不能直接 push 节点」的答案。
三、解法二:分治的两两归并
分治解法不引入任何额外数据结构:把 k 条链表两两配对,每对用「合并两个有序链表」(第 21 题,双指针一次归并)合并成一条;一轮下来链表数减半,重复 log k 轮,剩一条即答案。复杂度同为 O(N log k)——每轮处理全部 N 个节点,共 log k 轮——但空间账更好看:迭代版两两归并只需 O(1) 额外空间(堆版要 O(k) 的堆空间);递归版则要 O(log k) 的栈。
配对次序里藏着分治的效率来源:朴素方案慢是因为「长结果反复参与比较」,分治方案则保证每一轮参与合并的链表长度都尽量均衡——两个长度 L 的链表合并代价是 2L,比「一条长 2L 与一条短 L 合并」的常数更优,且 log k 轮里每条链表只被完整扫描 log k 次。迭代实现用 interval 变量控制配对间距(1、2、4、8……),代码比递归版稍绕但没有栈溢出风险。两法的取舍可以一句话说清:堆版「边到边出」,天然适合流式场景(数据源源不断到达,不需要一次性拿到全部链表);分治版「层层合并」,常数更小、缓存更友好,在全部数据已在手的场景(本题、外部排序的归并段)通常更快。共同的反面教材是方案零的 O(N·k):数据范围里 k 可以到 10⁴,N·k 最坏 10⁸ 级别,超时毫无悬念。
四、本地验证:对拍 30000 组
链表题的手工用例容易「看起来对」,验证策略与 LC84 相同:手工边界(k=0、全空链表、单链表单节点、一条极长一条极短)加随机对拍——生成 30000 组随机链表组(k 随机 0–8、每条长度随机、含空链表,值域 [-10⁴, 10⁴]),与「全部节点取出、排序、重建链表」的朴素参考实现逐节点比对。发布前本地运行:
手工边界通过;30000 组对拍全部一致
堆解法常见的翻车点——相等值的平局比较(上文序号技巧)、空链表入堆、某条链表耗尽后的堆收缩——在随机对拍下都会被系统性踩到,这正是对拍比手工用例可信的地方。
五、把这道题放回行业场景
K 路归并是那类「面试与生产同构」的 rare 题型。外部排序的经典流程正是:内存排序大文件切块 → 多个有序段落盘 → K 路归并成最终文件(堆归并是标准实现)——数据库的大结果集导出、日志系统的历史段合并都走这条路;日志/搜索的多路合并同理——多个分片各自有序,合并方用一个小顶堆按时间或分数取最小;Elasticsearch 的多分片 TopK、Kafka 多分区按序消费,骨架都是这道题。把行业场景的参数代回复杂度公式还能看到一个「面试不会考、生产很在乎」的推论:O(N log k) 里的 log k 通常小得可怜(k=100 时不到 7),真正的成本大头在 N——所以生产系统优化 K 路归并,方向往往不是「换更聪明的算法」,而是「减少要归并的总数据量」(提前过滤、提前聚合)与「让归并尽量流式」(边读边吐,不落地)。算法题给的是骨架,工程给的是血肉。
结语
合并 K 个升序链表是一道「干净的 Hard」:它不考验奇技淫巧,只考验对两个基础结构——堆与归并——的理解是否到了能写出无 bug 实现的程度。堆版教会你「K 路取最小」的模型,分治版教会你「均衡合并次序」的思想,两者殊途同归于 O(N log k);而对拍验证、平局裁决、边界处理这些工程细节,才是从「会做这道题」到「会写生产代码」之间的那段路。它还与本站的 LC4 构成一对有趣的镜像:同为有序数据合并,数组支持随机访问所以 LC4 能「跳着二分」,链表只能顺次走所以本题「堆取最小」——先看数据结构,再选算法这道工序,两道题合起来算是讲透了。下次在系统里遇到「把多条有序流合成一条」的需求,先想起这道题——然后想起它背后的那个堆。
参考资料
- LeetCode 23. Merge k Sorted Lists——题目原文与约束
- Merge K Sorted Lists(AlgoMaster DSA)——堆与分治两种方法的复杂度推导
- Merge K Sorted Lists - Leetcode 23(NeetCode 视频)——堆解法的可视化讲解
读者留言
COMMENTS 暂无还没有留言,来说第一句?