01

為什麼需要演算法分析

同一個演算法可以有很多種寫法,分析的是演算法本身而非程式碼風格。

先讀原文開場,旁邊就是白話

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

原文 · 演算法分析 CHAPTER TWO ALGORITHM ANALYSIS 2. 1 Objectives • To understand why algorithm analysis is important. • To be able to use “Big-O” to describe execution time. • To understand the “Big-O” execution time of common operations on Python lists and dictionaries.
白話導讀

同一個演算法可以有很多種寫法,分析的是演算法本身而非程式碼風格。

程式 vs 演算法

同一個演算法可以有很多種寫法,分析的是演算法本身而非程式碼風格。

STEP 1

深度探秘

演算法是「解法本身」,程式只是它的某種語言版本

一個常見的疑問

剛開始學寫程式時,大家很愛比較:「同一題,你的程式跟我的長得不一樣,到底誰寫得比較好?」

要回答這個問題,得先分清楚兩個容易混在一起的概念:

  • 演算法 (algorithm):一份抽象、一步一步的解題步驟說明。它不綁定任何程式語言,只要給定輸入,就能產生正確結果。
  • 程式 (program):把某個演算法用某種程式語言寫出來的成品。

同一個演算法,可以有「無數種」程式寫法——換個語言、換個變數名、換種寫法,都還是同一個演算法。

看下面兩段 Python,它們其實是同一個演算法(用累加器把 1 到 n 加起來),只是寫法天差地遠:

def sum_of_n(n):
    the_sum = 0
    for i in range(1, n + 1):
        the_sum = the_sum + i
    return the_sum

def foo(tom):
    fred = 0
    for bill in range(1, tom + 1):
        barney = bill
        fred = fred + barney
    return fred

foo 變數名亂取、還多了一個沒必要的指派,所以可讀性很差——但解法的本質完全一樣。

💡
關鍵

演算法是抽象的解題步驟,程式是它在某語言裡的一種實作;同一演算法可以有很多種程式。

STEP 2

生活妙喻

食譜 vs 一盤端出來的菜

把演算法想成「食譜」

  • 演算法就像一份食譜:先打蛋、再加糖、攪拌、進烤箱 180 度 20 分鐘。它描述的是「怎麼做」,跟你用哪個品牌的烤箱、用中文還是英文寫食譜都無關。
  • 程式則是照著食譜實際做出來的那盤菜:同一份食譜,阿明做一盤、阿華做一盤,賣相可能差很多,但「做法」是同一套。

所以當我們問「誰的程式比較好」,其實有兩種完全不同的問法:

你在意什麼 比較的對象 在這一章
好不好讀、好不好維護 程式碼風格 重要,但不是主角
解法本身有多省資源 演算法效率 ★ 本章主角

sum_of_nfoo 在「可讀性」上分高下,但在「演算法效率」上根本是同一回事。

💡
關鍵

食譜=演算法(抽象做法),端出的菜=程式(某次實作);可讀性和效率是兩條不同的評分線。

STEP 3

實用超能力

把焦點放在「運算資源」上

演算法分析在比什麼?

演算法分析 (algorithm analysis) 的核心:比較不同演算法使用運算資源的多寡,藉此說「這個比那個好,因為它更省」。

運算資源主要看兩種:

flowchart TD
    A[運算資源] --> B[空間 記憶體用量]
    A --> C[時間 執行所需步數]
    C --> D[本章重點 用 Big-O 描述時間]
  • 空間 (space):解題需要多少記憶體。通常由問題本身決定,偶爾某些演算法有特別的空間需求才需特別說明。
  • 時間 (time):執行所需的時間,又叫 execution time / running time,是本章的主要焦點。

實戰心法:當你看到兩段功能相同但寫法不同的程式,先問自己——「它們是不是同一個演算法?」如果是,那效率就一樣,差別只在風格;如果不是,才需要進一步用後面要學的 Big-O 來比效率。

💡
關鍵

演算法分析比的是運算資源(時間與空間)的使用效率,而不是程式碼好不好看。

🔆
生活妙喻:演算法與程式的關係 ≈ 食譜與端出來的菜

食譜是抽象做法(演算法),不綁語言或廚房;照著做出來的菜是程式,同一份食譜可以做出很多盤。

🔆
生活妙喻:可讀性 vs 效率 ≈ 字寫得漂亮 vs 答案算得對又快

字漂亮(可讀性)和解題又快又省(效率)是兩種不同的評分標準,一段程式可以字醜但解法很有效率,反之亦然。

本節字彙

algorithm(演算法)
一份與語言無關、一步一步的抽象解題步驟,給定輸入就能產生正確結果。
🧠 想成「食譜」:只講做法,不管你用哪個鍋。
program(程式)
把某個演算法用具體程式語言寫出來的成品。
🧠 照食譜端出來的那盤菜,每次做都可能長得不太一樣。
algorithm analysis(演算法分析)
依照演算法使用運算資源(時間、空間)的多寡來比較它們好壞的方法。
🧠 分析=幫演算法量「省不省」,不是量「美不美」。
小美把 sum_of_n 的變數改名、又多加一行沒必要的指派,寫成了 foo。從「演算法」的角度看,這兩段程式的關係是?
下列何者最能說明「演算法」與「程式」的差別?
演算法分析 (algorithm analysis) 主要是在比較什麼?

計時基準測試的陷阱

用 time 模組實際計時,並理解為何這種 benchmark 結果無法跨機器、跨語言比較。

STEP 1

深度探秘

用碼錶量真實秒數:benchmark 分析

最直覺的做法:拿碼錶計時

想知道 sum_of_n 跑多久?最直覺的方式就是基準測試 (benchmark):記下開始時間、跑完記下結束時間,相減就是花的秒數。

Python 的 time 模組有個 time() 函式,會回傳目前的系統時鐘秒數,前後各叫一次就能算出耗時:

