01

搜尋:怎麼找到一筆資料

最直覺的搜尋方式,以及它為什麼是 O(n)。

先讀原文段落,旁邊就是白話

這是一本英文書。左邊放原文、右邊放白話導讀——你既讀得懂,也順手碰了原文。

原文 · 搜尋:怎麼找到一筆資料 CHAPTER FIVE SORTING AND SEARCHING 5. 1 Objectives • To be able to explain and implement sequential search and binary search. • To be able to explain and implement selection sort, bubble sort, merge sort, quick sort, insertion sort, and shell sort. • To understand the idea of hashing as a search technique.
白話導讀

最直覺的搜尋方式,以及它為什麼是 O(n)。

循序搜尋:一個一個慢慢找

最直覺的搜尋方式,以及它為什麼是 O(n)。

STEP 1

深度探秘

搜尋到底在問什麼問題

搜尋是什麼

**搜尋(searching)**是「在一堆資料裡找出某一筆特定資料」的過程。最常見的回答只有兩種:(True)或 不在(False)。

在 Python 裡,問「某個東西在不在清單裡」超簡單,用 in 就好:

>>> 15 in [3, 5, 2, 4, 1]
False
>>> 3 in [3, 5, 2, 4, 1]
True

但這行簡單的程式碼背後,其實藏著一套「真的去翻找」的流程。我們關心的,正是這個流程怎麼運作、以及不同做法有多快。

循序搜尋

當資料像清單一樣一個接一個排好(有 index 0、1、2…),它們之間就有「線性」或「循序」的關係。最直覺的找法叫循序搜尋(sequential search):從第一個開始,一個一個往後比對,直到找到,或是翻完整串都沒找到。

def sequential_search(a_list, item):
    pos = 0
    found = False
    while pos < len(a_list) and not found:
        if a_list[pos] == item:
            found = True
        else:
            pos = pos + 1
    return found

變數 found 一開始是 False,一旦比中目標就變 True,迴圈也隨之停止。

💡
關鍵

循序搜尋就是從頭到尾一個一個比對,找到或翻完為止。

STEP 2

生活妙喻

在沒分類的鞋櫃裡找一隻鞋

翻鞋櫃比喻

想像你家有一個完全沒分類的長鞋櫃,鞋子隨便亂塞。你要找一雙紅色球鞋:

  • 你只能從最左邊那格開始,一格一格往右看
  • 看到就拿走(找到了,True)。
  • 一路看到最後一格還是沒有,就確定家裡沒這雙(不在,False)。

這正是循序搜尋。重點在於:因為鞋子沒有任何順序,你沒辦法跳著找,只能老老實實一格一格看。

flowchart TD
    A[從第一格開始] --> B{這格是目標嗎}
    B -- 是 --> C[找到了 回傳 True]
    B -- 否 --> D{還有下一格嗎}
    D -- 有 --> E[移到下一格]
    E --> B
    D -- 沒有 --> F[翻完了 回傳 False]
💡
關鍵

資料沒排序時,就像翻亂塞的鞋櫃,只能一格一格看。

STEP 3

實用超能力

排序過的清單能省一點力氣

算成本:用「比較次數」當尺

分析搜尋速度時,我們數的是比較的次數。假設清單有 $n$ 筆、且完全沒排序:

情況 最好 最差 平均
目標在清單裡 1 $n$ $\frac{n}{2}$
目標不在清單裡 $n$ $n$ $n$

當 $n$ 很大時,係數(像 $\frac{1}{2}$)都可以忽略,所以循序搜尋的複雜度是 $O(n)$。

如果清單是排好序的?

假設清單由小到大排好。找 50 時,比到 54 就會發現:54 已經比 50 大,而後面只會更大,不可能再有 50 了,可以立刻停止!

def ordered_sequential_search(a_list, item):
    pos = 0
    found = False
    stop = False
    while pos < len(a_list) and not found and not stop:
        if a_list[pos] == item:
            found = True
        else:
            if a_list[pos] > item:
                stop = True
            else:
                pos = pos + 1
    return found

好處只出現在「找不到」的時候:平均只要看 $\frac{n}{2}$ 個就能確定不在。但整體仍是 $O(n)$——排序對循序搜尋幫助有限。

💡
關鍵

排序只在「找不到」時幫循序搜尋省力,整體仍是 O(n)。

🔆
生活妙喻:循序搜尋 ≈ 翻一個沒分類的鞋櫃

鞋子亂塞、毫無順序,你只能從第一格一格一格看到最後一格。

🔆
生活妙喻:已排序清單提早停止 ≈ 按身高排隊找某個人

隊伍由矮到高排好,你要找 170 公分的人,看到 175 的人就知道再往後都更高,不用再找了。

本節字彙

搜尋 (searching)
在一堆資料裡找出某筆特定資料的過程,通常回答在或不在。
🧠 search=在資料堆裡『搜』來『尋』去。
循序搜尋 (sequential search)
從頭逐一往後比對的搜尋法。
🧠 sequential=照順序,一個排一個地看。
O(n)
時間隨資料量 n 成正比增加的複雜度等級。
🧠 資料多一倍,時間也大約多一倍。
你要在一個「完全沒排序」的清單裡用循序搜尋找一個『不存在』的數字,這時需要比較幾次才能確定它不在?
對清單 [15, 18, 2, 19, 18, 0, 8, 14, 19, 14] 做循序搜尋找 18,需要比較幾次才會找到?
對『已排序』清單 [3, 5, 6, 8, 11, 12, 14, 15, 17, 18] 用 ordered_sequential_search 找 13,需要比較幾次才能確定它不在?

二分搜尋:每次砍一半

利用已排序的特性,每次比較就淘汰一半資料,達到 O(log n)。

STEP 1

深度探秘

從中間下手,一次淘汰一半

聰明地利用排序

循序搜尋每比一次,最多只能排除一筆。但如果清單已經由小到大排好,我們可以聰明很多:二分搜尋(binary search)不從頭開始,而是先看正中間那筆。

比中間值之後有三種可能:

  • 中間就是目標 → 找到,結束。
  • 目標比中間 → 它只可能在左半邊,右半邊(含中間)全部淘汰。
  • 目標比中間 → 它只可能在右半邊,左半邊(含中間)全部淘汰。

然後對剩下的那半邊重複同樣動作。每比一次就砍掉一半的搜尋範圍。

def binary_search(a_list, item):
    first = 0
    last = len(a_list) - 1
    found = False
    while first <= last and not found:
        midpoint = (first + last) // 2
        if a_list[midpoint] == item:
            found = True
        else:
            if item < a_list[midpoint]:
                last = midpoint - 1
            else:
                first = midpoint + 1
    return found
💡
關鍵

二分搜尋從中間比,每次比較就淘汰掉一半的範圍。

STEP 2

生活妙喻

查字典找單字

查字典比喻

你不會從第一頁開始一頁一頁翻字典找「monkey」。你會直接翻到中間,看到「M」附近——欸,差不多到了;如果翻到「P」就往前一點、翻到「F」就往後一點。每翻一次,要找的範圍就縮小一半

二分搜尋就是這套「翻字典」策略。前提是字典已經照字母排序——沒排序的字典根本沒辦法這樣翻。

flowchart TD
    A[看中間那一筆] --> B{中間就是目標嗎}
    B -- 是 --> C[找到了]
    B -- 目標比較小 --> D[只看左半邊]
    B -- 目標比較大 --> E[只看右半邊]
    D --> A
    E --> A

