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),並理解為何只保留成長最快的主導項、丟掉常數與係數。

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

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

原文 · Big-O 記號法 Our goal then is to show how the algorithm’s execution time changes with respect to the size of the problem. Computer scientists prefer to take this analysis technique one step further. It turns out that the exact number of operations is not as important as determining the most dominant part of the 𝑇 (𝑛) function. In other words, as the problem gets larger, some portion of the 𝑇 (𝑛) function tends to overpower the rest.
白話導讀

用基本運算次數定義 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);解法二先排序再比對,效率被排序主導。

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

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

原文 · 變位詞偵測:同一問題的四種解法 us 77, 146, 816, 596 years to go through the entire list. This is probably not going to be a good solution. 4 Solution 4: Count and Compare Our final solution to the anagram problem takes advantage of the fact that any two anagrams will have the same number of a’s, the same number of b’s, the same number of c’s, and so on. In order to decide whether two strings are anagrams, we will first count the number of times each character occurs.
白話導讀

解法一用巢狀比對勾消字元得到 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 驗證這些差異。

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

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

原文 · Python 資料結構的效能 This is probably not going to be a good solution. 4 Solution 4: Count and Compare Our final solution to the anagram problem takes advantage of the fact that any two anagrams will have the same number of a’s, the same number of b’s, the same number of c’s, and so on. In order to decide whether two strings are anagrams, we will first count the number of times each character occurs. Since there are 26 possible characters, we can use a list of 26 counters, one for each possible character.
白話導讀

索引與 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 毫秒)。這印證了什麼?