import time

def sum_of_n_2(n):
    start = time.time()
    the_sum = 0
    for i in range(1, n + 1):
        the_sum = the_sum + i
    end = time.time()
    return the_sum, end - start

實測時可以看到一個規律:當 n 變成 10 倍,耗時也大約變成 10 倍。

n 約略耗時(秒)
10,000 0.0019
1,000,000 0.18

這暗示了 sum_of_n 的耗時和 n 大致成正比——但這還不是重點。

💡
關鍵

benchmark 就是用系統時鐘量出程式實際跑了幾秒,n 變大時這個迴圈版本的耗時也跟著成比例變大。

STEP 2

生活妙喻

不同跑道、不同天氣下的碼錶成績

用碼錶比兩個選手公平嗎?

還有一種完全不同的解法,直接套用數學公式,不用迴圈

$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$

def sum_of_n_3(n):
    return (n * (n + 1)) / 2

它快得驚人,而且不論 n 多大,耗時幾乎都是固定的一點點。

問題來了:如果我在老舊電腦上跑這個公式版、在最新電腦上跑迴圈版,碼錶可能反而說迴圈版比較快!

用碼錶量秒數,就像在不同跑道、不同天氣、不同鞋子下幫兩位選手計時——你量到的是「這次比賽的條件」,不是「選手本身的實力」。

flowchart TD
    A[同一段演算法] --> B[換一台電腦]
    A --> C[換一種語言]
    A --> D[換一個時段 系統忙碌程度]
    B --> E[碼錶秒數都不同]
    C --> E
    D --> E
    E --> F[無法當成演算法本身的客觀指標]
💡
關鍵

benchmark 量到的是『這次在這台機器、這個語言、這個時段』的結果,會隨環境變動,無法公平地比較演算法本身。

STEP 3

實用超能力

我們真正想要的:與實作無關的衡量

為什麼 benchmark 不夠用?

benchmark 量到的「真實秒數」其實依賴一堆與演算法無關的東西

  • 哪一台機器(CPU 新舊、規格)
  • 哪一種程式語言、哪一版編譯器/直譯器
  • 跑的時段(系統當下忙不忙)

換台機器或換個語言,數字就變了。所以它不能拿來公平比較「演算法本身」。

我們真正想要的是一種與程式、與電腦都無關的衡量方式——只描述演算法本身、可以跨實作比較。

實戰心法

  1. benchmark 仍然有用——當你想知道「在我這台機器上、現在這個版本」實際多快時。
  2. 但要比較「演算法誰比較好」時,碼錶會騙你,得改用下一節要學的 Big-O:它只看『工作量隨問題規模 n 怎麼成長』,不看你用哪台機器。
💡
關鍵

benchmark 依賴機器、語言、時段,無法客觀比較演算法;我們需要一個與實作無關、只看 n 成長趨勢的衡量法(Big-O)。

🔆
生活妙喻:benchmark 計時的侷限 ≈ 不同跑道、天氣下的碼錶成績

同一位選手在不同條件下計時會得到不同秒數,你量到的是比賽條件而非選手實力;benchmark 量到的是環境而非演算法本身。

🔆
生活妙喻:公式解 sum_of_n_3 的恆定耗時 ≈ 用乘法一次算出,不用一筆一筆加

套用 n(n+1)/2 公式就像直接報出答案,不管要加的數有多少,動作次數都一樣,所以耗時幾乎不隨 n 變。

本節字彙

benchmark(基準測試)
實際執行程式並記錄它花了多少真實時間(秒)的測量方法。
🧠 就是拿碼錶按下『開始/結束』量秒數。
execution time / running time(執行時間)
演算法執行完成所需的時間,是演算法分析最常關注的資源。
🧠 跑完一趟要多久。
implementation-independent(與實作無關)
一種不依賴特定機器、語言或環境的衡量方式,能單純描述演算法本身。
🧠 撇開『誰來跑、用什麼跑』,只看演算法自己。
阿傑在自己的筆電上用 time 模組量到演算法 X 花 0.2 秒、演算法 Y 花 0.5 秒,就斷言「X 一定比 Y 好」。這個推論最大的問題是?
對迴圈版的 sum_of_n_2 做 benchmark,發現 n 從 10,000 變成 1,000,000(10 倍再 10 倍)時,耗時大約也跟著變 10 倍。這現象暗示它的耗時大致與什麼成正比?
sum_of_n_3 直接用公式 n(n+1)/2 算總和,benchmark 顯示不論 n 多大耗時幾乎不變。最合理的解釋是?
02

Big-O 記號法

用基本運算次數定義 T(n),並理解為何只保留成長最快的主導項、丟掉常數與係數。

從 T(n) 到數量級

用基本運算次數定義 T(n),並理解為何只保留成長最快的主導項、丟掉常數與係數。

STEP 1

深度探秘

用步數函數 T(n) 描述工作量

數步數,而不是數秒數

既然碼錶會被環境干擾,那就改成數演算法要做幾個基本動作。先選一個合理的「基本運算單位」,再數它做了幾次。

sum_of_n 為例,選「指派 (assignment) 次數」當基本單位:

  • 一開始 the_sum = 01 次
  • 迴圈裡 the_sum = the_sum + i 跑 n 次:n 次

把總步數寫成一個函式 $T$:

$$T(n) = 1 + n$$

讀作:「解決規模為 $n$ 的問題要花 $1 + n$ 步。」這裡的 $n$ 稱為問題規模 (size of the problem)

規模越大,要做的步數通常越多——加總前 100,000 個整數,當然比加總前 1,000 個花更多步。我們想看的就是:步數如何隨 n 變化

💡
關鍵

T(n) 用基本運算次數描述演算法的工作量,n 是問題規模;sum_of_n 的 T(n)=1+n。

STEP 2

生活妙喻

