編碼 · 演算法
快速排序法教學:原理、程式碼與時間複雜度
簡答
快速排序法是一種分而治之的排序演算法。它挑選一個元素作為樞紐(pivot),重新排列陣列,使較小的元素排在樞紐前面、較大的排在後面(稱為分割),再遞迴地排序兩側。平均時間為 O(n log n),以 O(log n) 的堆疊空間原地排序,但樞紐持續選得不好時,會退化成 O(n²)。它不是穩定排序。搭配隨機或三數取中的樞紐,它是實務上最快的通用排序法之一。
開啟行排序用白話說明原理
- 選一個樞紐元素,例如最後一個元素,或隨機一個。
- 分割:把每個比樞紐小的元素移到它的左邊,每個比樞紐大的元素移到它的右邊。這時樞紐已在排序後的最終位置。
- 遞迴地排序樞紐左邊的部分與右邊的部分。之後不需要再合併任何東西。
合併排序是分割容易、合併困難。快速排序恰好相反:由分割負責主要工作,而把兩半接起來是免費的,因為它們已經在同一個陣列的正確位置上。
範例:一次 Lomuto 分割
以最後一個元素 70 為樞紐,分割 [10, 80, 30, 90, 40, 50, 70]。索引 i 標示小於樞紐的元素區域的終點;j 由左到右掃描。
| j | items[j] | 小於 70? | 動作 | 之後的陣列 |
|---|---|---|---|---|
| 0 | 10 | 是 | i = 0,交換 0↔0 | [10, 80, 30, 90, 40, 50, 70] |
| 1 | 80 | 否 | 無 | [10, 80, 30, 90, 40, 50, 70] |
| 2 | 30 | 是 | i = 1,交換 1↔2 | [10, 30, 80, 90, 40, 50, 70] |
| 3 | 90 | 否 | 無 | [10, 30, 80, 90, 40, 50, 70] |
| 4 | 40 | 是 | i = 2,交換 2↔4 | [10, 30, 40, 90, 80, 50, 70] |
| 5 | 50 | 是 | i = 3,交換 3↔5 | [10, 30, 40, 50, 80, 90, 70] |
| 結束 | 把樞紐放到 i + 1 = 4 | [10, 30, 40, 50, 70, 90, 80] |
70 現在位於索引 4,也就是它的最終位置。快速排序接著分別排序 [10, 30, 40, 50] 與 [90, 80]。
Python 實作
import random
def partition(a, lo, hi):
"""Lomuto partition of a[lo..hi] around a random pivot."""
r = random.randint(lo, hi)
a[r], a[hi] = a[hi], a[r] # move the pivot to the end
pivot = a[hi]
i = lo - 1
for j in range(lo, hi):
if a[j] < pivot:
i += 1
a[i], a[j] = a[j], a[i]
a[i + 1], a[hi] = a[hi], a[i + 1]
return i + 1
def quicksort(a, lo=0, hi=None):
"""Sort list a in place."""
if hi is None:
hi = len(a) - 1
while lo < hi:
p = partition(a, lo, hi)
if p - lo < hi - p: # recurse on the smaller side
quicksort(a, lo, p - 1)
lo = p + 1
else:
quicksort(a, p + 1, hi)
hi = p - 1
return a
print(quicksort([10, 80, 30, 90, 40, 50, 70])) # [10, 30, 40, 50, 70, 80, 90]
print(quicksort([3, 3, 3, 1])) # [1, 3, 3, 3]對較小的一側遞迴、對較大的一側用迴圈,即使在最差情況下,堆疊深度也維持在 O(log n)。為了說明概念,一個簡短但非原地的版本很受歡迎,不過它在每一層都會配置新的串列:
def quicksort_simple(items):
if len(items) <= 1:
return items
pivot = items[len(items) // 2]
less = [x for x in items if x < pivot]
equal = [x for x in items if x == pivot]
greater = [x for x in items if x > pivot]
return quicksort_simple(less) + equal + quicksort_simple(greater)
print(quicksort_simple([5, 2, 9, 2, 7])) # [2, 2, 5, 7, 9]時間與空間複雜度
| 情況 | 時間 | 原因 |
|---|---|---|
| 最佳 | O(n log n) | 每個樞紐都把範圍對半分 |
| 平均 | O(n log n) | 隨機樞紐能得到夠平衡的分割,約 1.39 n log₂ n 次比較 |
| 最差 | O(n²) | 每個樞紐都是最小或最大的元素 |
| 額外空間 | O(log n) | 對較小一側遞迴時的遞迴堆疊 |
經典的最差情況,是對已排序或反向排序的輸入,使用固定選第一個或最後一個元素的樞紐:每次分割只去掉樞紐,形成 n 層、每層 O(n) 的工作量。隨機樞紐讓任何輸入都極不可能發生這種情況。
快速排序與其他排序法的比較
| 演算法 | 最佳 | 平均 | 最差 | 額外空間 | 穩定 |
|---|---|---|---|---|---|
| 快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n) | 否 |
| 合併排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 是 |
| 堆積排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 否 |
| 插入排序 | O(n) | O(n²) | O(n²) | O(1) | 是 |
| 泡沫排序 | O(n) | O(n²) | O(n²) | O(1) | 是 |
| 選擇排序 | O(n²) | O(n²) | O(n²) | O(1) | 否 |
Timsort(Python 的 sorted) | O(n) | O(n log n) | O(n log n) | O(n) | 是 |
快速排序不是穩定的:分割時的交換可能讓元素越過與它相等的元素。它在記憶體中的陣列上通常勝過合併排序與堆積排序,因為內層迴圈很簡單、原地運作,而且大多依序存取記憶體。內省排序(introsort)被許多 C++ 標準函式庫的 std::sort 實作採用,它先用快速排序,遞迴太深時改用堆積排序,因此保證 O(n log n)。
樞紐選擇、變體與常見錯誤
- 樞紐選擇:第一個或最後一個元素(簡單,但對已排序的輸入是 O(n²))、隨機元素,或三數取中(第一個、中間、最後一個)。
- Hoare 分割:兩個索引相向移動,交換位於錯誤一側的成對元素。平均來說交換次數比 Lomuto 少。
- 三向分割(荷蘭國旗問題):分成小於、等於、大於三部分。它讓含有大量重複鍵值的陣列也能很快;單純的 Lomuto 在所有鍵值都相等時會退化為 O(n²)。
- 雙樞紐快速排序使用兩個樞紐與三個部分;Java 用它來排序基本型別的陣列。
- 快速選擇(quickselect)只用一次分割,並只遞迴進入其中一側,以平均 O(n) 的時間找出第 k 小的元素。
何時使用快速排序
在不需要穩定性時,快速排序是排序記憶體中陣列的良好預設選擇。在 Python 中請改用 sorted() 或 list.sort();它們使用的 Timsort 是穩定的,而且對部分已排序的資料很快。實作快速排序是非常常見的面試題,常接著問最差情況、樞紐選擇、穩定性,或用快速選擇找出第 k 大的元素。
常見問題
快速排序是穩定的嗎?
不是。分割會讓元素做長距離的交換,所以相等的元素可能改變相對順序。穩定的版本存在,但需要額外記憶體。
快速排序的最差情況是什麼?
O(n²),發生在每個樞紐都是其範圍中的最小值或最大值時,例如對已排序的輸入使用選最後一個元素的樞紐。實務上,隨機或三數取中的樞紐可避開這種情況。
為什麼快速排序在實務上很快?
它原地排序,內層迴圈只是簡單的比較與交換,而且依序掃描記憶體,能充分利用 CPU 快取。它平均的常數係數比堆積排序小,也不像合併排序那樣需要緩衝區。
快速排序和合併排序有什麼不同?
快速排序先分割、不需要合併;合併排序先切開,主要工作在合併。快速排序是原地的,但最差情況是 O(n²),且不穩定;合併排序是穩定的,永遠是 O(n log n),並需要 O(n) 的額外空間。
快速排序用多少記憶體?
它是原地排序。遞迴堆疊平均為 O(log n);如果總是先對較小的一側遞迴,最差情況也是 O(log n)。