分治法

二分搜尋是**分治法(divide and conquer)**的經典範例:把大問題切成小問題、解小問題、再組合答案。每次都把問題縮小成「對半邊清單再做一次二分搜尋」,這正是一個遞迴呼叫。

💡
關鍵

查字典時翻中間、再對半縮小範圍,就是二分搜尋的分治精神。

STEP 3

實用超能力

為什麼快到只要 log n 次

算成本:能砍幾次一半?

從 $n$ 筆開始,每比一次就剩一半:

比較次數 大約剩下幾筆
1 $\frac{n}{2}$
2 $\frac{n}{4}$
3 $\frac{n}{8}$
$i$ $\frac{n}{2^i}$

當砍到只剩 1 筆時就結束,也就是 $\frac{n}{2^i} = 1$,解出 $i = \log_2 n$。所以二分搜尋是 $O(\log n)$——資料量翻倍,也只多比一次!

切片版要小心

用遞迴搭配 Python 切片寫起來很漂亮:

def binary_search(a_list, item):
    if len(a_list) == 0:
        return False
    midpoint = len(a_list) // 2
    if a_list[midpoint] == item:
        return True
    elif item < a_list[midpoint]:
        return binary_search(a_list[:midpoint], item)
    else:
        return binary_search(a_list[midpoint + 1:], item)

但 Python 的切片是 $O(k)$,會讓它跑不到嚴格的對數時間。改用「傳起訖 index」就能解決。

提醒:對小清單來說,先排序再二分搜尋的成本不一定划算。要排一次、查很多次,排序的成本才被攤平。

💡
關鍵

二分搜尋是 O(log n),資料翻倍只多比一次,但前提是清單要先排序。

🔆
生活妙喻:二分搜尋 ≈ 查紙本字典

翻中間、看在前在後、再對半縮小,每翻一次範圍少一半,前提是字典照字母排好。

🔆
生活妙喻:分治法 ≈ 把一疊考卷對半分給助教改

大任務切成小任務各自處理,再把結果合起來,二分搜尋每次都把問題縮成半邊清單。

本節字彙

二分搜尋 (binary search)
在已排序清單中,每次比中間值並淘汰一半範圍的搜尋法。
🧠 binary=二,每次把資料一分為二。
分治法 (divide and conquer)
把大問題分成小問題、各自解決、再組合的策略。
🧠 divide=分,conquer=征服,分而治之。
O(log n)
資料量翻倍只增加固定一點工作量的複雜度等級,非常快。
🧠 每次砍一半,砍 log n 次就見底。
二分搜尋能成立、比循序搜尋快的『最關鍵前提』是什麼?
對已排序清單 [3, 5, 6, 8, 11, 12, 14, 15, 17, 18] 用遞迴二分搜尋找 8,比較的數字依序是哪一組?
二分搜尋是 O(log n)。如果清單從 1000 筆變成 2000 筆,最差情況下大約只會多比『幾次』?
02

雜湊:讓查找快到接近瞬間

用一個公式直接算出資料該放哪個格子,達到 O(1) 查找。

先讀原文段落,旁邊就是白話

這是一本英文書。左邊放原文、右邊放白話導讀——你既讀得懂,也順手碰了原文。

原文 · 雜湊:讓查找快到接近瞬間 h function to be perfect to still gain performance efficiency. One way to always have a perfect hash function is to increase the size of the hash table so that each possible value in the item range can be accommodated. This guarantees that each item will have a unique slot. Although this is practical for small numbers of items, it is not feasible when the number of possible items is large.
白話導讀

用一個公式直接算出資料該放哪個格子,達到 O(1) 查找。

雜湊表與雜湊函式

用一個公式直接算出資料該放哪個格子,達到 O(1) 查找。

STEP 1

深度探秘

用公式直接算出資料該放哪

從 O(log n) 再往前一步

二分搜尋靠『排序』達到 $O(\log n)$。雜湊(hashing)想做得更誇張:直接做到 $O(1)$——不論資料多少,查找都只要固定時間

秘訣是:別再『找』資料,而是一開始就把每筆資料放到一個可以用公式算出來的位置。要查的時候,用同一條公式算出位置,直接去那一格看就好。

雜湊函式決定資料位置的結構。">雜湊表與 slot

**雜湊表(hash table)**是一排編號的格子,每個格子叫 slot,從 0 開始編號。一開始全空(用 Python 的 None 表示)。下面是一個大小 $m = 11$ 的空雜湊表:

graph LR
    S0[slot0 空] --- S1[slot1 空] --- S2[slot2 空] --- S3[slot3 空] --- S4[slot4 空] --- S5[slot5 空]

雜湊函式

把資料對應到 slot 的那條公式,就是雜湊函式(hash function)。它吃進任何一筆資料,吐出一個介於 0 到 $m-1$ 的整數。

最基本的是餘數法(remainder method):拿資料除以表的大小,取餘數當位置:

$$h(item) = item \bmod 11$$

例如把 54、26、93、17、77、31 放進大小 11 的表:

資料 雜湊值
54 10
26 4
93 5
17 6
77 0
31 9

取餘數(模運算)幾乎一定會出現在所有雜湊函式裡,因為結果必須落在 slot 的編號範圍內。

💡
關鍵

雜湊用一條公式直接算出資料的位置,查找因此可達 O(1)。

STEP 2

生活妙喻

有規則的健身房置物櫃

置物櫃比喻

想像一間健身房有 11 個置物櫃(編號 0~10)。規則是:用你的會員號碼除以 11,餘數就是你的櫃號

  • 你不需要一個一個櫃子去拉門找東西。
  • 心算一下會員號 % 11,直接走到那個櫃子,一步到位。

這就是雜湊:位置不是用找的,是用算的。算位置只要一瞬間,所以查找是 $O(1)$。

字串也能雜湊

字串怎麼算?把每個字元的序數(ord)加起來再取餘數。例如『cat』:

>>> ord('c'), ord('a'), ord('t')
(99, 97, 116)
# 99 + 97 + 116 = 312, 312 % 11 = 4

def hash(a_string, table_size):
    sum = 0
    for pos in range(len(a_string)):
        sum = sum + ord(a_string[pos])
    return sum % table_size

有趣的是:字母相同順序不同的字(anagram,如 cat 與 act)會得到相同雜湊值。想避免可以把『字元位置』當權重,讓順序也參與計算。

💡
關鍵

雜湊就像『用會員號算出櫃號』,位置用算的而不是用找的。

STEP 3

實用超能力

好的雜湊函式長什麼樣,碰撞又是什麼

完美雜湊與它的代價

若一個雜湊函式能讓每筆資料都對應到獨一無二的 slot,就叫完美雜湊函式(perfect hash function)。但對任意資料,沒有系統化的方法保證做出完美雜湊。

一個笨方法是把表開到夠大、每個可能的值都有專屬格子。但若資料是九位數的身分證號,這要將近十億格——存 25 個學生卻開十億格,太浪費了。

所以我們的目標是:碰撞少、好算、分布均勻

幾種雜湊函式

  • 折疊法(folding):把資料切成等長小段相加再取餘。例如電話 436-555-4601 切成 43,65,55,46,01,相加 210,$210 \bmod 11 = 1$。
  • 平方取中法(mid-square):先平方,再取中間幾位數。例如 $44^2 = 1936$,取中間兩位 93,$93 \bmod 11 = 5$。