看跑車輸給卡車?看主導項就懂了

n 變大時,誰在主導?

電腦科學家更進一步:精確步數不重要,重要的是「成長最快的那一項」。

看另一個例子:

$$T(n) = 5n^2 + 27n + 1005$$

  • 當 $n$ 很小(1、2),看起來是常數 1005 最大。
  • 但當 $n$ 越來越大,$n^2$ 那一項會狠狠壓過其他項——其他項變得微不足道。
  • 連 $n^2$ 前面的係數 5 也變得不重要。

所以我們說它的數量級 (order of magnitude) 是 $f(n) = n^2$,記成 $O(n^2)$。

這就像高速公路上比拉力:起步時小排量的車(常數項)可能領先,但路一拉長,大馬力的引擎(最高次項)一定遠遠甩開所有人。我們在乎的是『路夠長之後誰贏』。

flowchart TD
    A[T n 等於 5n平方 加 27n 加 1005] --> B[n 很小時 常數 1005 看起來最大]
    A --> C[n 很大時 n平方 項壓過一切]
    C --> D[丟掉低次項與係數]
    D --> E[數量級 O n平方]
💡
關鍵

n 夠大時,T(n) 中成長最快的主導項會壓過其餘項;數量級只保留這個主導項。

STEP 3

實用超能力

Big-O:丟掉常數與係數

化簡成 Big-O 的兩條規則

數量級函式描述 $T(n)$ 中隨 $n$ 增加成長最快的部分,常稱為 Big-O 記號(O 代表 order),寫成 $O(f(n))$。

把 $T(n)$ 化成 Big-O,只要兩步:

  1. 找出最高次(成長最快)的項 —— 其他低次項全部丟掉。
  2. 丟掉那一項的常數係數

回到 sum_of_n:$T(n) = 1 + n$。當 $n$ 變大,常數 1 越來越不重要,丟掉它:

$$T(n) = 1 + n ;\Rightarrow; O(n)$$

再看 $T(n) = 5n^2 + 27n + 1005$:保留最高次 $n^2$、丟掉係數 5 與其他項:

$$O(n^2)$$

T(n) 主導項 Big-O
$1 + n$ $n$ $O(n)$
$5n^2 + 27n + 1005$ $n^2$ $O(n^2)$

實戰心法:拿到一個步數式,先圈出指數最高的項,再把數字係數擦掉,剩下的就是 Big-O。它告訴你「規模放大時,工作量會怎麼長」,而不是「現在精確幾步」。

💡
關鍵

Big-O = 保留 T(n) 的最高次主導項,並丟掉常數與係數,描述工作量隨 n 的成長趨勢。

🔆
生活妙喻:主導項壓過其他項 ≈ 高速公路上比馬力

起步時小車(常數項)可能領先,但路一長,大馬力引擎(最高次項)一定遠遠甩開;n 夠大時最高次項主導一切。

🔆
生活妙喻:丟掉常數與係數 ≈ 報路程只說『大概幾百公里』

描述趨勢時不必精確到個位數,幾百公里就夠;Big-O 也只保留主導項,省略不影響大趨勢的常數與係數。

本節字彙

T(n)(步數函式)
把演算法解決規模 n 的問題所需的基本運算次數寫成的函式。
🧠 T 想成 Time/Total steps,括號裡放問題規模 n。
problem size(問題規模 n)
用來衡量輸入大小的量,例如要加總的整數個數、字串長度。
🧠 n 越大=問題越大份。
Big-O / order of magnitude(數量級)
描述 T(n) 中成長最快的主導項,丟掉常數與係數後得到的記號,寫成 O(f(n))。
🧠 O for Order:只記『成長最快的那一項』。
某演算法的精確步數為 $T(n) = 1 + n$。它的 Big-O 是什麼?
對 $T(n) = 5n^2 + 27n + 1005$,為什麼當 n 很大時我們可以把 27n 和 1005 都丟掉?
把 $T(n)$ 化成 Big-O 的兩個關鍵動作是?

常見的成長等級

認識常數、對數、線性、log 線性、平方、立方、指數等常見數量級,以及最佳/最差/平均情況。

STEP 1

深度探秘

七種你會一再遇到的成長等級

常見的數量級家族

研究演算法時,會反覆碰到下面這幾個 Big-O 函式。由慢成長(好)到快成長(壞)排列:

$f(n)$ 名稱 直覺
$1$ 常數 Constant 不管 n 多大,動作次數固定
$\log n$ 對數 Logarithmic 每次把問題砍半,超省
$n$ 線性 Linear 規模放大幾倍,工作量就幾倍
$n \log n$ log 線性 Log Linear 好的排序大約是這個等級
$n^2$ 平方 Quadratic 常見於雙層巢狀迴圈
$n^3$ 立方 Cubic 三層巢狀迴圈
$2^n$ 指數 Exponential 規模一大就爆炸,幾乎不可行

越往下走,n 變大時耗時暴增得越誇張。挑演算法時,當然希望盡量往表格上方靠。

💡
關鍵

七種常見數量級從 O(1)、O(log n)、O(n)、O(n log n)、O(n^2)、O(n^3) 到 O(2^n),越往後成長越快越糟。

STEP 2

生活妙喻

小 n 看不出差別,大 n 見真章

為什麼要看「n 很大」的時候?

當 n 很小時,這些函式擠在一起、難分高下——常數項甚至可能讓『理論上較慢』的演算法暫時跑贏。但只要 n 一拉大,它們之間的差距就壁壘分明。

想像四個人爬樓梯到不同樓層:搭電梯($\log n$)、走樓梯($n$)、每層都繞一圈再上($n^2$)、每層把所有組合都走一遍($2^n$)。只爬 2 層,大家差不多;但要上 100 層,搭電梯的早就到了,$2^n$ 那位可能這輩子都到不了。

