目录

题目描述

23. 合并 K 个升序链表

image-20230305162555007

题意分析

输入是一个链表数组,数组里每条链表本身已经按升序排好,要求把它们合成一条升序链表并返回新的头节点。

把链表条数固定为 2,这就是第 21 题「合并两个有序链表」;本题是它从两条到 k 条的规模化版本,难度也从简单跳到了困难。多出来的那一层不在「怎么合并两条」,而在「同一时刻有 k 个来源,每一步该从谁那里取」——规模化把一个比较问题升级成了调度问题。

「每条链表本身已经升序」是全题唯一的题设条件,也是它区别于普通排序的地方。忽略这句话,把所有值收集起来排一遍序当然也能得到答案,但那意味着重新比较了大量早就确定了相对顺序的元素——题目被标为困难,考的正是怎么把这份已有的有序性用掉。

返回值是节点指针,题目也没要求保留输入结构,因此可以直接重接输入节点的指针,不必新建节点。这决定了合并过程的额外空间可以做到与节点总数无关。

需要单独想清楚的边界有四种:数组本身为空(长度 0),此时答案是空链表;数组非空但某些元素是空链表,它们不能参与后续比较,却也不能让流程崩掉;数组只有一个元素,答案就是它本身;所有元素都是空链表,答案仍是空。这四种里前两种最常被漏掉。

解法:最小堆多路归并

核心思路

每条链表当前未合并的头节点都是候选,所有剩余节点的最小值一定在这些候选中。用最小堆维护最多 k 个候选:每次取出最小节点接到结果末尾,再把它的后继加入堆。

解题步骤

  • 将每条非空链表的头节点加入最小堆。
  • 使用哨兵节点和尾指针维护结果链表。
  • 反复弹出堆顶节点接到结果末尾;若该节点有后继,将后继加入堆。
  • 堆为空时,返回哨兵节点的后继。

代码实现

class Solution {
    public ListNode mergeKLists(ListNode[] lists) {
        PriorityQueue<ListNode> heap =
                new PriorityQueue<>((a, b) -> Integer.compare(a.val, b.val));

        for (ListNode node : lists) {
            if (node != null) {
                heap.offer(node);
            }
        }

        ListNode dummy = new ListNode(0);
        ListNode tail = dummy;
        while (!heap.isEmpty()) {
            ListNode node = heap.poll();
            tail.next = node;
            tail = node;

            if (node.next != null) {
                heap.offer(node.next);
            }
        }
        return dummy.next;
    }
}
import "container/heap"

type nodeHeap []*ListNode

func (h nodeHeap) Len() int           { return len(h) }
func (h nodeHeap) Less(i, j int) bool { return h[i].Val < h[j].Val }
func (h nodeHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }

func (h *nodeHeap) Push(value any) {
    *h = append(*h, value.(*ListNode))
}

func (h *nodeHeap) Pop() any {
    old := *h
    last := len(old) - 1
    node := old[last]
    old[last] = nil
    *h = old[:last]
    return node
}

func mergeKLists(lists []*ListNode) *ListNode {
    minHeap := &nodeHeap{}
    heap.Init(minHeap)
    for _, node := range lists {
        if node != nil {
            heap.Push(minHeap, node)
        }
    }

    dummy := &ListNode{}
    tail := dummy
    for minHeap.Len() > 0 {
        node := heap.Pop(minHeap).(*ListNode)
        tail.Next = node
        tail = node

        if node.Next != nil {
            heap.Push(minHeap, node.Next)
        }
    }
    return dummy.Next
}

复杂度分析

  • 时间复杂度:$O(n \log k)$,n 为节点总数,堆中最多有 k 个节点。
  • 空间复杂度:$O(k)$,用于保存每条链表的当前候选节点。

关键点总结

  • 堆中每条非空链表最多保留一个候选节点。
  • 弹出节点后必须加入它的后继,才能继续归并该链表。
  • 直接复用原链表节点,只修改 next 指针,不需要复制节点。
  • 比较器使用 Integer.compare,避免相减造成整数溢出。

易错点总结

  • 将空链表放入 Java 的优先队列会抛出异常。
  • 弹出节点后忘记把后继入堆,会丢失该链表剩余部分。
  • 一次把所有节点放入堆会把空间从 $O(k)$ 扩大到 $O(n)$。
  • 比较器写成 a.val - b.val 可能发生整数溢出。

相似题目

题目 难度 考察点
21. 合并两个有序链表 简单 只有两条链,是本题的归并原语,不涉及候选集调度与合并顺序
88. 合并两个有序数组 简单 载体换成数组,因为能随机访问,可以从后往前填,做到不占额外空间
148. 排序链表 中等 对单条无序链表做归并排序,多了「快慢指针找中点并断链」这一步
373. 查找和最小的 K 对数字 中等 候选来自两个数组的下标组合而非链表,入堆时要去重,避免同一对被压两次
632. 最小区间 困难 同样多路推进 k 条有序表,但要维护候选集内的最大值,求覆盖所有表的最小区间
LCR 078. 合并 K 个升序链表 困难 与本题同题换皮,可原样套用堆或分治两种写法
剑指 Offer 25. 合并两个排序的链表 简单 与 21 同题换皮,常被额外要求给出递归版的两路归并
滴滴面试题-合并 k 个排序数组 中等 数据源换成数组,堆里必须存「数组下标 + 元素下标」二元组,节点不自带后继指针