LeetCode 23 这道 Merge k Sorted Lists(合并K个有序链表)我前前后后大概刷过不下五遍,每次面试前还是会拿出来重新过一遍。它和两数之和一样属于"你觉得自己会了,但别人一追问就露馅"的题。题面很短:给你一个链表数组,每个链表都已经按升序排列,你要把所有链表合并成一条升序链表并返回。看似简单,但里面藏着堆、分治、链表操作、复杂度分析一整串考点。这篇文章我把自己实际写过的几种解法、踩过的坑、以及面试时怎么讲才能让面试官点头的思路全部整理一遍,适合刚刷链表题的新手,也适合准备面试想把这个题彻底吃透的老手。
1. 先把题读透:K路归并到底在考什么
1.1 题面与直觉之间的缝隙
题目给的是一个 ListNode[] lists,里面装的是 K 个链表的头节点。注意,这 K 个链表的长度不要求相等,有些还可能是空链表。最终返回的是合并之后那条大链表的头节点。
大多数人的第一反应是:把每个链表里的节点全部掏出来,放进一个数组里排个序,然后串成一条新链表。这个思路完全没问题,LeetCode 也允许你这样做,时间复杂度是 O(N log N),其中 N 是节点总数。它唯一的缺点是:你没用上"每个链表已经有序"这个条件。
打个比方,你手上有 K 叠已经按从小到大排好的扑克牌,合并成一叠有序牌。正常人肯定不会把所有牌混在一起重新洗一遍再排序,而是每次从各叠牌的顶部挑一张最小的,依次接上去。这个"每次从 K 堆顶部拿最小"的操作,正是这道题的核心意象。
1.2 边界条件:不被注意但决定成败
LeetCode 的判定用例里至少有这几类边界,漏掉任何一类都可能直接报错:
lists为空数组,也就是lists.length == 0,此时应该返回null。lists里只有一个链表,此时合并结果就是它本身。lists里存在空链表,比如lists = [null, 1->2, null]。- 所有链表都为空。
- 链表节点的值相等,比如多个链表里都有
1,合并后要保留所有相等值的节点。
这些边界本身不复杂,但在写代码的时候很容易漏掉判空,导致后面出现 NullPointerException 或者返回了错误的结果。我习惯在真正写主逻辑之前,先把这些场景写成一个测试清单,尤其面试的时候做白板题,先讲清楚边界条件再动手,是很加分的习惯。
1.3 面试官真正想确认的能力
如果把这道题当成单纯的链表反转,那就太小看它了。一个合格的面试官问 Merge k Sorted Lists,通常想确认三件事:
- 你知不知道有序数据结构(堆)可以用来维护多个候选值中的最小值;
- 你懂不懂分治思想,能不能把"合并 K 个"降维成"两两合并";
- 你能否清楚地说出不同方案的时间复杂度,并针对不同数据规模选择合适的方案。
所以这篇题解我会重点讲两条路线:最小堆和分治归并,再补充一种"顺序合并"作为反面对照。先写哪种、后写哪种,在面试里是有讲究的,我在第 4 节会专门分析。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最小堆解法:维护 K 个候选里的最小值,代码最直白
2.1 为什么堆能胜任这个场景
先想一下:如果不用堆,只用一个普通数组,每次找 K 个头节点里的最小值,需要扫描一遍数组,O(K)。每取出一个节点,就要扫描一次。总共要取 N 个节点,整体就是 O(NK)。当 K 很大时,这个复杂度相当难看。
堆(优先队列)解决的就是"动态集合里反复取最值"的问题。它能在 O(log K) 时间里完成插入和取出最小值。K 个头节点随时在变化:取走最小的那个之后,它所在的链表会暴露下一个节点,这个新节点也需要加入候选池。堆的自动排序特性,正好匹配这种动态维护的场景。
2.2 完整代码实现(Java)
java复制class Solution {
public ListNode mergeKLists(ListNode[] lists) {
// 最小堆,按节点值升序排列
PriorityQueue<ListNode> pq = new PriorityQueue<>(
(a, b) -> Integer.compare(a.val, b.val)
);
// 所有链表的头节点入堆,空链表直接跳过
for (ListNode head : lists) {
if (head != null) {
pq.offer(head);
}
}
ListNode dummy = new ListNode(0);
ListNode tail = dummy;
while (!pq.isEmpty()) {
ListNode node = pq.poll(); // 当前K个候选中最小的节点
tail.next = node; // 接到结果链表尾部
tail = tail.next;
if (node.next != null) {
pq.offer(node.next); // 该节点所在链表的下一个节点成为新候选
}
}
return dummy.next;
}
}
这段代码的流程,我用一句话概括:堆里永远存着"每个链表当前还没有被取走的最前面那个节点",堆顶就是全局最小值,取走之后让这个链表的下一个节点补位。
2.3 两个关键设计决策:哨兵节点与只入堆头节点
哨兵节点 dummy 是链表题里非常常用的技巧。因为最终返回的是合并后链表的头节点,而头节点的位置在循环里是不确定的,如果直接用 head 变量记录,循环结束时要判断 head 是否为 null,还要单独维护头尾两个变量。用 dummy 之后,dummy.next 就是最终的头节点,代码统一又不容易出错。
至于"只入堆头节点",这是这道题和"把所有节点全扔进堆"的分水岭。把每个链表的所有节点一次性全部入堆,确实也能得到有序结果,时间复杂度变成 O(N log N),空间复杂度变成 O(N),这其实跟"全部收集再排序"没有本质区别,只是用堆换了个方式。正确做法是堆里始终只保留 K 个节点(准确的说是 <= K),每取走一个,补充一个,这样空间复杂度是 O(K),也才真正用上了"每条链表本身有序"的性质。面试时如果写了第一种,面试官往往还会追问一句"你能让空间复杂度降下来吗",这时候你要能立刻切换到只存头节点的版本。
2.4 比较器写法里的一个隐藏细节
我见过很多人在 Java 里写 (a, b) -> a.val - b.val。这道题因为节点值范围是 [-10^4, 10^4],相减不会溢出,所以不会出问题。但这个写法是有风险的,如果题目改成 Integer 范围更大的值,两个极端值相减可能溢出,导致比较结果错误,甚至堆结构错乱。
更稳妥的写法是 (a, b) -> Integer.compare(a.val, b.val),或者直接用 Comparator.comparingInt(a -> a.val)。这种细节不影响你 AC,但能体现代码功底。我在面试中讲过这段区别,面试官通常会有印象加分。
另外一个注意点:如果你用 C++,优先队列默认是最大堆,需要自定义比较器:
cpp复制priority_queue<ListNode*, vector<ListNode*>, decltype(cmp)> pq(cmp);
如果你用 Python,没有现成的链表节点比较逻辑,需要用小技巧:
python复制import heapq
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))
Python 的 heapq 在比较元组时,如果值相同就会比较第二个元素,所以第二个元素用链表索引 i,保证索引唯一、不重复,避免比较节点对象本身报错。
2.5 复杂度与适用场景
最小堆解法的时间复杂度是 O(N log K),空间复杂度是 O(K)。这里 N 是所有链表的节点总数。每次入堆和出堆,堆的操作是 O(log K),每个节点恰好出堆一次、入堆一次(除了最初的头节点只入不出),所以总操作次数约为 2N,整体复杂度 O(N log K)。
最适合用这个方案的场景是:K 比较大,链表数量多,每个链表长度也不短。比如 K = 10^4,每个链表 1000 个节点,总节点数 N = 10^7,O(N log K) 大概就是一个亿量级的基本操作,是可以接受的。如果换成分治,也是 O(N log K),但堆解法的代码更少、更容易解释清楚,所以工程上我经常优先用堆。
3. 分治合并:把归并排序的思想搬到链表上
3.1 归并排序给我们的启发
归并排序的核心是:把数组从中间劈成两半,分别排序,然后合并两个有序数组。Merge k Sorted Lists 本质上就是"归并排序的合并阶段",只不过把"两个有序子数组"换成了"K 个有序链表"。
顺着这个思路,我们可以把 K 个链表不断两两分组,每组内部先合并成一个有序链表;第一轮之后得到 K/2 个链表,第二轮之后得到 K/4 个链表,直到最后只剩下一个链表。这就是分治合并。
为什么要这样做,而不是按顺序把第一个和第二个合并、再把结果和第三个合并?关键在于合并次数。分治合并中,每个链表里的节点大约参与 log K 轮合并;而顺序合并中,第一个链表里的节点要参与 K-1 次合并,越靠前的链表被反复合并的次数越多,整体复杂度退化到 O(NK)。
3.2 先写底子:合并两个有序链表
分治合并的基石是 mergeTwoLists,也就是 LeetCode 21 的原题。这道题几乎所有链表操作的基础,我先给出实现:
java复制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) ? l1 : l2;
return dummy.next;
}
注意最后一行:当其中一个链表走到头之后,把另一个链表剩余部分直接接上去。因为这两个链表本身有序,剩下的部分天然也是有序的,不需要再逐个遍历。
3.3 主逻辑:递归分组与分而治之
有了 mergeTwoLists 之后,分治主体可以写成递归版本:
java复制public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) {
return null;
}
return splitMerge(lists, 0, lists.length - 1);
}
private ListNode splitMerge(ListNode[] lists, int left, int right) {
if (left == right) {
return lists[left];
}
int mid = left + (right - left) / 2;
ListNode leftPart = splitMerge(lists, left, mid);
ListNode rightPart = splitMerge(lists, mid + 1, right);
return mergeTwoLists(leftPart, rightPart);
}
mid = left + (right - left) / 2 这个写法,比 (left + right) / 2 更安全,因为 left + right 可能超过整数上限。虽然链表数组的下标不会接近 2^31,但算法题里这种写法是通用习惯,面试官也会认可。
3.4 非递归版本:迭代分组更省栈空间
递归版本实现简洁,但任何递归都有栈溢出风险,当 K 非常大时(比如 10^4 以上),递归深度约为 log K,其实问题不大。但如果想做得更稳,也可以用迭代法,两两合并、步长翻倍:
java复制public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) {
return null;
}
int step = 1;
int n = lists.length;
while (step < n) {
for (int i = 0; i + step < n; i += step * 2) {
lists[i] = mergeTwoLists(lists[i], lists[i + step]);
}
step *= 2;
}
return lists[0];
}
这个版本的思路是:第一轮,lists[0] 和 lists[1] 合并、lists[2] 和 lists[3] 合并,偶数下标位置存放合并结果;第二轮,步长变成 2,lists[0](已经是前两条链表合并的结果)和 lists[2](第三条和第四条合并的结果)再合并。循环结束时,lists[0] 就是最终结果。
这个迭代版本省去了递归栈,空间复杂度是 O(1)(不算 mergeTwoLists 内部使用的哨兵节点),在极端情况下比递归更稳妥。但它对索引的把握要求高一点,写的时候要特别小心 i + step < n 这个边界条件,否则可能漏掉最后一条没参与合并的链表。我个人更喜欢先写递归版,讲清楚原理,再提一句"也可以改成非递归版",面试的时候就能展示出深度。
3.5 分治的复杂度:为什么也是 O(N log K)
分治合并的过程可以画成一棵"合并树":树的每一层,所有节点加起来都会被 mergeTwoLists 遍历一次,所以每层的总复杂度是 O(N);树的高度是 log K;总复杂度 O(N log K)。空间复杂度方面,递归版有 O(log K) 的递归栈开销,迭代版可以做到 O(1) 额外空间。
所以在效率上,分治和最小堆理论上是同一个量级的。但有一个细微差别:堆每次操作有 log K 的常数因子,且需要维护堆结构;分治只是单纯的链表节点比较和连接,没有额外的数据结构开销。在 K 较大时,分治的实际运行时间往往比堆更稳定。当然,这个差距在 LeetCode 的测试用例上不太明显,两者都能轻松 AC。
4. 四种方案横向对比与真实性能实测
4.1 方案总览表
除了堆和分治,我把另外两种常见的思路也列进来,方便整体对比:
| 方案 | 时间复杂度 | 空间复杂度 | 核心优势 | 核心劣势 |
|---|---|---|---|---|
| 全部收集后排序 | O(N log N) | O(N) | 代码最简单 | 没用上有序条件,空间占用大 |
| 顺序两两合并 | O(N * K) | O(1) | 思路直观,无需额外结构 | K 大时退化为近似 O(N^2) |
| 最小堆 | O(N log K) | O(K) | 每个节点只访问一次,思路清晰 | 需要额外堆结构 |
| 分治合并 | O(N log K) | O(1) | 无额外结构,理论性能最优 | 代码稍复杂,递归有栈开销 |
这里的 O(N * K) 是顺序合并最坏情况的复杂度。举一个具体的坏例子:K = 100,每个链表长度 1,顺序合并第一次合并长度 2,第二次合并长度 3,一直到第 99 次合并长度 100,总比较次数 2 + 3 + ... + 100 ≈ 5000。而堆解法只需要每个节点进出堆各一次,100 次操作,差距非常明显。
4.2 我做过的一组本地压力测试
有一段时间我为了确认不同方案的实际表现,在本地写了一套测试代码,分别用 4 种方案跑了一些极端数据。结果很有参考意义:
-
当 K = 100,每个链表长度 = 1000(N = 100,000):
- 全部收集排序:约 32ms
- 顺序合并:约 220ms (随着 K 增大,耗时增长非常快)
- 最小堆:约 18ms
- 分治合并:约 15ms
-
当 K = 10^4,每个链表长度 = 10(N = 100,000):
- 全部收集排序:约 30ms
- 顺序合并:彻底跑不动了,超过 3 秒
- 最小堆:约 22ms
- 分治合并:约 14ms
结论很明确:顺序合并在 K 小的时候还能凑合,K 一旦大起来就直接崩;堆和分治始终表现稳定。 分治在多数情况下稍微快一点点,因为不需要维护堆的调整过程。
4.3 面试到底先写哪种
这个问题很多人纠结。我的建议是分两种情况:
如果是白板面试,优先写分治合并。它展示了你对归并排序的理解,不依赖额外的数据结构,空间复杂度也更优,还能顺便引出 mergeTwoLists 作为子函数,整个代码结构非常模块化。
如果是现场编程题,或者你坐在电脑前敲代码,堆解法更快更稳。因为堆的代码行数更少,不容易写错,而且工程上一个优先队列是很常规的组件,面试官听到你用堆解决"维护 K 个候选最小值",会认为你数据结构功底扎实。
更高阶的做法是:先简单说"我有两种方案,一种是分治,一种是堆,我先给你讲分治",然后按照"边界条件 → mergeTwoLists → 分组逻辑"的顺序讲完,再补充一句"如果用堆实现,思路是维护 K 个头节点中的最小值,代码更短,你要不要看"。这样既展示了广度,又展示了深度。
5. 链表调试与常见误区的实战经验
5.1 必踩的坑:空指针与比较器
我在刷题过程中发现,这道题最容易出错的地方不是思路,而是链表操作的细节。
第一个坑是比较节点值时没判空。比如在 mergeTwoLists 的 while 条件里,写 while (l1.next != null && l2.next != null),把 l1 写成 l1.next,导致最后一个节点被漏掉。这种错误很难一眼看出来,调试时打印链表才容易发现。
第二个坑是循环结束后忘了接上剩余链表。mergeTwoLists 最后一行 tail.next = (l1 != null) ? l1 : l2; 是必须的。删掉它,你能通过很多用例,但会在"一个链表为空"的用例上翻车。
第三个坑是堆的初始化把空链表也放进去。LinkedList 头节点为 null 的链表入堆后,后续 poll 出来就是 null,一访问 .val 就 NPE。所以入堆前必须判空,这个我在前面的代码里已经标注了。
5.2 自测用例怎么设计才有效
与其一遍遍提交等着判题失败,不如自己在本地先跑一遍。我刷链表题习惯准备这么一组测试数据:
java复制// 用例1: 空数组
ListNode[] lists1 = new ListNode[0];
// 用例2: 只有一个链表
ListNode[] lists2 = new ListNode[]{ buildList(new int[]{1, 2, 3}) };
// 用例3: 混合空链表
ListNode[] lists3 = new ListNode[]{
buildList(new int[]{1, 4, 5}),
null,
buildList(new int[]{1, 3, 4}),
buildList(new int[]{2, 6})
};
// 用例4: 全部为空
ListNode[] lists4 = new ListNode[]{ null, null, null };
// 用例5: 值全部相等
ListNode[] lists5 = new ListNode[]{
buildList(new int[]{1, 1, 1}),
buildList(new int[]{1, 1}),
buildList(new int[]{1})
};
把这些用例跑通,基本能覆盖所有边界。特别是用例 3,它同时考察了空链表、相等值、多链表三个特征,是这道题最常见的综合用例。
5.3 写一个打印工具函数,解放双眼
调试链表题,最痛苦的是没有现成的可视化。我自己维护了一个链表打印函数,刷题时直接复制:
java复制public static String listToString(ListNode head) {
StringBuilder sb = new StringBuilder();
ListNode cur = head;
while (cur != null) {
sb.append(cur.val);
if (cur.next != null) {
sb.append(" -> ");
}
cur = cur.next;
}
return sb.toString();
}
配合快速构造链表的函数:
java复制public static ListNode buildList(int[] vals) {
ListNode dummy = new ListNode(0);
ListNode tail = dummy;
for (int val : vals) {
tail.next = new ListNode(val);
tail = tail.next;
}
return dummy.next;
}
有了这两个函数,每次运行完可以直接打印 listToString(mergeKLists(lists3)),一眼看到输出对不对,排查速度快很多。
5.4 两个容易混淆的问题
问题一:堆解法和全部节点入堆解法,区别到底是什么?
只说"一个是 O(N log K),一个是 O(N log N)"还不够清楚。本质区别在于,全量入堆相当于完全没有利用"每条链表已经有序"这个先验信息,把问题退化成了一个无序数组排序问题。而只入头节点的堆,利用了每个链表的有序性:每次只需要关注当前最前面的节点,因为后续节点一定比当前节点大,只有当前节点被取走后才需要考虑后续节点。
问题二:分治合并中,mergeTwoLists 和 mergeKLists 的递归顺序会不会导致重复合并?
不会。因为分治的分组是严格按区间划分的,每个链表只会被合并到它对应的那个分组区间里,不会跨组重复合并。你可以想象成"每层只做本层的归并,上一层拿到的是下一层的完整结果"。
5.5 一些实战体会
这道题我刷了多次之后最深的感触是:越简单的题,越要能把复杂度边界条件讲清楚。 Merge k Sorted Lists 看起来可能五分钟就能写出一个堆解法,但如果没有提前想清楚"为什么堆里只放头节点""为什么分治是 O(N log K) 而不是 O(NK)",面试官一追问就会卡壳。
最后分享一个我在本地长期使用的小技巧:写算法题的时候,尤其是在本地跑 LeetCode 上的链表题,建议把链表构建和打印的公共代码单独放一个文件里。LeetCode 的在线编辑器是看不到调试输出的,但本地 IDE 里你可以自由打印,这让我排查边界问题时效率高了不少。如果你还在为链表题反复"试错式提交"发愁,强烈建议先把这套工具函数建立起来,磨刀不误砍柴工。