flowchart TD
    A[n 很小] --> B[各等級擠在一起 難分高下]
    C[n 很大] --> D[O log n 最快]
    C --> E[O n 次之]
    C --> F[O n平方 明顯變慢]
    C --> G[O 2的n 次方 爆炸性變慢]

這也是為什麼 Big-O 永遠在問:「當 n 趨近很大,誰主導?」

💡
關鍵

n 小時各等級難分高下,n 大時差距才壁壘分明,所以 Big-O 永遠看大 n 的成長趨勢。

STEP 3

實用超能力

最佳、最差、平均情況

同一演算法也可能有好幾個臉孔

有時候演算法的效能不只看問題規模 n,還取決於資料的實際內容。這時要分三種情況描述:

  • 最佳情況 (best case):資料剛好讓它跑超快。
  • 最差情況 (worst case):碰到特別刁鑽的資料,跑得特別慢。
  • 平均情況 (average case):一般狀況下落在兩者之間。

例子:在一堆名單裡找某個名字。如果要找的人剛好排第一個(最佳),找一次就好;如果排最後或根本不在(最差),得整份翻完。同一個『逐一找』演算法,會因資料而有天壤之別。

實戰心法

  1. 報告效率時,講清楚你說的是哪一種情況——很多演算法的『最差』比『平均』糟很多。
  2. 不要被某個特別好(或特別壞)的個案誤導,要看它在實務上最常發生的情況,並對最差情況有心理準備。
💡
關鍵

當效能取決於資料內容時,要區分最佳、最差、平均情況,避免被單一個案誤導。

🔆
生活妙喻:不同成長等級的差距 ≈ 爬樓梯:電梯 vs 樓梯 vs 走遍所有組合

爬少數幾層大家差不多,但要爬上百層,O(log n) 早就到了、O(2^n) 卻可能永遠到不了;n 越大等級差距越懸殊。

🔆
生活妙喻:最佳/最差/平均情況 ≈ 在名單裡找名字

要找的人排第一是最佳、排最後或不在是最差、平常落在中間是平均;同一演算法因資料不同而有不同表現。

本節字彙

logarithmic(對數 $O(\log n)$)
每一步把問題規模大約砍半的成長等級,非常省,規模翻倍只多一步左右。
🧠 對半砍、對半砍……砍幾次到 1 就是 log n。
exponential(指數 $O(2^n)$)
規模每增加 1,工作量大約翻倍的成長等級,n 一大就爆炸,通常不可行。
🧠 2 的 n 次方:n 多 1,量就乘 2。
worst case(最差情況)
某組特別不利的資料下,演算法表現最差的情形。
🧠 墨菲定律版:最壞會發生的那一種輸入。
下列哪一個成長等級,當 n 變得非常大時「成長最快、最糟糕」?
為什麼比較不同 Big-O 等級時,要看「n 很大」的情況而不是「n 很小」?
一個演算法在某些資料下飛快、某些刁鑽資料下卻很慢。我們應該如何描述它的效能?

分析真實程式碼

練習數一段巢狀迴圈程式的指派次數,得到 T(n) 後判斷 Big-O。

STEP 1

深度探秘

拆成區塊、逐塊累加步數

把程式拆成幾塊來數

看一段示範程式(它不做什麼有意義的事,但很適合練習分析):

a = 5
b = 6
c = 10
for i in range(n):
    for j in range(n):
        x = i * i
        y = j * j
        z = i * j
for k in range(n):
    w = a * k + 45
    v = b * b
d = 33

把指派次數拆成四塊累加:

  1. 開頭 a, b, c 三個指派:常數 3
  2. 雙層巢狀迴圈裡有 3 個指派,各跑 $n^2$ 次:$3n^2$。
  3. 單層迴圈裡有 2 個指派,各跑 $n$ 次:$2n$。
  4. 最後 d = 33:常數 1

加起來:

$$T(n) = 3 + 3n^2 + 2n + 1 = 3n^2 + 2n + 4$$

💡
關鍵

分析程式時把它拆成幾個區塊,分別數每塊的指派次數再累加,得到完整的 T(n)。

STEP 2

生活妙喻

巢狀迴圈=乘法

迴圈巢狀就是次數相乘

判斷迴圈貢獻多少次數,有個超好用的直覺:迴圈疊幾層,n 就乘幾次。

想像你在填一張 $n \times n$ 的座位表:外層迴圈是『第幾排』,內層是『第幾個座位』。要填滿整張表,得做 $n \times n = n^2$ 次。每多巢狀一層,就像多了一個維度,次數再乘一個 n。

flowchart TD
    A[單層迴圈 跑 n 次] --> B[貢獻 n]
    C[雙層巢狀 跑 n 乘 n 次] --> D[貢獻 n平方]
    E[三層巢狀 跑 n 乘 n 乘 n 次] --> F[貢獻 n立方]
  • 沒在迴圈裡的指派:只算 1 次(常數)。
  • 在單層迴圈裡:乘 $n$。
  • 在雙層巢狀迴圈裡:乘 $n^2$。

所以看到雙層巢狀就先警覺:這裡很可能藏著 $n^2$。

💡
關鍵

迴圈每多巢狀一層,內部動作的執行次數就多乘一個 n:單層 n、雙層 n^2、三層 n^3。

STEP 3

實用超能力

看指數最高項決定 Big-O

從 T(n) 收斂到 Big-O

有了 $T(n) = 3n^2 + 2n + 4$,套用前面學的規則:看指數最高的項。

  • 最高次是 $n^2$,它會主導;其餘 $2n$ 與常數 4 丟掉,係數 3 也丟掉。

$$T(n) = 3n^2 + 2n + 4 ;\Rightarrow; O(n^2)$$

實戰演練——快速判讀幾個常見片段:

# 片段一:雙層巢狀 → O(n^2)
for i in range(n):
    for j in range(n):
        test = test + i * j

