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) 查找。

雜湊表與雜湊函式

用一個公式直接算出資料該放哪個格子,達到 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²) 排序法

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

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

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

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

進階排序:更快的排序法

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

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

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

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²)?