搜尋排序演算法

Chapter 5 — Searching, Hashing & Sorting (Interactive Visualization)
循序搜尋|二分搜尋|雜湊|氣泡|選擇|插入|希爾|合併|快速
向下捲動開始互動
CONTENTS · 內容目錄
PROLOGUE · 開場

先看懂:演算法分析的「比較」與「目標」

本章探討兩件事:搜尋(searching) 從一堆資料中找出特定元素,排序(sorting) 把資料重新排列成有序狀態。我們會從 Python in 運算子開始,逐步揭開背後的演算法,並且用「比較次數」當作分析的單位

分析的兩個約定 1. 計算單位 = 比較次數。對搜尋而言,是「拿目標和某個元素比一下」算一次;對排序而言,是「比較兩個元素誰大誰小」算一次。
2. 等機率假設。搜尋成功時,假設目標出現在每個位置的機率都相同;這樣才能合理地討論「平均情況」。

顏色語義(整頁通用)

未處理 正在比較 正在交換 已排序 樞紐值 (pivot) 插入鍵值 (cur_val) 找到目標

每一節都採用相同的版面:左邊是「視覺化畫布」與「控制列」,右邊是「即時統計」、「對應虛擬碼(pseudo-code)」與「複雜度分析」。請大膽地按 ▶ 開始 看完整動畫,或按 → 單步 一格一格觀察。

PART 03 · 雜湊

雜湊:用「函數」一次跳到該去的位置

有沒有可能讓搜尋變成 $O(1)$?只要我們事先決定每個值該存哪裡就行。hash function $h(\text{item})$ 把每個值對應到一個槽(slot)的索引。最簡單的 hash 函數是 remainder method:$h(\text{item}) = \text{item} \bmod m$,其中 $m$ 是表的大小。

範例:m = 11,存入 [54, 26, 93, 17, 77, 31]
$54 \bmod 11 = 10$ $26 \bmod 11 = 4$ $93 \bmod 11 = 5$
$17 \bmod 11 = 6$ $77 \bmod 11 = 0$ $31 \bmod 11 = 9$
全部都落在不同槽,load factor $\lambda = 6/11 \approx 0.55$。

雜湊表 (m = 11)

輸入要插入或搜尋的值
餘數雜湊函數
$h(item) = item \bmod m$
$m = 11$(表大小,建議用質數)
即時統計 LIVE
已用槽數0 / 11
負載因子 λ0.00
本次比較次數0
何謂碰撞 collision
兩個不同的 item 雜湊到同一個 slot就是碰撞。處理方法:
線性探查 往後找第一個空的
鏈結法 在該 slot 接一條 list
複雜度
理想 (無碰撞)$O(1)$
線性探查 search$\frac{1}{2}\!\left(1+\frac{1}{1-\lambda}\right)$
鏈結法 search$1 + \lambda/2$

其他常見的雜湊函數

折疊法 folding method
把 item 切成等長的片段,加總後再取餘數。例如電話號碼 436-555-4601 切成 43, 65, 55, 46, 01,加總得 $210$;除以 11 得 $h = 210 \bmod 11 = 1$。
進階:反轉版本把每隔一片反過來再相加,例如 34 + 56 + 55 + 64 + 10 = 219 → 219 mod 11 = 10
平方取中法 mid-square
先把 item 平方,取中間幾位數,再取餘數。
例:item = 44 → $44^2 = 1936$ → 取中間兩位 93 → $93 \bmod 11 = 5$。
範例對照表   54→3, 26→1, 93→9, 17→6, 77→4, 31→8

ⓘ 對 17, 31 這類平方後位數不夠的情況,先補 0 至偶數位再取「正中間兩位」做 mod 11 是統一的做法。例如 $17^2 = 289$ → 補成 0289 → 取 28 → $28 \bmod 11 = 6$;$31^2 = 961$ → 補成 0961 → 取 96 → $96 \bmod 11 = 8$。

字串雜湊 string hashing
字串可以用每個字元的 ordinal value(ASCII 碼)來算:
def hash_str(s, m): return sum(ord(c) for c in s) % m
陷阱:"cat"、"act"、"tac" 全是同樣 ord 總和,會撞在一起(anagrams 衝突)。改良:用位置當權重,例如 $\sum i \cdot \text{ord}(c_i) \bmod m$。
設計目標
理想的 hash 函數應該:
易於計算 不能比直接搜尋還慢
分布均勻 減少碰撞機率
Perfect hash 對特定資料集無碰撞,但不存在通用方法

碰撞解決:除了線性探查還有什麼?

rehash 通式:$\text{rehash}(\text{pos}) = (\text{pos} + \text{skip}) \bmod m$ 線性探查 (linear probing):skip = 1,每次往後找下一格。簡單但容易產生聚集 (clustering)——許多碰撞在同一段連續 slot 累積,後續插入會被牽連。