# 片段二:兩個各自獨立的單層迴圈 → n + n = 2n → O(n)
for i in range(n):
    test = test + 1
for j in range(n):
    test = test - 1

# 片段三:每次 i 砍半 → O(log n)
i = n
while i > 0:
    k = 2 + 2
    i = i // 2

心法:先看迴圈巢狀層數抓出最高次項(並注意『砍半』型迴圈是對數),兩個並排的迴圈是相加不是相乘,最後丟掉常數與係數就得到 Big-O。

💡
關鍵

拿到 T(n) 後保留指數最高的主導項、丟掉常數與係數,即得 Big-O;巢狀相乘、並排相加、砍半是對數。

🔆
生活妙喻:雙層巢狀迴圈是 $n^2$ ≈ 填滿 n 乘 n 的座位表

外層選排、內層選座位,填滿整張表要做 n×n 次;每多巢狀一層就像多一個維度,次數再乘一個 n。

🔆
生活妙喻:兩個並排迴圈相加 ≈ 先掃完一條走廊,再掃另一條走廊

兩個獨立、非巢狀的單層迴圈是先後各跑 n 次,合計 2n(相加),化簡後仍是 O(n),不是 n^2。

本節字彙

nested loop(巢狀迴圈)
迴圈裡面又包著迴圈,內部動作的執行次數是各層次數相乘。
🧠 圈中圈:層數相乘,雙層就是 n²。
dominant term(主導項)
T(n) 中指數最高、n 變大時成長最快、最終決定 Big-O 的那一項。
🧠 指數最大的就是老大,其他都退場。
$O(\log n)$ 迴圈
每次把計數變數大約砍半(如 i = i // 2)的迴圈,執行次數約為 log n。
🧠 看到『砍半』就想到對數。
以下程式片段的 Big-O 執行時間是? ```python test = 0 for i in range(n): for j in range(n): test = test + i * j ```
以下程式片段的 Big-O 執行時間是? ```python test = 0 for i in range(n): test = test + 1 for j in range(n): test = test - 1 ```
以下程式片段的 Big-O 執行時間是? ```python i = n while i > 0: k = 2 + 2 i = i // 2 ```
03

變位詞偵測:同一問題的四種解法

解法一用巢狀比對勾消字元得到 O(n^2);解法二先排序再比對,效率被排序主導。

逐一勾消法與排序比對法

解法一用巢狀比對勾消字元得到 O(n^2);解法二先排序再比對,效率被排序主導。

STEP 1

深度探秘

什麼是變位詞?解法一:逐一勾消

變位詞偵測問題

如果一個字串是另一個字串重新排列而成,它們就是變位詞 (anagram)。例如 heartearthpythontyphon。假設兩字串等長、都由 26 個小寫英文字母組成,目標是寫一個函式判斷它們是不是變位詞。

解法一:逐一勾消 (checking off) —— 檢查 s1 的每個字元是否都出現在 s2 中,找到就把它『勾消』(換成 None)。

def anagram_solution1(s1, s2):
    a_list = list(s2)
    pos1 = 0
    still_ok = True
    while pos1 < len(s1) and still_ok:
        pos2 = 0
        found = False
        while pos2 < len(a_list) and not found:
            if s1[pos1] == a_list[pos2]:
                found = True
            else:
                pos2 = pos2 + 1
        if found:
            a_list[pos2] = None
        else:
            still_ok = False
        pos1 = pos1 + 1
    return still_ok

注意這是雙層迴圈:外層掃 s1 的每個字元,內層在 s2 的清單裡找它。

💡
關鍵

變位詞是彼此重排而成的字串;解法一用雙層迴圈,逐一拿 s1 的字元到 s2 清單裡找並勾消。

STEP 2

生活妙喻

為什麼勾消法是 O(n^2)?

數一數總共要看幾次

s1 有 $n$ 個字元,每個字元都可能要掃過 s2 清單裡最多 $n$ 個位置。最壞情況下,造訪次數是 1 到 n 的總和:

$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2} = \frac{1}{2}n^2 + \frac{1}{2}n$$

n 變大時 $n^2$ 項主導,$\frac{1}{2}$ 係數可丟,所以是 $O(n^2)$。

像在一疊 n 張名片裡幫每個人找配對:第一個人翻整疊、第二個人翻剩下的……每個人都得翻一輪,加起來大約是 $n^2$ 的工作量。

flowchart TD
    A[外層 取 s1 的每個字元 共 n 個] --> B[內層 在 s2 清單裡逐一尋找 最多 n 次]
    B --> C[總造訪次數 約 n 乘 n 的一半]
    C --> D[數量級 O n平方]
💡
關鍵

勾消法每個 s1 字元都要掃 s2 最多 n 次,總和為 n(n+1)/2,主導項是 n^2,故 O(n^2)。

STEP 3

實用超能力

解法二:排序後比對,但別被表象騙了

排序比對法

解法二:先排序再比對 (sort and compare) —— 兩個變位詞排序後一定變成完全相同的字串,所以把兩字串各自排序,再逐位比對:

def anagram_solution2(s1, s2):
    a_list1 = list(s1)
    a_list2 = list(s2)
    a_list1.sort()
    a_list2.sort()
    pos = 0
    matches = True
    while pos < len(s1) and matches:
        if a_list1[pos] == a_list2[pos]:
            pos = pos + 1
        else:
            matches = False
    return matches

陷阱來了:乍看最後只有一個單層迴圈比對 n 個字元,好像是 $O(n)$?

別忘了那兩次 sort() 不是免費的!排序通常是 $O(n^2)$ 或 $O(n \log n)$(後面章節會學)。排序的成本主導了後面的單層比對。

心法:分析整體 Big-O 時,要看最貴的那一步。這裡最貴的是排序,所以整體 Big-O 等於排序的數量級,而不是那個誤導人的單層迴圈。

