複雜度分析練習題
範圍 — 20 道自我測驗題 — 讀程式片段、說出複雜度、再對答案檢查自己。 另見 — complexity_cheatsheet.md — 參考用的對照表;time_space_complexity.md — 完整的推導過程。
練習從程式片段判斷時間複雜度與空間複雜度。 Google 面試官非常愛追問這一塊——預期會有後續問題,例如 「還能更快嗎?」以及「如果最佳化的話空間是多少?」
How to Use
- Read the code snippet
- Determine Time and Space complexity before looking at the answer
- Check your reasoning against the explanation
- Star (⭐) problems you got wrong — revisit them
Want to be marked rather than self-graded? The site’s Complexity Quiz draws random Python snippets from this repo, takes a typed answer for time and space, and scores each one — with a per-topic breakdown at the end.
練習 1:巢狀迴圈
for (int i = 0; i < n; i++) {
for (int j = i; j < n; j++) {
// O(1) work
}
}
答案
時間:O(N²) 內層迴圈執行次數:N + (N-1) + (N-2) + … + 1 = N(N+1)/2 → O(N²)
空間:O(1)
常見錯誤:因為「j 從 i 開始」就說是 O(N)。這個級數的總和仍然是平方級。
練習 2:以乘法遞增的迴圈
int i = 1;
while (i < n) {
// O(1) work
i *= 2;
}
答案
時間:O(log N) i 依序取 1、2、4、8、…,直到 i ≥ N。也就是 log₂(N) 次迭代。
空間:O(1)
練習 3:以除法遞減的迴圈
for (int i = n; i >= 1; i /= 2) {
for (int j = 0; j < i; j++) {
// O(1) work
}
}
答案
時間:O(N) 內層迴圈執行次數:N + N/2 + N/4 + … + 1 = 2N → O(N)(等比級數!)
空間:O(1)
常見錯誤:說成 O(N log N)。每次迭代的工作量都減半——等比級數收斂到 2N。
練習 4:遞迴費氏數列(樸素版)
def fib(n):
if n <= 1: return n
return fib(n-1) + fib(n-2)
答案
時間:O(2^N) — 更精確地說是 O(φ^N),其中 φ ≈ 1.618(黃金比例) 每次呼叫都分岔成 2 個子呼叫。這棵樹約有 2^N 個節點。
空間:O(N) — 遞迴堆疊深度為 N(會一路沿著最左的分支下去,之後才回傳)
常見錯誤:說空間是 O(2^N)。堆疊在任一時刻只裝著一條路徑。
練習 5:迴圈中的 HashMap
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < n; i++) {
map.put(nums[i], i);
}
for (int i = 0; i < n; i++) {
int complement = target - nums[i];
if (map.containsKey(complement)) return true;
}
答案
時間:O(N) 平均 — HashMap 的 put/get 是攤還 O(1) 空間:O(N) — 最多存 N 筆資料
注意:如果所有 key 都雜湊到同一個 bucket,最壞情況是 O(N²)。面試時可以提一下,但主要陳述平均情況。
練習 6:排序 + 二分搜尋
nums.sort() # Timsort
for x in nums:
idx = bisect_left(nums, target - x)
答案
時間:O(N log N) — 排序是 O(N log N),迴圈是 N × O(log N) = O(N log N)。總計:O(N log N)。
空間:Timsort(Python)為 O(N),原地排序(quicksort)則為 O(log N)
練習 7:格子上的 BFS
from collections import deque
queue = deque([(0, 0)])
visited = set()
visited.add((0, 0))
while queue:
r, c = queue.popleft()
for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
nr, nc = r+dr, c+dc
if 0 <= nr < m and 0 <= nc < n and (nr,nc) not in visited:
visited.add((nr, nc))
queue.append((nr, nc))
答案
時間:O(M·N) — 每一格最多被拜訪一次 空間:O(M·N) — visited 集合 + 佇列最多可裝 M·N 格
常見錯誤:因為有 4 個方向就說成 O(M·N·4)。那個 4 是常數——要省略。
練習 8:合併排序
def mergeSort(arr):
if len(arr) <= 1: return arr
mid = len(arr) // 2
left = mergeSort(arr[:mid])
right = mergeSort(arr[mid:])
return merge(left, right) # merge is O(N)
答案
時間:O(N log N) — 共 log N 層,每一層合併的總工作量為 O(N)
空間:O(N) — 合併時會建立新陣列;遞迴堆疊是 O(log N),但被合併陣列的 O(N) 蓋過去
注意:arr[:mid] 會複製一份——這就是那個 O(N) 空間。原地合併排序只要 O(log N) 空間,但實作困難得多。
練習 9:產生所有子集合
def subsets(nums):
result = [[]]
for num in nums:
result += [curr + [num] for curr in result]
return result
答案
時間:O(N × 2^N) — 共 2^N 個子集合,每個最長 N,複製都要成本
空間:O(N × 2^N) — 要存下所有子集合
逐步分析:處理完 k 個元素後,result 中有 2^k 個子集合。我們複製全部並加入 num → 2^k 份平均長度 k/2 的複本。
練習 10:滑動視窗
left = 0
max_len = 0
freq = {}
for right in range(len(s)):
freq[s[right]] = freq.get(s[right], 0) + 1
while len(freq) > k:
freq[s[left]] -= 1
if freq[s[left]] == 0: del freq[s[left]]
left += 1
max_len = max(max_len, right - left + 1)
答案
時間:O(N) — left 指標只會往前走,總移動次數 ≤ N。每個字元最多進出視窗各一次。
空間:O(K) 或 O(min(N, Σ)),其中 Σ = 字母集大小
常見錯誤:因為 for 迴圈裡有 while 迴圈就說是 O(N²)。但 left 永遠不會往回走——攤還後是 O(N)。
練習 11:帶路徑壓縮 + 按秩合併的併查集
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]); // path compression
return parent[x];
}
void union(int x, int y) {
int px = find(x), py = find(y);
if (px == py) return;
if (rank[px] < rank[py]) parent[px] = py;
else if (rank[px] > rank[py]) parent[py] = px;
else { parent[py] = px; rank[px]++; }
}
答案
時間:每次操作 O(α(N)) — α 是反 Ackermann 函數,對所有實務上的輸入而言等同 O(1)(當 N ≤ 2^65536 時 α(N) ≤ 5)
空間:O(N) — parent 與 rank 陣列
關鍵洞見:只做路徑壓縮是攤還 O(log N)。只做按秩合併也是 O(log N)。兩者合用:O(α(N)) ≈ O(1)。
練習 12:字典樹的插入與搜尋
void insert(String word) {
TrieNode node = root;
for (char c : word.toCharArray()) {
if (node.children[c - 'a'] == null)
node.children[c - 'a'] = new TrieNode();
node = node.children[c - 'a'];
}
node.isEnd = true;
}
答案
時間:O(M),其中 M = 單字長度
空間:最壞情況 O(M)(沒有共用前綴時的新節點數)
N 個平均長度 M 的單字,整棵字典樹最壞情況是 O(26 × M × N),但因為前綴共用,實際上通常小很多。
練習 13:堆積(heap) — 最近的 K 個點
import heapq
def kClosest(points, k):
return heapq.nsmallest(k, points, key=lambda p: p[0]**2 + p[1]**2)
答案
時間:O(N log K) — nsmallest 維護一個大小為 K 的 max-heap,處理全部 N 個點
空間:O(K) — 堆積(heap) 本身
替代做法:QuickSelect 平均 O(N) 時間、O(1) 額外空間(但會改動輸入)。
練習 14:使用二維表格的 DP
// LC 72 — Edit Distance
int[][] dp = new int[m + 1][n + 1];
for (int i = 0; i <= m; i++) dp[i][0] = i;
for (int j = 0; j <= n; j++) dp[0][j] = j;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (word1.charAt(i-1) == word2.charAt(j-1))
dp[i][j] = dp[i-1][j-1];
else
dp[i][j] = 1 + Math.min(dp[i-1][j-1],
Math.min(dp[i-1][j], dp[i][j-1]));
}
}
答案
時間:O(M·N) — 填滿 M×N 的表格,每格 O(1)
空間:O(M·N) — 但可用滾動陣列最佳化到 O(min(M,N))(只需要前一列)
後續追問:「空間可以再省嗎?」→ 用大小為 min(M,N)+1 的一維陣列,並用一個暫存變數保存對角線值來原地更新。
練習 15:回溯 — N 皇后
def solveNQueens(n):
def backtrack(row, cols, diags, anti_diags):
if row == n:
result.append(board_snapshot())
return
for col in range(n):
if col in cols or (row-col) in diags or (row+col) in anti_diags:
continue
# place queen and recurse
backtrack(row+1, cols|{col}, diags|{row-col}, anti_diags|{row+col})
答案
時間:O(N!) — 第 0 列有 N 種選擇、第 1 列約 N-1、第 2 列約 N-2,依此類推(有剪枝的話實務上少得多)
空間:O(N) — 遞迴深度為 N,每個集合最多裝 N 個元素
注意:解的數量成長速度遠低於 N!。當 N=8 時,8! = 40320 種擺法中只有 92 組解。
練習 16:攤還分析 — 動態陣列
ArrayList<Integer> list = new ArrayList<>();
for (int i = 0; i < n; i++) {
list.add(i); // occasionally triggers resize and copy
}
答案
時間:總共 O(N),每次 add 攤還 O(1)
擴容發生在大小為 1、2、4、8、…、N 的時候。總複製次數:1 + 2 + 4 + … + N = 2N → O(N)。
空間:O(N)
關鍵洞見:即使單次 add 可能是 O(N)(擴容當下),攤還成本仍是 O(1),因為擴容以指數方式變得稀少。
練習 17:在迴圈中串接字串
String result = "";
for (int i = 0; i < n; i++) {
result += chars[i]; // creates new String each time
}
答案
時間:O(N²) — 每次串接都會複製整個既有字串。複製量:1 + 2 + 3 + … + N = O(N²)
空間:O(N) — 結果字串(中間產生的字串會被 GC 回收)
修正方式:改用 StringBuilder,總時間變 O(N)。
這是經典的面試陷阱。在 Java 中一定要提到 StringBuilder/StringBuffer。
練習 18:雙指標 — Container with Most Water
left, right = 0, len(height) - 1
max_area = 0
while left < right:
max_area = max(max_area, min(height[left], height[right]) * (right - left))
if height[left] < height[right]:
left += 1
else:
right -= 1
答案
時間:O(N) — 每次迭代移動一個指標,總共最多 N 次迭代
空間:O(1)
為什麼可行:移動較矮的那一側是唯一可能增加面積的做法(移動較高的一側只會縮小寬度,而高度不會增加)。
練習 19:樹的 DFS 加上序列化
def serialize(root):
if not root: return "null"
return str(root.val) + "," + serialize(root.left) + "," + serialize(root.right)
答案
時間:O(N²) — 在字串串接會產生複本的語言中(Python、Java String)。每次串接都會複製愈來愈長的字串。
空間:O(N) — 遞迴堆疊(若樹平衡:O(log N) 堆疊 + O(N) 結果字串)
修正方式:用 list 收集、最後再 join → O(N) 時間。
def serialize(root):
parts = []
def dfs(node):
if not node: parts.append("null"); return
parts.append(str(node.val))
dfs(node.left)
dfs(node.right)
dfs(root)
return ",".join(parts) # O(N) total
練習 20:搭配優先佇列的 Dijkstra
PriorityQueue<int[]> pq = new PriorityQueue<>((a,b) -> a[1] - b[1]);
pq.offer(new int[]{src, 0});
int[] dist = new int[V];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[src] = 0;
while (!pq.isEmpty()) {
int[] curr = pq.poll();
int u = curr[0], d = curr[1];
if (d > dist[u]) continue; // skip outdated entries
for (int[] edge : graph[u]) {
int v = edge[0], w = edge[1];
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.offer(new int[]{v, dist[v]});
}
}
}
答案
時間:O((V + E) log V) — 每個頂點只會被真正取出一次(靠跳過檢查),每條邊只鬆弛一次,堆積(heap) 操作為 O(log V)。堆積(heap) 中最多可能有 E 筆資料 → 嚴格說是 O((V + E) log E) = O((V + E) log V),因為 log E ≤ log V² = 2 log V。
空間:O(V + E) — dist 陣列 O(V),堆積(heap) 最多裝 O(E) 筆
注意:如果少了 if (d > dist[u]) continue 這個檢查,就會處理到過期的資料,可能變成 O(E log E) 且常數更差。
為自己評分
| 分數 | 程度 | 下一步 |
|---|---|---|
| 答對 18-20 題 | 已達 Google 水準 | 專注在速度——30 秒內講清楚 |
| 答對 14-17 題 | 就差一點 | 複習 complexity_cheatsheet.md 中的數學直覺 |
| 答對 10-13 題 | 還需加強 | 研讀等比級數、攤還分析、遞迴樹 |
| 答對 < 10 題 | 基礎不足 | 從 Big-O 基礎開始,用簡單例子練習 |