碰撞

問題來了:如果 44 也要進來,$44 \bmod 11 = 0$,但 77 已經佔了 slot 0。兩筆以上資料算到同一格,就叫碰撞(collision)。碰撞會破壞 $O(1)$ 的美夢,下一節專門處理它。

雜湊函式也不能太複雜——如果算位置比直接做循序或二分搜尋還久,就本末倒置了。

💡
關鍵

好的雜湊函式要碰撞少、好算、分布均勻;多筆資料算到同格就叫碰撞。

🔆
生活妙喻:雜湊函式 ≈ 用會員號算置物櫃號

規則固定(號碼除以櫃數取餘),不用一個個找,算一下就知道東西在哪一櫃。

🔆
生活妙喻:碰撞 ≈ 兩個人算出同一個櫃號

餘數相同時兩筆資料想擠進同一格,就像兩個會員被分到同一個置物櫃。

🔆
生活妙喻:完美雜湊要開超大表 ≈ 為每個身分證號都留一個櫃子

雖然保證不撞,但要開將近十億個櫃子只放 25 個人,極度浪費。

本節字彙

雜湊表 (hash table)
一排編號 slot 組成、靠雜湊函式決定資料位置的結構。
🧠 hash=剁碎重組,把資料剁出一個位置。
雜湊函式 (hash function)
把資料對應到 0 到 m-1 之間某個 slot 的公式。
🧠 丟資料進去,吐一個格子號出來。
餘數法 (remainder method)
拿資料除以表大小、取餘數當雜湊值的最基本雜湊函式。
🧠 item % size,餘數就是位置。
碰撞 (collision)
兩筆以上資料算出相同雜湊值、想佔同一 slot 的情況。
🧠 兩台車開進同一個停車格就撞了。
為什麼雜湊表查找能達到 O(1),比二分搜尋的 O(log n) 還快?
在大小為 11 的雜湊表中,用餘數法 h(item)=item%11,鍵 26 與 93 會落在哪兩個 index?
用『把每個字元 ord 值相加再取餘數』這種雜湊函式,下列哪個現象一定會發生?

碰撞與解決碰撞

兩筆資料算到同一格怎麼辦?線性探測、再雜湊與鏈結法。

STEP 1

深度探秘

兩筆資料想擠同一格怎麼辦

碰撞一定會發生

上一節我們看到:當 44 想進來時,$44 \bmod 11 = 0$,但 slot 0 已經被 77 佔走了。兩筆資料算到同一格,就是碰撞(collision)。除非用完美雜湊(通常做不到),否則碰撞遲早會發生,所以一定要有一套處理規則,這就叫解決碰撞(collision resolution)

開放定址法 + 線性探測

一種思路是:撞到了就往後找下一個空格。從原本的位置開始,一格一格往後看,遇到第一個空的就放進去(必要時繞回 slot 0 形成一個圈)。這種『找下一個開放的地址』叫開放定址法(open addressing);一格一格地找則叫線性探測(linear probing)

以資料 54,26,93,17,77,31,44,55,20 為例(表大小 11):

  • 44 想進 slot 0,但被 77 佔了 → 往後到 slot 1(空),放下。
  • 55 也想進 slot 0 → 往後找到 slot 2(空),放下。
  • 20 算到 slot 9(被 31 佔),往後看 10、0、1、2 都滿,最後在 slot 3 找到空位。
flowchart TD
    A[算出原始 slot] --> B{這格空嗎}
    B -- 空 --> C[放進去]
    B -- 滿 --> D[往後看下一格 必要時繞回 0]
    D --> B
💡
關鍵

碰撞時,線性探測會從原位置往後一格一格找,直到遇到空格。

STEP 2

生活妙喻

客滿的停車場找車位

停車場比喻

想像一個環形停車場,你的『指定車位』是用車牌算出來的。但你開到時發現有人停了——線性探測就像:繞著往前一格一格看,停進第一個看到的空位

搜尋時也得用同一套規則:要找 20,先算到 slot 9,發現是 31 不是 20。你不能直接說『不在』,因為它可能因為碰撞被擠到後面去了。所以得從 slot 10 開始往後找,直到找到 20、或遇到一個空格(空格代表後面不可能再有它了)。

群聚問題

線性探測有個毛病叫群聚(clustering):很多資料撞在同一個雜湊值附近時,會把周圍格子塞成一坨。這一坨會連累後來的資料(像 20 就被迫繞過一整坨才找到位置)。

💡
關鍵

線性探測像環形停車場找位,但容易在熱門位置附近塞成一坨(群聚)。

STEP 3

實用超能力

再雜湊、二次探測與鏈結法

再雜湊:撞了之後跳著找

撞到後找下一格的通用動作叫再雜湊(rehashing)。線性探測的再雜湊是:

$$rehash(pos) = (pos + 1) \bmod size$$

為了打散群聚,可以改成『加 3』跳著找:

$$rehash(pos) = (pos + 3) \bmod size$$

一般化就是 $rehash(pos) = (pos + skip) \bmod size$。

注意:skip 的大小要能讓所有 slot 終究都被走訪到,否則表會有一部分永遠用不到。常建議把表大小設成質數,這也是我們一直用 11 的原因。

二次探測

**二次探測(quadratic probing)**不用固定 skip,而是用一連串完全平方數當間隔:第一次 $h$,接著 $h+1, h+4, h+9, h+16 \ldots$。這能進一步減少群聚。

鏈結法

另一條完全不同的路是鏈結法(chaining):讓每個 slot 不只放一筆,而是掛一整串(一條鏈)。撞到就掛到同一格的鏈上。

graph LR
    Slot0[slot0] --> A77[77] --> A44[44] --> A55[55]
    Slot4[slot4] --> B26[26]

查找時先算 slot,再在那一格的鏈上做一次小搜尋。平均而言每條鏈很短,所以還是很有效率。

💡
關鍵

再雜湊可跳著找或用平方數打散群聚;鏈結法則讓每格掛一串資料。

🔆
生活妙喻:線性探測 ≈ 客滿停車場往前找空位

指定車位被佔,就繞著往前一格一格看,停進第一個空位。

🔆
生活妙喻:群聚 ≈ 熱門出口附近擠成一坨車

大家都想停在某個熱點附近,周圍位置被塞滿,連累後來的人要繞更遠。

🔆
生活妙喻:鏈結法 ≈ 每個櫃子掛一串鑰匙圈

同一格可以掛很多筆資料成一串,撞到就加到串上,查時在串上找。

本節字彙

解決碰撞 (collision resolution)
當兩筆資料算到同格時,決定第二筆該放哪的系統化方法。
🧠 撞車了要有處理 SOP。
線性探測 (linear probing)
開放定址法的一種,從原位置一格一格往後找空格。
🧠 linear=直線,一格接一格地探。
再雜湊 (rehashing)
碰撞後依某公式再算下一個候選位置的過程。
🧠 re-hash=再算一次位置。
鏈結法 (chaining)
讓每個 slot 掛一串資料來容納多筆碰撞資料的方法。
🧠 chain=鏈條,一格串一串。
用線性探測時,要查 20,算出 slot 9 卻發現是 31。為什麼『不能』直接回傳『不在』?
把鍵 113, 117, 97, 100, 114, 108, 116, 105, 99 依序插入大小 11 的表(餘數法 + 線性探測 +1)。最後內容最接近下列哪個?(__ 代表空格)
課文建議把雜湊表大小設成『質數』(例如 11),最主要的理由是什麼?