💡
關鍵

排序比對法的單層比對是 O(n),但兩次排序(O(n log n) 或 O(n^2))才是最貴步驟,主導整體 Big-O。

🔆
生活妙喻:勾消法 O(n^2) ≈ 在一疊名片裡幫每個人找配對

每個人都要翻過一整輪名片,n 個人加起來大約是 n×n 的工作量,所以是平方等級。

🔆
生活妙喻:排序成本主導整體 ≈ 搬家:打包最花時間,開門只是一瞬間

整趟搬家的時間由最耗時的打包決定,不是由最後開門那一下決定;整體 Big-O 由最貴的排序步驟主導,而非便宜的比對。

本節字彙

anagram(變位詞)
由另一個字串的字母重新排列而成的字串,例如 heart 與 earth。
🧠 字母全一樣,只是換位置坐。
checking off(逐一勾消)
解法一的策略:把 s1 的每個字元拿到 s2 中尋找並標記,全找到就是變位詞。
🧠 找到一個就劃掉一個,像點名打勾。
dominant step(主導步驟)
演算法中成本最高、決定整體 Big-O 的那個步驟,例如排序比對法中的排序。
🧠 整體快慢看最慢的那一關。
勾消法 anagram_solution1 的 Big-O 是 $O(n^2)$。最主要的原因是?
排序比對法 anagram_solution2 最後只有一個單層迴圈比對 n 個字元。為什麼它的整體 Big-O 並不是 $O(n)$?
下列哪一組字串「不是」變位詞?

暴力法的災難與計數比對法

暴力法產生所有排列是 O(n!) 完全不可行;計數比對法用 26 個計數器達到 O(n),但犧牲空間換時間。

STEP 1

深度探秘

解法三:暴力法 O(n!) 的恐怖

暴力法:把所有可能都試一遍

暴力法 (brute force) 的精神是「窮舉所有可能性」。對變位詞問題來說,就是用 s1 的字母產生所有可能的排列字串,再看 s2 有沒有出現在裡面。

問題在於排列的數量:第一個位置有 $n$ 種選擇、第二個有 $n-1$ 種、第三個有 $n-2$ 種……一路下去,總數是

$$n \times (n-1) \times (n-2) \times \cdots \times 2 \times 1 = n!$$

而 $n!$(階乘)成長得比指數 $2^n$ 還快

若 s1 長 20 個字元,就有 $20! = 2{,}432{,}902{,}008{,}176{,}640{,}000$ 種候選字串。就算一秒處理一個,也要約 771 億年才跑得完!

flowchart TD
    A[暴力法 產生 s1 的所有排列] --> B[排列數為 n 階乘]
    B --> C[n 階乘 成長比 2 的 n 次方 還快]
    C --> D[20 個字元就要 771 億年]
    D --> E[結論 完全不可行]
💡
關鍵

暴力法窮舉 s1 的所有排列,數量是 n!,成長比指數還快,實務上完全不可行。

STEP 2

生活妙喻

解法四:計數比對,聰明的線性解

用 26 個計數器數字母

解法四:計數比對 (count and compare) —— 兩個變位詞,每個字母出現的次數一定一樣(一樣多的 a、一樣多的 b……)。於是用 26 個計數器,分別數兩字串中各字母出現幾次,最後比兩排計數器是否完全相同。

def anagram_solution4(s1, s2):
    c1 = [0] * 26
    c2 = [0] * 26
    for i in range(len(s1)):
        pos = ord(s1[i]) - ord('a')
        c1[pos] = c1[pos] + 1
    for i in range(len(s2)):
        pos = ord(s2[i]) - ord('a')
        c2[pos] = c2[pos] + 1
    j = 0
    still_ok = True
    while j < 26 and still_ok:
        if c1[j] == c2[j]:
            j = j + 1
        else:
            still_ok = False
    return still_ok

就像兩家超商各自盤點 26 種商品的庫存表,最後把兩張表並排對一對——只要逐項清點,不必把商品兩兩配對。

關鍵:這裡的迴圈都沒有巢狀!前兩個數字母的迴圈各跑 n 次,第三個比對迴圈固定跑 26 次。

💡
關鍵

計數比對法用 26 個計數器分別統計字母次數再比對,全是非巢狀迴圈。

STEP 3

實用超能力

O(n) 的代價:用空間換時間

算一下計數比對法的 Big-O

把步數加起來:兩個數字母迴圈各 $n$ 次,比對迴圈固定 $26$ 次:

$$T(n) = 2n + 26$$

丟掉常數 26 與係數 2,得到 $O(n)$——線性!這是四種解法裡最好的。

解法 策略 Big-O
1 勾消 雙層比對 $O(n^2)$
2 排序比對 排序後比 $O(n \log n)$(受排序主導)
3 暴力 窮舉排列 $O(n!)$
4 計數比對 數字母再比 $O(n)$

但天下沒有白吃的午餐:解法四為了跑出線性時間,多用了兩個長度 26 的計數陣列——它犧牲空間換取時間

心法:演算法設計常要在時間與空間之間取捨 (time-space trade-off)。這裡多用一點空間很划算;但若字母表有數百萬種字元,額外空間就值得擔心了。身為設計者,要依問題情境決定怎麼用運算資源最划算。

💡
關鍵

計數比對法 T(n)=2n+26 是 O(n),但用額外計數陣列換來速度,體現時間與空間的取捨。

🔆
生活妙喻:暴力法 O(n!) 的爆炸 ≈ 把 20 個字母的所有排法都試一遍

排列數是 n 階乘,20 個字母就有兩百多兆種,一秒試一個也要 771 億年,完全不切實際。

🔆
生活妙喻:計數比對法 ≈ 兩家店並排對庫存盤點表

各自統計 26 種商品的數量,最後逐項對一對即可判定是否一致,不必把商品兩兩配對,所以是線性。