+3 探查 (plus-3):skip = 3,跳格搜尋。要求 skip 與 $m$ 互質,否則會循環走不完整個表(這也是為何 $m$ 常選質數)。

平方探查 (quadratic probing):skip 不是定值,而是 $1, 4, 9, 16, \ldots$(連續完全平方數)。即 $h, h+1, h+4, h+9, \ldots$。能有效打散聚集。

鏈結法 (chaining):每個 slot 存一條 list(或其他 collection),所有 hash 到該 slot 的 item 都掛在同一條鏈上。$\lambda$ 可以超過 1。

應用:Map ADT (字典/HashTable)

用 hash 表實作 key-value 字典 Python 的 dict 背後就是 hash table。Map ADT 定義為「key 與 data value 之間關聯的無序集合」,包含 6 個核心操作:
操作語意
Map()建立一個新的空 map
put(key, val)新增 key–value 對;若 key 已存在則覆寫舊值
get(key)傳回對應 value,找不到則回傳 None
del map[key]刪除指定的 key–value 對
size()回傳目前儲存的 key–value 對總數
inkey in map 回傳 True/False
class HashTable: def __init__(self): self.size = 11 self.slots = [None] * self.size # 存 key self.data = [None] * self.size # 平行陣列存 value def hash_function(self, key, size): return key % size def rehash(self, old_hash, size): return (old_hash + 1) % size def put(self, key, val): # 用 hash + rehash 找空位插入;若 key 已存在則覆寫 def get(self, key): # 同樣的 probe 路徑找回,position == start_slot 時停止 def __getitem__(self, key): return self.get(key) # 支援 h[key] def __setitem__(self, key, data): self.put(key, data) # 支援 h[key] = v
關鍵:用兩個平行 list (slotsdata) 分別存 key 和 value,索引位置必須對齊。get 時要走和 put 一樣的 rehash 路徑,且要偵測「繞回起點」(position == start_slot) 以結束搜尋(代表整個探查鏈都沒有該 key)。

分析:載入因子 $\lambda$ 與比較次數

當 $\lambda$ 變大,效能如何下降? 線性探查 + 開放定址 (open addressing):
 ・成功搜尋平均比較次數 $\approx \dfrac{1}{2}\!\left(1 + \dfrac{1}{1-\lambda}\right)$
 ・失敗搜尋平均比較次數 $\approx \dfrac{1}{2}\!\left(1 + \left(\dfrac{1}{1-\lambda}\right)^2\right)$

鏈結法 (chaining):
 ・成功搜尋平均比較次數 $\approx 1 + \dfrac{\lambda}{2}$
 ・失敗搜尋平均比較次數 $\approx \lambda$

當 $\lambda \to 1$ 時,線性探查的失敗搜尋會爆炸性增加;鏈結法則是線性增加,較為穩定。
小實驗 試試用「線性探查」54, 26, 93, 17, 77, 31, 44, 55, 20 全部插入:你會發現 44、55、20 都會發生碰撞,被推到別的槽去。再切換到「鏈結法」清空、重做一次,會看到撞到的 item 直接掛在槽下面,不會佔走別人的位置。
PART 04 · 氣泡排序

氣泡排序:相鄰兩個比一比,大的往後送

Bubble sort 的想法很單純:每一輪從頭走到尾,遇到「左邊比右邊大」就交換。經過一輪之後,最大的元素一定會被送到最右邊——就像氣泡浮到水面一樣。下一輪只要處理剩下的部分,依此類推。

按「開始」執行氣泡排序
速度
即時統計 LIVE
比較
0
交換
0
當前 pass0
虛擬碼 CODE
def bubble_sort(a): for i in range(len(a)-1, 0, -1): for j in range(i): if a[j] > a[j+1]: a[j], a[j+1] = a[j+1], a[j]
複雜度
比較次數$\binom{n}{2} = \frac{n(n-1)}{2}$
時間 (各情況)$O(n^2)$
空間$O(1)$
短路最佳化 short bubble 若某一輪都沒發生交換,代表已經完全排序了——可以提早結束!這時最佳情況變成 $O(n)$(已排序時只需一輪)。但平均與最差仍是 $O(n^2)$。
虛擬碼 — bubble_sort_short CODE
def bubble_sort_short(a_list): for i in range(len(a_list)-1, 0, -1): exchanges = False for j in range(i): if a_list[j] > a_list[j+1]: exchanges = True a_list[j], a_list[j+1] = a_list[j+1], a_list[j] if not exchanges: break # 已經排序好,可以離開
PART 05 · 選擇排序

選擇排序:每一輪挑出最小者放到前面

