bigywater.tech
算法与数据结构 · LeetCode 23

LeetCode 23. 合并 K 个升序链表

给定 k 个升序链表,把它们合并成一个升序链表。最直接的想法是依次把每条链表合并到结果里,但结果越来越长,前面的结点会被反复比较。常见的更好做法有两种:用最小堆每次取出 k 个链表头里最小的;或者分治,两两合并再逐轮合并。本文选分治,因为它只需要上一题的"合并两个链表",五种语言都不需要另写堆。用真实数据比较顺序合并和分治合并,给出五种语言的实现,并把合并的轮次做成分步演示。

5,000
组随机对拍,与暴力解的结果全部一致
5 步
分步演示,每一帧都来自解法的真实执行,另附 5 种语言实现

题意与示例

完整题目见官方页面(链接在文末)。链表在测试里用整数数组表示,输入是若干个数组,结果是一个数组。基准解把所有元素拉平后直接排序,与链表合并是两种独立的写法。一共 10 组用例,覆盖:官方示例、没有链表、只有一条空链表、全是空链表、只有一条链表、多条相同的链表、空链表夹在中间:

真实实测 · 本机执行输出
输入 [[[1,4,5],[1,3,4],[2,6]]]
输出 [1,1,2,3,4,4,5,6]
输入 [[]]
输出 []
输入 [[[]]]
输出 []
输入 [[[],[]]]
输出 []
输入 [[[1]]]
输出 [1]
输入 [[[1],[1],[1]]]
输出 [1,1,1]

顺序合并 vs 分治合并

顺序合并是把第 1 条并入结果、再把第 2 条并入结果……每次合并的长度是已经合并好的部分加上新的一条,总共约 k²·m/2 次比较(m 是每条的长度)。分治合并每一轮总的处理量是全部结点数 N,一共约 log k 轮,总共 N log k。下面每条链表 20 个结点,k 从 50 增加到 3200,每个规模取三次中最快的一次,两种写法结果一致:

真实实测 · 本机执行输出
每条链表 20 个结点,k 条链表:逐条顺序合并 vs 分治合并(每个规模取三次中最快)
k=   50: 顺序合并      0.86 ms    分治合并     0.27 ms    结果一致=True
k=  200: 顺序合并     13.00 ms    分治合并     1.54 ms    结果一致=True
k=  800: 顺序合并    198.71 ms    分治合并     7.35 ms    结果一致=True
k= 3200: 顺序合并   3799.00 ms    分治合并    40.44 ms    结果一致=True

k 从 800 到 3200(4 倍)时,顺序合并的耗时增长约 19 倍,接近平方增长(16 倍);分治合并增长约 5.5 倍,略高于线性(4 倍),符合 N log k。k = 3200 时,顺序合并用了 3.8 秒,分治合并只用了 40ms,约 94 倍。

分治:五种语言的实现

merge(lo, hi) 返回第 lo 到第 hi 条链表合并后的结果:只有一条就直接返回;否则合并左半和右半各自的结果,再把这两个结果合并。合并两条链表就是上一题的做法。没有链表时 lo > hi,返回空链表。Python、Java、Go、JavaScript、C++ 都是真实编译运行的。

