cv.cm

編碼 · 演算法

快速排序法教學:原理、程式碼與時間複雜度

閱讀約 7 分鐘

簡答

快速排序法是一種分而治之的排序演算法。它挑選一個元素作為樞紐(pivot),重新排列陣列,使較小的元素排在樞紐前面、較大的排在後面(稱為分割),再遞迴地排序兩側。平均時間為 O(n log n),以 O(log n) 的堆疊空間原地排序,但樞紐持續選得不好時,會退化成 O(n²)。它不是穩定排序。搭配隨機或三數取中的樞紐,它是實務上最快的通用排序法之一。

開啟行排序

用白話說明原理

  1. 選一個樞紐元素,例如最後一個元素,或隨機一個。
  2. 分割:把每個比樞紐小的元素移到它的左邊,每個比樞紐大的元素移到它的右邊。這時樞紐已在排序後的最終位置。
  3. 遞迴地排序樞紐左邊的部分與右邊的部分。之後不需要再合併任何東西。

合併排序是分割容易、合併困難。快速排序恰好相反:由分割負責主要工作,而把兩半接起來是免費的,因為它們已經在同一個陣列的正確位置上。

範例:一次 Lomuto 分割

以最後一個元素 70 為樞紐,分割 [10, 80, 30, 90, 40, 50, 70]。索引 i 標示小於樞紐的元素區域的終點;j 由左到右掃描。

jitems[j]小於 70?動作之後的陣列
010是i = 0,交換 0↔0[10, 80, 30, 90, 40, 50, 70]
180否無[10, 80, 30, 90, 40, 50, 70]
230是i = 1,交換 1↔2[10, 30, 80, 90, 40, 50, 70]
390否無[10, 30, 80, 90, 40, 50, 70]
440是i = 2,交換 2↔4[10, 30, 40, 90, 80, 50, 70]
550是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)。

全部編碼教學