Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

5

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 26 · 臺南 AQI 21 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

排序與搜尋

排序與搜尋(進階):為什麼世界上最快的排序不是任何單一演算法

越過漸近複雜度的地平線,從決策樹下界、Timsort、快取階層到 B 樹與 learned index,看真實系統如何在理論保證與硬體現實之間握手

為什麼世界上最快的排序,不是任何單一演算法?

如果你讀過入門篇,你已經知道在字典裡查單字,用「對半翻」的二分搜尋(binary search)比一頁頁翻快得多;也知道把資料排好序之後,搜尋會變得輕鬆。那是漂亮的世界觀,但它有一個隱藏的前提:我們假設所有比較的成本一樣、資料剛好放得進記憶體、而且我們只在乎「比較幾次」。

真實世界沒有這麼仁慈。當你在 Python 裡呼叫 sorted()、在 C++ 裡呼叫 std::sort()、或在 Java 裡呼叫 Arrays.sort(),背後執行的都不是課本上那個單一演算法。它們是混血兒——Timsort、introsort、dual-pivot quicksort——每一個都是工程師為了對抗真實資料的「壞脾氣」而拼裝出來的。

這篇文章要帶你越過入門篇的地平線:我們不再問「哪個演算法比較快」,而是問「快」到底是什麼意思,以及為什麼一個理論上完美的演算法,在真實 CPU 上可能輸給一個「看起來比較笨」的對手。

排序與搜尋進階概念示意圖

比較排序的下界:為什麼沒有人能突破 $O(n \log n)$

入門篇告訴你合併排序(merge sort)與快速排序(quick sort)都是 $O(n \log n)$。但你有沒有想過一個更深的問題:有沒有可能發明一個比 $O(n \log n)$ 更快的比較排序?

答案是不可能,而且我們可以證明它。這是計算機科學少數能給出「理論天花板」的優美時刻。

想像排序的過程,本質上是在一棵「決策樹」(decision tree)上走路。每一次「比較兩個元素」,就是一個分岔:左邊代表 $a < b$,右邊代表 $a > b$。對 $n$ 個元素來說,可能的排列順序共有 $n!$ 種,而每一種正確的輸出都必須對應到決策樹的一片葉子——因為演算法必須能區分這 $n!$ 種情況。

一棵高度為 $h$ 的二元樹,最多有 $2^h$ 片葉子。要容納 $n!$ 片葉子,必須滿足:

$$2^h \geq n! \quad\Longrightarrow\quad h \geq \log_2(n!)$$

