Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

排序與搜尋

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

越過漸近複雜度的地平線,從決策樹下界、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 共讀助教正在陪你讀:排序與搜尋(進階):為什麼世界上最快的排序不是任何單一演算法
嗨!我是這篇文章的共讀助教,只根據〈排序與搜尋(進階):為什麼世界上最快的排序不是任何單一演算法〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。