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
這招俗稱雙指標——這裡的「指標」指的是 i、j 這兩個「正在看第幾格」的索引(競程慣用語),不是 11.2 的指標型態。效率很漂亮:每一步都有一個元素進入 c,總共 n 個元素就是 O(n)——合併是便宜的。
¶讓排好的段落越變越長
合併零件有個前提:兩邊都要先排好。但手上是一堆亂序資料,哪來「已經排好」的兩疊?
關鍵在換個角度看:單獨一個元素,本身就是排好的——只有一個數,不可能亂。於是可以像滾雪球一樣做下去:
- 一開始,把數列看成 n 段,每段長度 1。每段都是排好的。
- 把相鄰的兩段合併起來,得到一堆長度 2 的段。每段還是排好的。
- 再把相鄰的兩段合併,段長變成 4,然後 8、16……
- 段長蓋過整個數列時,全部只剩一段——那就是排序完成的數列。
每一輪的段長都是上一輪的兩倍,而每一輪都只用到那個已經寫好的合併零件。拿 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;
}
執行結果(輸入 6 與 6 3 2 5 4 1):
1 2 3 4 5 6
中間那一大段就是前面的合併零件,只是換了名字、換了範圍:i、j 改叫 start1、start2,處理的不再是整個陣列而是其中一段([low, mid) 與 [mid, high)),結果寫進 b 的第 k 格。至於 b[k++] = a[start1++]; 這種寫法,意思是「先用現在的 k 和 start1,用完各自加一」,攤開來就是零件裡那三行 c[k] = a[i]; i++; k++;。
剩下幾個地方值得一句話:
seg += seg就是seg = seg * 2——段長每輪加倍。迴圈條件寫seg < n:段長已經蓋過整個數列,就沒有第二段可以合併了。start += seg + seg:一次跳過剛剛合併掉的那兩段,直接站到下一組的開頭。- 兩個
min是防越界的。最後一組常常不完整:段長 2、n = 6 時,start會走到 4,這時mid與high都被壓成 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) | 這一節的合併排序 | 內建 sort(11.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:第一輪它沒有對象,被原封搬了過去,第二輪才被併進來。