Python · 解法代码class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next def merge_two(a, b): dummy = tail = ListNode() while a and b: if a.val <= b.val: tail.next, a = a, a.next else: tail.next, b = b, b.next tail = tail.next tail.next = a or b return dummy.next def merge_k_lists(lists): def merge(lo, hi): # 分治:先各自合并左半和右半,再合并这两个结果 if lo > hi: return None if lo == hi: return lists[lo] mid = (lo + hi) // 2 return merge_two(merge(lo, mid), merge(mid + 1, hi)) return merge(0, len(lists) - 1)
真实实测 · Python · 前几组用例的输出
$ python3 solution.py   # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON
[[[1,4,5],[1,3,4],[2,6]]]  →  [1,1,2,3,4,4,5,6]
[[]]  →  []
[[[]]]  →  []
[[[],[]]]  →  []
[[[1]]]  →  [1]
[[[1],[1],[1]]]  →  [1,1,1]
Java · 解法代码class ListNode { int val; ListNode next; ListNode(int v) { val = v; } } class Solution { public ListNode mergeKLists(ListNode[] lists) { return merge(lists, 0, lists.length - 1); } private ListNode merge(ListNode[] lists, int lo, int hi) { // 分治:先各自合并左半和右半,再合并这两个结果 if (lo > hi) return null; if (lo == hi) return lists[lo]; int mid = (lo + hi) >>> 1; return mergeTwo(merge(lists, lo, mid), merge(lists, mid + 1, hi)); } private 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; } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl
[[[1,4,5],[1,3,4],[2,6]]]  →  [1,1,2,3,4,4,5,6]
[[]]  →  []
[[[]]]  →  []
[[[],[]]]  →  []
[[[1]]]  →  [1]
[[[1],[1],[1]]]  →  [1,1,1]
Go · 解法代码type ListNode struct { Val int Next *ListNode } func mergeTwo(a, b *ListNode) *ListNode { dummy := &ListNode{} tail := dummy for a != nil && b != nil { if a.Val <= b.Val { tail.Next, a = a, a.Next } else { tail.Next, b = b, b.Next } tail = tail.Next } if a != nil { tail.Next = a } else { tail.Next = b } return dummy.Next } func mergeKLists(lists []*ListNode) *ListNode { var merge func(lo, hi int) *ListNode // 分治:先各自合并左半和右半,再合并这两个结果 merge = func(lo, hi int) *ListNode { if lo > hi { return nil } if lo == hi { return lists[lo] } mid := (lo + hi) / 2 return mergeTwo(merge(lo, mid), merge(mid+1, hi)) } return merge(0, len(lists)-1) }
真实实测 · Go · 前几组用例的输出
$ go build -o bin . && ./bin < cases.jsonl
[[[1,4,5],[1,3,4],[2,6]]]  →  [1,1,2,3,4,4,5,6]
[[]]  →  []
[[[]]]  →  []
[[[],[]]]  →  []
[[[1]]]  →  [1]
[[[1],[1],[1]]]  →  [1,1,1]
JavaScript · 解法代码class ListNode { constructor(val = 0, next = null) { this.val = val; this.next = next; } } function mergeTwo(a, b) { const dummy = new ListNode(); let tail = dummy; while (a && b) { if (a.val <= b.val) { tail.next = a; a = a.next; } else { tail.next = b; b = b.next; } tail = tail.next; } tail.next = a || b; return dummy.next; } function mergeKLists(lists) { const merge = (lo, hi) => { // 分治:先各自合并左半和右半,再合并这两个结果 if (lo > hi) return null; if (lo === hi) return lists[lo]; const mid = (lo + hi) >> 1; return mergeTwo(merge(lo, mid), merge(mid + 1, hi)); }; return merge(0, lists.length - 1); }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js < cases.jsonl
[[[1,4,5],[1,3,4],[2,6]]]  →  [1,1,2,3,4,4,5,6]
[[]]  →  []
[[[]]]  →  []
[[[],[]]]  →  []
[[[1]]]  →  [1]
[[[1],[1],[1]]]  →  [1,1,1]
C++ · 解法代码#include <vector> using namespace std; struct ListNode { int val; ListNode* next; ListNode(int v) : val(v), next(nullptr) {} }; class Solution { ListNode* mergeTwo(ListNode* a, ListNode* b) { ListNode dummy(0); ListNode* tail = &dummy; while (a && b) { if (a->val <= b->val) { tail->next = a; a = a->next; } else { tail->next = b; b = b->next; } tail = tail->next; } tail->next = a ? a : b; return dummy.next; } ListNode* merge(vector<ListNode*>& lists, int lo, int hi) { // 分治:先各自合并左半和右半,再合并这两个结果 if (lo > hi) return nullptr; if (lo == hi) return lists[lo]; int mid = (lo + hi) / 2; return mergeTwo(merge(lists, lo, mid), merge(lists, mid + 1, hi)); } public: ListNode* mergeKLists(vector<ListNode*>& lists) { return merge(lists, 0, (int)lists.size() - 1); } };
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl
[[[1,4,5],[1,3,4],[2,6]]]  →  [1,1,2,3,4,4,5,6]
[[]]  →  []
[[[]]]  →  []
[[[],[]]]  →  []
[[[1]]]  →  [1]
[[[1],[1],[1]]]  →  [1,1,1]
真实实测 · 本机执行输出
共 10 组用例(含边界),以暴力解的结果为基准逐组比对:
  Python      通过 10/10
  Java        通过 10/10
  Go          通过 10/10
  JavaScript  通过 10/10
  C++         通过 10/10

复杂度实测

每条链表 20 个结点,k 分别为 100、1000、5000、20000,链表是交错的(第 i 条是 i、i+k、i+2k……):

真实实测 · 本机执行输出
链表条数 k(每条 20 个结点) | 暴力解 | 本文解法
       100 |     0.139 ms | 0.717 ms
      1000 |     1.004 ms | 10.176 ms
      5000 |     5.356 ms | 58.133 ms
     20000 |    20.711 ms | 395.029 ms

本文解法从 5000 到 20000(4 倍)耗时增长约 6.8 倍,符合 N log k 略高于线性的预期。这里"暴力解"是 Python 内置的 sorted,是 C 实现,而且能识别已有序的片段,所以更快;同时 solve 的耗时里还包含把数组建成链表、再把结果转回数组的开销,所以这个对比不能说明链表合并慢,只能说明本文解法的增长趋势。

随机对拍

Python 版本另外和"拉平后排序"做了随机对拍:

真实实测 · 本机执行输出
随机对拍 5000 组(0 到 9 条链表、每条长度 0 到 6、取值 -10 到 10(包含空链表、没有链表、大量重复值)),solve 与 brute 的结果全部一致。

另外四种语言只在上面 10 组固定用例上验证过,没有做大规模随机对拍。

分步演示:两两合并、逐轮减半

下面的演示用 4 条链表 [1, 4, 5]、[1, 3, 4]、[2, 6]、[0, 7]。每一步都真实调用了上面的合并函数,运行器还检查了最终结果与 solve 的返回值一致。第 1 轮把 4 条两两合并成 2 条,第 2 轮再合并成 1 条。

合并 K 个升序链表:[[1, 4, 5], [1, 3, 4], [2, 6], [0, 7]]

    参考与说明

    • 官方题目页:LeetCode 23. 合并 K 个升序链表。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚;耗时对比只用 Python 测量。
    • 没有覆盖:最小堆(优先队列)的做法,以及它与分治的实际耗时对比;演示里的分治是按"每一轮两两配对"的方式演示,代码里是递归的按区间二分,两者合并的结果相同,但合并的顺序不同。
    京ICP备2026067502号-1京公网安备11010502063487号