从逐一合并到多路归并:力扣23「合并K个升序链表」的三种解法进化之路
当两条链表的合并变成 K 条链表的混战时,你的武器库需要从"单兵突进"升级为"集团作战"。这道题,正是对归并思想和优先级队列的终极检验。
前言
在力扣21「合并两个有序链表」中,我们学会了如何优雅地将两条有序链表合并成一条。那如果这个数字从 2 变成了 K 呢?今天我们要攻克的,正是这道大名鼎鼎的力扣23. 合并K个升序链表(Merge k Sorted Lists)。
这道题在LeetCode上标记为困难(Hard) ,但它的解法思路其实清晰明了。它之所以被评为 Hard,是因为它考察的不是单一的算法技巧,而是你对多种数据结构与算法范式的综合运用能力 。在字节跳动、腾讯、Google、Amazon的面试中,这道题几乎是"必刷清单"上的常客。
题目要求你合并 k 个升序链表,返回一个升序链表。这 K 条链表的总节点数为 N。
面对 K 条链表,最 naive 的想法无非是"一条一条地合并",但这样效率堪忧。今天的文章,我们就沿着**"从低效到高效"、"从直观到精妙"的路径,一步步拆解三种主流解法:顺序合并 、分治归并和优先队列(最小堆)**。读完这篇,你不仅会做这道题,更会理解"多路归并"问题的通用解法范式。
题目回顾
给你一个链表数组,每个链表都已经按升序排列。请你将所有链表合并到一个升序链表中,返回合并后的链表。
示例:
rust输入: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约束条件:
k == lists.length,0 <= k <= 10^4,0 <= lists[i].length <= 500,总节点数N不超过10^4。
核心难点:从"一对一"到"一对多"的质变
在力扣21中,我们有两个指针分别指向两个链表的当前节点,每次取较小的那个。这相当于一场擂台赛,只有两个选手,每次比较即可出结果。
但现在有 K 个选手(链表)。每次我们要从 K 个链表的头节点中选出最小的那个 。这就涉及到一个核心问题:如何高效地从 K 个候选者中取出最小值?
- 方案一:每次遍历所有 K 个头节点找最小值。复杂度 O(K),太慢。
- 方案二:把所有节点收集起来排序。O(N log N),浪费了链表有序的特性。
- 方案三:使用优先级队列(小根堆),让堆来帮我们维护 K 个头节点的顺序。O(log K) 的取数效率。
- 方案四:两两归并,把 K 个链表的合并问题转化为 log K 轮的两两合并问题。
今天,我们就从最"笨"的方案一开始,一步一个脚印。
第一层:顺序合并(暴力复用21题) ------ 最直观的"逐一击破"
如果 K=2 我会做,那 K=3 呢?把前两个合并的结果,再和第三个合并。K=4 呢?以此类推。
这就是顺序合并 :维护一个结果链表 ans,初始为 null 或者第一个链表,然后遍历 lists 数组,不断将 ans 与 lists[i] 合并(调用力扣21的 mergeTwoLists)。
java
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) return null;
ListNode ans = null;
for (ListNode list : lists) {
ans = mergeTwoLists(ans, list);
}
return ans;
}
// 复用 21 题的标准解法
private ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0);
ListNode tail = dummy;
while (l1 != null && l2 != null) {
if (l1.val <= l2.val) {
tail.next = l1;
l1 = l1.next;
} else {
tail.next = l2;
l2 = l2.next;
}
tail = tail.next;
}
tail.next = (l1 == null) ? l2 : l1;
return dummy.next;
}
}
复杂度分析:
- 时间复杂度: 设总节点数为
N,k为链表条数。第一次合并长度N1,第二次合并长度N1+N2......总耗时约为O(k * N)。在最坏情况下(每条链表长度均匀),N = k * m,复杂度是O(k^2 * m),也就是O(kN)。当k很大时,这个算法会超时。 - 空间复杂度:
O(1)。
点评: 这个解法虽然慢,但它是逻辑最安全 的保底方案。如果你在面试中一时想不起堆或分治,写出这个并指出它的时间复杂度瓶颈,面试官通常会引导你优化。千万不要觉得它简单就不屑于写------能跑通的代码永远优于写不出的完美代码。
第二层:分治法(归并思想) ------ 教科书级的"两两归并"
我们刚才的瓶颈在于:ans 链表变得越来越长,每次合并都要遍历整个 ans,导致大量重复遍历。
如果借鉴归并排序的思想:先把 K 条链表两两配对,合并成 K/2 条,再两两配对,合并成 K/4 条......直到剩下最后一条。 这个过程就像一棵二叉树,每条链表只参与了 log K 次合并,且每次合并的总代价是 O(N),整体复杂度骤降。
这就是分治归并法。它把"长链表的反复合并"变成了"多对短链表的均衡合并"。
核心实现方式(递归分治)
java
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) return null;
return merge(lists, 0, lists.length - 1);
}
private ListNode merge(ListNode[] lists, int left, int right) {
if (left == right) return lists[left];
int mid = left + (right - left) / 2;
ListNode l1 = merge(lists, left, mid);
ListNode l2 = merge(lists, mid + 1, right);
return mergeTwoLists(l1, l2);
}
// 同样复用 21 题
private ListNode mergeTwoLists(ListNode l1, ListNode l2) {
// ...(同上)
}
}
执行过程图解(以 4 条链表为例):
rust
第1轮:合并(0,1) -> list01,合并(2,3) -> list23
第2轮:合并(list01, list23) -> 结果
这形成了完美的平衡二叉树,高度为 log k。
复杂度分析:
- 时间复杂度:
O(N log k)。其中N是总节点数,k是链表条数。每一层归并都要处理所有N个节点,共有log k层。 - 空间复杂度:
O(log k)。递归调用栈的深度。
点评: 这是面试中最推荐的解法 。它完美地结合了力扣21(合并两个链表)和归并排序的分治思想,代码清晰,逻辑严谨。如果你担心递归栈空间,不用担心,这里 log k 最大也不过 14(当 k=10000 时),完全安全。
第三层:优先队列(最小堆) ------ 最优雅的"多路归并"
如果不想写递归,还有另一种极其优雅的解法:最小堆(PriorityQueue)。
既然我们每次都要从 K 个链表的头节点中找最小的,为什么不把这 K 个头节点放进一个自动排序的容器 里呢?Java 中的 PriorityQueue 正好提供了 O(log K) 的插入和弹出操作。
核心思想
- 创建一个最小堆,堆中存储
ListNode节点,并按照节点的val值排序。 - 遍历
lists数组,将所有非空链表的头节点放入堆中。 - 当堆不为空时:
- 弹出堆顶节点(即当前最小的节点),接入结果链表的尾部。
- 如果该节点有
next节点,将next节点放入堆中(继续参与后续的竞争)。
- 返回结果链表。
这个思路就像一场"淘汰赛":K 条链表的头节点站在擂台上,最小的出列,然后它的下一位替补上场。
java
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) return null;
// 定义小根堆的比较器
PriorityQueue<ListNode> pq = new PriorityQueue<>(
(a, b) -> a.val - b.val
);
// 将 K 个头节点入堆
for (ListNode head : lists) {
if (head != null) pq.offer(head);
}
ListNode dummy = new ListNode(0);
ListNode tail = dummy;
while (!pq.isEmpty()) {
ListNode minNode = pq.poll(); // 取出最小值
tail.next = minNode;
tail = tail.next;
// 将取出节点的下一个节点入堆
if (minNode.next != null) {
pq.offer(minNode.next);
}
}
return dummy.next;
}
}
python
class Solution:
def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
import heapq
# 堆中存储 (val, index, node),因为 ListNode 不可比较,需要 index 防止比较报错
heap = []
for i, head in enumerate(lists):
if head:
heapq.heappush(heap, (head.val, i, head))
dummy = ListNode(0)
tail = dummy
while heap:
val, i, node = heapq.heappop(heap)
tail.next = node
tail = tail.next
if node.next:
heapq.heappush(heap, (node.next.val, i, node.next))
return dummy.next
复杂度分析:
- 时间复杂度:
O(N log k)。每个节点都会经历一次入堆和一次出堆,每次操作的复杂度是O(log k)。 - 空间复杂度:
O(k)。堆中最多同时存储k个节点。
点评: 这是最符合人类直觉的"多路归并"实现。它不需要递归,逻辑清晰直白,而且在处理动态数据流(比如 K 个无限流)时,这是唯一的解法。在面试中,如果你能解释清楚为什么用 PriorityQueue 而不是自己维护数组,面试官会认为你对数据结构的理解非常到位。
第四层:深度辨析 ------ 分治 vs 堆,谁才是最优解?
很多同学会纠结:分治和堆的时间复杂度都是 O(N log k),到底选哪个?
| 维度 | 分治归并法 | 优先队列法 |
|---|---|---|
| 时间复杂度 | O(N log k) | O(N log k) |
| 空间复杂度 | O(log k)(递归栈) | O(k)(堆大小) |
| 代码风格 | 递归,结构清晰,树形思维 | 迭代,逻辑直观,流式思维 |
| 稳定性 | 稳定,不依赖比较器 | 需要注意节点不可比较的问题(要用索引绕开) |
| 适用场景 | 链表数组固定,一次性合并 | 也适合流式数据(如实时合并多个数据流) |
| 面试推荐度 | ⭐⭐⭐⭐⭐(分治更显功力) | ⭐⭐⭐⭐⭐(堆更显数据结构功力) |
我的建议是:两种都要会。 如果面试官先问你"合并两个有序链表",然后追问"如果是 K 个呢",你从分治法的角度切入("我把 K 个分成两半,递归合并"),会显得非常自然。如果面试官问你"如果要处理流式数据怎么办",堆法就派上了用场。
深度总结:三种解法进化图谱
| 解法 | 时间复杂度 | 空间复杂度 | 核心思想 | 面试推荐度 |
|---|---|---|---|---|
| 顺序合并(逐一合并) | O(kN) | O(1) | 复用21题,循环合并 | ⭐⭐(仅用于引入问题) |
| 分治归并法 | O(N log k) | O(log k) | 两两配对,树形归并 | ⭐⭐⭐⭐⭐(首选,经典) |
| 优先队列法 | O(N log k) | O(k) | 小根堆维护K个头节点 | ⭐⭐⭐⭐⭐(优雅,通用) |
从这道题中我们学到了什么?
-
"分治"是解决 K 个同类子问题的通用范式。 无论是合并 K 个有序链表,还是计算 K 个数的最大值,分治都能把复杂度从
O(K * N)降为O(N log K)。 -
优先队列是"动态多路归并"的利器。 当数据是流式输入、无法一次性获取全貌时(比如合并多个实时日志流),堆几乎是最优解。
-
写代码前先评估"数据规模"。如果 K 很小(比如 K=3),顺序合并完全够用;如果 K 很大,必须上堆或分治。面试中,分析完复杂度再动手,是专业的表现。
-
复用已有的工具。 力扣21的
mergeTwoLists是我们的"轮子"。这道题无论是分治还是顺序合并,都在反复利用这个轮子。工程化思维:永远不要重复造轮子。
最后的一些心里话
力扣23是一道经典的"多路归并"问题,它让我们看到,当问题的维度从 2 升维到 K 时,单纯的暴力会失效,我们必须引入更高级的数据结构或算法范式。
刷题的一大乐趣,就是看到不同的解法如何从同一个问题中生长出来。顺序合并像"愚公移山",分治像"兵团作战",而优先队列像"精英擂台"。三种解法,三种世界观,但它们最终都指向同一个正确答案。
当你能够在面试中,根据自己的状态(写得顺手哪种就写哪种)和面试官的追问,灵活地在分治和堆之间切换时,这道题你就真的完全拿下了。
记住今天的口诀:
K链合并并不难,顺序合并最直观。 若要效率翻几番,分治堆排任你选。 分治递归两两合,堆顶弹出小优先。
如果你觉得这篇题解帮你彻底搞懂了合并K个有序链表,欢迎点赞、收藏、转发!我们接下来准备进入一个全新的数据结构篇章------二叉树,你准备好了吗? 🚀