語法書 / AA 競程語法書 下冊 / 第十一單元 / 合併排序法(延伸知識)

11.12 合併排序法(延伸知識)

泡沫排序 O(n^2)、計數排序挑食——那 sort 那種「不挑值域、又只要 O(n \log n)」的排序法長什麼樣?經典代表是合併排序法(Merge Sort)。它由一個核心零件加上一層外圈迴圈組成,兩樣你現在都學得會。

核心零件:合併兩個已排序數列

問題:手上有兩疊已經排好的數列,要把它們合成一疊排好的。方法就像兩隊人馬各自按身高排好、要合併成一隊:看兩隊的排頭,矮的先進隊伍,重複到有一隊走光,另一隊剩下的直接接上:

#include <iostream>
using namespace std;

int main() {
    int a[4] = {1, 3, 5, 9};       // 已排序
    int b[3] = {2, 3, 8};          // 已排序
    int c[7];                      // 合併結果

    int i = 0, j = 0, k = 0;       // i 看 a、j 看 b、k 寫 c
    while (i < 4 && j < 3) {
        if (a[i] <= b[j]) {        // 兩隊排頭比一比,小的先走
            c[k] = a[i];
            i++;
        } else {
            c[k] = b[j];
            j++;
        }
        k++;
    }
    while (i < 4) { c[k] = a[i]; i++; k++; }   // a 還有剩:全部搬過去
    while (j < 3) { c[k] = b[j]; j++; k++; }   // b 還有剩:全部搬過去

    for (int t = 0; t < 7; t++) {
        cout << c[t] << ' ';
    }
    cout << '\n';
    return 0;
}

執行結果:

1 2 3 3 5 8 9

這招俗稱雙指標——這裡的「指標」指的是 ij 這兩個「正在看第幾格」的索引(競程慣用語),不是 11.2 的指標型態。效率很漂亮:每一步都有一個元素進入 c,總共 n 個元素就是 O(n)——合併是便宜的

讓排好的段落越變越長

合併零件有個前提:兩邊都要先排好。但手上是一堆亂序資料,哪來「已經排好」的兩疊?

關鍵在換個角度看:單獨一個元素,本身就是排好的——只有一個數,不可能亂。於是可以像滾雪球一樣做下去:

  1. 一開始,把數列看成 n 段,每段長度 1。每段都是排好的。
  2. 相鄰的兩段合併起來,得到一堆長度 2 的段。每段還是排好的。
  3. 再把相鄰的兩段合併,段長變成 4,然後 816……
  4. 段長蓋過整個數列時,全部只剩一段——那就是排序完成的數列。

每一輪的段長都是上一輪的兩倍,而每一輪都只用到那個已經寫好的合併零件。拿 6, 3, 2, 5, 4, 1 跑一次:

目前段長 這一輪合併誰跟誰 這一輪結束後的數列
開始 每個數自成一段 [6]\ [3]\ [2]\ [5]\ [4]\ [1]
1 [6][3][2][5][4][1] [3\ 6]\ [2\ 5]\ [1\ 4]
2 [3\ 6][2\ 5][1\ 4] 沒有對象 [2\ 3\ 5\ 6]\ [1\ 4]
4 [2\ 3\ 5\ 6][1\ 4] [1\ 2\ 3\ 4\ 5\ 6]

6 個數只花了 3 輪。注意第 2 輪那個沒有對象的段落:它就原封不動留在原位,等下一輪再找到對象——這種「湊不成一對」的情況在程式裡會自動處理好,等一下看程式碼時會再回來看它。

完整程式碼

#include <iostream>
#include <algorithm>   // min 住在這(上冊 7.6)
using namespace std;

