PROLOGUE · 開場
先看懂:演算法分析的「比較」與「目標」
本章探討兩件事:搜尋(searching) 從一堆資料中找出特定元素,排序(sorting) 把資料重新排列成有序狀態。我們會從 Python in 運算子開始,逐步揭開背後的演算法,並且用「比較次數」當作分析的單位 。
分析的兩個約定
1. 計算單位 = 比較次數。 對搜尋而言,是「拿目標和某個元素比一下」算一次;對排序而言,是「比較兩個元素誰大誰小」算一次。
2. 等機率假設。 搜尋成功時,假設目標出現在每個位置的機率都相同;這樣才能合理地討論「平均情況」。
顏色語義(整頁通用)
未處理
正在比較
正在交換
已排序
樞紐值 (pivot)
插入鍵值 (cur_val)
找到目標
每一節都採用相同的版面:左邊是「視覺化畫布」與「控制列」,右邊是「即時統計」、「對應虛擬碼(pseudo-code)」與「複雜度分析」。請大膽地按 ▶ 開始 看完整動畫,或按 → 單步 一格一格觀察。
PART 01 · 循序搜尋
循序搜尋:從頭到尾一個一個比
把資料想像成一排櫃子,要找某個物件,最直覺的辦法就是從第 0 格開始、一格一格打開來看 。這就是 sequential search ,也叫 linear search。它不要求資料有序 ,是處理一般 list 時最基本的搜尋方法。
虛擬碼 CODE
def sequential_search (a_list, item): pos = 0 while pos < len (a_list): if a_list[pos] == item: return True pos = pos + 1 return False
複雜度
最佳 (item 在頭) $O(1)$
最差 (沒找到) $O(n)$
平均 (找得到) $O(n/2)=O(n)$
為何平均是 n/2?
在「等機率假設」下,目標出現在每個位置的機率相同。若 list 長度是 $n$,找到所需的比較次數為 $1, 2, \ldots, n$,平均為 $\dfrac{1+2+\cdots+n}{n} = \dfrac{n+1}{2}$;用大 O 仍是 $O(n)$。找不到的情形需要 $n$ 次比較 ——必須逐一檢查每一格。
有序版本:可以提前停止
如果 list 已經排序好 了,就有捷徑:當看到的元素已經比目標大(升冪情況下),後面就不可能有了,可以立刻回傳 False。雖然找到的時候沒省到比較次數,但找不到時平均能少做一半 ——預期比較次數降到 $n/2$(仍是 $O(n)$,但常數變小)。
虛擬碼 — ordered_sequential_search CODE
def ordered_sequential_search (a_list, item):
pos = 0
while pos < len (a_list):
if a_list[pos] == item:
return True
if a_list[pos] > item: # 提前結束的關鍵
return False
pos = pos + 1
return False
關鍵差異對比表
情境 最佳 最差 平均
item 在 list 中(兩種版本) $1$ $n$ $n/2$
item 不在 — 普通版 $n$ $n$ $n$
item 不在 — 有序版 $1$ $n$ $n/2$
結論:兩種版本的
大 $O$ 都是 $O(n)$ ,有序版只是
常數變小 。要真正快,得換演算法 → 二分搜尋。
PART 02 · 二分搜尋
二分搜尋:每次砍掉一半的可能位置
當 list 已排序 ,可以用更聰明的策略:直接看正中間 那一個。比目標大就往左半找;比目標小就往右半找;剛好相等就找到了。每次比較排除掉一半 ,所以總比較次數最多 $\lceil \log_2 n \rceil + 1$。
當前指標 LIVE
first 0
last 9
midpoint = (first+last)//2 4
比較次數 0
結果 —
虛擬碼 CODE
def binary_search (a_list, item): first = 0 last = len (a_list) - 1 while first <= last: midpoint = (first + last) // 2 if a_list[midpoint] == item: return True elif item < a_list[midpoint]: last = midpoint - 1 else : first = midpoint + 1 return False
複雜度
最佳 (mid 命中) $O(1)$
最差 / 平均 $O(\log n)$
前置條件 必須已排序
為何是 log n?
每次比較讓搜尋範圍變成原本的一半。$n \to n/2 \to n/4 \to \cdots \to 1$,要做 $\log_2 n$ 次切割。
數值感受: $n=1{,}000{,}000$ 的 list,循序搜尋最多 $10^6$ 次比較,二分搜尋只要 $\lceil \log_2 10^6 \rceil = 20$ 次!
陷阱
二分搜尋的排序成本 不能忽略。若 list 只搜尋一次,先 $O(n\log n)$ 排序再 $O(\log n)$ 搜尋,反而慢於直接 $O(n)$ 循序搜尋。多次搜尋同一份排序好的資料 時,二分搜尋才划算。
分而治之的另一種寫法:遞迴版
講義也提供 recursive binary search ,本質上和迴圈版本完全等價,但更能體現「divide and conquer 」的概念——把問題切成更小的子問題,再對其中一邊遞迴呼叫。
虛擬碼 — binary_search_rec CODE
def binary_search_rec (a_list, item):
if len (a_list) == 0 : # base case: 找不到
return False
midpoint = (len (a_list) - 1 ) // 2
if a_list[midpoint] == item:
return True
elif item < a_list[midpoint]:
return binary_search_rec (a_list[:midpoint], item) # 左半
else :
return binary_search_rec (a_list[midpoint+1 :], item) # 右半
小細節
這個版本用 list slicing (a_list[:midpoint]),看起來簡潔,但每次切片其實會複製整個子陣列 ,多花 $O(n)$ 空間,整體變 $O(n \log n)$ 而不是 $O(\log n)$!更乾淨的寫法是傳入 start、end 索引(講義 Exercise 1:binary_search_rec2(a_list, item, start, last)),不切片就能保留 $O(\log n)$ 空間複雜度。
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$。
餘數雜湊函數
$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 對總數
in key 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 (
slots、
data) 分別存 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 的想法很單純:每一輪從頭走到尾,遇到「左邊比右邊大」就交換。經過一輪之後,最大的元素一定會被送到最右邊 ——就像氣泡浮到水面一樣。下一輪只要處理剩下的部分,依此類推。
虛擬碼 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 把交換成本壓到最低。(講義版本以「找最小放前面」實作;也有等價的「找最大放後面」變體。)
虛擬碼
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)而不是交換 ,所以可能比氣泡排序快一點。
虛擬碼
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),但此時資料已經幾乎排序好 。
› 按「開始」執行希爾排序
陣列:
隨機洗牌
套用
gap 序列:
n/2 折半 (Shell 原版)
2^k − 1 = 1,3,7,15,... (Hibbard)
虛擬碼 (對齊講義 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 時自然有序,直接回傳。
虛擬碼
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 就到位了。
› 按「開始」執行快速排序
陣列:
隨機洗牌
套用
pivot 策略:
第一個 (講義版本)
三者取中 median-of-three
即時狀態 LIVE
當前 pivot —
左右範圍 [first, last] —
left_mark —
right_mark —
虛擬碼
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_mark、right_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 — 前六筆都直接落空槽
item item % 11 落點 slot 狀況
54 10 10 空 ✓
26 4 4 空 ✓
93 5 5 空 ✓
17 6 6 空 ✓
77 0 0 空 ✓
31 9 9 空 ✓
此時表的狀態:
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 = 1 44 占用
2 $2^2 = 4$ (0+4) % 11 = 4 26 占用
3 $3^2 = 9$ (0+9) % 11 = 9 31 占用
4 $4^2 = 16$ (0+16) % 11 = 5 93 占用
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 = 10 54 占用
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 % 11,rehash = (p+1) % 11。依序執行下列 4 條 h[key] = val,每一條剛好觸發一個分支:
步驟 操作 hash slots[hash] 走哪條分支 是 collision 嗎? 為何
1 h[77]="bird"0 None① 外層 if — slot 0 空,直接寫入,不必探查
2 h[77]="eagle"0 77② 內層 if 否 slot 0 已有的 key 就是 77 → 同 key 重新賦值,直接覆寫,不啟動 rehash
3 h[44]="goat"0 77 (≠ 44)③ 探查後 if 是 44 和 77 hash 值都是 0(不同 key、相同 hash) → 真 collision → 進 while:next_slot = 1、slots[1] is None → 退出 → 在 slot 1 新插入
4 h[44]="lamb"0 77 (≠ 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 條件的原因。