演算法概念
貪婪不是一個演算法,是一種策略:每一步都選當下看起來最好的選項,不回頭、不後悔。Prim、Kruskal、Dijkstra、Huffman 都是貪婪,但解的問題各不相同。
要讓「每步選當下最好」得到全局最優,問題本身要有兩個性質:
- 貪婪選擇性質:每步的貪婪選擇不會讓之後的最優解消失——局部最優能組合成全域最優。
- 最優子結構:整體最優解包含子問題的最優解。
兩個條件缺一不可。實務判斷能不能用貪婪:先想一個貪婪策略,再找反例;找到反例就改用動態規劃。
找零例子:台灣面額(1、5、10、50、100、500)對貪婪友善,付 100 買 73、找 27,貪婪拿 10+10+5+1+1 剛好 5 枚是最少的。但換成 1、3、4 元找 6 元,貪婪選 4+1+1 = 3 枚,最優解 3+3 = 2 枚——這套面額上貪婪就壞了。
各適用情境
圖論演算法的複雜度怎麼看
接下來的 Prim、Kruskal、Sollin、Dijkstra 複雜度都用 (頂點數)和 (邊數)兩個變數表示,跟前篇用單一 的寫法不同。怎麼對應到常見等級?
- 的範圍:最少 (樹)、最多 (完全圖),所以 介於 和 之間
- 在稀疏圖()約是 等級;在稠密圖()約是 等級
- 不管是哪一種,都仍是多項式複雜度 (polynomial),不是指數級
看到 就想成「最壞是 」,代進去就能對應到常見等級的直覺。
Prim(最小生成樹)
演算法策略
最小生成樹(MST):連通加權無向圖,找出一棵包含所有節點、總邊權最小的樹。Prim 的招式是從任意節點出發,每次從「一端在樹內、另一端在樹外」的候選邊中挑最便宜的一條加入,直到所有節點都被吸進樹裡。像一棵樹不斷長大。
複雜度
用 min-heap 存候選邊。每條邊最多進 heap 一次共 次、每次 → 總共 。適合稠密圖。
範例
題目:6 個節點 A–F,邊(含權重):A-B(4), B-C(3), D-E(7), E-F(9), A-D(1), B-E(2), C-F(6), A-E(8), C-E(5)。從 A 出發求 MST。
圖解:
步驟:
| 輪 | 目前樹內 | 候選邊 | 選擇 | 累計 |
|---|---|---|---|---|
| 1 | {A} | A-D(1), A-B(4), A-E(8) | A-D(1) | 1 |
| 2 | {A, D} | A-B(4), D-E(7), A-E(8) | A-B(4) | 5 |
| 3 | {A, D, B} | B-E(2), B-C(3), D-E(7), A-E(8) | B-E(2) | 7 |
| 4 | {A, D, B, E} | B-C(3), E-F(9)(其餘兩端已在樹內) | B-C(3) | 10 |
| 5 | {A, D, B, E, C} | C-F(6), E-F(9) | C-F(6) | 16 |
| 6 | 全部 | — | — | 16 |
MST = {A-D, B-E, B-C, A-B, C-F},總權重 16。
Python 實作
import heapq
def prim(graph, start):
in_tree = set()
heap = [(0, start)]
total = 0
while heap:
weight, node = heapq.heappop(heap)
if node in in_tree:
continue
in_tree.add(node)
total += weight
for neighbor, w in graph[node]:
if neighbor not in in_tree:
heapq.heappush(heap, (w, neighbor))
return totalKruskal(最小生成樹)
演算法策略
跟 Prim 同樣解 MST,但招式不同:把所有邊按權重由小到大排序,依序看——加進去不會成環就加、會成環就跳過,直到用掉 條邊。判斷成環用 Union-Find(並查集):兩節點已連通代表加了會成環。
複雜度
排序 主導,Union-Find 每次操作攤銷近 。總共 。適合稀疏圖。
範例
題目:跟 Prim 同一張圖。
圖解:
步驟:邊排序後:A-D(1), B-E(2), B-C(3), A-B(4), C-E(5), C-F(6), D-E(7), A-E(8), E-F(9)。
| 步 | 邊 | 已連通? | 動作 | 分量 |
|---|---|---|---|---|
| 1 | A-D(1) | 否 | 加 | {A,D}, {B}, {C}, {E}, {F} |
| 2 | B-E(2) | 否 | 加 | {A,D}, {B,E}, {C}, {F} |
| 3 | B-C(3) | 否 | 加 | {A,D}, {B,E,C}, {F} |
| 4 | A-B(4) | 否 | 加 | {A,D,B,E,C}, {F} |
| 5 | C-E(5) | 是 | 跳 | 同上 |
| 6 | C-F(6) | 否 | 加 | 全部連通 |
總權重 ,跟 Prim 結果一致。
Python 實作(含 Union-Find)
def kruskal(nodes, edges):
parent = {n: n for n in nodes}
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(x, y):
rx, ry = find(x), find(y)
if rx == ry:
return False
parent[ry] = rx
return True
edges.sort(key=lambda e: e[2])
total = 0
for u, v, w in edges:
if union(u, v):
total += w
return totalSollin / Borůvka(最小生成樹)
演算法策略
另一種 MST 招式。每一輪,所有分量同時各自找自己最便宜的跨分量邊,一次全部加進去、合併分量。重複直到只剩一個分量。1926 年發明、比 Prim/Kruskal 都早,天生適合平行運算。
複雜度
每輪分量數至少減半(每個分量都至少跟一個鄰居合併)→ 最多 輪,每輪掃一遍 條邊 → 。
範例
題目:跟 Prim/Kruskal 同一張圖。
圖解:
步驟:
第一輪:每個節點自成一分量,各挑自己最小的外連邊。
| 分量 | 最小外連邊 |
|---|---|
| {A} | A-D(1) |
| {B} | B-E(2) |
| {C} | B-C(3) |
| {D} | A-D(1) |
| {E} | B-E(2) |
| {F} | C-F(6) |
去重後本輪加入:{A-D(1), B-E(2), B-C(3), C-F(6)},累計 12。合併後分量:{A, D}、{B, C, E, F}。
第二輪:兩個分量各找最小外連邊。
| 分量 | 候選 | 最小 |
|---|---|---|
| {A, D} | A-B(4), A-E(8), D-E(7) | A-B(4) |
| {B, C, E, F} | A-B(4), A-E(8), D-E(7) | A-B(4) |
加入 A-B(4)、合併為一個分量 → 停止。總權重 。
Python 實作
def sollin(nodes, edges):
parent = {n: n for n in nodes}
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(x, y):
rx, ry = find(x), find(y)
if rx == ry:
return False
parent[ry] = rx
return True
total = 0
num_components = len(nodes)
while num_components > 1:
cheapest = {}
for u, v, w in edges:
ru, rv = find(u), find(v)
if ru == rv:
continue
for r in [ru, rv]:
if r not in cheapest or w < cheapest[r][2]:
cheapest[r] = (u, v, w)
for u, v, w in cheapest.values():
if union(u, v):
total += w
num_components -= 1
return total三種 MST 演算法比較
| Prim | Kruskal | Sollin | |
|---|---|---|---|
| 方向 | 從節點出發擴張 | 從邊出發合併 | 每輪每分量同時挑 |
| 適合 | 稠密圖 | 稀疏圖 | 平行運算 |
| 需要 | min-heap | Union-Find + 排序 | Union-Find |
| 複雜度 | $O( | E | \log |
三種挑邊順序不同,但若 MST 唯一,最終 MST 一致;若存在多棵 MST,總權重必定相同。
Dijkstra(單源最短路徑)
演算法策略
從起點 出發,找到所有其他節點的最短距離。每輪從「還沒確定最短距離」的節點中挑 dist 最小的那個 ,把它標為已確定,再透過它鬆弛鄰居的距離(若 dist[u] + w(u, v) < dist[v] 就更新)。「每次選目前最近的」就是貪婪。
正確性要求:所有邊權非負。有負邊時一個還沒確定的節點可能之後被更新、Dijkstra 的「確定就不改」假設就破了——要改用 Bellman-Ford。
複雜度
用 min-heap: 次 extract-min + 最多 次 push,每次 → 總共 。
範例
題目:有向圖 8 個頂點 0–7,13 條邊:
| 邊 | 權 | 邊 | 權 | 邊 | 權 |
|---|---|---|---|---|---|
| 0 → 1 | 9 | 2 → 6 | 6 | 4 → 5 | 3 |
| 0 → 2 | 8 | 3 → 5 | 10 | 5 → 7 | 4 |
| 1 → 2 | 13 | 3 → 7 | 2 | 6 → 7 | 13 |
| 1 → 5 | 5 | 4 → 0 | 12 | 7 → 6 | 10 |
| 4 → 3 | 15 |
從頂點 4 出發。
圖解:
步驟(每輪選 dist 最小的未確定節點、鬆弛鄰居):
| 輪 | 選 | dist[0] | dist[1] | dist[2] | dist[3] | dist[4] | dist[5] | dist[6] | dist[7] |
|---|---|---|---|---|---|---|---|---|---|
| 初始 | — | ∞ | ∞ | ∞ | ∞ | 0 | ∞ | ∞ | ∞ |
| 1 | 4 (0) | 12 | ∞ | ∞ | 15 | 0 | 3 | ∞ | ∞ |
| 2 | 5 (3) | 12 | ∞ | ∞ | 15 | 0 | 3 | ∞ | 7 |
| 3 | 7 (7) | 12 | ∞ | ∞ | 15 | 0 | 3 | 17 | 7 |
| 4 | 0 (12) | 12 | 21 | 20 | 15 | 0 | 3 | 17 | 7 |
| 5 | 3 (15) | 12 | 21 | 20 | 15 | 0 | 3 | 17 | 7 |
| 6 | 6 (17) | 12 | 21 | 20 | 15 | 0 | 3 | 17 | 7 |
| 7 | 2 (20) | 12 | 21 | 20 | 15 | 0 | 3 | 17 | 7 |
| 8 | 1 (21) | 12 | 21 | 20 | 15 | 0 | 3 | 17 | 7 |
逐輪口述:
- 選 4(dist = 0),鬆弛 4→0 得 12、4→3 得 15、4→5 得 3。
- 選 5(dist = 3,目前未確定中最小),鬆弛 5→7 得 。
- 選 7(dist = 7),鬆弛 7→6 得 。
- 選 0(dist = 12),鬆弛 0→1 得 21、0→2 得 20。
- 選 3(dist = 15),嘗試鬆弛 3→5(25 > 3 不更新)、3→7(17 > 7 不更新)。
- 選 6、2、1 都只是確定身份,無可更新。
按路徑長度遞增排序:
| 次 | 路徑 | 長度 |
|---|---|---|
| 1 | 4 → 5 | 3 |
| 2 | 4 → 5 → 7 | 7 |
| 3 | 4 → 0 | 12 |
| 4 | 4 → 3 | 15 |
| 5 | 4 → 5 → 7 → 6 | 17 |
| 6 | 4 → 0 → 2 | 20 |
| 7 | 4 → 0 → 1 | 21 |
Python 實作
import heapq
def dijkstra(graph, start):
dist = {node: float('inf') for node in graph}
dist[start] = 0
heap = [(0, start)]
while heap:
d, node = heapq.heappop(heap)
if d > dist[node]:
continue
for neighbor, weight in graph[node]:
new_dist = d + weight
if new_dist < dist[neighbor]:
dist[neighbor] = new_dist
heapq.heappush(heap, (new_dist, neighbor))
return dist易錯點:dist 最小 輸入順序
本例中 4 的三個鄰居是 {0, 3, 5},看起來會被「平行」確定。實際上 Dijkstra 每輪只確定 1 個(dist 最小者)。被確定後還可能更新別的未確定節點——選完 5 後 dist[7] 從 變成 7,7 就比 0、3 更早被確定。
Dijkstra 不能處理負權邊。有負邊要用 Bellman-Ford(),不是貪婪而是反覆對所有邊鬆弛。
Huffman 編碼
演算法策略
資料壓縮:高頻字元用短編碼、低頻字元用長編碼。用前綴碼(沒有任何字元的編碼是另一個的前綴)保證解碼無歧義。
建 Huffman Tree 的招式:把每個字元做成節點、權重為頻率。每輪用 min-heap 取出最輕的兩個節點,合併成新節點(權重 = 兩子權重之和)塞回 heap。重複到只剩一個根。從根到葉的路徑(左 0、右 1)就是該字元的編碼。
複雜度
建 heap 、合併 輪,每輪兩次 pop + 一次 push 共 → 總共 。
範例
題目:字元頻率 A
、B、C、D、E、F,求 Huffman 編碼。圖解:
步驟(每輪取兩個最小合併):
| 輪 | heap 狀態 | 取出 | 合併 | 塞回 |
|---|---|---|---|---|
| 1 | 5, 9, 12, 13, 16, 45 | 5, 9 | 14 | 12, 13, 14, 16, 45 |
| 2 | 12, 13, 14, 16, 45 | 12, 13 | 25 | 14, 16, 25, 45 |
| 3 | 14, 16, 25, 45 | 14, 16 | 30 | 25, 30, 45 |
| 4 | 25, 30, 45 | 25, 30 | 55 | 45, 55 |
| 5 | 45, 55 | 45, 55 | 100 | 100 |
編碼結果:
| 字元 | 頻率 | 編碼 | 位元數 |
|---|---|---|---|
| F | 45 | 0 | 1 |
| C | 12 | 100 | 3 |
| D | 13 | 101 | 3 |
| A | 5 | 1100 | 4 |
| B | 9 | 1101 | 4 |
| E | 16 | 111 | 3 |
加權長度 bit。固定 3 bit 需要 bit,Huffman 省 25% 以上。
Python 實作
import heapq
def huffman(freq):
heap = [(f, i, c) for i, (c, f) in enumerate(freq.items())]
heapq.heapify(heap)
counter = len(freq)
while len(heap) > 1:
f1, _, left = heapq.heappop(heap)
f2, _, right = heapq.heappop(heap)
heapq.heappush(heap, (f1 + f2, counter, (left, right)))
counter += 1
codes = {}
def build(node, prefix=''):
if isinstance(node, str):
codes[node] = prefix
return
build(node[0], prefix + '0')
build(node[1], prefix + '1')
build(heap[0][2])
return codes活動選擇(Activity Selection)
演算法策略
一間會議室、多場會議各有開始與結束時間,不能重疊,目標是選最多場。貪婪招式:按結束時間由早到晚排序,依序挑不衝突的。理由很直觀——越早結束留越多空間給後面。
複雜度
排序 主導;掃一遍 。總共 。
範例
題目:
| 會議 | 開始 | 結束 |
|---|---|---|
| A | 1 | 4 |
| B | 3 | 5 |
| C | 0 | 6 |
| D | 5 | 7 |
| E | 3 | 9 |
| F | 6 | 10 |
| G | 8 | 11 |
圖解:
步驟:按結束時間排序 A(4) < B(5) < C(6) < D(7) < E(9) < F(10) < G(11)。
| 考慮 | 上次結束 | 是否衝突 | 動作 |
|---|---|---|---|
| A | — | — | 選 |
| B | 4 | 開始 3 < 4 → 衝突 | 跳 |
| C | 4 | 開始 0 < 4 → 衝突 | 跳 |
| D | 4 | 開始 5 ≥ 4 | 選 |
| E | 7 | 開始 3 < 7 → 衝突 | 跳 |
| F | 7 | 開始 6 < 7 → 衝突 | 跳 |
| G | 7 | 開始 8 ≥ 7 | 選 |
結果:A、D、G,共 3 場。
Python 實作
def activity_selection(activities):
activities.sort(key=lambda a: a[2]) # (name, start, end)
selected = []
last_end = float('-inf')
for name, start, end in activities:
if start >= last_end:
selected.append(name)
last_end = end
return selected凸包(Graham Scan)
演算法策略
給平面一堆點,找最小凸多邊形把所有點包起來。像用橡皮筋圈釘子、收縮後的形狀。Graham Scan 的招式:
- 找 最小(平手取 最小)的點當起點。
- 其他點依對起點的極角排序。
- 依序掃過去,維護一個 stack——新來的點造成「右轉」就把 stack 頂彈掉(它不是凸包點),形成「左轉」才 push。
判左轉右轉用外積:三點 ,
左轉(保留)、 右轉或共線(彈出 )。
複雜度
排序 主導;掃描每個點最多 push / pop 一次 → 。總共 。
範例
題目:5 個點 、、、、( 在內部)。
圖解:
步驟:起點 。極角排序後順序 。
| 步 | 處理 | 轉向檢查 | 外積 | 動作 | stack |
|---|---|---|---|---|---|
| 0 | — | — | — | 初始 | [P0] |
| 1 | P1 | — | — | push | [P0, P1] |
| 2 | P2 | P0 → P1 → P2 | 左轉、push | [P0, P1, P2] | |
| 3 | P4 | P1 → P2 → P4 | 左轉、push | [P0, P1, P2, P4] | |
| 4 | P3 | P2 → P4 → P3 | 右轉、彈 P4 | [P0, P1, P2] | |
| 4 | P3 | P1 → P2 → P3 | 左轉、push | [P0, P1, P2, P3] |
凸包 = ( 被正確排除)。
Python 實作
import math
def graham_scan(points):
start = min(points, key=lambda p: (p[1], p[0]))
points.remove(start)
points.sort(key=lambda p: math.atan2(p[1] - start[1], p[0] - start[0]))
stack = [start]
for p in points:
while len(stack) >= 2:
cross = ((stack[-1][0] - stack[-2][0]) * (p[1] - stack[-2][1])
- (stack[-1][1] - stack[-2][1]) * (p[0] - stack[-2][0]))
if cross <= 0:
stack.pop()
else:
break
stack.append(p)
return stack另一招:Jarvis March(Gift Wrapping)
從最左點出發,每次找「最外側」的下一個點,一圈繞回來。時間 ( 為凸包上點數)—— 小時比 Graham 快,最壞 。
Bin Packing(近似解)
演算法策略
個物品、大小 ,箱容量 ,目標用最少箱子。這題是 NP-Hard,貪婪只能給近似解:
- Next Fit (NF):只看最後一個箱子,塞不下就開新箱。
- First Fit (FF):從第一個箱子掃,第一個塞得下的就放。
- First Fit Decreasing (FFD):先把物品由大到小排序再跑 FF。大的先進、小的填縫。
複雜度
- NF:
- FF / FFD:(FFD 排序主導;FF 每次找可用箱平均 )
近似比:
| 策略 | 近似比 |
|---|---|
| Next Fit | |
| First Fit | |
| First Fit Decreasing |
範例
題目:物品 、,跑 FFD。
圖解:
步驟:排序後 。
| 步 | 物品 | 動作 | 箱況(剩餘容量) |
|---|---|---|---|
| 1 | 0.7 | 開 Bin1 | Bin1: 0.3 |
| 2 | 0.5 | Bin1 不行 → 開 Bin2 | Bin1: 0.3, Bin2: 0.5 |
| 3 | 0.5 | Bin2 剛好 | Bin1: 0.3, Bin2: 0.0 |
| 4 | 0.4 | 都不行 → 開 Bin3 | Bin1: 0.3, Bin2: 0.0, Bin3: 0.6 |
| 5 | 0.3 | Bin1 剛好 | Bin1: 0.0, Bin2: 0.0, Bin3: 0.6 |
| 6 | 0.2 | Bin3 可塞 | Bin3: 0.4 |
| 7 | 0.2 | Bin3 可塞 | Bin3: 0.2 |
| 8 | 0.1 | Bin3 可塞 | Bin3: 0.1 |
3 個箱子。物品總大小 2.9、下界 → FFD 打到最佳。
Python 實作(FFD)
def first_fit_decreasing(items, capacity=1.0):
items_sorted = sorted(items, reverse=True)
bins = []
for item in items_sorted:
for b in range(len(bins)):
if bins[b] + item <= capacity:
bins[b] += item
break
else:
bins.append(item)
return len(bins)貪婪的陷阱:0/1 背包
演算法策略(失敗示範)
背包容量 、每件物品只能整件拿或不拿、最大化總價值。乍看貪婪可以按「性價比(價值/重量)」由高到低選——但這是錯的。
為什麼貪婪壞在這裡:貪婪選擇性質不成立。選了性價比最高的物品,剩下容量可能裝不下另一件「絕對價值更高」的物品。
範例
題目:背包容量 ,三件物品:
| 物品 | 重 | 價值 | 性價比 |
|---|---|---|---|
| A | 1 | 6 | 6.0 |
| B | 3 | 10 | 3.3 |
| C | 4 | 12 | 3.0 |
貪婪按性價比:選 A(1)+ B(3)= 4 ≤ 5,總價值 16。剩 1 容量,裝不下 C。
最優解:A(1)+ C(4)= 5,總價值 18。
貪婪錯了 2。0/1 背包必須用動態規劃。
若物品可以切割(分數背包),貪婪按性價比是對的——把最後一件切一部分塞滿。但 0/1 背包(整件拿或不拿)不行。考試常出這題陷阱:沒明說可切就是 0/1。
貪婪 vs 動態規劃
| 貪婪 | 動態規劃 | |
|---|---|---|
| 決策 | 每步選局部最優、不回頭 | 考慮所有可能、記錄子問題解 |
| 額外條件 | 要有貪婪選擇性質 | 只要有最優子結構 |
| 速度 | 通常較快 | 通常較慢 |
| 正確性 | 要證明 | 狀態設計對就對 |
| 典型 | MST、Dijkstra、Huffman、活動選擇 | 0/1 背包、LCS、編輯距離 |
常見考法
| 題型 | 必備技能 |
|---|---|
| Prim | 從起點擴張、列「樹內/候選邊/選哪條/累計」表 |
| Kruskal | 邊排序後逐條看、Union-Find 偵測環;列「邊/是否連通/動作」表 |
| Sollin(Borůvka) | 每輪每分量挑自己最便宜的外連邊、合併; 輪 |
| Dijkstra | 列 dist[] 演化表、標每輪選誰;負權邊不能用 |
| Graham Scan | 最低點起、極角排序、stack + 外積判左右轉 |
| Huffman | min-heap 每輪取兩小合併;列合併過程與編碼表 |
| 活動選擇 | 按結束時間遞增排序、依序挑不衝突 |
| Bin Packing | FF / FFD 手跑;近似比要背 |
| 分數背包 | 按性價比排序、最後一件切割 |
| 0/1 背包(陷阱) | 性價比貪婪不保證最優、必須 DP |
Dijkstra 負權邊陷阱:考試常故意放一條負權邊看你會不會用 Dijkstra 出錯。0/1 背包:題目沒明說「可切割」就是 0/1,貪婪一定錯。
Latest Updates
- 2026.04.02 Content updated