Selection sort 跟氣泡排序的比較次數一樣多,但聰明在「每一輪只交換一次」:先掃一遍找出未排序區裡最小的元素,再把它和未排序區的第一格直接交換,就完成一輪。氣泡排序每比一次就可能交換,selection sort 把交換成本壓到最低。(講義版本以「找最小放前面」實作;也有等價的「找最大放後面」變體。)

按「開始」執行選擇排序
速度
即時統計 LIVE
比較
0
交換
0
本輪當前最小 idx
虛擬碼
def selection_sort(a_list): for i, item in enumerate(a_list): min_idx = len(a_list) - 1 for j in range(i, len(a_list)): if a_list[j] < a_list[min_idx]: min_idx = j if min_idx != i: a_list[min_idx], a_list[i] = a_list[i], a_list[min_idx]
複雜度
比較次數$\frac{n(n-1)}{2}$
交換次數最多 $n-1$
時間$O(n^2)$
vs. 氣泡 比較次數相同都是 $O(n^2)$,但 selection sort 每輪最多交換一次,氣泡排序每輪可能交換很多次。當「交換成本」很高(例如要移動的物件很大)時,selection sort 表現會比 bubble sort 好。
PART 06 · 插入排序

插入排序:像整理撲克牌

Insertion sort 把陣列分成「已排序」(左半)和「未排序」(右半)兩區。每一輪取出未排序的第一張當 cur_val(current value,當前要插入的值),往左在已排序區裡找出該插入的位置——遇到比它大的,就把它右移一格讓出空間。注意:這裡做的是位移(shift)而不是交換,所以可能比氣泡排序快一點。

按「開始」執行插入排序
速度
即時統計 LIVE
比較
0
位移 shift
0
當前 cur_val
虛擬碼
def insertion_sort(a_list): for i in range(1, len(a_list)): cur_val = a_list[i] cur_pos = i while cur_pos > 0 and a_list[cur_pos-1] > cur_val: a_list[cur_pos] = a_list[cur_pos-1] cur_pos = cur_pos - 1 a_list[cur_pos] = cur_val
複雜度
最佳 (已排序)$O(n)$
最差 / 平均$O(n^2)$
為什麼適合「幾乎排序好」的資料? insertion sort 處理已排序資料時非常快——每個 key 只需要一次比較就能確認位置,時間複雜度降到 $O(n)$。如果你正在「插入新資料到已排序的 list」這個情境,insertion sort 是最自然的選擇。
PART 07 · 希爾排序

希爾排序:插入排序的「跳格」加速版

Shell sort 是 1959 年 Donald Shell 提出的方法。觀察到 insertion sort 對「幾乎排序好」的資料很快,但對亂序資料慢。Shell sort 的策略:先用一個較大的 gap 把陣列拆成數個「子陣列」(每個子陣列的元素彼此相距 gap),對每個子陣列各自做插入排序;接著縮小 gap 再做一次;最後 gap = 1(變成普通的 insertion sort),但此時資料已經幾乎排序好

按「開始」執行希爾排序
速度
即時統計 LIVE
當前 gap
比較
0
交換
0
虛擬碼 (對齊講義 cell 194)
def shell_sort(a_list):    sublist_count = len(a_list) // 2    while sublist_count > 0:        for pos_start in range(sublist_count):            gap_insertion_sort(a_list, pos_start, sublist_count)        sublist_count = sublist_count // 2 def gap_insertion_sort(a_list, start, gap):    for i in range(start + gap, len(a_list), gap):        cur_val = a_list[i]        cur_pos = i        while cur_pos >= gap and a_list[cur_pos - gap] > cur_val:            a_list[cur_pos] = a_list[cur_pos - gap]            cur_pos = cur_pos - gap        a_list[cur_pos] = cur_val
複雜度
時間 (依 gap 序列)$O(n^{3/2}) \sim O(n^2)$
Hibbard 序列 $2^k-1$$O(n^{3/2})$
空間$O(1)$
關鍵直覺 當 gap 大時:每個子陣列很短,大跨度的調整很快,亂度迅速降低。
當 gap 小時:陣列已接近排好,小範圍的微調很便宜
Shell sort 不像 merge / quick sort 那麼快,但程式碼短、不需要遞迴、空間 $O(1)$,是嵌入式系統與小型應用常見的選擇。

講義原始實作(cell 194)

外層 shell_sort 維護當前 sublist_count(折半),對每個 pos_start 呼叫 gap_insertion_sort,把該子陣列做插入排序。

def shell_sort(a_list):
    sublist_count = len(a_list) // 2
    while sublist_count > 0:
        for pos_start in range(sublist_count):
            gap_insertion_sort(a_list, pos_start, sublist_count)
        print("After increments of size", sublist_count, "the list is", a_list)
        sublist_count = sublist_count // 2


def gap_insertion_sort(a_list, start, gap):
    for i in range(start + gap, len(a_list), gap):
        cur_val = a_list[i]
        cur_pos = i
        while cur_pos >= gap and a_list[cur_pos - gap] > cur_val:
            a_list[cur_pos] = a_list[cur_pos - gap]
            cur_pos = cur_pos - gap
        a_list[cur_pos] = cur_val


