Hot 100 --- 合并 K 个升序链表

本文概览:本文以LeetCode经典题目"合并 K 个升序链表"为例,从顺序合并的暴力思路入手,分析为什么越早参与合并的节点会被重复遍历,再引出分治合并,将时间复杂度从 O(Nk) 优化到 O(N log k)


一、题目

二、题目分析

给定一个链表数组 lists,其中每个链表都已经按照升序排列,要求将这 k 个升序链表合并成一个新的升序链表

目标:合并多个有序链表,并返回合并后的链表头节点

核心特点:每个链表本身已经有序,所以问题不是排序每个链表,而是如何高效地把多个有序链表合并起来

这道题和上一题"排序链表"很像:

  • 排序链表:原链表无序,需要先拆分,再排序合并
  • 合并 K 个升序链表:每条链表已经有序,只需要考虑怎么高效合并

最终都离不开一个基础操作:合并两个有序链表

思路概览

Java实现代码如下

Java 复制代码
public ListNode mergeKLists(ListNode[] lists) {
    // 递归找到数组的中间节点
    if (lists == null || lists.length == 0)
        return null;
    return mergeArray(lists, 0, lists.length - 1);
}

private ListNode mergeArray(ListNode[] lists, int left, int right) {
    if (left == right)
        return lists[left];
    // 找到中间节点
    int mid = left + (right - left) / 2;
    // 递归合并左半部分的链表
    ListNode leftNode = mergeArray(lists, left, mid);
    // 递归合并右半部分的链表
    ListNode rightNode = mergeArray(lists, mid + 1, right);
    // 合并左半部分和右半部分的链表
    return mergeTwoLists(leftNode, rightNode);
}

private ListNode mergeTwoLists(ListNode left, ListNode right) {
    ListNode dummy = new ListNode(0);
    dummy.next = left;
    // 记录当前节点的位置
    ListNode cur = dummy;
    while (left != null && right != null) {
        // 比较两个节点的值
        if (left.val <= right.val) {
            // 将当前节点指向左节点
            cur.next = left;
            // 左节点指针向右移动
            left = left.next;
        } else {
            // 将当前节点指向右节点
            cur.next = right;
            // 右节点指针向右移动
            right = right.next;
        }
        cur = cur.next;
    }
    cur.next = left != null ? left : right;
    return dummy.next;
}

思路简要说明

  1. 分治拆分链表数组 :将 lists 按中点分成左右两半,递归处理

  2. 递归合并左右结果:左半部分合并成一个有序链表,右半部分合并成一个有序链表

  3. 合并两个有序链表 :用和"排序链表"中一样的 mergeTwoLists 方法,将左右两个有序链表合并成最终链表

三、思路详解

暴力解法入手:顺序合并

最直观的做法是:遍历链表数组,每次拿当前结果链表和下一个链表进行合并

比如有 4 个链表:

复制代码
L1, L2, L3, L4

顺序合并就是:

复制代码
先合并:merge(L1, L2) → M12
再合并:merge(M12, L3) → M123
再合并:merge(M123, L4) → M1234

这个思路很简单,因为我们已经会合并两个有序链表了。问题在于:越早参与合并的节点,会被反复遍历很多次

假设每条链表长度都为 n,一共有 k 条链表:

  • 第一次合并:L1 + L2,需要遍历 2n 个节点
  • 第二次合并:M12 + L3,需要遍历 3n 个节点
  • 第三次合并:M123 + L4,需要遍历 4n 个节点
  • ...
  • 最后一次合并:需要遍历 kn 个节点

总操作次数大约是:

复制代码
2n + 3n + 4n + ... + kn
= n × (2 + 3 + 4 + ... + k)
≈ O(n × k²)

如果总节点数记为 N = n × k,那么复杂度就是 O(Nk)

核心瓶颈 :越早加入结果链表的节点,被重复遍历的次数越多。比如 L1 中的节点,从第一次合并开始,每一轮都会参与后续合并,重复参与了很多次

关键思考:能不能让每个链表参与合并的次数更平均,而不是让前面的链表被反复合并?

分治优化思路

顺序合并的问题在于合并过程太"偏":一直拿已经合并好的大链表去合并新的小链表,导致大链表越来越大,重复遍历越来越多

更合理的方式是:像归并排序一样,采用分治

分治的思路是:

  1. k 个链表从中间分成左右两半
  2. 左半部分合并成一个有序链表
  3. 右半部分合并成一个有序链表
  4. 最后再把左右两个有序链表合并

也就是:

复制代码
L1, L2, L3, L4, L5, L6, L7, L8

先分成两半:
[L1, L2, L3, L4]   [L5, L6, L7, L8]

继续分:
[L1, L2] [L3, L4] [L5, L6] [L7, L8]

继续分到单个链表:
L1 L2 L3 L4 L5 L6 L7 L8

开始合并:
merge(L1,L2), merge(L3,L4), merge(L5,L6), merge(L7,L8)

再合并:
merge(M12,M34), merge(M56,M78)

最后合并:
merge(M1234,M5678)

这个过程可以看成一棵二叉树:

复制代码
                 merge全部
              /            \
        merge左半          merge右半
        /      \           /      \
   merge12   merge34   merge56   merge78
    /  \      /  \      /  \      /  \
   L1  L2    L3  L4    L5  L6    L7  L8