const int MAX_N = 200000;
int a[MAX_N], b[MAX_N];   // a:目前的資料;b:這一輪合併出來的結果

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n; i++) {
        cin >> a[i];
    }

    // seg=目前每段的長度:1、2、4、8……直到蓋過整個數列
    for (int seg = 1; seg < n; seg += seg) {
        // 每次處理相鄰的兩段,start 是左段的開頭
        for (int start = 0; start < n; start += seg + seg) {
            int low = start;                        // 左段開頭
            int mid = min(start + seg, n);          // 左段結尾=右段開頭
            int high = min(start + seg + seg, n);   // 右段結尾
            int k = low;                            // 結果要寫進 b 的第幾格

            int start1 = low, end1 = mid;     // 左段還沒搬走的範圍
            int start2 = mid, end2 = high;    // 右段還沒搬走的範圍
            while (start1 < end1 && start2 < end2) {
                if (a[start1] <= a[start2]) {      // 兩段的排頭比一比,小的先走
                    b[k++] = a[start1++];
                } else {
                    b[k++] = a[start2++];
                }
            }
            while (start1 < end1) b[k++] = a[start1++];   // 左段還有剩:全部搬過去
            while (start2 < end2) b[k++] = a[start2++];   // 右段還有剩:全部搬過去
        }

        for (int i = 0; i < n; i++) {   // 這一輪的成果搬回 a,下一輪繼續用
            a[i] = b[i];
        }
    }

    for (int i = 0; i < n; i++) {
        cout << a[i] << ' ';
    }
    cout << '\n';
    return 0;
}

執行結果(輸入 66 3 2 5 4 1):

1 2 3 4 5 6

中間那一大段就是前面的合併零件,只是換了名字、換了範圍:ij 改叫 start1start2,處理的不再是整個陣列而是其中一段([low, mid)[mid, high)),結果寫進 b 的第 k 格。至於 b[k++] = a[start1++]; 這種寫法,意思是「先用現在的 kstart1,用完各自加一」,攤開來就是零件裡那三行 c[k] = a[i]; i++; k++;

剩下幾個地方值得一句話:

  • seg += seg 就是 seg = seg * 2——段長每輪加倍。迴圈條件寫 seg < n:段長已經蓋過整個數列,就沒有第二段可以合併了。
  • start += seg + seg :一次跳過剛剛合併掉的那兩段,直接站到下一組的開頭。
  • 兩個 min 是防越界的。最後一組常常不完整:段長 2n = 6 時,start 會走到 4,這時 midhigh 都被壓成 6,右段變成空的——第一個 while 一次都不會跑,第二個 while[1, 4] 原封搬進 b。上面表格裡那個「沒有對象的段落」,程式就是這樣自動處理掉的。
  • 為什麼要 b 陣列? 因為合併時左段的元素還沒搬走,若直接寫回 a,會蓋掉自己等一下要讀的資料。所以先寫進 b,一輪結束再整批搬回 a

有多快?

每一輪裡,所有段落合起來剛好把 n 個元素各搬一次,所以一輪是 O(n)。而段長從 1 開始加倍——1, 2, 4, 8, \ldots——要幾輪才蓋過 n?就是「2 要乘幾次才超過 n」,答案是 \log_2 n 輪(n = 10^6 大約 20 輪)。兩者相乘,總運算量 O(n \log n),跟內建 sort 同級。

同一台機器、同樣的測法(11.4 那張表的來源),三種排序法擺在一起:

資料量 n 泡沫排序(11.10 這一節的合併排序 內建 sort11.4
10^5 9.9 0.008 0.005
10^6 不用試了 0.09 0.06

自己手寫的版本比內建 sort 慢一點點——那是 sort 額外的工程優化,量級完全相同;而跟泡沫排序的差距是一千倍以上O(n^2)O(n \log n) 的差別長什麼樣,這張表講得比任何說明都清楚。

動手試試看:在「搬回 a」那個迴圈後面加一行輸出,把整個陣列印出來,拿 6 3 2 5 4 1 跑一次,對照上面那張表格——你會親眼看到段落一輪一輪變長。接著把輸入換成 7 個數 6 3 2 5 4 1 7,觀察那個落單的 7:第一輪它沒有對象,被原封搬了過去,第二輪才被併進來。