a_list = [5, 16, 20, 12, 3, 8, 9, 17, 19, 7, 11]
shell_sort(a_list)
print(a_list)
PART 08 · 合併排序

合併排序:分而治之的經典範例

Merge sort 是一個遞迴演算法,分成兩個動作:
(1) Divide 切: 把 list 從中間切成左、右兩半,各自呼叫 merge sort。
(2) Conquer + Merge 合: 兩半都排序好之後,合併成一個有序的大 list——用兩個指標分別走過左、右,每次把較小的那個寫進新 list。
Base case:list 長度 ≤ 1 時自然有序,直接回傳。

按「開始」執行合併排序
速度

遞迴呼叫樹 (recursion tree)

即時統計 LIVE
比較
0
寫入
0
當前階段
虛擬碼
def merge_sort(a): if len(a) <= 1: return mid = len(a) // 2 L, R = a[:mid], a[mid:] merge_sort(L) merge_sort(R) # merge L and R back into a i = j = k = 0 while i < len(L) and j < len(R): if L[i] <= R[j]: a[k] = L[i]; i += 1 else: a[k] = R[j]; j += 1 k += 1
複雜度
時間 (各情況)$O(n \log n)$
空間$O(n)$ 額外
是否穩定穩定 ✓
為何是 n log n? 每一層遞迴把問題切成兩半 → 共 $\log_2 n$ 層;每一層的「merge 動作」總共要看過所有 $n$ 個元素 → 每層 $O(n)$。整體 $O(n) \times O(\log n) = O(n \log n)$。
這是比較式排序的下界——任何只透過比較來排序的演算法都至少要 $\Omega(n\log n)$,merge sort 達到這個下界。
代價:額外空間 merge sort 需要額外 $O(n)$ 的暫存空間來存 L、R 半段(Python 的 slicing 也會建立新 list)。處理超大資料時,這個記憶體開銷可能成為問題。
PART 09 · 快速排序

快速排序:選一個 pivot,把比它小、比它大的分開

Quicksort 也是分而治之,但策略不同:先選一個元素當 pivot(樞紐值),用一個 partition 過程把陣列重新排成「比 pivot 小pivot比 pivot 大」三段——pivot 就確定到了它的最終位置。接著對左、右兩段各自遞迴呼叫 quicksort。和 merge sort 不一樣,不需要額外陣列,partition 就地完成。

partition 的雙指標技術 從 pivot 右邊開始:left_mark 從左往右走、找大於 pivot 的right_mark 從右往左走、找小於 pivot 的。兩者都停下時就交換它們。當 left_mark 越過 right_mark 之後,把 pivot 和 right_mark 交換——pivot 就到位了。
按「開始」執行快速排序
速度
即時狀態 LIVE
當前 pivot
左右範圍 [first, last]
left_mark
right_mark
比較
0
交換
0
虛擬碼
def quick_sort_helper(a_list, first, last): if first < last: split = partition(a_list, first, last) quick_sort_helper(a_list, first, split-1) quick_sort_helper(a_list, split+1, last)def partition(a_list, first, last): pivot_val = a_list[first] left_mark, right_mark = first+1, last done = False while not done: while left_mark <= right_mark and a_list[left_mark] <= pivot_val: left_mark += 1 while left_mark <= right_mark and a_list[right_mark] >= pivot_val: right_mark -= 1 if right_mark < left_mark: done = True else: a_list[left_mark], a_list[right_mark] = a_list[right_mark], a_list[left_mark] a_list[first], a_list[right_mark] = a_list[right_mark], a_list[first] return right_mark # split point
複雜度
最佳 / 平均$O(n \log n)$
最差 (pivot 極差)$O(n^2)$
空間 (遞迴)$O(\log n)$ 期望
最差情況:當 pivot 是極端值 若每次選的 pivot 剛好是最大或最小(例如已排序的陣列 + 「選第一個當 pivot」策略),每次只能切下 1 個元素 → 退化成 $O(n^2)$。median-of-three 取「first、middle、last 三者中位數」當 pivot,能大幅降低最差情況的機率,對「幾乎排序好」的資料尤其有效。
vs. merge sort quicksort 就地排序,不需要 $O(n)$ 額外空間,常數係數也比 merge sort 小,實務上通常更快。但 quicksort 不穩定、且最差是 $O(n^2)$;merge sort 任何情況都是 $O(n\log n)$ 且穩定。Python 的 list.sort() 用的是 Timsort,正是 merge sort 的最佳化變種。
PART 10 · 依賴的資料結構

每個演算法需要什麼底層支援?