🔆
生活妙喻:時間與空間取捨 ≈ 多租一個倉庫換更快出貨

計數比對法多用兩個計數陣列(空間),換來線性的速度(時間);多用空間是否划算要看問題情境。

本節字彙

brute force(暴力法)
窮舉所有可能性來解題的策略,常常成本極高(如此處的 O(n!))。
🧠 硬碰硬把所有可能都試一遍。
factorial $n!$(階乘)
n×(n−1)×…×2×1,成長速度比指數 $2^n$ 還快的數量級。
🧠 排隊全排列的種數,n 一大就天文數字。
time-space trade-off(時間空間取捨)
用更多記憶體換取更快速度(或反之)的設計抉擇。
🧠 拿空間買時間,看划不划算。
暴力法產生 s1 的所有排列來找變位詞,其候選字串數量是 $n!$。為什麼這個方法實務上不可行?
計數比對法 anagram_solution4 為什麼能達到 $O(n)$?
計數比對法能跑出線性時間,但書中特別提醒它付出了什麼代價?
04

Python 資料結構的效能

索引與 append 是 O(1),串接與 pop(0) 是 O(n),並用 timeit 驗證這些差異。

List 操作的效率

索引與 append 是 O(1),串接與 pop(0) 是 O(n),並用 timeit 驗證這些差異。

STEP 1

深度探秘

哪些 list 操作快、哪些慢

Python list 各操作的 Big-O

Python 設計者在實作 list 時做了取捨:讓最常用的操作盡量快,少數較不常用的操作就犧牲一些。結果如下表:

操作 Big-O
索引 x[i] $O(1)$
索引指派 x[i] = v $O(1)$
append $O(1)$
pop()(尾端) $O(1)$
pop(i)(指定位置) $O(n)$
insert(i, item) $O(n)$
del $O(n)$
迭代 iteration $O(n)$
in(contains) $O(n)$
切片取得 [x:y] $O(k)$
串接 concatenate $O(k)$
sort $O(n \log n)$

重點先記住:索引、index 指派、append、尾端 pop() 都是 $O(1)$(超快、與長度無關);而 insertpop(0)indel 等需要搬動或掃描元素的,是 $O(n)$。

💡
關鍵

list 的索引、index 指派、append、尾端 pop() 是 O(1);insert、pop(0)、in、del 等需搬移或掃描元素的是 O(n)。

STEP 2

生活妙喻

append vs 串接:建一條 list 的四種方法

建立 0 到 n 的清單,哪種寫法快?

書中比較四種建清單的方法:

def test1():           # 用串接 +
    l = []
    for i in range(1000):
        l = l + [i]

def test2():           # 用 append
    l = []
    for i in range(1000):
        l.append(i)

def test3():           # list comprehension
    l = [i for i in range(1000)]

def test4():           # list(range(...))
    l = list(range(1000))

timeit 跑 1000 次的結果(毫秒):

方法 約略耗時
串接 concat 6.54
append 0.31
comprehension 0.15
list(range) 0.07

串接 l = l + [i] 像每加一本書就整箱書搬到新箱子重排一次($O(k)$);append 則像直接把書塞進現有箱子的空位($O(1)$)。難怪 append 快了 20 倍,而 list comprehension 又比 append 快約一倍。

💡
關鍵

append 是 O(1) 遠快於 O(k) 的串接;list comprehension 又比 append 迴圈更快,選對建清單的工具差很多。

STEP 3

實用超能力

為什麼 pop(0) 比 pop() 慢這麼多

pop() 與 pop(0) 的天壤之別

pop()(拿尾端)是 $O(1)$,但 pop(0)(拿開頭)是 $O(n)$。原因藏在 Python 的實作:從開頭拿走一個元素時,後面所有元素都要往前挪一格

flowchart TD
    A[pop 從尾端拿] --> B[直接移除最後一個 其他不動] --> C[O 1]
    D[pop 0 從開頭拿] --> E[後面每個元素都往前挪一格] --> F[O n]

這是設計者刻意的取捨——正因如此,索引 x[i] 才能維持 $O(1)$。

timeit 在兩百萬元素的 list 上實測:pop() 約 0.0003 毫秒,pop(0) 約 4.82 毫秒,差了約 16,000 倍!而且在不同長度的 list 上重複測量會看到:pop() 耗時幾乎不變(平的線),pop(0) 耗時隨長度線性上升——正好印證 $O(1)$ 與 $O(n)$。

實戰心法:需要反覆從開頭刪元素時,別用 list 的 pop(0);想清楚操作落在 $O(1)$ 還是 $O(n)$,在對的場合選對的工具,效能差距可能上萬倍。

💡
關鍵

pop() 是 O(1)、pop(0) 是 O(n),因為從開頭移除要把後面所有元素往前挪;選對操作效能可差上萬倍。

🔆
生活妙喻:append vs 串接 ≈ 塞進現有箱子 vs 每次整箱搬到新箱

append 直接放進現有空位是 O(1);串接 l = l + [i] 等於把整箱重排一次是 O(k),所以 append 快很多。

🔆
生活妙喻:pop(0) 是 O(n) ≈ 排隊第一個人走了,後面所有人往前挪一步

從 list 開頭移除元素時,後面每個元素都要往前移一格,元素越多搬得越久,所以是線性。

本節字彙

$O(1)$(常數時間)
操作耗時與資料結構大小無關,不論多大都一樣快,如索引與 append。
🧠 一步到位,跟 list 多長無關。
concatenation(串接)
用 + 把兩個 list 接起來的操作,成本 O(k),k 為被接上 list 的大小。
🧠 + 接清單要複製,比 append 慢。
timeit(計時模組)
Python 用來在一致、乾淨環境下重複執行並量測程式碼耗時的模組。
🧠 比手動 time 更可靠的碼錶。
下列哪一個 list 操作「不是」 $O(1)$?
實驗中 `l = l + [i]`(串接)建清單比 `l.append(i)` 慢很多。主因是?
在兩百萬元素的 list 上,`pop()` 約 0.0003 毫秒、`pop(0)` 約 4.82 毫秒。最能解釋這差距的是?

