LeetCode 23|困难
核心思想:最小堆(优先队列)
关键词:链表合并、数据结构选型、思维迁移
一、为什么这道题一定要和「合并两个有序链表」放在一起看?
在上一篇文章《合并两个升序链表》中,我们做了一件非常简单、但非常重要的事:
每一步,只从两个链表的头节点中选一个最小的
代码的本质是:
比较 list1.val 和 list2.val
选小的那个,接到结果链表后面
现在这道题只是把问题从:
2 个有序链表
升级成了:
K 个有序链表
关键问题只有一个:
如果不止两个“头节点”,我该如何高效地选出最小的那个?
二、暴力思路为什么不理想?
最直观的想法是:
- 每次遍历 K 个链表的当前头节点
- 手动找最小值
这样做的问题是:
- 每合并一个节点,都要扫一遍 K
- 总节点数为 N
- 时间复杂度会退化为:
O(N * K)
当 K 较大时,性能会非常差。
三、关键转折点:我们真正需要的能力是什么?
回到问题本身,其实我们只需要一种能力:
在 K 个候选节点中,快速找到当前最小的那个
而这,正是 最小堆(优先队列) 最擅长解决的事情。
四、为什么最小堆是“刚刚好”的数据结构?
最小堆能提供三件关键能力:
- 插入元素:
O(log K) - 取出最小元素:
O(log K) - 堆顶永远是当前最小值
这和我们合并链表的过程完全契合:
每次只关心「当前所有链表的头节点中,谁最小」
五、完整 Java 实现
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
PriorityQueue<ListNode> pq = new PriorityQueue<>(
(a, b) -> a.val - b.val
);
// 哑节点,统一处理结果链表
ListNode dummy = new ListNode(0);
ListNode cur = dummy;
// 初始化:把每条链表的头节点放进堆
for (ListNode node : lists) {
if (node != null) {
pq.offer(node);
}
}
// 不断从堆中取最小节点
while (!pq.isEmpty()) {
ListNode currentNode = pq.poll();
cur.next = currentNode;
cur = cur.next;
// 把当前节点的下一个重新放回堆中
if (currentNode.next != null) {
pq.offer(currentNode.next);
}
}
return dummy.next;
}
}
六、这段代码到底在“循环”什么?
可以把整个过程理解成一个不断重复的动作:
- 从 K 个链表的当前头节点中,选一个最小的
- 把它接到结果链表后面
- 把它所在链表的“下一个节点”重新加入候选集合
最小堆保证了:
候选集合里,永远能在 O(log K) 时间内找到最小值
七、为什么每次只把 currentNode.next 放回堆?
这是一个非常容易被忽略、但极其重要的细节。
原因是:
- 每条链表本身是有序的
- 当前节点已经被取走
- 它后面的节点,才是这条链表“下一次能参与比较的最小值”
所以:
堆中始终只维护
“每条链表当前还没合并的最前面那个节点”
八、和「合并两个有序链表」的对应关系
你可以把这道题理解成下面这句话:
把「两个头节点比大小」这一步,升级成「K 个头节点比大小」
对比关系如下:
| 场景 | 候选节点数量 | 选最小的方式 |
|---|---|---|
| 合并 2 个链表 | 2 | if / else |
| 合并 K 个链表 | K | 最小堆 |
思维没有跳跃,只是工具升级了。
九、时间与空间复杂度分析
设:
- 链表总节点数为
N - 链表数量为
K
时间复杂度
O(N log K)
- 每个节点都会入堆、出堆一次
- 每次堆操作是
log K
空间复杂度
O(K)
- 堆中最多同时存在 K 个节点
十、为什么这道题值得反复理解?
因为它传达了一个非常重要的算法思想:
当“选择最优”成为瓶颈时,优先考虑堆这种数据结构
这个思想不仅适用于链表,还会反复出现在:
- Top K 问题
- 多路归并
- 数据流处理
十一、总结
从合并两个有序链表,到合并 K 个有序链表:
- 思维没有推翻重来
- 只是把“比较的对象”从 2 个扩展到了 K 个
- 最小堆,刚好补齐了这一能力缺口
算法的进阶,往往不是更复杂,而是更合适。