二叉树高度是 log₂k,也就是说每个节点最多只会参与 log₂k 层合并,而不是像顺序合并那样,前面的节点可能参与接近 k 次合并

为什么分治后复杂度是 O(N log k)?

设所有链表的节点总数为 N,链表数量为 k

在分治合并中:

  • 每一层合并,所有节点总共只会被遍历一次,所以每层代价是 O(N)
  • 一共有 log₂k 层合并

所以总时间复杂度是:

复制代码
O(N) × O(log k) = O(N log k)

这比顺序合并的 O(Nk) 好很多,尤其当链表数量 k 很大时,差距非常明显

举个简单对比:

  • 顺序合并:节点可能被重复遍历接近 k 次
  • 分治合并:节点只会参与 log₂k 层合并

k = 1024 时:

  • 顺序合并最多接近 1024 层重复合并
  • 分治合并只有 log₂1024 = 10 层

这就是分治优化的核心收益

代码一:递归拆分链表数组

主函数只做两件事:

  1. 处理空数组
  2. 调用递归函数合并整个数组范围
Java 复制代码
public ListNode mergeKLists(ListNode[] lists) {
    if (lists == null || lists.length == 0)
        return null;
    return mergeArray(lists, 0, lists.length - 1);
}

mergeArray(lists, left, right) 表示:把 lists[left..right] 范围内的所有链表合并成一个有序链表

Java 复制代码
private ListNode mergeArray(ListNode[] lists, int left, int right) {
    if (left == right)
        return lists[left];
    int mid = left + (right - left) / 2;
    ListNode leftNode = mergeArray(lists, left, mid);
    ListNode rightNode = mergeArray(lists, mid + 1, right);
    return mergeTwoLists(leftNode, rightNode);
}

这里的逻辑非常像"排序链表":

  • left == right:说明当前范围只有一个链表,它本身已经有序,直接返回
  • 找中点 mid
  • 递归合并左半部分
  • 递归合并右半部分
  • 最后合并左右两个有序链表

代码二:合并两个有序链表

合并两个有序链表的代码和上一题排序链表中的合并逻辑是一样的

Java 复制代码
private ListNode mergeTwoLists(ListNode left, ListNode right) {
    ListNode dummy = new ListNode(0);
    dummy.next = left;
    ListNode cur = dummy;
    while (left != null && right != null) {
        if (left.val <= right.val) {
            cur.next = left;
            left = left.next;
        } else {
            cur.next = right;
            right = right.next;
        }
        cur = cur.next;
    }
    cur.next = left != null ? left : right;
    return dummy.next;
}

核心思路是:

  • leftright 分别指向两个有序链表当前节点
  • 每次比较 left.valright.val
  • 谁更小,就把谁接到结果链表后面
  • 被接上的链表指针向后移动
  • 最后把剩余链表直接接到结果后面

这里用了 dummy 哑节点,是为了简化头节点处理。cur 始终指向结果链表的尾节点

举例:

复制代码
left:  1 → 4 → 5
right: 1 → 3 → 4

合并过程:

步骤 left right 选择 结果链表
1 1 1 left的1 1
2 4 1 right的1 1 → 1
3 4 3 right的3 1 → 1 → 3
4 4 4 left的4 1 → 1 → 3 → 4
5 5 4 right的4 1 → 1 → 3 → 4 → 4
6 5 null 接上left剩余 1 → 1 → 3 → 4 → 4 → 5

完整分治流程示例

假设有 4 个链表:

复制代码
L1: 1 → 4 → 5
L2: 1 → 3 → 4
L3: 2 → 6
L4: 0 → 7

分治过程如下:

复制代码
mergeArray(0,3)
├── mergeArray(0,1)
│   ├── L1
│   ├── L2
│   └── merge(L1,L2) → 1→1→3→4→4→5
├── mergeArray(2,3)
│   ├── L3
│   ├── L4
│   └── merge(L3,L4) → 0→2→6→7
└── merge(左半结果, 右半结果)
    → 0→1→1→2→3→4→4→5→6→7

可以看到,分治不是从左到右一个一个合并,而是先让相邻链表两两合并,再逐层往上合并。这样每一层处理的总节点数都是 N,层数是 log k

四、复杂度分析

设:

  • k 为链表数量
  • N 为所有链表节点总数

时间复杂度:O(N log k)

每一层合并会遍历所有节点一次,总代价 O(N);分治树高度为 O(log k),所以总时间复杂度为 O(N log k)

空间复杂度:O(log k)

递归调用栈深度是 log k。如果不考虑递归栈,合并链表本身只使用常数指针变量

五、总结

这道题的关键是:不要顺序合并,否则越早加入的链表会被反复遍历,时间复杂度会退化到 O(Nk)

分治优化的核心是:

  1. 把链表数组不断从中间拆分
  2. 拆到单个链表时直接返回
  3. 左右两边分别合并成有序链表
  4. 最后用合并两个有序链表的方法合并左右结果

本题本质上和"排序链表"一样,都依赖归并思想,只不过:

  • 排序链表是先拆一个无序链表,再合并排序
  • 合并 K 个升序链表是拆链表数组,再合并多个已经有序的链表