同樣的演算法,搬到不同的資料結構上效能可能截然不同——甚至根本跑不動。本節整理本章九個演算法各自能在哪些資料結構上有效執行,重點放在「為什麼某些演算法必須用 Array 而不能用 Linked List」這個關鍵差異上。

兩種基本存取模式

Random Access — 隨機存取 FAST
可以用「索引」$O(1)$ 跳到任意位置。陣列 (Array) 最典型——記憶體連續,a[i] 直接用 base + i × sizeof(T) 算出位址即可。

代表結構 Python list、NumPy ndarray、C 風格 array、Java ArrayList
關鍵動作 a[i] = $O(1)$,a[i] = v = $O(1)$,連續走訪 cache 友善
Sequential Access — 循序存取 SLOW INDEX
只能從頭往後一個一個走(透過 node.next);想跳到第 $k$ 個必須走過前 $k-1$ 個 → $O(k)$。

代表結構 Linked List (鏈結串列)、Iterator、Generator、Stream
優勢 中間插入/刪除 $O(1)$(已知前一節點時)、不用連續記憶體

核心對照表:每個演算法所需的資料結構

演算法 必要屬性 Array Linked List 關鍵原因
循序搜尋 可逐一拜訪 ✓ 自然 ✓ 自然 只需 sequential access,兩種結構都可以
有序循序搜尋 可逐一拜訪 + 有序 遇到 a[pos] > item 即停,鏈結串列也行
二分搜尋 $O(1)$ 隨機存取 + 有序 必須 ✗ 不行 跳到 midpoint 在鏈結串列要 $O(n)$,破壞 $\log n$
雜湊表 (Hashing) 固定大小 array 當底層 必須 $h(\text{item}) \to$ slot 索引,必須隨機存取
氣泡排序 隨機存取 + 可變 △ 慢但可 需要 a[j] 與 a[j+1] 的相鄰比較交換
選擇排序 隨機存取 + 可交換 △ 慢但可 找到 min_idx 後 swap a[i] ↔ a[min_idx]
插入排序 隨機存取(位移) △ 變體可行 位移 a[cur_pos-1] → a[cur_pos] 在 array 是 $O(1)$
希爾排序 隨機存取(gap 跳格) 必須 ✗ 不行 需要 a[pos − gap],鏈結串列做不到「跳 gap 格」
合併排序 可拆分 + 可合併 ✓ 但需 $O(n)$ 額外空間 更佳! $O(1)$ 額外空間 鏈結串列 merge 只重接指標,不必複製 ★
快速排序 雙向隨機存取(雙指標) 必須 ✗ 不行 partition 需要左右雙指標雙向走,singly-linked 無法

自然支援  可以但效能下降  演算法的核心動作做不到,必須換結構

三個值得記住的關鍵案例

為何「Binary Search 必須要 Array」? Binary search 每次跳到 midpoint = (first+last)//2
 ・在 Array:用記憶體位址計算就能 $O(1)$ 取到 a[midpoint]
 ・在 Linked List:要走到第 midpoint 個 node 必須從頭走 midpoint 步,每次比較都要 $O(n)$

這樣總時間就從 $O(\log n)$ 退化成 $O(n \log n)$,比循序搜尋還慢!結論:排序好的資料若以 Linked List 儲存,binary search 沒有意義,必須轉成 Array(或一開始就用 Array)。
為何「Quick Sort 不能用 Singly Linked List」? Partition 需要兩個指標 left_markright_mark ——一個從左往右、一個從右往左走。

單向鏈結串列只能從左往右(每個 node 只有 next,沒有 prev),right_mark-- 沒有有效率的實作方式。雖然 doubly-linked list 可以雙向走,但每次比較都要追指標、cache miss 嚴重,常數係數比 Array 大太多。

這就是為什麼快速排序的教科書版本永遠用 Array 來講——partition 的優美只在 Array 上才成立。
為何「Merge Sort 反而適合 Linked List」?★ 反直覺! Array 版的 merge sort 需要 $O(n)$ 額外空間存 L、R 子陣列。但Linked List 版的 merge 只需重接指標:把較小的 node 從左/右串列上「摘下」,接到結果串列尾端,完全不需要複製資料,額外空間只有 $O(1)$(幾個指標變數而已)。

換句話說,merge sort 在 linked list 上反而更省記憶體、更乾淨。所以當資料天生就是 linked list 形式(functional 語言、stream pipeline、某些 OS 內部資料結構),merge sort 是首選排序法。

實務小知識:Java 的 java.util.LinkedList.sort() 內部就是先轉成 Array 再用 mergesort,因為 cache 表現更好;但概念上 linked list mergesort 是經典範例。

Hash Table 自身的內部結構

