編碼 · 演算法
大O符號圖解與範例:時間複雜度入門
簡答
大O符號(Big O notation)描述演算法的執行時間或記憶體用量,如何隨輸入規模 n 增長。它只保留成長最快的那一項並捨去常數,因此 3n² + 5n + 2 個步驟寫成 O(n²)。常見的類別由快到慢依序為 O(1)、O(log n)、O(n)、O(n log n)、O(n²) 與 O(2ⁿ)。有了它,你不必在特定機器上實際執行,就能比較不同演算法。
大O符號解決什麼問題
用秒數計時,只能反映某一台機器、某一種語言、某一筆輸入的情況。大O符號回答的是更有用的問題:如果輸入變成 10 倍,演算法要多做多少工作?多做 10 倍工作的演算法,與多做 100 倍工作的演算法,在規模變大時表現截然不同。
形式上,若存在常數 c 與 n₀,使得對所有 n ≥ n₀ 都有 f(n) ≤ c · g(n),則稱 f(n) 為 O(g(n))。白話來說,當 n 夠大時,g 是 f 成長速度的上界,並且忽略常數倍數。
- n 代表輸入的規模:串列的長度、字串的字元數、圖中的節點數。
- 時間複雜度計算基本步驟的數量(比較、指派、算術運算)。
- 空間複雜度計算額外使用的記憶體,不包含輸入本身。
如何化簡運算式
先把步驟數寫成 n 的函式,再套用兩條規則:捨去常數係數,只保留主導項。
| 精確步驟數 | 套用的規則 | 大O |
|---|---|---|
5 | 常數 | O(1) |
3n + 10 | 捨去 3 與 10 | O(n) |
n²/2 + n/2 | 保留 n²,捨去 1/2 | O(n²) |
2n log n + 100n | n log n 比 n 成長更快 | O(n log n) |
n³ + 2ⁿ | 2ⁿ 比 n 的任何次方成長都快 | O(2ⁿ) |
當演算法有兩個獨立的輸入時,兩者都要保留:先走訪長度為 n 的串列,再走訪長度為 m 的串列,是 O(n + m);迴圈裡再套一層迴圈則是 O(n · m)。對數的底數在大O中並不重要,因為 log₂ n 與 log₁₀ n 只相差一個常數倍。
常見的複雜度類別
| 類別 | 名稱 | n = 1,000 時的步驟數 | 典型例子 |
|---|---|---|---|
| O(1) | 常數 | 1 | 讀取 items[i]、在 dict 中查詢鍵(平均情況) |
| O(log n) | 對數 | 約 10 | 在已排序的串列中做二分搜尋 |
| O(n) | 線性 | 1,000 | 找出串列中的最大值 |
| O(n log n) | 線性對數 | 約 10,000 | 合併排序、Python 的 sorted() |
| O(n²) | 平方 | 1,000,000 | 比較每一對元素、泡沫排序 |
| O(2ⁿ) | 指數 | 一個 302 位數 | 嘗試每一個子集合 |
| O(n!) | 階乘 | 還要大得多 | 嘗試每一種排列順序 |
粗略的參考:在現代電腦上,Python 的簡單迴圈每秒可執行數千萬個基本步驟,編譯式語言則快得多。因此 O(n log n) 處理數百萬筆資料沒問題,O(n²) 在數萬筆左右就會變慢,而 O(2ⁿ) 超過約 40 到 60 筆資料就毫無希望了。
從 Python 程式碼判斷複雜度
先看迴圈。對輸入跑單一迴圈是 O(n);在同一份輸入上巢狀兩層迴圈是 O(n²);每次把剩餘範圍減半的迴圈是 O(log n)。
def first(items): # O(1): one step, whatever the size
return items[0]
def total(items): # O(n): touches each item once
s = 0
for x in items:
s += x
return s
def has_duplicate(items): # O(n^2): compares every pair
for i in range(len(items)):
for j in range(i + 1, len(items)):
if items[i] == items[j]:
return True
return False
def halvings(n): # O(log n): n is halved each step
steps = 0
while n > 1:
n //= 2
steps += 1
return steps
print(first([7, 8]), total([1, 2, 3]), has_duplicate([1, 2, 1]), halvings(1024))
# 7 6 True 10函式庫呼叫也有自己的成本。x in some_list 是 O(n),x in some_set 平均是 O(1),list.insert(0, x) 因為每個元素都要位移所以是 O(n),sorted() 則是 O(n log n)。如果迴圈呼叫 n 次 O(n) 的操作,即使看起來只有一層迴圈,整體仍是 O(n²)。
def has_duplicate_fast(items): # O(n) time, O(n) extra space
seen = set()
for x in items:
if x in seen: # average O(1) set lookup
return True
seen.add(x)
return False
print(has_duplicate_fast([3, 1, 4, 1, 5])) # True
print(has_duplicate_fast(list(range(10)))) # False最佳、平均與最差情況
同一個演算法在相同規模、不同內容的輸入上,工作量可能不同,所以複雜度常分成三種情況來說明。
| 演算法 | 最佳 | 平均 | 最差 |
|---|---|---|---|
| 線性搜尋 | O(1),目標在第一個位置 | O(n) | O(n),目標不存在 |
| 插入排序 | O(n),已經排好序 | O(n²) | O(n²),反向排列 |
| 快速排序 | O(n log n) | O(n log n) | O(n²),樞紐選得不好 |
| 雜湊表查詢 | O(1) | O(1) | O(n),大量碰撞 |
人們只說「X 的複雜度」時,通常指最差情況;對雜湊表與快速排序則多半指平均情況。攤銷成本(amortized cost)是第四個概念:對 Python 串列 append 時,串列擴充儲存空間的那一次是 O(n),但在大量 append 的平均下是 O(1)。
大O、大Ω與大Θ
| 符號 | 意義 | 範例 |
|---|---|---|
| O(g) | 上界:成長速度不超過 g | 線性搜尋是 O(n) |
| Ω(g) | 下界:成長速度至少和 g 一樣快 | 任何比較式排序在最差情況下都是 Ω(n log n) |
| Θ(g) | 緊密界:同時是 O(g) 與 Ω(g) | 合併排序是 Θ(n log n) |
嚴格來說,O(n) 的演算法同時也是 O(n²),因為 O 只是上界。日常用法中,人們寫大O,其實想表達自己所知最緊的界,也就是 Θ 所表示的意思。
常見錯誤
- 忘了內建操作的成本,例如對串列使用
in,或切片items[1:](會複製元素,複製 k 個元素就是 O(k))。 - 算錯遞迴呼叫:函式對 n − 1 呼叫自己兩次,是 O(2ⁿ),不是 O(n)。
- 忽略空間:建立大小為 n 的新串列,或遞迴深度達 n,都要花 O(n) 的記憶體。
- 寫成 O(2n) 或 O(n + 5)。兩者都化簡為 O(n)。
常見問題
O(1) 是什麼意思?
O(1) 代表常數時間:工作量不隨輸入規模增加。無論串列有 10 個還是 1,000 萬個元素,用索引讀取一個元素都是 O(1)。
O(log n) 比 O(n) 快嗎?
是的,在 n 很大時。輸入加倍,O(log n) 的演算法只多一步,O(n) 的演算法則工作量加倍。對一百萬筆資料,log₂ n 約為 20。
Python sorted() 的時間複雜度是多少?
Python 的 sorted() 與 list.sort() 最差情況為 O(n log n),對已排序的輸入為 O(n),並使用 O(n) 的額外記憶體。
大O是用來衡量秒數嗎?
不是。它衡量的是工作量如何隨輸入規模成長。實際花費的秒數取決於機器、程式語言,以及大O所忽略的常數係數。
時間複雜度與空間複雜度有什麼不同?
時間複雜度計算演算法執行的步驟數;空間複雜度計算所需的額外記憶體。兩者都以大O表示,而且常常需要取捨,例如用集合(set)換取更短的執行時間。