LeetCode Hot 100 的第 23 题“合并 K 个升序链表”,是所有链表题里最能体现“多路归并”思想的一道。我第一次碰到这题时,第一反应是“把所有节点塞进数组,排序,再串回去”,结果被面试官追问了一句“如果链表总长度上千万,内存只够放几条链表怎么办”,当场哑火。后来我把暴力解法、顺序两两合并、分治合并、优先队列这四种思路全部过了一遍,才真正意识到这题的核心考点根本不是“你会不会遍历链表”,而是你对多个有序序列合并的复杂度掌控力,以及能不能在约束变化时快速切换方案。
这篇文章想覆盖两类读者:一类是马上要面试、需要把 Hot 100 刷透的候选人,另一类是工作中遇到“多个有序来源需要归并”这类场景的开发者。不管你目前处于哪个阶段,看完这篇文章你能带走的是:四种算法的完整代码、每一步的复杂度推导、以及实际刷题和面试时容易踩的坑。最后我会聊聊这题的面试追问方向,这部分价值不比代码本身低。
1. 题目和边界条件:动手前先盘清楚这三点
1.1 原题描述与示例
题目本身不长:给你一个链表数组,每个链表都已经按升序排列。请你将所有链表合并到一个升序链表中,返回合并后的链表。
对应的经典输入输出:
text复制输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
输入:lists = []
输出:[]
输入:lists = [[]]
输出:[]
第一种输入其实藏了一个容易忽视的点:链表“1 -> 4 -> 5”和“1 -> 3 -> 4”里有两个值都为 1 的节点,它们在合并后都可以存在,并不需要去重。题目只要求“升序”,没有要求“严格升序”。
1.2 输入规模决定了你能用哪些算法
LeetCode 原题给的约束有这几个关键数字:
- k 的最大值通常是 10^4,也就是最多有一万个链表;
- 每个链表的最大长度为 500;
- 所有链表节点总数不超过 10^4。
注意“节点总数不超过 10^4”这个约束很重要。它的存在会直接影响暴力法的可行性。如果总节点数只有一万,暴力法的 O(N log N) 排序在时间上是完全能扛住的,LeetCode 官方也会把这种解法列为“通过”的方案之一。但面试场景里,面试官通常不满足于你只给出暴力法,他更想看到你能不能在约束变化时给出更优解。
另外,lists.length 可以是 0,也就是空数组,而不是“数组里放了 null”。这意味着代码第一行就要处理 lists == null || lists.length == 0 这种边界。很多人在笔试时漏掉这个判断,直接 lists[0] 就炸了。
1.3 链表题最致命的坑:循环引用
合并链表时我们一定会重接 next 指针,如果某一步指针处理不对,很容易构造出环。比如你先接了一条链表的指针,又把这条链表的尾部接到另一条链表的中间节点,而那个节点又指回之前的位置,就会死循环。
最典型的场景是:用数组存储所有节点后,排序完直接 node.next = 下一个节点,但忘记把最后一个节点的 next 置为 null。原链表里最后一个节点的 next 本来指向 null 还好,但如果某个子链表只有一个节点,而这个节点在排序后不是最后一个,它旧的 next 可能还指向 null 以外的节点,这时候就需要手动切断。
还有一个隐藏问题:如果你把原链表的节点收集到数组里,排序后重新串接,相当于“复用了原链表的节点对象”。这是允许的,LeetCode 只要求返回的链表在值上满足升序,不要求生成新节点。但复用节点时要保证不会因为节点之间的旧指针造成环。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法:全捞出来排序,能通过但别止步于此
2.1 思路一句话版
遍历所有链表,把每个节点都放进一个数组;对数组按节点值排序;最后按顺序把所有节点串成一个新链表。
2.2 Java 代码实现
java复制class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) {
return null;
}
List<ListNode> nodes = new ArrayList<>();
for (ListNode head : lists) {
while (head != null) {
nodes.add(head);
head = head.next;
}
}
nodes.sort((a, b) -> Integer.compare(a.val, b.val));
ListNode dummy = new ListNode(0);
ListNode cur = dummy;
for (ListNode node : nodes) {
cur.next = node;
node.next = null; // 关键:切断旧指针,防止出现环
cur = cur.next;
}
return dummy.next;
}
}
这里我特意在循环里加了 node.next = null。为什么要这么做?因为 nodes 里的节点来自不同的原始链表,它们在加入数组时还保留着原链表里的 next 指向。排序之后,前一个节点的 next 应该指向后一个节点,但最后一个节点的 next 必须为 null。如果不在循环里统一处理,最后会出现两种情况:
- 如果原链表中某个节点的 next 恰好指向 null,那问题不大;
- 如果某个节点原本不是链表尾巴,排序后被放到了最后一个位置,它的 next 会继续指向原来的后继节点,这样合并后的链表尾部会多出一截,甚至形成环。
所以统一 node.next = null 是最稳妥的做法。
2.3 复杂度分析
设所有链表节点总数为 N:
- 遍历所有节点:O(N)
- 排序:O(N log N)
- 重新串接:O(N)
总时间复杂度是 O(N log N),空间复杂度 O(N)。
对照题目的约束,N <= 10^4,所以这个解法在 LeetCode 上能跑过,耗时一般在 4ms 到 8ms 之间,属于可接受范围。
2.4 为什么说它“不是最优解”
如果你只追求 AC,这个解法足够了。但面试的时候,暴力解法的两个弱点会被放大:
第一,空间复杂度是 O(N)。面试官会问“能不能把空间压到 O(k)”,这里的 k 是链表数量,而不是节点总数。当 N 远大于 k 时,O(N) 和 O(k) 的差距会非常大。比如一个场景里 k=100,但总节点数有 100 万,数组需要存 100 万节点,而优先队列只需要存 100 个节点。
第二,排序浪费了“输入已经是升序”这个条件。每个链表内部已经有序,暴力法完全没有利用这个信息,相当于把一堆已经排好序的文件打乱再重新排序。这在一个有经验的工程师眼里是不合理的。
3. 顺序两两合并:代码最像模板,但复杂度踩坑
3.1 思路:先复用“合并两个有序链表”
LeetCode 第 21 题“合并两个有序链表”大家应该都写过。这个解法就是不断调用 mergeTwoLists,先把第一个链表和第二个链表合并,得到的结果再和第三个链表合并,依次执行下去。
java复制class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) {
return null;
}
ListNode res = null;
for (ListNode list : lists) {
res = mergeTwoLists(res, list);
}
return res;
}
private ListNode mergeTwoLists(ListNode a, ListNode b) {
ListNode dummy = new ListNode(0);
ListNode cur = dummy;
while (a != null && b != null) {
if (a.val < b.val) {
cur.next = a;
a = a.next;
} else {
cur.next = b;
b = b.next;
}
cur = cur.next;
}
cur.next = (a == null) ? b : a;
return dummy.next;
}
}
3.2 这个解法的复杂度到底是多少
有人会把复杂度算成 O(N log k),这是错的。顺序合并的时间复杂度是 O(k * N),其中 k 是链表数量,N 是总节点数。我们推导一下:
假设每个链表长度大约为 m,总节点数 N = k * m。
- 第一次合并,合并两个长度 m 的链表,代价 O(2m)。
- 第二次合并,合并结果长度 2m 和第三个长度 m 的链表,代价 O(3m)。
- 第三次合并,代价 O(4m)。
累计代价是 O(2m + 3m + ... + (k+1)m) = O(k^2 * m) = O(k * N)。
如果 k 很小,比如 2 或 3,这个解法完全没问题。但如果 k 是 10^4,每次合并都要把之前已经合并好的长链表再完整走一遍,性能会急剧下降。LeetCode 的测试用例里有一个包含很多条链表的 case,顺序两两合并有时候会跑到上百毫秒,而分治和优先队列只需要几毫秒。
3.3 res = null 的初始化
这段代码里我初始 res = null,然后第一次调用 mergeTwoLists(null, lists[0])。mergeTwoLists 里对 a 为 null 的判断是天然支持的,因为 while (a != null && b != null) 会直接跳过循环,然后 cur.next = (a == null) ? b : a 会返回 b。
注意这里有一个小坑:不能用 new ListNode(0) 来初始化 res,否则合并结果开头会多出一个值为 0 的节点。如果你真的用了一个 dummy 节点,那么 mergeTwoLists 返回的应该是一个包含 dummy 的链表,最后还得想办法去掉它,很麻烦。所以初始化直接给 null 是最省事的。
4. 分治合并:像归并排序一样“两两配对”
4.1 核心思想:每次只合并相邻的两条
分治的思路是:把 k 条链表两两配对,每一对先合并,得到 k/2 条链表;再两两配对,得到 k/4 条链表;直到只剩下一条链表。
这个过程和归并排序的合并阶段完全一致。区别只在于归并排序处理数组,这里处理链表。
为什么分治比顺序合并快?因为每条链表参与合并的次数从 k 次减少到了 log k 次。第一条链表的节点在分治过程中会被合并 log k 次,而不是 k 次。总代价从 O(k*N) 降到 O(N log k)。
4.2 递归写法
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 l, int r) {
if (l == r) {
return lists[l];
}
int mid = l + (r - l) / 2;
ListNode left = merge(lists, l, mid);
ListNode right = merge(lists, mid + 1, r);
return mergeTwoLists(left, right);
}
}
这里递归的终止条件是 l == r,直接返回对应链表。当 l > r 的情况不会出现,因为我们在进入递归前已经判断过 lists.length == 0。
mid = l + (r - l) / 2 而不是 (l + r) / 2,主要是为了防止整数溢出。虽然这里的 k 最大 10^4,l + r 不会溢出,但这是一个好的编码习惯,面试官看到也会加分。
4.3 迭代写法
递归写法清晰,但需要额外的 O(log k) 栈空间。如果对空间有严格要求,可以用迭代实现。
java复制class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) {
return null;
}
int interval = 1;
int n = lists.length;
while (interval < n) {
for (int i = 0; i + interval < n; i += interval * 2) {
lists[i] = mergeTwoLists(lists[i], lists[i + interval]);
}
interval *= 2;
}
return lists[0];
}
}
关键点是 i += interval * 2。第一轮 interval = 1,合并下标 (0,1)、(2,3)、(4,5) 等;第二轮 interval = 2,合并下标 (0,2)、(4,6) 等;第三轮 interval = 4,依此类推。
这里有一个细节需要留意:for 循环的判断条件是 i + interval < n,不是 i < n。因为当 i + interval >= n 时,右边没有配对的链表,不需要合并。最终 lists[0] 就是合并后的完整链表。
迭代写法有一个副作用:它会修改传入的 lists 数组内容。lists[i] = mergeTwoLists(...) 会覆盖原数组中的引用。如果面试官特别说明“不能修改输入”,那应该使用递归版本,或者在迭代前复制一份数组。
4.4 复杂度分析
设总节点数 N,链表数量 k:
- 时间:O(N log k)。每一轮把所有节点访问一遍,总共 log k 轮。
- 空间:递归写法 O(log k)(递归栈);迭代写法 O(1)(不算答案占用的空间)。
空间上的优势非常明显。对比暴力法的 O(N) 空间,分治法的空间开销小到可以忽略。
5. 优先队列解法:工程里最实用的“K 路归并”
5.1 为什么要用最小堆
前面几种解法里,每次从多个链表里选节点时,都要反复扫描或者重复合并。优先队列的思路是:把 k 条链表的当前头节点全部放进一个最小堆,每次堆顶就是所有链表当前节点中最小的那个。
把最小节点弹出后,如果这个节点所在的链表还有后继节点,就把后继节点加入堆。这样堆的大小最多只有 k,外层循环执行 N 次,每次堆操作代价 O(log k),总时间复杂度 O(N log k)。
这个思路在实际工程里非常常见,比如外部排序、数据库归并、日志文件合并等。面试官问这题,很大概率就是希望考察你是否熟悉这种“K 路归并用堆”的套路。
5.2 Java PriorityQueue 实现
java复制import java.util.PriorityQueue;
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) {
return null;
}
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 cur = dummy;
while (!pq.isEmpty()) {
ListNode node = pq.poll();
cur.next = node;
cur = cur.next;
if (node.next != null) {
pq.offer(node.next);
}
}
return dummy.next;
}
}
这里有几个关键细节:
- 初始化堆时只入队各链表的头节点,不用把所有节点都入队,否则堆就没意义了。
- 比较器用
Integer.compare(a.val, b.val),不要写成a.val - b.val。虽然 LeetCode 的值域是 -10^4 到 10^4,不会溢出,但写成减法在更广泛的场景下有隐患。 - 弹出堆顶节点后,要把
node.next入队。这里不需要处理node.next = null,因为链表的最后一个节点原本的 next 就是 null。
5.3 Python heapq 实现
Python 的 heapq 比较的是对象本身,而 ListNode 没有定义比较大小的方法,直接 heappush(heap, node) 会报错。常见方案是使用元组 (node.val, index, node),把 index 作为第二关键字,避免值相同时再去比较 ListNode。
python复制class Solution:
def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
if not lists:
return None
import heapq
heap = []
for i, head in enumerate(lists):
if head:
heapq.heappush(heap, (head.val, i, head))
dummy = ListNode(0)
cur = dummy
while heap:
val, i, node = heapq.heappop(heap)
cur.next = node
cur = cur.next
if node.next:
heapq.heappush(heap, (node.next.val, i, node.next))
return dummy.next
为什么元组里要带上 index?因为当两个节点的 val 相等时,Python 会继续比较元组的第二个元素 index。如果 index 也相等,才会比较第三个元素。由于 index 是每个链表唯一的,不会出现比较 ListNode 的情况。如果写成 (node.val, node),当两个节点值相等时,heapq 会去比较两个 ListNode 对象,直接抛出 TypeError: '<' not supported between instances of 'ListNode' and 'ListNode'。
5.4 堆解法的时间与空间
- 时间:O(N log k)。堆大小最多 k,每个节点进出堆一次。
- 空间:O(k)。
这里的空间优势在 k 远小于 N 时尤其明显。比如 k=100,N=100 万,堆只需要 100 个槽位,而暴力法需要 100 万个槽位。
5.5 堆解法的一个变体:只存节点的值
偶尔会看到有人用“先把所有节点的值放进小顶堆,再依次创建新节点”来做。这种做法也能 AC,但它破坏了原链表节点的复用,增加了新建节点的开销。如果面试官考点是“能否复用节点”,这种做法会扣分。我建议按上面的方式实现,只存节点引用,不单独存值。
6. 四种解法对比:选哪个取决于场景
6.1 复杂度总览
| 解法 | 时间复杂度 | 空间复杂度 | 是否利用有序性 | 代码复杂度 |
|---|---|---|---|---|
| 暴力收集 + 排序 | O(N log N) | O(N) | 未利用 | 最低 |
| 顺序两两合并 | O(k * N) | O(1) | 利用但不充分 | 最低 |
| 分治合并 | O(N log k) | O(log k) | 充分利用 | 中等 |
| 优先队列 | O(N log k) | O(k) | 充分利用 | 中等 |
在实际 LeetCode 提交中,当 k 比较大而每个链表比较短时,分治和优先队列的优势会非常明显。我印象里某些测试用例下,顺序两两合并耗时会超过 100ms,而分治解法能跑到 3ms 左右。这个差距在真实面试中可能不一定会被测试到,但复杂度分析是必须脱口而出的。
6.2 实际提交的耗时感受
我自己提交时观察到的一个现象是:优先队列解法和分治解法在 LeetCode 上的耗时非常接近,通常都在 2ms 到 5ms 之间。原因是总节点数 N 被限制在 10^4 以内,log k 和 log N 的差距并不大,堆操作或者递归调用的常数差异反而占了主导。
如果追求极致性能,分治迭代法通常是最快的,因为它没有堆的维护成本,也没有递归栈的额外开销。但优先队列解法的优势在于思路通用,遇到“K 个有序数组合并”之类的问题可以直接迁移。
6.3 场景化选择建议
- 笔试/机试:优先队列。代码不容易出错,复杂度达标。
- 面试口头讲解:分治。更容易展示你对“递归分治”和“归并思想”的理解。
- 工程实现:优先队列。利于流式处理,不需要一次性把所有数据加载到内存。
- 数据量极小(k <= 5):顺序两两合并就够,代码最简单。
7. 复盘:这道题容易踩的坑和面试官追问
7.1 我实际踩过的坑
我调试这道题时遇到过一个很诡异的现象:合并后的链表在某些测试用例里莫名其妙地多了一截,而且是逆序的。后来发现是 node.next = null 这行没写对位置。我在排序后串接节点时,直接写了 dummy.next = node,但用的是 dummy 本身而不是 cur,导致只接上了第一个节点,后面的节点全丢了。
另一个坑是优先队列的比较器。我一开始写的是:
java复制PriorityQueue<ListNode> pq = new PriorityQueue<>((a, b) -> a.val - b.val);
在小数据量下一切正常,但当我自定义测试用例,把所有节点的值改成接近 Integer.MAX_VALUE 和 Integer.MIN_VALUE 的边界时,出现了溢出,堆的顺序完全乱了。虽然 LeetCode 原题取值范围不会触发这个问题,但刷题时养成用 Integer.compare 的习惯,能省去很多不必要的麻烦。
还有一次我在迭代分治的 for 循环里写错了步长:
java复制for (int i = 0; i + interval < n; i += interval) // 错误
步长应该是 interval * 2,但是写成了 interval。结果第一轮还行,第二轮开始链表配对错乱,合并结果时对时错。这种低级错误在面试紧张时很容易犯,建议写完代码后手动模拟一下 interval = 1 和 interval = 2 两轮,确认配对关系。
7.2 面试官常见的追问方向
这道题作为一个高频题,面试官几乎不会只问“能不能做出来”,必定会追加一两个问题:
-
“如果改成合并 K 个有序数组怎么办?”
思路类似。数组和链表的最大区别是数组可以通过下标随机访问,但删除和插入代价高。可以用优先队列维护每个数组的当前指针;也可以用分治,每次合并两个有序数组。数组版本的空间复杂度和链表版本不一样,因为合并两个有序数组通常需要额外 O(n) 的辅助空间。
-
“如果数据量很大,无法全部加载到内存怎么办?”
这是外部归并排序的经典场景。做法是把每个有序链表拆成多个文件分块,每次只加载一部分到内存,用优先队列做多路归并,把归并结果写回磁盘。面试官考察的是你有没有处理大规模数据的经验。
-
“能不能不修改输入的链表节点?”
上面的解法都是复用原有节点,只修改 next 指针。如果要求不修改原链表,就需要新建 node,空间复杂度会上升到 O(N)。面试时被问到这一点,第一时间回答“当前实现会改变原链表结构,如果题目要求不能修改输入,我会改为创建新节点”。
-
“如果链表中存在大量重复值,对结果有没有影响?”
没有。升序合并保持稳定性即可,不需要去重。用优先队列时要注意比较器对重复值的处理不会有副作用。
7.3 从这道题延伸出的“多路归并”模板
这道题的价值不只是 AC 本身,更在于它可以抽象出一种通用模板:
- 有一组有序的数据源;
- 每次需要取当前最小的一个;
- 数据源总数 k 远大于单条数据源的长度。
在这种情况下,“优先队列”是几乎固定的答案。后续不管遇到“合并多个有序流”“Top K 问题”“外部排序”,思路都是一致的。面试官想看的是你能不能把这题总结成一种方法论,而不是背代码。
最后再分享一个小技巧:刷这类链表合并题,建议自己动手写一个 ListNode 转数组、数组转 ListNode 的工具函数,方便本地调试。LeetCode 的测试用例输入看起来像数组,但实际传进来的是链表对象,不写转换工具的话,想手动构造测试用例会很痛苦。我用 Java 刷题时,习惯在本地维护一个 ListNodeUtil 类,里面放 createList(int[]) 和 toArray(ListNode) 两个静态方法。这两个方法很短,但能帮你在调试上省下大量时间。