Hash Table 是什麼底層結構?答:仍然是 Array! Hash table 本身的底層就是一個固定大小($m$)的 Array:
線性 / 平方探查
純 Array of (key, value);衝突時往別的 slot 找空位
鏈結法 chaining
Array of Linked List;每個 slot 是一條 list 的 head
Map ADT 實作(講義版本)
兩個平行 Array:slots[] 存 key、data[] 存 value,索引位置一一對齊
這就是為何 Python dict、Java HashMap 內部都看得到 Array — 雜湊表離不開 random access。

選擇底層結構的決策樹

給定情境,該選哪種底層結構?
① 需要快速隨機存取 + 排序後重複搜尋?
  → Array + 排序 $O(n\log n)$ 一次 + 二分搜尋 $O(\log n)$ 多次

② 大量「鍵值對」key-value 查詢?
  → Hash Table(底層 Array),平均 $O(1)$ 查詢

③ 頻繁從中間插入/刪除元素?
  → Linked List,必要時排序選 merge sort($O(1)$ 額外空間版本)

④ 資料天然有序、僅追加(如 log)?
  → 順序儲存(Array 或 file stream),搜尋用循序或二分皆可

⑤ 記憶體吃緊、不能用額外 $O(n)$ 暫存?
  → 排序選 quick sort(in-place);搜尋選 binary search(也 in-place)

⑥ 必須穩定且效能保證 $O(n\log n)$?
  → merge sort,犧牲 $O(n)$ 空間換取保證
本章其他資料結構的關係(前情提要) 本書前幾章學過的資料結構,在本章扮演的角色:
  • Stack:quick sort / merge sort 的遞迴呼叫堆疊就是 stack;如果改寫成 iterative 版本,會明確使用一個 stack 來模擬遞迴。
  • Queue:iterative merge sort 的「bottom-up」版本可以用 queue 來組織待合併的子串列。
  • Linked List:chaining 法雜湊表的 collision bucket、merge sort 的最佳載體。
  • Tree:合併排序的遞迴呼叫關係本身就是一棵二元樹(PART 08 的視覺化就是把這棵樹畫出來)。
搜尋與排序不是孤立的章節——它們是建立在前面所有資料結構之上的應用範例
REFERENCE · 比較總覽

九個演算法一次比較

搜尋演算法

演算法最佳平均最差前置條件空間
循序搜尋$O(1)$$O(n)$$O(n)$$O(1)$
循序搜尋 (有序)$O(1)$$O(n/2)$$O(n)$已排序$O(1)$
二分搜尋$O(1)$$O(\log n)$$O(\log n)$已排序$O(1)$
雜湊 (理想)$O(1)$$O(1)$$O(n)$有 hash 函數$O(m)$

排序演算法

演算法最佳平均最差空間穩定就地
氣泡排序 Bubble$O(n)$ ★$O(n^2)$$O(n^2)$$O(1)$
選擇排序 Selection$O(n^2)$$O(n^2)$$O(n^2)$$O(1)$
插入排序 Insertion$O(n)$$O(n^2)$$O(n^2)$$O(1)$
希爾排序 Shell$O(n\log n)$$O(n^{3/2})$$O(n^2)$$O(1)$
合併排序 Merge$O(n\log n)$$O(n\log n)$$O(n\log n)$$O(n)$
快速排序 Quick$O(n\log n)$$O(n\log n)$$O(n^2)$$O(\log n)$

★ 氣泡排序的 $O(n)$ 最佳情況需要使用「短路最佳化」(一輪沒交換就停)。

選擇指南

情境建議演算法原因
資料量小 (n < 20)插入排序常數小、邏輯簡單
資料幾乎已排序插入排序 / 氣泡 (短路)$O(n)$ 最佳
需要穩定且效能保證合併排序始終 $O(n\log n)$、穩定
記憶體有限希爾 / 快速排序就地排序
實務通用、最快快速排序 (含中位數策略)常數最小
大量搜尋同份資料排序後二分搜尋 / 雜湊表建立成本攤提
需要 $O(1)$ 平均搜尋雜湊表用空間換時間

關鍵概念複習

穩定 stable 當兩個元素「比較相等」時,若排序後它們的相對順序不變,演算法就是穩定的。在多鍵排序(先按姓名再按部門)時很重要。
就地 in-place 僅使用 $O(1)$ 或 $O(\log n)$ 額外空間(除了輸入本身)的排序。merge sort 不是就地(需要 $O(n)$ 暫存),quick sort 是(只需遞迴堆疊)。
比較式排序的下界 任何只透過「兩兩比較」來排序的演算法,最差情況都至少要 $\Omega(n\log n)$ 次比較。merge / heap / 平均下的 quick sort 都達到這個下界。要打破它必須使用「非比較」方法,例如 counting sort 或 radix sort(要求資料形態特定)。
SUP · 補充

補充:兩個 PDF 上不容易看出來的細節

