Learning Objectives
- 寫出計數排序,說明它的時間是 O(n + k),以及它適用的資料(非負整數、值的範圍小)
- 說明基數排序從個位數排到最高位,每一輪都必須用穩定的排序
- 用「n! 種排列、每次比較只能分成兩邊」說明比較排序至少要約 n log n 次比較
- 使用 Python、C++、Java 的內建排序依多個鍵排序,並寫出自訂比較
- 分辨哪些內建排序保證穩定,需要時選對函數
只靠比較,最快能多快
第 06~08 課的排序(選擇、插入、氣泡、合併、快速)有一個共同點:它們只靠「比較兩個元素誰大」來決定順序。 最快的合併排序是 O(n log n)。有沒有可能更快?
這一課分成三部分:
- 不用比較的排序:如果資料是範圍不大的整數,例如 0~100 分的成績,可以直接數每個分數有幾個人,O(n + k) 就排好(k 是值的範圍)
- 比較排序的極限:只靠比較的話,任何方法在最壞情況下都至少要比較約 n log₂n 次,合併排序已經達到這個等級
- 用好內建排序:實際寫程式時幾乎都用語言內建的排序,重點是會依多個條件排序、寫對比較函數,以及知道它穩不穩定
計數排序:先數一數,再依序寫回去
假設要排序的都是 0~5 的整數。準備一個長度 6 的陣列 count,count[v] 記錄「值 v 出現幾次」:
- 掃一遍資料,每看到一個 x,就把
count[x]加 1 - 從 v = 0 到 5,值 v 出現幾次,就依序寫幾個 v 到輸出
整個過程沒有任何兩個元素互相比較。第 1 步掃 n 個元素,第 2 步走過 k 個可能的值、總共寫出 n 個元素,所以是 O(n + k),額外空間也是 O(n + k)。
在動畫裡,count 陣列下方的索引就是「值」,格子裡是「出現次數」。
計數排序:count 陣列怎麼變
前半段每讀一個數,count 對應的格子就加 1(黃色);讀完時 count 是 [1, 2, 1, 2, 1, 1]:0 出現 1 次、1 出現 2 次……後半段 v 從 0 走到 5,依次數把值寫進 out。
計數排序的限制
計數排序很快,但有明確的使用條件:
| 條件 | 原因 |
|---|---|
| 值必須是整數(或能對應成整數,例如字母 a~z) | 要當作 count 的索引 |
| 值的範圍 k 不能太大 | count 要開 k 格。排序 10 個介於 0~10 億的數,要開 10 億格,比資料本身大得多 |
| 負數要先位移 | 例如範圍 -50~50,用 count[x + 50] |
適合的例子:考試成績(0~100)、年齡、年份、某一位數字(0~9)。不適合的例子:任意的浮點數、很長的字串、範圍很大的身分證字號。
上面的寫法直接寫出「值」,只適合排序整數本身。如果要排序的是帶有其他資料的紀錄(例如依分數排序學生),
要改用累加計數:把 count 累加起來,count[d] 就變成「鍵 ≤ d 的共有幾個」,也就知道鍵為 d 的最後一筆該放在哪個位置。
從資料的最後一筆往前放,鍵相同的紀錄會維持原本的先後,所以這個寫法是穩定的。下一個範例就是這種寫法。
基數排序:一位數一位數排
三位數的範圍是 0~999,count 要開 1,000 格。換個想法:每次只看一位數(0~9,只要 10 格),從個位數排起:
- 依個位數排序
- 依十位數排序
- 依百位數排序
排完百位數,整個陣列就排好了。這叫做 LSD 基數排序(least significant digit first,從最低位開始的基數排序)。
關鍵在於每一輪都必須是穩定的排序。例如 329 和 355 的百位數都是 3,排百位數時兩者「平手」; 穩定的排序會保留上一輪(依十位數)排出的先後,329 在 355 前面,結果才正確。
取出某一位數:x // exp % 10(C++、Java 寫 x / exp % 10),exp 依序是 1、10、100。
d 位數的資料要排 d 輪,每輪 O(n + 10),總共 O(d × n)。
基數排序:三輪穩定的計數排序
每一輪分三段:數出這一位數是 0~9 的各有幾個;把 count 累加;再從 a 的最後一個往前,依 count 放進 out。看第一輪:個位數都是 7 的 457 和 657,放的時候先放後面的 657 到較後的位置,所以 out 裡 457 仍在 657 前面。
想一想:計數與基數排序
計數排序排序 n 個介於 0~k-1 的整數,時間複雜度是多少?
下面的程式會印出什麼?
基數排序先依個位數、再依十位數排序。如果每一輪改用不穩定的排序,會發生什麼事?
比較排序的下限:為什麼不可能比 n log n 更快
計數排序之所以能突破 O(n log n),是因為它直接用值當索引,不是靠比較。只靠比較的排序,有一個無法突破的下限,可以這樣理解:
- n 個不同的元素,有 n! 種可能的排列順序(3 個元素有 3 × 2 × 1 = 6 種)。排序演算法必須能分辨出輸入是哪一種,因為每一種需要不同的搬動方式。
- 每比較一次(
a < b嗎?),只能得到「是」或「否」,把可能性分成兩邊。 - 把所有可能的比較過程畫成一棵樹(決策樹):每個內部節點是一次比較,往左是「是」、往右是「否」,每片葉子是一種確定的排列。 比較 k 次之後,最多只能分出 2ᵏ 種不同的結果。
- 要分辨 n! 種排列,至少需要 2ᵏ ≥ n!,也就是最壞情況下至少要比較約 log₂(n!) 次(決策樹的高度)。
log₂(n!) 和 n log₂n 是同一個等級(n! 大約是 n 個「接近 n 的數」相乘),所以任何比較排序的最壞情況都至少是 Ω(n log n)。 這裡的 Ω 表示「至少是這個等級」。合併排序最壞是 O(n log n),已經達到這個下限,在「只靠比較」的前提下不可能有等級上更好的方法。
下面的程式對 n = 1~10 算出「2ᵏ 第一次超過 n! 的 k」,也就是最壞情況至少要比較幾次。
數一數:分辨 n! 種排列至少要比幾次
每換一個 n,power 從 1 開始一直乘 2,直到不小於 n!。n = 3 時有 6 種排列,比較 2 次只能分出 4 種,不夠,所以至少要 3 次。n 從 5 到 10,次數從 7 增加到 22,每多一個元素大約多 log₂n 次(n 在 8 附近時 log₂n 約是 3),n 個元素累積起來就是 n log n 的等級。
用好內建排序:依多個條件排序
實際寫程式時,幾乎都直接用語言內建的排序,它們都是 O(n log n),而且經過大量最佳化。要學的是怎麼告訴它依什麼排。
Python:用 key 指定「每個元素拿什麼來比」;回傳元組(tuple)就會依序比較每個欄位。數字欄位要由大到小時加負號:
students.sort(key=lambda s: (-s[1], s[0])) # 分數由高到低,同分依名字
C++:傳入比較函數(常用 lambda),x 應該排在 y 前面時回傳 true。一定要用嚴格的 < 或 >,不能用 <=,否則 std::sort 的行為未定義:
sort(v.begin(), v.end(), [](const Student& x, const Student& y) {
if (x.score != y.score) return x.score > y.score; // 分數高的在前
return x.name < y.name; // 同分依名字
});
Java:傳入 Comparator,回傳負數表示 x 在前、0 表示相等、正數表示 y 在前。用 Integer.compare 而不是 x - y,避免相減時溢位:
list.sort((x, y) -> {
if (x.score() != y.score()) return Integer.compare(y.score(), x.score());
return x.name().compareTo(y.name());
});
下面的範例用整數示範:先只依個位數排序,再加上「個位數相同時比數字本身」的第二個條件。
自訂排序:個位數與穩定性
第一次只看個位數。42 和 22 的個位數都是 2,排序後 42 仍在 22 前面,因為原本就是 42 在前,而這三種寫法都是穩定的排序。第二次加上「個位數相同時比數字本身」,22 就排到 42 前面了。Python 的 sorted 排好才指定給 a,所以排序途中 a 不變;C++ 的排序直接在 a 裡交換,可以看到途中的變化。
哪些內建排序是穩定的
| 語言 | 寫法 | 穩定嗎 |
|---|---|---|
| Python | list.sort()、sorted() |
保證穩定(Timsort,合併排序的變形) |
| Java | Arrays.sort(物件陣列)、List.sort、Collections.sort |
保證穩定 |
| Java | Arrays.sort(int[]) 等基本型別 |
基本型別相等就是一樣的值,穩定與否看不出差別 |
| C++ | std::sort |
不保證穩定 |
| C++ | std::stable_sort |
保證穩定 |
在 C++ 裡,需要「相同鍵維持原本順序」時要用 stable_sort。std::sort 在資料少時常常剛好維持原順序,但標準沒有保證,資料一多就可能打亂,這類錯誤很難發現。
穩定排序有一個實用的技巧:依多個條件排序時,可以從次要條件排到主要條件。 例如先依名字排一次,再依分數(由高到低)穩定地排一次,結果就是「分數高的在前,同分依名字」,和用元組或比較函數一次排好相同:
students.sort(key=lambda s: s[0]) # 次要條件:名字
students.sort(key=lambda s: s[1], reverse=True) # 主要條件:分數(穩定,同分保留名字的順序)
這和基數排序先排個位、再排十位是同一個道理。
想一想:下限與穩定性
4 個不同的元素,為什麼任何比較排序在最壞情況下都至少要比較 5 次?
下面的程式會印出什麼?
在 C++ 裡,要依成績排序學生,而且同分的學生必須維持原本的名單順序,應該用哪一個?
實作:成績排名
五位學生的名字與分數。請排序成「分數由高到低;同分時依名字的字母順序」,再依序印出。預期輸出在右邊;可以切換成你熟悉的語言。
重點整理
- 計數排序:數出每個值出現幾次再依序寫回,O(n + k);只適用於整數且範圍 k 不大
- 用累加計數並從後往前放,計數排序就是穩定的,可以排序帶有其他資料的紀錄
- LSD 基數排序:從最低位排到最高位,每一輪用穩定的計數排序,d 位數共 O(d × n)
- 比較排序的下限:n! 種排列、每次比較只能分成兩邊,所以最壞至少要約 log₂(n!) 次,也就是 Ω(n log n);合併排序已經達到這個等級
- 內建排序:Python 用
key(元組可依多個欄位)、C++ 用回傳 bool 的比較函數(嚴格的<)、Java 用Comparator(Integer.compare) - Python 的排序、Java 的物件排序保證穩定;C++ 的
std::sort不保證,要穩定請用std::stable_sort
下一課:堆疊與佇列。從排序換到資料結構,看「後進先出」和「先進先出」兩種存取規則能解決哪些問題。