用雜湊實作 Map 與效能分析

把雜湊表包裝成像字典一樣好用的 Map ADT,並用負載因子分析查找成本。

STEP 1

深度探秘

Map 抽象資料型別與雜湊實作

Map 是什麼

Python 最好用的集合之一是字典(dictionary),它存的是鍵-值對(key-value pair):用 key 查出對應的 value。這個概念抽象起來就叫 map

**Map 抽象資料型別(ADT)**是一群 key 對 value 的對應,key 不重複(一對一)。主要操作:

  • Map():建立空 map。
  • put(key, val):放入一對;key 已存在就更新 value。
  • get(key):依 key 取出 value,沒有就回 None
  • dellen()in:刪除、計數、判斷是否存在。

字典最大的好處就是『給 key 能極快查到 value』,而雜湊表正好能提供接近 $O(1)$ 的查找,是實作 map 的理想底層。

用兩個平行清單實作

class HashTable:
    def __init__(self):
        self.size = 11
        self.slots = [None] * self.size
        self.data = [None] * self.size

slots 存 key、data 存對應的 value,位置一一對應:key 在 slots 的第幾格,它的 value 就在 data 的第幾格。表大小設質數 11 讓解決碰撞更有效率。

💡
關鍵

Map 是 key 對 value 的對應,用雜湊表當底層能達到接近 O(1) 的查找。

STEP 2

生活妙喻

put 與 get 像置物櫃存取包裹

put:放包裹

put 先用雜湊函式算出 key 該在哪格:

  • 那格是空的 → 直接放 key 與 data。
  • 那格已經是同一個 key → 更新 data(取代舊值)。
  • 那格被別的 key 佔了(碰撞)→ 用 rehash 一格格往後找,直到找到空格或同一個 key。
def put(self, key, data):
    hash_value = self.hash_function(key, len(self.slots))
    if self.slots[hash_value] == None:
        self.slots[hash_value] = key
        self.data[hash_value] = data
    else:
        if self.slots[hash_value] == key:
            self.data[hash_value] = data  # 取代
        else:
            next_slot = self.rehash(hash_value, len(self.slots))
            while self.slots[next_slot] != None and self.slots[next_slot] != key:
                next_slot = self.rehash(next_slot, len(self.slots))
            if self.slots[next_slot] == None:
                self.slots[next_slot] = key
                self.data[next_slot] = data
            else:
                self.data[next_slot] = data  # 取代

get:取包裹

get 同樣先算起始格,沿 rehash 往後找;關鍵是用 position == start_slot 判斷『繞了一整圈回到起點』,避免無限迴圈——繞回起點代表找遍了都沒有。

flowchart TD
    A[算出起始 slot] --> B{這格是目標 key 嗎}
    B -- 是 --> C[回傳對應的 data]
    B -- 否且非空 --> D[rehash 到下一格]
    D --> E{繞回起點了嗎}
    E -- 是 --> F[找遍了 回傳 None]
    E -- 否 --> B
    B -- 遇到空格 --> F

再用 __getitem____setitem__ 重載,就能像字典一樣用 h[key] 存取。

💡
關鍵

put 像放包裹(撞了往後找空格),get 像取包裹(繞回起點就代表沒有)。

STEP 3

實用超能力

負載因子如何決定查找成本

負載因子 λ

理想上雜湊查找是 $O(1)$,但碰撞會讓比較次數變多。分析雜湊最關鍵的數字是負載因子(load factor)

$$\lambda = \frac{\text{資料筆數}}{\text{表大小}}$$

  • $\lambda$ 小:表很空,碰撞少,資料多半就在它該在的格子。
  • $\lambda$ 大:表快滿了,碰撞多,要找空格或追鏈更費力。

平均比較次數公式

開放定址 + 線性探測

  • 成功查找約 $\frac{1}{2}\left(1 + \frac{1}{1-\lambda}\right)$
  • 失敗查找約 $\frac{1}{2}\left(1 + \left(\frac{1}{1-\lambda}\right)^2\right)$

鏈結法

  • 成功查找約 $1 + \frac{\lambda}{2}$
  • 失敗查找約 $\lambda$

直覺解讀

你會發現:$\lambda$ 越接近 1(表越滿),$\frac{1}{1-\lambda}$ 就爆炸性變大,查找急遽變慢。這告訴我們一個實務重點:雜湊表不該裝太滿,通常在負載因子超過某門檻(例如 0.7)時就該擴充表的大小,重新雜湊,才能維持接近 $O(1)$ 的效能。

💡
關鍵

負載因子越接近 1 查找越慢,所以雜湊表不該裝太滿,必要時要擴充重雜湊。

🔆
生活妙喻:Map ADT ≈ 通訊錄

用姓名(key)查電話(value),一個名字對一筆資料,put 是新增或修改、get 是查號。

🔆
生活妙喻:get 繞回起點停止 ≈ 繞停車場一圈回到起點

從指定車位出發繞一圈又回到原點都沒看到你的車,就確定車不在這座停車場。

🔆
生活妙喻:負載因子 ≈ 停車場的滿位率

車位越滿,每次進場找空位要繞越久;越接近全滿,等待時間暴增。

本節字彙

Map(抽象資料型別)
一群唯一 key 對應 value 的集合,支援 put、get、del 等操作。
🧠 map=對照表,key 對應到 value。
put / get
put 放入或更新一對 key-value;get 依 key 取出 value。
🧠 put=放進去,get=拿出來。
負載因子 (load factor) λ
資料筆數除以表大小,衡量雜湊表多滿。
🧠 λ 越大代表越擠,越擠越慢。
HashTable 用 slots 與 data 兩個平行清單實作 Map,它們之間的關係是什麼?
在 get 方法中,為什麼要檢查 position == start_slot(是否繞回起點)這個條件?
用 put 對一個『已經存在的 key』再放一次資料,會發生什麼事?
03

排序入門:三種 O(n²) 排序法

相鄰兩兩比較並交換,每一輪把最大值送到定位。

先讀原文段落,旁邊就是白話

這是一本英文書。左邊放原文、右邊放白話導讀——你既讀得懂,也順手碰了原文。

原文 · 排序入門:三種 O(n²) 排序法 As before, we will have a result for both a successful and an unsuccessful search. For a suc- cessful search using open addressing with linear probing, the average number of comparisons is approximately 1 2 (︀ 1 + 1 1−𝜆 )︀ and an unsuccessful search gives 1 2 (︁ 1 + (︀ 1 1−𝜆 )︀ 2 )︁ If we are using chaining, the average number of comparisons is 1 + 𝜆 2 for the successful case, and simply 𝜆 comparisons if the search is unsuccessful. 3 Sorting Sorting is the process of placing elements from a collection in some kind of order. For example, a list of words could be sorted alphabetically or by length.
白話導讀

相鄰兩兩比較並交換,每一輪把最大值送到定位。

氣泡排序:讓大數慢慢浮上來

相鄰兩兩比較並交換,每一輪把最大值送到定位。

STEP 1

深度探秘

相鄰兩兩比較、看誰該往後

排序在做什麼