這一節針對 PDF 講義中兩個容易卡住的點補完整的逐步追蹤:(A)quadratic probing 把 77、44、55 放到表裡時為什麼會落在那個位置;(B)Map ADT 的 put() 為什麼有 4 個 if/else 分支、每個分支什麼時候會被觸發,以及哪個才是真正的 collision。

A. Quadratic probing 全程追蹤(m = 11)

設定 雜湊函數:$h(\text{item}) = \text{item} \bmod 11$。
Rehash:$\text{rehash}(p, \text{skip}) = (p + \text{skip}) \bmod 11$,其中 skip 依序取 $1^2, 2^2, 3^2, 4^2, 5^2, \ldots = 1, 4, 9, 16, 25, \ldots$(這也是「quadratic」的由來)。
插入順序54, 26, 93, 17, 77, 31, 44, 55, 20(與教材 PDF 圖示同)。

Step 1 — 前六筆都直接落空槽

itemitem % 11落點 slot狀況
541010空 ✓
2644空 ✓
9355空 ✓
1766空 ✓
7700空 ✓
3199空 ✓

此時表的狀態:

idx :  0    1    2    3    4    5    6    7    8    9    10
val : 77    .    .    .   26   93   17    .    .   31    54

Step 2 — 插 44(44 % 11 = 0,撞 77)

嘗試skip計算位置slot 內容結果
1$1^2 = 1$(0+1) % 11 = 1落 slot 1

Step 3 — 插 55(55 % 11 = 0,撞 77;之後又連撞 4 次)

嘗試skip計算位置slot 內容結果
1$1^2 = 1$(0+1) % 11 = 144占用
2$2^2 = 4$(0+4) % 11 = 426占用
3$3^2 = 9$(0+9) % 11 = 931占用
4$4^2 = 16$(0+16) % 11 = 593占用
5$5^2 = 25$(0+25) % 11 = 3落 slot 3
關鍵 skip 序列是「連續完全平方數本身」($1, 4, 9, 16, 25$),不是累加。每次都從原始 hash 位置 $h$ 起算,所以第 4 次跳到 $(0+16) \bmod 11 = 5$、第 5 次跳到 $(0+25) \bmod 11 = 3$ — 兩次都「跨過」了表的另一端。這正是 quadratic probing 能打散 linear probing 那種連續聚集(clustering)的原因。

Step 4 — 插 20(20 % 11 = 9,撞 31)

嘗試skip計算位置slot 內容結果
1$1^2 = 1$(9+1) % 11 = 1054占用
2$2^2 = 4$(9+4) % 11 = 2落 slot 2

最終表狀態

idx :  0    1    2    3    4    5    6    7    8    9    10
val : 77   44   20   55   26   93   17    .    .   31    54

B. Map ADT put() 的 4 個分支(最容易跟 collision 搞混的地方)

先講清楚:什麼是 collision、什麼不是? Collision 的判準是「hash 值相同」,不是「key 相同」 — 千萬別搞反。

正式定義:兩個不同的 item 經過 hash_function 後得到同一個 hash 值(也就是 hash(k₁) == hash(k₂),因此被映射到同一個 slot),這才叫 collision。判 collision 看的是「hash 值」這個運算結果,不是「key 本身」。

同一個 key 重複 put(如 h[77]="bird" 然後 h[77]="eagle")— hash 值當然會相同,但兩次塞的是同一個 item,只是更新同一個 entry,這不是 collision,也不會觸發任何 rehash。collision 必須是「兩個不同 item 撞到同一格」。

狀況兩個 item 的 hash 值是同一個 key 嗎?這算 collision 嗎?put 要做的事
情況一相同(必然)是(同 key 重複 put)不是直接覆寫舊值(字典「同 key 重新賦值」的語意)
情況二相同(hash 撞到了)(不同 key)啟動 rehash 探查,找下一個位置
情況三不同不是各走各的 slot,互不相干

情況一、二 在程式裡都會「slots[hash] 已經有東西」,但只有情況二才是真 collision — 差別就在那個被佔的 slot 裡放的 key 跟我「是不是同一個 item」。

教科書版本的 put()

def put(self, key, data): hash_value = self.hash_function(key, len(self.slots))   if self.slots[hash_value] is None: # ① 外層 if self.slots[hash_value] = key self.data[hash_value] = data else: # 外層 else:slot 被佔了,但被誰佔? if self.slots[hash_value] == key: # ② 內層 if:被「同一個 key」佔(不是 collision) self.data[hash_value] = data else: # 內層 else:hash 值撞到別人的 key → 真 collision next_slot = self.rehash(hash_value, len(self.slots)) while (self.slots[next_slot] is not None and self.slots[next_slot] != key): next_slot = self.rehash(next_slot, len(self.slots))   if self.slots[next_slot] is None: # ③ 探查後 if:路徑上撞到空槽 → 新插入 self.slots[next_slot] = key self.data[next_slot] = data else: # ④ 探查後 else:路徑上撞到「同 key」 → 覆寫 self.data[next_slot] = data

