23. 合并K个排序链表(Merge k Sorted Lists)

频次 ★★★★ · 难度 🔴 · 高频:阿里/字节

题目

给定 k 个升序链表,将它们合并为一个升序链表并返回。

示例

输入: lists = [[1,4,5],[1,3,4],[2,6]]
输出: 1 -> 1 -> 2 -> 3 -> 4 -> 4 -> 5 -> 6

思路

小顶堆(优先队列):维护一个按节点值排序的最小堆,初始把每条链表的头节点都放进去。每次弹出堆顶(当前全局最小的节点)接到结果链表末尾,如果它还有下一个节点就把 next 放入堆中。堆里最多同时存在 k 个节点,每次弹出/插入都是 O(log k)。

(也可以两两合并 —— 分治法,把 k 条链表两两合并直到剩一条,复杂度同样是 O(n log k),思路上是”归并排序”的链表版;堆做法代码更短。)

代码

public ListNode mergeKLists(ListNode[] lists) {
    PriorityQueue<ListNode> pq = new PriorityQueue<>((a, b) -> a.val - b.val);
    for (ListNode node : lists) {
        if (node != null) {
            pq.offer(node);
        }
    }
 
    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;
}

复杂度

  • 时间:O(n log k) — n 是节点总数,堆大小恒为 k
  • 空间:O(k) — 堆最多存 k 个节点

边界条件

  • lists 为空数组:堆一直为空,循环不执行,返回 dummy.next == null
  • 所有链表都为空:初始化时全部被过滤掉,堆为空,同上返回 null
  • 只有一条非空链表:堆里只有一个节点,每次弹出后放入它的 next,退化为直接遍历这条链表。

变式

  • 分治法(两两合并):把 k 条链表两两配对合并(类似归并排序的思路),合并后剩下 k/2 条,重复直到只剩一条,复杂度同样是 O(n log k),不需要额外的堆结构,但需要实现”合并两个有序链表”这个子过程。
  • 顺序合并(一条一条往后接):每次把结果和下一条链表合并,复杂度是 O(k × n),当 k 较大时明显慢于堆或分治解法,通常作为”暴力解法”先提出再优化。

易错点

  • 堆里存的是节点而不是值,弹出后要检查 node.next 是否为空再决定要不要继续放入堆。
  • k 条链表里可能有空链表(null),初始化时要过滤掉,否则堆里会存入 null 导致比较器调用时空指针异常。

面试追问

  • 堆解法和分治解法,时间复杂度都是 O(n log k),实际该选哪个? 两者渐进复杂度相同,分治法常数因子更小(不需要维护堆的上浮下沉开销),但堆解法代码更短、更容易写对;面试中如果被问”还有更优的做法吗”,分治法是标准答案,但堆解法作为第一反应也是可以接受的。
  • 如果 k 特别大(远大于每条链表的长度),有没有更好的做法? 分治法在这种场景下优势更明显,因为它是”多条短链表两两合并”,递归的层数是 log k,堆解法的堆大小恒为 k,当 k 很大时堆的调整开销(log k)仍然存在,但分治法可以更好地利用局部性;实际数量级差异不大时两者选择不影响整体复杂度量级。

关联题