**排序(sorting)**是把一堆資料依某種順序排好,例如數字由小到大、單字依字母。分析排序時我們關心兩個動作:**比較(compare)兩個值誰大誰小,以及交換(exchange / swap)**把放錯位置的兩個值對調。交換是相對昂貴的動作。

氣泡排序

**氣泡排序(bubble sort)**會對清單做很多輪(pass)。每一輪從頭開始,比較相鄰的兩個元素,順序不對就交換。每跑完一輪,就會把這輪最大的值『推』到它最終的位置——就像大泡泡慢慢浮到水面。

$n$ 個元素第一輪要比 $n-1$ 對,第二輪因為最大值已就位只剩 $n-2$ 對……總共要 $n-1$ 輪。

def bubble_sort(a_list):
    for pass_num in range(len(a_list) - 1, 0, -1):
        for i in range(pass_num):
            if a_list[i] > a_list[i + 1]:
                temp = a_list[i]
                a_list[i] = a_list[i + 1]
                a_list[i + 1] = temp

在 Python 裡還能用同時指定一行搞定交換:a, b = b, a

💡
關鍵

氣泡排序每輪比較相鄰兩兩元素並交換,把最大值一路推到定位。

STEP 2

生活妙喻

汽水裡的大泡泡浮上來

泡泡浮起來

想像一排高矮不一的人站著,你規定:從左邊開始,相鄰兩人比身高,高的往右站。一路比到底,最高的人就會被一路換到最右邊。第二輪再做一次,第二高的就站到倒數第二……每一輪都有一個『最大泡泡』浮到正確位置。

flowchart TD
    A[從最左邊開始] --> B{相鄰兩個 左邊比右邊大嗎}
    B -- 是 --> C[交換兩者]
    B -- 否 --> D[不動]
    C --> E[往右移一格]
    D --> E
    E --> F{這一輪比完了嗎}
    F -- 沒有 --> B
    F -- 完成 --> G[最大值已就定位 進入下一輪]

為什麼說它『浪費』

氣泡排序常被認為最沒效率,因為它會在『還不知道最終位置』時就不斷交換,做了很多白工。但它有個獨門好處,下一步揭曉。

💡
關鍵

氣泡排序像汽水裡的大泡泡,每輪把最大的那顆推到最上面。

STEP 3

實用超能力

為何是 O(n²) 與 short bubble 的小聰明

算成本

不管資料怎麼排,氣泡排序都要做 $n-1$ 輪。各輪比較次數是 $n-1, n-2, \ldots, 1$,加起來是前 $n-1$ 個整數的總和:

$$\sum_{i=1}^{n-1} i = \frac{1}{2}n^2 - \frac{1}{2}n$$

所以氣泡排序是 $O(n^2)$。最好情況(已排序)不需任何交換,最差情況每次比較都要交換。

Short bubble 的小聰明

氣泡排序有個別人沒有的能力:如果某一輪完全沒有發生交換,代表清單已經排好了,可以提早收工。這個改良叫 short bubble:

def short_bubble_sort(a_list):
    exchanges = True
    pass_num = len(a_list) - 1
    while pass_num > 0 and exchanges:
        exchanges = False
        for i in range(pass_num):
            if a_list[i] > a_list[i + 1]:
                exchanges = True
                a_list[i], a_list[i + 1] = a_list[i + 1], a_list[i]
        pass_num = pass_num - 1

對『幾乎已排好』的清單,short bubble 能很快察覺並停下,這時它反而有優勢。

💡
關鍵

氣泡排序是 O(n²),但 short bubble 能在某輪零交換時察覺已排好而提早停。

🔆
生活妙喻:氣泡排序 ≈ 汽水裡的大泡泡浮頂

每一輪兩兩比較把最大值往後推,像最大的泡泡浮到最上面。

🔆
生活妙喻:交換成本高 ≈ 搬兩箱重物對調位置

比較像用眼睛看一下,交換像實際搬動兩箱貨,後者費力得多。

🔆
生活妙喻:short bubble 提早停 ≈ 整理書架時發現已經整齊了

巡了一圈完全不用動任何一本書,就知道書架已排好,不必再巡。

本節字彙

排序 (sorting)
把集合中的元素依某種順序排列的過程。
🧠 sort=分門別類排好。
氣泡排序 (bubble sort)
反覆兩兩相鄰比較並交換,每輪把最大值推到定位的排序法。
🧠 大數像泡泡一輪輪浮上來。
交換 (exchange / swap)
把兩個放錯位置的元素對調,通常需暫存變數,成本較高。
🧠 swap=對調,Python 可用 a,b=b,a。
short bubble
偵測到某輪無交換即提早結束的氣泡排序改良版。
🧠 沒交換就早退。
氣泡排序在『每一輪』完成後,能確定什麼事?
對清單 [19, 1, 9, 7, 3, 10, 13, 15, 8, 12] 做氣泡排序,完整跑完『三輪』後,最大的三個數字會在哪裡?
為什麼氣泡排序的比較次數是 O(n²)?

選擇排序:每輪只交換一次

改良氣泡排序,每一輪先找出最大值再一次到位。

STEP 1

深度探秘

先找出最大值,最後才動手交換

選擇排序的改良點

**選擇排序(selection sort)**是氣泡排序的改良版。它的核心想法是:每一輪只交換一次

做法是:在一輪掃描中先用眼睛找出最大值在哪裡(記住它的位置),整輪掃完之後,才把這個最大值和該輪最後一格交換一次,讓它一步到位。

和氣泡排序一樣,第一輪後最大值就位、第二輪後次大值就位……同樣需要 $n-1$ 輪。

def selection_sort(a_list):
    for fill_slot in range(len(a_list) - 1, 0, -1):
        pos_of_max = 0
        for location in range(1, fill_slot + 1):
            if a_list[location] > a_list[pos_of_max]:
                pos_of_max = location
        temp = a_list[fill_slot]
        a_list[fill_slot] = a_list[pos_of_max]
        a_list[pos_of_max] = temp

注意內層迴圈只更新 pos_of_max(記位置),完全不交換;交換只在外層、每輪一次。

💡
關鍵

選擇排序每輪先找出最大值的位置,最後才交換一次到定位。

STEP 2

生活妙喻

選秀挑出最高的人再請他站定位

選秀比喻

想像你要幫一排人由矮到高排好。氣泡排序像『相鄰兩人不順眼就一直互換』,換個不停。

選擇排序更冷靜:你先用眼睛掃過所有人,記住『最高的是第幾個』,掃完才請他一個人走到隊伍最後。下一輪再掃剩下的人,找出次高的請他站倒數第二……

你看的次數(比較)跟氣泡排序一樣多,但你只請人移動最少的次數——每輪頂多請一個人換位置。

flowchart TD
    A[從未排序區開始掃描] --> B[記住目前看到的最大值位置]
    B --> C{還有下一個嗎}
    C -- 有 --> D[比較並更新最大值位置]
    D --> C
    C -- 沒有 --> E[把最大值與本輪末格交換一次]
    E --> F[進入下一輪 範圍縮小一格]
💡
關鍵

選擇排序像『先看清楚誰最高,再請他一次到位』,移動次數最少。

STEP 3

實用超能力

比較一樣多、交換卻少很多

比較次數:和氣泡一樣

選擇排序的比較次數和氣泡排序相同,都是把前 $n-1$ 個整數加起來:

$$\sum_{i=1}^{n-1} i = \frac{1}{2}n^2 - \frac{1}{2}n$$

所以時間複雜度仍是 $O(n^2)$。

但交換次數差很多

關鍵差別在交換:選擇排序每輪最多只交換一次,總共最多 $n-1$ 次。而氣泡排序在過程中可能交換非常多次。

書中那個 9 元素的例子:

排序法 交換次數
氣泡排序 20
選擇排序 8

由於交換是昂貴動作,選擇排序在實測中通常跑得比氣泡排序快。

重點觀念:兩個演算法雖然同屬 $O(n^2)$,實際速度仍可能有差,因為大 O 忽略了『比較』與『交換』成本不同這件事。

💡
關鍵

選擇排序與氣泡排序比較次數相同,但交換次數少很多,所以實測更快。

🔆
生活妙喻:選擇排序 ≈ 選秀先看誰最高再請他就位

整輪只用眼睛挑出最大值,最後請他一個人移動一次,移動成本最低。

🔆
生活妙喻:比較與交換成本不同 ≈ 看貨 vs 搬貨

用眼睛看哪箱最重很便宜,真的搬動箱子才費力;選擇排序看得多、搬得少。

本節字彙

選擇排序 (selection sort)
每輪先找出最大值位置、再交換一次使其就位的排序法。
🧠 select=挑選出最大值再放好。
pos_of_max
選擇排序中用來記住『目前看到的最大值在哪格』的變數。
🧠 記位置,不急著換。
原地排序
直接在原清單上交換、幾乎不需額外記憶體的排序方式。
🧠 in-place=就地解決。
選擇排序相對於氣泡排序,最主要的改良是什麼?
選擇排序與氣泡排序的時間複雜度都是 O(n²),但實測中選擇排序通常較快,為什麼?
對清單 [11, 7, 12, 14, 19, 1, 6, 18, 8, 20] 做選擇排序(每輪把最大值送到末端),完整跑完三輪後,下列哪個是合理的結果?

插入排序:像整理手中的撲克牌

維護一段已排序子清單,把新元素插回正確位置。

STEP 1

深度探秘

維護一段已排序子清單

已排序子清單、把新元素插回正確位置的排序法。">插入排序的核心

**插入排序(insertion sort)**雖然也是 $O(n^2)$,但做法很不一樣。它的關鍵是:在清單前段維護一段『已經排好序』的子清單,然後一次拿一個新元素,把它『插回』這段子清單的正確位置,讓已排序子清單長大一格。

一開始我們假設『只有第 0 格』本身就是已排序的子清單。接著從 index 1 到 $n-1$,每個元素都拿去和前面已排序的子清單比對。

def insertion_sort(a_list):
    for index in range(1, len(a_list)):
        current_value = a_list[index]
        position = index
        while position > 0 and a_list[position - 1] > current_value:
            a_list[position] = a_list[position - 1]
            position = position - 1
        a_list[position] = current_value

位移,不是交換

注意 a_list[position] = a_list[position - 1] 這行是位移(shift):把比 current_value 大的元素往右挪一格,騰出空間,最後才把 current_value 放進那個空位。這跟前面那種完整『交換』不同。

💡
關鍵

插入排序維護一段已排序子清單,把每個新元素位移插回正確位置。

STEP 2

生活妙喻

整理手上的撲克牌

撲克牌比喻

打牌時你抓牌、整理手牌的方式,幾乎就是插入排序:

  • 你手上左邊那幾張已經排好了。
  • 抓到一張新牌,你從右往左看,把比它大的牌往右推,騰出空檔。
  • 推到遇到比它小的牌(或推到最左)就停,把新牌插進那個空檔。

第五張牌插入的細節:已排好的是 17,26,54,77,93,現在要插 31。先比 93(往右推)、再比 77、54(都往右推),比到 26 比 31 小,停!把 31 放進空出的位置。現在已排好六張。

flowchart TD
    A[拿起下一個新元素] --> B{它左邊那張比它大嗎}
    B -- 是 --> C[把左邊那張往右移一格]
    C --> B
    B -- 否 或已到最左 --> D[把新元素放進空出的位置]
    D --> E[已排序子清單長大一格]
💡
關鍵

插入排序就像整理手牌:把新牌往左插進已排好的牌堆裡。

STEP 3

實用超能力

位移比交換便宜、近乎排好時超快

算成本

最差情況的比較次數一樣是前 $n-1$ 個整數的總和,所以是 $O(n^2)$。但最好情況(清單已排好)每輪只要比較一次就發現不用移動,這時接近 $O(n)$!

位移比交換便宜

一個常被忽略的重點:一次位移大約只要一次指定(assignment),而一次完整交換要三次指定。所以位移大約只花交換三分之一的工。這讓插入排序在實測中表現往往相當好。

動作 指定次數
交換(swap) 3
位移(shift) 1

什麼時候特別適合

  • 資料量小。
  • 資料幾乎已經排好(只有少數元素位置不對)。

這兩種情況下,插入排序又快又簡單,也是後面『希爾排序』的基礎。

💡
關鍵

插入排序用便宜的位移取代交換,對近乎排好的清單接近 O(n),表現很好。

🔆
生活妙喻:插入排序 ≈ 整理手中的撲克牌

左邊已排好,新牌從右往左找位置插進去,把大的牌往右推騰位。

🔆
生活妙喻:位移而非交換 ≈ 書架上挪書騰出一格

把右邊的書一本本往右推騰出空位,再把新書放進去,比兩本書互換省力。

🔆
生活妙喻:近乎排好時很快 ≈ 整理只亂了一兩張的牌

若手牌幾乎排好,每張新牌幾乎不用推,整理一下就完成。

本節字彙

插入排序 (insertion sort)
維護已排序子清單、把新元素插回正確位置的排序法。
🧠 insert=插入,一張張插回排好的牌堆。
位移 (shift)
把元素往一個方向挪一格以騰出空間,只需一次指定。
🧠 shift=挪一格,比交換省。
已排序子清單
插入排序中位於前段、已維持有序的那一段元素。
🧠 左邊那段永遠是排好的。
插入排序在進行時,清單『前段』始終維持什麼狀態?
插入排序用『位移(shift)』而非『交換(swap)』來騰位置,這帶來什麼好處?
對清單 [15, 5, 4, 18, 12, 19, 14, 10, 8, 20] 做插入排序,完整跑完『三輪』(處理完 index 1、2、3)後,下列哪個結果合理?
04

進階排序:更快的排序法

先用大間隔把資料粗略排好,再逐步縮小間隔。

先讀原文段落,旁邊就是白話

這是一本英文書。左邊放原文、右邊放白話導讀——你既讀得懂,也順手碰了原文。

原文 · 進階排序:更快的排序法 alled a merge, is performed. Merging is the process of taking two smaller sorted lists and combining them together into a single, sorted, new list. 22 shows our familiar example list as it is being split by merge_sort. 23 shows the simple lists, now sorted, as they are merged back together.
白話導讀

先用大間隔把資料粗略排好,再逐步縮小間隔。

希爾排序:間隔遞減的插入排序

先用大間隔把資料粗略排好,再逐步縮小間隔。

STEP 1

深度探秘

用間隔把清單拆成多個子清單

間隔把清單分組做插入排序的排序法。">希爾排序的巧思