把 4 個分支按「碰撞 / 探查」分類

hash 第一次命中(無探查)rehash 探查之後
新增 entry(slot 是空的)① 外層 if③ 探查後 if
↑ 唯一真正在「處理 collision」的分支
覆寫已有同 key(不是 collision)② 內層 if④ 探查後 else
同 key 在探查路徑上,沿路找回去覆寫

情境設定:4 步剛好走 4 個分支

h = HashTable(size=11)hash = key % 11rehash = (p+1) % 11。依序執行下列 4 條 h[key] = val,每一條剛好觸發一個分支:

步驟操作hashslots[hash]走哪條分支是 collision 嗎?為何
1h[77]="bird"0None① 外層 ifslot 0 空,直接寫入,不必探查
2h[77]="eagle"077② 內層 ifslot 0 已有的 key 就是 77 → 同 key 重新賦值,直接覆寫,不啟動 rehash
3h[44]="goat"077 (≠ 44)③ 探查後 if44 和 77 hash 值都是 0(不同 key、相同 hash) → 真 collision → 進 while:next_slot = 1slots[1] is None → 退出 → 在 slot 1 新插入
4h[44]="lamb"077 (≠ 44)④ 探查後 else否(很容易被誤認)hash slot 雖然被別人佔,但 44 真正的家在 slot 1(步驟 3 放的)→ while 沿著 rehash 路徑走到 slot 1 找到 key 44 → 退出 → 覆寫 slot 1 的值

每一步之後的表狀態(slots / data 同時看)

init     :  slots = [ . , . , . , . , . , . , . , . , . , . , . ]
            data  = [ . , . , . , . , . , . , . , . , . , . , . ]

step 1 (① h[77]="bird"):
            slots = [77 , . , . , . , . , . , . , . , . , . , . ]
            data  = ["bird", . , . , . , . , . , . , . , . , . , . ]

step 2 (② h[77]="eagle"):  ← 同 key 覆寫,slots 不變
            slots = [77 , . , . , . , . , . , . , . , . , . , . ]
            data  = ["eagle", . , . , . , . , . , . , . , . , . , . ]

step 3 (③ h[44]="goat"):   ← collision,rehash 到 slot 1
            slots = [77 , 44 , . , . , . , . , . , . , . , . , . ]
            data  = ["eagle","goat", . , . , . , . , . , . , . , . , . ]

step 4 (④ h[44]="lamb"):   ← 不是 collision,沿著 rehash 路徑找到既存 key 44 並覆寫
            slots = [77 , 44 , . , . , . , . , . , . , . , . , . ]
            data  = ["eagle","lamb", . , . , . , . , . , . , . , . , . ]

注意 slots 在第 4 步完全沒動,只動 data — 這正是「同一個 key 永遠只占一格」的保證。

最容易誤會的兩件事 誤會 1:「分支 ② 是 collision」
分支 ② 發生時 slots[hash] == key,這是同一個 key 第二次 put。雖然 hash 值相同,但 collision 的判準是「不同 item 的 hash 值相同」,這裡兩次 put 的根本是同一個 item,所以不是 collision,只是字典重新賦值。真正在處理 collision 的路徑是「外層 else → 內層 else → 進 rehash 迴圈」,亦即步驟 3、4 走的路。

誤會 2:「分支 ④ 是 collision」
分支 ④ 表示「我這個 key 之前因為 hash 撞到別人(步驟 3:44 跟 77 hash 值都是 0)被擠到 slot 1;現在我又要 put 同一個 key,沿著當年的 rehash 路徑走回去把舊值覆寫」。這次 put 的還是同一個 key,不是新的不同 item 撞到,所以也不是新的 collision 事件,跟字典 d[k] = v2 改寫舊值是完全一樣的語意。新的 collision 只在分支 ③(不同 item hash 到已被別人佔的格、需要找新位置安置)才發生。

結論put 的 4 個分支中,只有 ③ 是真正在「解決 collision」;① 是無事發生,②、④ 都是「同 key 覆寫」(差別只在 hash 第一次就命中、還是探查後才命中)。判 collision 看的是「不同 item、相同 hash 值」,不是「同一個 key 第二次來」。
為什麼 while 要同時檢查 is not None!= key 迴圈兩個條件任一不滿足就停:
  • 撞到 None 停 → 落到分支 ③(新插入)
  • 撞到 == key 停 → 落到分支 ④(覆寫)
不能只檢查 is not None — 否則「同 key 已在路徑上」(步驟 4 那種情況)時會繼續往下走,最後在另一個 slot 又寫一次,製造出兩個 slots 都是同一個 key。字典就壞掉了:get 查回來的值會看 rehash 路徑長度而異。這就是分支 ④ 必須存在、while 必須帶 != key 條件的原因。