Dictionary 操作與計時實驗

字典以鍵存取,get/set/contains 平均都是 O(1),並用實驗對比 list 與 dict 的 in 操作。

STEP 1

深度探秘

字典用鍵存取,多數操作是 O(1)

Python dictionary 各操作的 Big-O

字典 (dictionary) 和 list 最大的不同:你用鍵 (key) 而不是位置來存取資料。它的操作效率如下:

操作 Big-O
copy 複製 $O(n)$
get item 取值 $O(1)$
set item 設值 $O(1)$
delete item 刪除 $O(1)$
in(contains) $O(1)$
iteration 迭代 $O(n)$

重點:取值、設值、刪除、用 in 檢查某個鍵在不在,平均都是 $O(1)$——不論字典裡有多少筆資料都一樣快。

一個重要的小註腳:表中的效率是平均情況 (average case)。在某些罕見情形下,contains、get item、set item 可能退化成 $O(n)$;至於為什麼,要等後面講字典的實作方式時才會揭曉。

💡
關鍵

字典用鍵存取,get item、set item、delete、contains 平均都是 O(1);這些是平均情況,罕見時可能退化成 O(n)。

STEP 2

生活妙喻

list 的 in vs 字典的 in

找東西:翻箱倒櫃 vs 直接定位

同樣是檢查「某個值在不在容器裡」,list 和 dict 差很多:

  • list 的 in 是 $O(n)$:得從頭一個一個比對,最壞要翻完整個 list。
  • 字典的 in 是 $O(1)$:透過鍵直接定位,不管字典多大都一樣快。

list 找東西像在沒有索引的倉庫裡翻箱倒櫃,東西越多翻越久;字典找東西像查有頁碼的字典/電話簿,直接翻到那一頁,跟整本多厚無關。

flowchart TD
    A[檢查某值是否在容器中] --> B[用 list 的 in]
    A --> C[用字典的 in]
    B --> D[逐一比對 最壞翻完整個 list O n]
    C --> E[用鍵直接定位 O 1]

書中的對照實驗顯示:當容器有 10,000 筆時字典比 list 快約 89 倍;到了 990,000 筆時,竟快了約 11,603 倍

💡
關鍵

list 的 in 是 O(n)(逐一比對),字典的 in 是 O(1)(用鍵直接定位);規模越大,字典的速度優勢越懸殊。

STEP 3

實用超能力

用 timeit 做可靠的對照實驗

如何公平地比較 list 與 dict

書中用一段實驗驗證上述差異——對同樣的值,分別在 list 與 dict 上做 number in container

import timeit
import random

for i in range(10000, 1000001, 20000):
    t = timeit.Timer("random.randrange(%d) in x" % i,
                     "from __main__ import random, x")
    x = list(range(i))            # x 是 list
    lst_time = t.timeit(number=1000)
    x = {j: None for j in range(i)}  # x 改成 dict
    d_time = t.timeit(number=1000)
    print("%d,%10.3f,%10.3f" % (i, lst_time, d_time))

結果:list 的 in 耗時隨規模線性上升(印證 $O(n)$);dict 的 in 耗時幾乎恆定不變(印證 $O(1)$)——10,000 筆與 990,000 筆都約 0.004 毫秒。

為什麼用 timeit 而不是手動 time?

  • 它在乾淨、隔離的命名空間裡跑(from __main__ import ... 把要測的東西匯入),避免你隨手建的變數干擾結果。
  • 重複執行多次(這裡每項 1000 次),用統計平均把『電腦剛好在忙』之類的雜訊抹平,量測更可靠。

實戰心法:當你要頻繁查詢「某個東西在不在」,且資料量大,優先選字典(或集合)而非 list——這是把 $O(n)$ 換成 $O(1)$ 的關鍵決策。

💡
關鍵

用 timeit 在乾淨環境重複多次量測,能可靠驗證 list 的 in 隨規模線性上升、dict 的 in 恆定;頻繁查詢時選字典。

🔆
生活妙喻:list 的 in 是 O(n) ≈ 在沒有索引的倉庫翻箱倒櫃

要確認某物在不在,只能一箱箱翻,東西越多翻越久,所以耗時隨規模線性上升。

🔆
生活妙喻:字典的 in 是 O(1) ≈ 查有頁碼的電話簿

透過鍵直接翻到對應那一頁,不管整本多厚都一樣快,所以耗時恆定。

🔆
生活妙喻:timeit 的隔離與重複 ≈ 在無干擾的實驗室重複多次測量

把待測東西匯入乾淨命名空間並重複跑多次取平均,能排除雜散變數與偶發忙碌,得到可靠數據。

本節字彙

dictionary(字典)
用鍵 key 而非位置來存取值的資料結構,多數操作平均為 O(1)。
🧠 key 對 value,像查字典翻到那個詞。
contains(`in` 操作)
檢查某元素或鍵是否在容器中;list 為 O(n),dict 為 O(1)。
🧠 問『在不在』:list 要翻,dict 直接答。
average case(平均情況)
一般資料下的典型表現;字典 O(1) 是平均情況,罕見時可能退化成 O(n)。
🧠 通常如此,偶爾例外。
下列哪一個字典操作「不是」平均 $O(1)$?
同樣用 `value in container` 檢查存在性,為什麼字典通常遠快於 list?
對照實驗顯示:容器從 10,000 筆增加到 990,000 筆時,list 的 in 耗時明顯增加,而字典的 in 耗時幾乎不變(都約 0.004 毫秒)。這印證了什麼?