希爾排序(shell sort)又叫『遞減增量排序』,是插入排序的升級版。它的獨門巧思在於怎麼挑子清單:不是把連續相鄰的元素分成一組,而是用一個間隔(gap,又叫 increment) $i$,每隔 $i$ 個取一個元素組成子清單,再對每個子清單做插入排序。

例如 9 個元素、間隔 3,就會分出 3 個子清單(位置 0,3,6/1,4,7/2,5,8)。把這幾個子清單各自排好之後,整串雖然還沒完全排好,但每個元素都被搬到更接近它最終位置的地方了

接著把間隔縮小(例如從 $\frac{n}{2}$、$\frac{n}{4}$……一路減到 1),最後一次間隔為 1 的排序,其實就是一次標準的插入排序。

💡
關鍵

希爾排序用間隔把清單分成多個子清單各自插入排序,再逐步縮小間隔。

STEP 2

生活妙喻

搬家先粗分區域再細排

搬家比喻

想像你要把一大堆書照編號排好。如果一開始就一本一本相鄰比較(純插入排序),離家很遠的書要一格一格慢慢挪過來,超累。

希爾排序的策略像搬家先『大跳躍歸位』:

  • 先用大間隔:把書粗略丟到『大概對的那一區』,遠方的書一次就跨很大步靠近目標。
  • 再用小間隔:在小範圍內微調。
  • 最後間隔 1:做一次細部插入排序,但因為大家都已經很接近定位,幾乎不用再挪。
flowchart TD
    A[大間隔 把元素大步搬到大概位置] --> B[縮小間隔 在較小範圍微調]
    B --> C[間隔縮到 1 做一次插入排序]
    C --> D[因為已近乎排好 這次幾乎不用搬]

關鍵直覺:早期的大間隔排序讓清單『越來越接近排好』,使最後那次插入排序變得超輕鬆。

💡
關鍵

希爾排序像搬家先大步粗分、再細排,讓最後的插入排序幾乎不用搬。

STEP 3

實用超能力

程式碼與介於 O(n) 到 O(n²) 的效能

程式碼

def shell_sort(a_list):
    sublist_count = len(a_list) // 2
    while sublist_count > 0:
        for start_position in range(sublist_count):
            gap_insertion_sort(a_list, start_position, 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):
        current_value = a_list[i]
        position = i
        while position >= gap and a_list[position - gap] > current_value:
            a_list[position] = a_list[position - gap]
            position = position - gap
        a_list[position] = current_value

gap_insertion_sort 其實就是一個『步幅為 gap』的插入排序——把原本的 -1 換成 -gap

效能

你可能會想:最後一步既然是完整插入排序,怎麼可能比插入排序快?關鍵就是:前面幾輪已經把清單變得『更有序』,最後那次插入排序需要的比較與位移都很少

希爾排序的完整分析很複雜,但大致落在 $O(n)$ 與 $O(n^2)$ 之間。若改用特定的間隔序列,例如 $2^k - 1$(1, 3, 7, 15, 31…),可達到約 $O(n^{3/2})$。間隔的選擇正是希爾排序效能的關鍵。

💡
關鍵

希爾排序效能介於 O(n) 與 O(n²) 之間,間隔序列的選擇是決定速度的關鍵。

🔆
生活妙喻:希爾排序 ≈ 搬家先大步粗分區域再細排

先用大間隔把遠方元素一次跨大步靠近目標,再逐步縮小間隔微調。

🔆
生活妙喻:間隔 gap ≈ 跳格子的步幅

gap 決定子清單由相隔多遠的元素組成,步幅大就跳得遠、歸位快。

🔆
生活妙喻:最後一次插入排序很輕鬆 ≈ 預先排過的考卷再校對一次

因為前面已大致排好,最後細校只需動到極少數位置。

本節字彙

希爾排序 (shell sort)
用遞減間隔把清單分組做插入排序的排序法。
🧠 間隔由大縮到 1,故稱遞減增量排序。
間隔 / gap (increment)
希爾排序中相隔多少個元素取一個來組子清單的步幅。
🧠 gap=間隙,跳幾格取一個。
gap_insertion_sort
步幅為 gap 的插入排序,把比較與位移的距離由 1 改成 gap。
🧠 插入排序把 -1 換成 -gap。
希爾排序與插入排序最根本的差別在哪裡?
為什麼希爾排序最後雖然要做一次『間隔 1』的標準插入排序,整體卻能比直接做插入排序快?
對清單 [5, 16, 20, 12, 3, 8, 9, 17, 19, 7] 以 gap=3 完成所有子清單的插入排序後,下列哪個結果合理?

合併排序:切一半再合併

遞迴地把清單切到剩一個,再把已排序的兩半合併起來。

STEP 1

深度探秘

分治法:切到剩一個,再合併回去

合併兩個已排序子清單的分治排序法。">合併排序的兩個動作

合併排序(merge sort)是用分治法改善排序效能的第一個例子。它是遞迴的,分兩個動作:

  1. 分(split):如果清單長度大於 1,就從中間切成左右兩半,對兩半各自遞迴呼叫 merge_sort。
  2. 合(merge):當兩半都排好後,把兩個已排序的小清單合併成一個更大的已排序清單。

基底條件(base case)是:清單空的或只有一個元素,依定義就已經排好,直接返回。

def merge_sort(a_list):
    if len(a_list) > 1:
        mid = len(a_list) // 2
        left_half = a_list[:mid]
        right_half = a_list[mid:]
        merge_sort(left_half)
        merge_sort(right_half)
        # 合併 left_half 與 right_half 回 a_list
        i = j = k = 0
        while i < len(left_half) and j < len(right_half):
            if left_half[i] < right_half[j]:
                a_list[k] = left_half[i]; i += 1
            else:
                a_list[k] = right_half[j]; j += 1
            k += 1
        while i < len(left_half):
            a_list[k] = left_half[i]; i += 1; k += 1
        while j < len(right_half):
            a_list[k] = right_half[j]; j += 1; k += 1

清單長度是奇數也沒關係,左右兩半最多差一個。

💡
關鍵

合併排序遞迴地把清單對半切到剩一個,再把已排序的兩半合併起來。

STEP 2

生活妙喻

兩疊已排好的考卷合成一疊

合併兩疊考卷

『合併』這個動作最好懂:想像你和同學各拿著一疊已經按分數由低到高排好的考卷,要合成一大疊排好的。

你們同時看各自最上面那張,誰的分數小就先放到新疊,然後那個人翻下一張。一直比下去,直到其中一疊先發完,再把另一疊剩下的整批接上去。因為兩疊原本就排好了,這樣合出來的大疊也是排好的。

flowchart TD
    A[原始清單] --> B[切成左半與右半]
    B --> C[左半再遞迴切到剩一個]
    B --> D[右半再遞迴切到剩一個]
    C --> E[兩兩合併成排好的小清單]
    D --> E
    E --> F[一層層合併成完整排好的清單]

切的時候像把考卷不斷對半分到每人手上只剩一張;合的時候再一層層比較合回去。

💡
關鍵

合併就像把兩疊各自排好的考卷,比最上面誰小誰先放,併成一大疊。

STEP 3

實用超能力

穩定的 O(n log n) 與空間代價

算成本

合併排序有兩個過程要算:

  • :把清單對半切,能切 $\log n$ 次(就像二分搜尋)。
  • :每一層合併都要碰過全部 $n$ 個元素。

所以是『$\log n$ 層 × 每層 $n$ 個工』:

$$O(n \log n)$$

更棒的是,不論輸入長怎樣,合併排序都保證是 $O(n \log n)$——這是它最大的賣點之一。

代價:額外空間

天下沒有白吃的午餐。left_half = a_list[:mid] 這類切片會複製出新清單,需要額外記憶體來存兩半。當資料很大時,這個額外空間可能成為問題。

另外,因為切片是 $O(k)$,嚴格來說要改成『傳起訖 index』才能保證真正的 $O(n \log n)$,但核心觀念不變。

一句話總結

合併排序:速度穩、保證 $O(n \log n)$,但要用記憶體換。

💡
關鍵

合併排序保證 O(n log n),但需要額外記憶體存切出來的兩半。

🔆
生活妙喻:合併排序 ≈ 把兩疊排好的考卷合成一疊

比較兩疊最上面誰小誰先放,逐張併出一大疊有序清單。

🔆
生活妙喻:分治法的分 ≈ 把考卷對半分到每人只剩一張

一張考卷本身就是『排好的』,正是遞迴的基底條件。

🔆
生活妙喻:額外空間代價 ≈ 合考卷需要一張空桌子鋪開

合併過程要另開空間暫放兩半,資料越大這張桌子越占地方。

本節字彙

合併排序 (merge sort)
遞迴對半切再合併兩個已排序子清單的分治排序法。
🧠 merge=合併,先切後併。
合併 (merge)
把兩個已排序小清單比對首元素、逐一取較小者併成一個有序清單。
🧠 兩疊比頭、誰小先放。
基底條件 (base case)
遞迴停止的條件,這裡是清單長度 ≤ 1 時直接視為已排序。
🧠 切到剩一個就停。
合併排序的『基底條件(base case)』是什麼?為什麼這時不用再做任何事?
合併排序為什麼是 O(n log n)?
合併兩個已排序子清單 [1, 5, 9] 與 [2, 6, 8] 時,第一個被放進結果清單的是哪個數?依據什麼?

快速排序:用 pivot 分邊

選一個 pivot 把資料分成左小右大,再各自遞迴排序,不需額外空間。

STEP 1

深度探秘

選一個基準值,把資料分成左小右大

快速排序的目標

快速排序(quick sort)同樣用分治法,想要拿到合併排序的速度優勢,卻不需要額外的記憶體空間。代價是:它不保證每次都對半切,切歪了效能會變差。

核心步驟:

  1. 選一個基準值(pivot value)(這裡簡單地用第一個元素)。
  2. 透過 partition(分割) 把比 pivot 小的移到左邊、比 pivot 大的移到右邊,找出 pivot 的最終位置——稱為分割點(split point)
  3. 對分割點左右兩半遞迴做快速排序。
def quick_sort(a_list):
    quick_sort_helper(a_list, 0, len(a_list) - 1)

def quick_sort_helper(a_list, first, last):
    if first < last:
        split_point = partition(a_list, first, last)
        quick_sort_helper(a_list, first, split_point - 1)
        quick_sort_helper(a_list, split_point + 1, last)

pivot 一旦放到分割點,它就永遠定位了,因為左邊全比它小、右邊全比它大。

💡
關鍵

快速排序選一個 pivot,用 partition 把資料分成左小右大,再各自遞迴。

STEP 2

生活妙喻

用一個身高基準把人分成兩排

分邊比喻

想像體育老師要排隊。他隨手指定一個同學當基準(pivot),然後喊:『比他矮的站左邊、比他高的站右邊!』喊完之後,這位基準同學站的位置就是他最終該站的位置(左邊都比他矮、右邊都比他高)。

接著對『左邊那群』和『右邊那群』各自再玩一次同樣的遊戲,遞迴下去,整排就排好了。

partition 怎麼動手

用兩個標記 left_mark(從左往右)和 right_mark(從右往左)夾擊:

  • left_mark 往右走,直到遇到一個比 pivot 大的值(這個該去右邊卻待在左邊)。
  • right_mark 往左走,直到遇到一個比 pivot 小的值。
  • 兩邊都卡住時,交換這兩個放錯邊的值,繼續夾。
  • 當 right_mark 跑到 left_mark 左邊時停止,right_mark 就是分割點,把 pivot 換過去定位。
flowchart TD
    A[選第一個元素當 pivot] --> B[left_mark 右移找比 pivot 大的]
    B --> C[right_mark 左移找比 pivot 小的]
    C --> D{兩標記交錯了嗎}
    D -- 否 --> E[交換這兩個放錯邊的值]
    E --> B
    D -- 是 --> F[把 pivot 換到 right_mark 即分割點]
💡
關鍵

partition 像喊『比基準矮的站左、高的站右』,讓 pivot 一次定位。

STEP 3

實用超能力

平均 O(n log n)、最差 O(n²) 與 median of three

算成本

  • 平均/理想:若每次 partition 都切在中間,會有 $\log n$ 層,每層檢查全部 $n$ 個元素,得 $O(n \log n)$,而且不用像合併排序那樣多花記憶體
  • 最差:若 pivot 每次都選到最小或最大值,切出來會是『0 個』和『$n-1$ 個』,極度不平衡,退化成 $O(n^2)$,還要背負遞迴開銷。
情況 複雜度 額外空間
合併排序 永遠 $O(n \log n)$ 需要
快速排序(平均) $O(n \log n)$ 幾乎不需要
快速排序(最差) $O(n^2)$ 幾乎不需要

median of three

為了避免切歪,可以用三數取中(median of three)選 pivot:看第一、中間、最後三個元素,取它們的中位數當 pivot。這在『原始資料本來就有點排序』時特別有用,能挑到比較像『中間值』的 pivot,降低退化成 $O(n^2)$ 的機會。

一句話:快速排序通常很快又省空間,但最怕 pivot 選得爛——所以怎麼選 pivot 很重要。

💡
關鍵

快速排序平均 O(n log n) 又省空間,但 pivot 選爛會退化成 O(n²),可用三數取中改善。

🔆
生活妙喻:快速排序 / pivot ≈ 用一個基準同學把大家分成矮的一排高的一排

比基準矮的站左、高的站右,基準同學當場定位,再對兩排各自重玩。

🔆
生活妙喻:partition 雙標記夾擊 ≈ 兩個人從兩端往中間找站錯邊的人

一個從左找太高的、一個從右找太矮的,找到就互換,直到交錯。

🔆
生活妙喻:最差情況退化 ≈ 每次都挑到全班最矮的當基準

基準若總是極端值,每次只分出一個人,等於沒切好,變得超慢。

本節字彙

快速排序 (quick sort)
選 pivot 做 partition 分邊、再遞迴排序的原地分治排序法。
🧠 quick=快,平均很快但怕選錯 pivot。
基準值 (pivot value)
用來把清單分成左小右大兩邊的參考元素。
🧠 pivot=支點,繞著它分兩邊。
分割點 (split point)
partition 後 pivot 的最終位置,左邊全比它小、右邊全比它大。
🧠 pivot 一到分割點就永久定位。
三數取中 (median of three)
取首、中、尾三元素的中位數當 pivot,以降低切歪風險。
🧠 三個裡挑中間,避免選到極端。
快速排序相對於合併排序,最主要的優勢是什麼?
partition 完成後,pivot 所在的『分割點』有什麼重要性質?
快速排序在什麼情況下會退化成 O(n²)?