跳到主要內容

演算法

同一個問題,不同的解題思路

主題 · 考題:合併 K 個排序串列

考題:合併 K 個排序串列

一個一個合併是 O(kN);用堆積每次挑最小的開頭,或兩兩分治合併,都降到 O(N log k)。

解法
4
4
題目
把 k 個排好序的串列合併成一個排好序的串列。例如 [1,4,5]、[1,3,4]、[2,6] → [1,1,2,3,4,4,5,6]。N 是全部值的個數。
1/18
#0
  1. 8
  2. 22
  3. 33
  4. 92
#1
  1. 7
  2. 23
  3. 50
  4. 97
#2
  1. 36
  2. 62
  3. 69
  4. 76
#3
  1. 52
  2. 73
  3. 74
  4. 87
結果
    堆積(由小到大):第 #1 #0 #2 #3 個串列
    下一個還沒讀的值堆積頂端剛取出

    把 4 個串列的第一個值放進最小堆積。

    同一份輸入,所有解法
    解法答案比較次數額外記憶體Big O
    一個一個合併16 個,已排序31複製 16 個O(kN)
    堆積挑最小的開頭16 個,已排序38堆積 4 個O(N log k)
    兩兩分治合併16 個,已排序28複製 16 個O(N log k)
    亮起來的是這一步執行的程式碼
    function mergeTwo(a: number[], b: number[]): number[] {
    const out: number[] = [];
    let i = 0, j = 0;
    while (i < a.length && j < b.length) {
    if (b[j] < a[i]) out.push(b[j++]);
    else out.push(a[i++]);
    }
    return out.concat(a.slice(i), b.slice(j));
    }
    // O(kN): the merged list is re-read every time another one is added.
    function mergeOneByOne(lists: number[][]): number[] {
    let acc: number[] = [];
    for (const list of lists) acc = mergeTwo(acc, list);
    return acc;
    }
    // O(N log k): a min-heap holds each list's next value.
    function mergeHeap(lists: number[][]): number[] {
    const heap: [number, number, number][] = []; // [value, list, index]
    const less = (x: number[], y: number[]) => x[0] < y[0] || (x[0] === y[0] && x[1] < y[1]);
    const push = (e: [number, number, number]) => {
    heap.push(e);
    for (let i = heap.length - 1; i > 0 && less(heap[i], heap[(i - 1) >> 1]); i = (i - 1) >> 1) {
    [heap[i], heap[(i - 1) >> 1]] = [heap[(i - 1) >> 1], heap[i]];
    }
    };
    const pop = (): [number, number, number] => {
    const top = heap[0], last = heap.pop()!;
    if (heap.length) {
    heap[0] = last;
    for (let i = 0; ; ) {
    const l = 2 * i + 1, r = l + 1;
    let m = i;
    if (l < heap.length && less(heap[l], heap[m])) m = l;
    if (r < heap.length && less(heap[r], heap[m])) m = r;
    if (m === i) break;
    [heap[i], heap[m]] = [heap[m], heap[i]];
    i = m;
    }
    }
    return top;
    };
    lists.forEach((list, k) => { if (list.length) push([list[0], k, 0]); });
    const out: number[] = [];
    while (heap.length) {
    const [value, k, i] = pop();
    out.push(value);
    if (i + 1 < lists[k].length) push([lists[k][i + 1], k, i + 1]);
    }
    return out;
    }
    // O(N log k): merge in pairs, halving the number of lists each round.
    function mergeDivide(lists: number[][]): number[] {
    let current = lists;
    while (current.length > 1) {
    const next: number[][] = [];
    for (let i = 0; i < current.length; i += 2) {
    next.push(i + 1 < current.length ? mergeTwo(current[i], current[i + 1]) : current[i]);
    }
    current = next;
    }
    return current[0] ?? [];
    }

    模型假設與範圍

    • Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。

    什麼時候用

    • 面試先說「兩兩合併的函式」怎麼寫(合併兩個排序串列是基本功),再指出一個一個合併會一直重讀前面的結果,改用堆積挑最小的開頭。
    • 不想寫堆積時,兩兩分治合併一樣是 O(N log k),而且只用到合併兩個串列的函式。

    和其他主題的關係

    時間與空間複雜度(Big O)

    操作平均最差
    一個一個合併
    第 i 次合併要重讀前面 i 個串列
    O(kN)O(kN)
    堆積挑最小的開頭
    堆積裡永遠只有 k 個
    O(N log k)O(N log k)
    兩兩分治合併
    log k 輪,每輪每個值讀一次
    O(N log k)O(N log k)

    空間:O(1) / O(k) / O(1),串列重新串接、不複製時;陣列版本另外要 O(N)

    Big O 實測:n 變大時步數怎麼長

    數的是:比較次數(n 是串列數 k,每串 50 個值)

    Big On = 4n = 16n = 64成長倍數:實測(理論)
    一個一個合併O(n²)4456,642101,206×227 (×256)
    堆積挑最小的開頭O(n log n)4824,40929,448×61 (×48)
    兩兩分治合併O(n log n)3963,15919,059×48 (×48)

    每串長度固定時,N 和 k 成正比,所以 O(kN) 看起來是 k²,O(N log k) 是 k log k。

    和其他做法比

    k = 4(N = 200)k = 16(N = 800)k = 64(N = 3,200)
    一個一個合併4456,642101,206
    堆積挑最小的開頭4824,40929,448
    兩兩分治合併3963,15919,059

    每個串列 50 個值,數比較次數。k 從 4 變成 64(16 倍),一個一個合併變成 227 倍——前面合好的部分每加一個串列就重讀一次;堆積和兩兩合併都只變成大約 48 倍。

    真實世界裡的它

    • LeetCode 23(合併 K 個排序串列);外部排序:資料大到記憶體放不下時,先切成排序好的小檔,再做 k 路合併。
    • 資料庫的 LSM tree(Log-Structured Merge tree)合併(compaction)、搜尋引擎合併多個分片的結果,都是同一招。

    取捨與陷阱

    • Python 的 heapq 遇到相同的值會去比下一個欄位:放 (值, 串列編號, 索引) 而不是節點物件,否則會比較節點而出錯。
    • 空串列要先跳過,不然一開始就會對空串列取第一個值。
    • O(kN) 和 O(N log k) 在 k 很小時差不多;差距是 k 變大時才出現(見下表)。

    LeetCode 練習