← 返回题目列表

如何合并 K 个有序链表?为什么分治比逐个合并快?

高频 中等 第 9 / 23 题 更新于 2026/07/28
合并K个有序链表分治链表归并

简化版

分治两两配对合并:把 K 个有序链表两两合并成 K/2 个,再两两合并成 K/4 个……log K 轮后合成一个。设总节点数为 N,每一轮所有合并加起来都要扫 N 个节点,共 log K 轮,所以是 O(N log K)。而「用一条结果链依次去合并剩下每一条」是 O(N·K),慢得多——因为前面的节点被反复扫描。也可以用最小堆达到同样的 O(N log K)。

详细版

ListNode mergeKLists(ListNode[] lists) {
    if (lists == null || lists.length == 0) return null;
    return merge(lists, 0, lists.length - 1);
}
// 分治:合并 lists[lo..hi]
ListNode merge(ListNode[] lists, int lo, int hi) {
    if (lo == hi) return lists[lo];
    int mid = lo + (hi - lo) / 2;
    ListNode l = merge(lists, lo, mid);       // 左半合成一条
    ListNode r = merge(lists, mid + 1, hi);   // 右半合成一条
    return mergeTwo(l, r);                     // 合并两条有序链表
}
// 合并两个有序链表,O(1) 额外空间
ListNode mergeTwo(ListNode a, ListNode b) {
    ListNode dummy = new ListNode(0), tail = dummy;
    while (a != null && b != null) {
        if (a.val <= b.val) { tail.next = a; a = a.next; }
        else                { tail.next = b; b = b.next; }
        tail = tail.next;
    }
    tail.next = (a != null) ? a : b;          // 接上剩余
    return dummy.next;
}
  • 分治结构:把 K 条链表当成「区间」,递归对半分,底层合并两条。
  • 复杂度:时间 O(N log K),空间 O(log K)(递归栈;mergeTwo 本身 O(1))。
  • 对比:逐个合并 O(N·K);最小堆 O(N log K)。

完整版教学

一、逐个合并为什么慢:重复扫描

最直觉的写法是:拿第 1 条当结果,去和第 2 条合并,再拿结果去和第 3 条合并……

问题出在结果链越来越长,却被一遍遍重新扫描。假设每条链有 n 个节点、共 k 条(总数 N=nk):

  • 合并第 1、2 条:扫 2n
  • 再合并第 3 条:结果已有 2n,扫 3n
  • ……合并第 k 条:扫 kn

总代价 2n+3n+…+kn ≈ O(k²n) = O(k·N)k 越大越亏,因为最早的那批节点被参与了近 k 次合并、反复搬运。

二、分治:两两配对,log K 轮

分治换个合并顺序,避免「一条长链反复被扫」:

第 1 轮:(L1,L2) (L3,L4) (L5,L6) (L7,L8)  →  4 条
第 2 轮:(L12,L34) (L56,L78)              →  2 条
第 3 轮:(L1234,L5678)                    →  1 条

每一轮把链表数量减半,所以只需 log₂K 轮就归成一条。关键差别是:分治里每个节点在每一轮只被搬运一次,不会像逐个合并那样被反复扫描。

三、每轮 O(N)、共 log K 轮 → O(N log K)

算总代价:

  • 每一轮:这一轮的所有两两合并,加起来恰好把全部 N 个节点各处理一次,所以每轮是 O(N)(不管这一轮分成多少组,节点总数就是 N)。
  • 轮数:每轮链表数减半,共 log₂K 轮。
  • 合计O(N) × log K = O(N log K)

递归实现里没有「显式的轮」,但递归树的每一层深度对应一轮,效果一样:树高 log K,每层合并的节点总数 O(N)。

四、合并两个有序链表(O(1) 空间)

分治的底层操作是「合并两条有序链表」,这本身是道基础题:用一个哨兵头结点 dummy 和尾指针 tail,双指针比较两条链的头,把较小的接到 tail 后面,直到一条走完,再把另一条剩余部分整体接上。

链表合并的好处是不需要额外数组——只改指针(tail.next),空间 O(1)。这也是为什么归并思想在链表上特别合适(相比数组归并要 O(n) 辅助空间)。用 a.val <= b.val 取左边可保持稳定(相等节点原相对顺序不变)。

五、堆解法对比

另一种主流解法是最小堆(优先队列):把 K 条链表的头结点都放进一个大小为 K 的小顶堆,每次弹出最小的接到结果后面,再把它的 next 入堆。

  • 每个节点进出堆各一次,堆操作 O(log K),共 N 个节点 → O(N log K),和分治相同。
  • 空间 O(K)(堆里最多 K 个节点)。

取舍:分治不需要额外数据结构、常数略小、递归栈 O(log K);堆写法更直观、且适合数据流式地不断产生新元素的场景。两者渐进复杂度一致,面试都可作为标准答案,能说清「为什么都是 O(N log K)」是重点。

六、递归式、合并证明与数字推演

这道题的分治闭环是:每一轮把链表两两合并,所有节点只被扫描一次,链表数量约减半。递归调用只保证子问题正确,原问题能否正确仍取决于合并步骤是否覆盖所有情况且不重不漏。

总节点 N;每轮 Θ(N),轮数 ceil(log₂K),总 Θ(N log K)
递归树核对:每层子问题数 × 单个子问题的非递归代价

带数字推演:8 条各 100 节点的链表共 800 节点,分治 3 轮,每轮约扫描 800 个节点。推演时应记录每层输入规模、进入哪些子问题、合并新增了什么信息,不能只写最终答案。

记忆钩子:先写“分成什么、递归返回什么、怎样合并”,再列递推式;只会套主定理而说不清合并,说明算法还没有真正掌握。

七、实现代价、退化条件与替代方案

实现边界是:合并两个链表要保存 next 并正确接尾;K=0、空链表和大量空表都要处理。除了渐进时间,还要把递归栈、辅助数组、输入是否被修改以及最坏输入考虑进去。

检查项面试中要回答的内容
基本情况规模 0 或 1 时如何直接返回
规模缩小每次递归是否严格靠近基本情况
合并正确性子解怎样推出原问题答案
资源代价递归深度、辅助结构与数据复制
退化保护随机化、阈值切换、预排序或迭代改写

测试至少覆盖最小规模、奇偶长度、全部相等、严格有序/逆序、极端偏斜划分和会触发最大计数或溢出的数据。若存在更直接的线性算法、堆算法或动态规划,还要说明分治方案的教学价值与工程取舍。

八、常见误区与追问

  • 误区:逐个合并也是 O(N log K)。 逐个加入会让已合并长链反复扫描,均长时可到 O(NK)。
  • 误区:分治需要复制所有节点。 可原地重连 next,辅助递归/轮次空间 O(log K) 或 O(1)。
  • 误区:每轮成本越来越大所以不是 O(N)。 链表数减少、单表变长,但该轮所有节点总数仍是 N。
  • 追问:为什么堆解法也是 N log K? 每个节点进行一次大小至多 K 的入堆/出堆。
  • 追问:什么时候选堆? 输入流式到达或希望随时取当前最小节点时。
  • 追问:稳定性怎样定义? 值相等时固定先取某一输入链,可保持输入内相对顺序。

九、加强记忆

合并 K 个有序链表用分治:两两配对合并,每轮减半,共 log K 轮;每轮把全部 N 个节点各处理一次 O(N) → 总 O(N log K)。它比「一条结果链依次合并剩余每条」的 O(N·K) 快,因为后者让早期节点被反复扫描。底层的「合并两条有序链表」用 dummy 头 + 双指针、只改指针、空间 O(1)。等价的最小堆解法也是 O(N log K)。