而樹的高度 $h$ 就是「最壞情況下要做幾次比較」。利用 Stirling 近似(Stirling's approximation):

$$\log_2(n!) \approx n \log_2 n - n \log_2 e = \Theta(n \log n)$$

所以任何以「兩兩比較」為基礎的排序,最壞情況都至少要做 $\Omega(n \log n)$ 次比較。Timsort 再聰明、quicksort 再快,都逃不出這個牢籠。

動手算一下

你可能會說:「等等,那計數排序(counting sort)、基數排序(radix sort)不是號稱 $O(n)$ 嗎?這不是打臉了嗎?」

沒有打臉。關鍵在於:這些演算法不靠比較。 計數排序直接用元素的「值」當陣列索引,跳過了比較這個動作,所以不受 $\Omega(n \log n)$ 下界約束。代價是它需要知道值域範圍,且只適用於整數或可映射到整數的鍵。

def counting_sort(arr, max_val):
    """對 0..max_val 範圍內的整數排序,時間 O(n + max_val)"""
    count = [0] * (max_val + 1)
    for x in arr:           # 統計每個值出現幾次(不做任何比較!)
        count[x] += 1
    result = []
    for value, freq in enumerate(count):
        result.extend([value] * freq)
    return result

print(counting_sort([3, 1, 4, 1, 5, 9, 2, 6, 5, 3], 9))
# [1, 1, 2, 3, 3, 4, 5, 5, 6, 9]

注意這裡沒有任何一行在比較兩個元素的大小——它是在「數數」。這就是為什麼下界不適用。但若值域 max_val 大到 $10^{18}$,這個方法會瞬間崩潰,記憶體爆炸。理論的優美與工程的現實,永遠在拉扯。

真實世界的排序:Timsort 為什麼贏

理論說 $O(n \log n)$ 是天花板,但天花板下還有很大的高度差。Python 的 list.sort() 用的是 Timsort,由 Tim Peters 在 2002 年設計,後來被 Java、Android、Rust(早期)採用。它的核心洞見是一句樸素的觀察:

真實世界的資料幾乎從不是隨機的——它常常「部分已排序」。

你的銷售紀錄大致按時間遞增、你的日誌檔案大多有序、使用者名單往往一段一段有規律。Timsort 專門剝削這種「殘餘秩序」。

它的做法是先掃描陣列,找出已經天然遞增(或遞減)的連續區段,稱為 run。如果一段資料本來就排好了,Timsort 直接拿來用,一次比較都不浪費。然後它用一套精心設計的合併策略把這些 run 併起來。

def find_runs(arr):
    """示意:把陣列切成已經有序的 run(簡化版,未含反轉遞減段)"""
    runs = []
    i = 0
    n = len(arr)
    while i < n:
        start = i
        i += 1
        while i < n and arr[i] >= arr[i - 1]:  # 持續遞增就延長這個 run
            i += 1
        runs.append(arr[start:i])
    return runs

print(find_runs([1, 3, 5, 2, 4, 6, 8, 7]))
# [[1, 3, 5], [2, 4, 6, 8], [7]]  ← 三個天然有序段

對「幾乎已排序」的資料,Timsort 可以達到接近 $O(n)$ 的最佳情況——遠快於每次都假設最壞的標準合併排序。這就是為什麼「最壞情況複雜度相同」的兩個演算法,在真實負載下表現可能天差地遠。入門篇談的是漸近複雜度(asymptotic complexity),這裡談的是常數因子與資料分布——那才是工程師每天真正在搏鬥的戰場。

看一個例子

讓我們用實驗感受「資料分布」的威力。對同樣大小、但「已排序程度」不同的資料排序:

import random, time

def timed_sort(data):
    t = time.perf_counter()
    sorted(data)
    return (time.perf_counter() - t) * 1000  # 毫秒

n = 2_000_000
random_data = [random.random() for _ in range(n)]
sorted_data = sorted(random_data)              # 完全有序
nearly = sorted_data[:]                         # 幾乎有序:只打亂 0.1%
for _ in range(n // 1000):
    i, j = random.randrange(n), random.randrange(n)
    nearly[i], nearly[j] = nearly[j], nearly[i]

print(f"隨機資料:    {timed_sort(random_data):.1f} ms")
print(f"幾乎已排序:  {timed_sort(nearly):.1f} ms")
print(f"完全已排序:  {timed_sort(sorted_data):.1f} ms")

在多數機器上,你會看到「完全已排序」比「隨機」快好幾倍。同一個 sorted()、同樣的 $n$,但結果差異來自資料的內在結構,而非演算法的漸近上限。這是入門篇的 big-O 視角看不見的真相。

隱藏的成本:記憶體階層與快取

現在來談一件大學課堂常常略過、但決定真實效能的事:現代 CPU 存取記憶體的速度,差距大到驚人。

從 CPU 暫存器拿一筆資料約 1 個週期;從 L1 快取約 4 個週期;從主記憶體(RAM)卻要 200 個週期以上——慢了五十倍。這意味著演算法跑得快不快,常常不取決於「做幾次運算」,而取決於「它讀記憶體的方式對快取友不友善」。

這解釋了一個違反直覺的現象:理論上較慢的演算法,有時跑得比較快。

考慮快速排序 vs. 合併排序。兩者都是 $O(n \log n)$,但快速排序通常在實務上更快,部分原因是它就地(in-place)操作,存取的記憶體位置集中、連續,對快取友善(cache-friendly)。合併排序則需要額外的輔助陣列,資料在記憶體中跳來跳去,快取命中率(cache hit rate)較差。

# 概念示意:快速排序的「分區」是連續掃描,對快取友善
def quicksort_partition(arr, low, high):
    pivot = arr[high]
    i = low - 1
    for j in range(low, high):          # 從左到右「連續」掃描 → 快取喜歡這樣
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1

這也是為什麼 C++ 的 std::sort 採用 introsort:它平時跑快速排序(快取友善、常數小),但會監測遞迴深度,一旦發現快速排序退化到 $O(n^2)$ 的危險(例如遇到惡意建構的輸入),就切換成堆積排序(heapsort)保底,鎖死最壞情況在 $O(n \log n)$。這是「實務速度」與「理論保證」的精妙妥協——兩個世界的握手。

搜尋的進階:當「排好序再二分」不夠用

入門篇的二分搜尋很美,但它假設資料是靜態的、放在連續陣列裡。真實系統很少這麼單純。如果資料不斷被新增、刪除,每次插入都要維持排序,移動陣列元素的成本是 $O(n)$,這會拖垮整個系統。

於是我們需要自平衡搜尋樹(如紅黑樹,red-black tree)或雜湊表(hash table)。但這裡我想介紹一個更貼近資料庫底層、卻常被大學課程忽略的結構:B 樹(B-tree)

為什麼資料庫(MySQL、PostgreSQL)的索引幾乎都用 B 樹,而不是二元搜尋樹?答案又回到記憶體階層。當資料大到放不進 RAM、必須存在磁碟(disk)上時,每一次磁碟讀取的成本是天文數字——比 RAM 慢上萬倍。

二元搜尋樹每個節點只有 2 個分支,搜尋 $n$ 筆資料需要 $\log_2 n$ 次「跳節點」,每跳一次可能就是一次磁碟讀取。但 B 樹的每個節點可以有幾百個分支,於是樹變得又寬又矮,搜尋只需 $\log_{几百} n$ 次跳躍。對 10 億筆資料,二元樹要約 30 次磁碟讀取,B 樹可能只要 3 到 4 次。

class BTreeNode:
    """B 樹節點:一個節點裝「很多」個鍵,刻意配合磁碟分頁大小"""
    def __init__(self, leaf=True):
        self.keys = []        # 一個節點存數百個鍵 → 樹變矮 → 磁碟讀取次數少
        self.children = []    # 分支數 = len(keys) + 1
        self.leaf = leaf

    def search(self, key):
        i = 0
        while i < len(self.keys) and key > self.keys[i]:
            i += 1
        if i < len(self.keys) and self.keys[i] == key:
            return self          # 找到了
        if self.leaf:
            return None          # 到葉子還沒找到 → 不存在
        return self.children[i].search(key)  # 往下一層(可能觸發一次磁碟讀取)

關鍵設計哲學:B 樹節點的大小,刻意對齊磁碟的分頁大小(page size,通常 4KB 或 8KB)。 每讀一次磁碟,就把一整頁、塞滿數百個鍵的節點一次抓進來,最大化每次昂貴 I/O 的價值。這是「演算法設計必須臣服於硬體現實」的最佳教材——入門篇的 big-O 計數完全看不到 I/O 這個維度。

動手算一下:分支數如何壓垮樹高

假設我們要搜尋 10 億($10^9$)筆資料:

  • 二元搜尋樹:樹高 $\approx \log_2(10^9) \approx 30$,最壞 30 次磁碟讀取。
  • B 樹(每節點 256 個分支):樹高 $\approx \log_{256}(10^9) = \frac{\log_2 10^9}{\log_2 256} = \frac{30}{8} \approx 3.75$,約 4 次磁碟讀取。
import math
for branching in [2, 16, 256]:
    height = math.log(10**9, branching)
    print(f"分支數 {branching:>4}:樹高約 {height:.1f} 層 → 約 {math.ceil(height)} 次磁碟讀取")
# 分支數    2:樹高約 29.9 層 → 約 30 次磁碟讀取
# 分支數   16:樹高約  7.5 層 → 約  8 次磁碟讀取
# 分支數  256:樹高約  3.7 層 → 約  4 次磁碟讀取

從 30 次降到 4 次——這 7 倍的差距,就是你每天用 Google 或刷社群媒體時,背後讓查詢「秒回」的物理原因之一。 演算法的勝負,最終常常是在「貼著硬體設計」的層次上決定的。

重點回顧

  • 比較排序有理論天花板:任何靠兩兩比較的排序,最壞情況都不可能突破 $\Omega(n \log n)$,這由決策樹高度與 $n!$ 種排列嚴格證明。要更快,必須跳出「比較」框架(如計數排序、基數排序),但得付出值域與記憶體的代價。
  • 漸近複雜度不等於真實速度:兩個都是 $O(n \log n)$ 的演算法,因常數因子、資料分布、快取友善度而表現懸殊。Timsort 剝削「部分已排序」可達近 $O(n)$。
  • 記憶體階層是隱形主宰:RAM 比快取慢 50 倍、磁碟比 RAM 慢上萬倍。快取友善的快速排序常勝過理論相當的合併排序;introsort 兼顧速度與保底。
  • 動態資料與大資料需要不同結構:靜態陣列適合二分搜尋,但頻繁增刪需要平衡樹;資料大到上磁碟時,B 樹用「寬而矮」把昂貴的磁碟 I/O 次數壓到極低。
  • 好的工程師同時看兩層:理論(big-O 保證正確性與上界)與硬體(快取、I/O、資料分布決定真實效能)缺一不可。

深入探討(研究所視角)

如果你想再往前走,這裡有幾條通往研究前沿的路徑。

Cache-oblivious 演算法。 我們談的 B 樹是 cache-aware:它必須知道分頁大小才能調參。1999 年 Frigo 等人提出 cache-oblivious 模型——設計不需知道快取參數,卻能在「任意層級的記憶體階層」上自動達到漸近最優 I/O 複雜度的演算法。代表作是 funnel sort 與遞迴式 van Emde Boas 佈局(recursive vEB layout)。其核心是把問題遞迴地切成子問題,使得在每一個未知大小的快取層級上,存取模式都恰好是局部的。這是演算法理論與系統架構交會處最深刻的成果之一。

自適應排序與 entropy 下界。 Timsort「剝削殘餘秩序」可以被嚴格形式化。對於已有 $k$ 個逆序對(inversions)的序列,存在演算法在 $O(n \log(k/n + 1))$ 時間內排好——當 $k$ 很小時趨近 $O(n)$。更一般地,自適應排序(adaptive sorting)以各種「無序度量」(measures of disorder,如 Inv、Runs、Max-Reg)為參數刻畫複雜度。Timsort 本身的最壞情況分析意外地棘手,2015 年研究者才用形式化方法(含 Coq 證明)發現並修補了其合併不變量中的一個邊界錯誤——這是「業界廣泛部署的程式碼,其正確性仍可能藏著數學漏洞」的著名案例。

Learned index structures。 2018 年 Kraska 等人提出一個顛覆性想法:索引的本質是一個函數——給定鍵,預測它在排序陣列中的位置。既然如此,何不用機器學習模型(甚至神經網路)去學習這個累積分布函數(CDF),取代 B 樹?對於分布規律的資料,learned index 可以更小、更快。這條路把「資料結構」與「機器學習」縫在一起,至今仍是資料庫頂會(SIGMOD、VLDB)的活躍題目,也是「搜尋」這個古老問題在 AI 時代的新生。

並行與外部記憶體模型。 當資料大到單機放不下,分析框架要換成 external memory model(I/O 複雜度,以 $B$ 為區塊大小、$M$ 為記憶體大小,排序下界為 $\Theta(\frac{n}{B} \log_{M/B} \frac{n}{B})$)或 PRAM / work-span 模型(並行演算法以 work 與 span 雙參數刻畫)。MapReduce 式的分散式排序、GPU 上的並行基數排序,都是這些理論的工程化身。當你下次看到「排序」兩個字時,記得它早已不是課本裡那個在單一陣列上交換元素的小程式——它是橫跨理論、硬體、分散式系統與機器學習的一整片風景。

AI 共讀助教正在陪你讀:排序與搜尋(進階):為什麼世界上最快的排序不是任何單一演算法
嗨!我是這篇文章的共讀助教,只根據〈排序與搜尋(進階):為什麼世界上最快的排序不是任何單一演算法〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。