編碼 · 演算法
貪婪演算法教學:原理、程式碼與範例
簡答
貪婪演算法一步步建構解,每一步都選擇當下看起來最好的選項,且不會回頭修改先前的選擇。它快速又簡單,但只對具有貪婪選擇性質的問題才正確,也就是局部最佳的選擇一定屬於某個最佳解。經典且正確的貪婪演算法包括區間排程、霍夫曼編碼、Dijkstra 演算法,以及 Kruskal 與 Prim 的最小生成樹演算法。
貪婪演算法解決什麼問題
貪婪演算法針對最佳化問題:選出最多的活動、用最少的硬幣、建出最便宜的網路、把檔案壓縮成最少的位元。貪婪演算法不會探索每種組合,而是把選項排序或排定優先順序,並在每一步承諾選擇最佳的那個。這通常只需 O(n log n) 時間,往往就是排序的成本。
困難之處不在程式碼,而在證明。許多看似自然的貪婪規則其實是錯的,所以你必須證明該規則正確,或拿它和暴力解法互相比對測試。
用白話說明原理
- 找出一個能為選項排名的規則,例如「最早結束者優先」或「最輕的邊優先」。
- 依這個規則將選項排序,或把它們放進優先佇列。
- 逐一檢視選項,凡是仍符合限制條件的就選取。
- 絕不撤銷已做的選擇。
貪婪演算法要正確,必須同時具備兩個性質。貪婪選擇性質:存在一個最佳解包含第一個貪婪選擇。最佳子結構:做出該選擇後,剩下的問題是同一問題的較小實例。常用的證明方式是交換論證:取任何一個最佳解,證明把其中的某個選擇換成貪婪選擇並不會使結果變差。
範例:區間排程
你有一間會議室和這些申請(開始, 結束):A (1, 4)、B (3, 5)、C (0, 6)、D (5, 7)、E (3, 9)、F (5, 9)、G (6, 10)、H (8, 11)。要排入盡可能多的互不重疊的會議。正確的貪婪規則是:在所有於上一場已排入會議結束後才開始的會議中,永遠挑結束最早的那場。
| 會議(依結束時間排序) | 開始, 結束 | 是否在上次結束之後開始? | 決定 | 上次結束時間 |
|---|---|---|---|---|
| A | 1, 4 | 是(尚未排入任何會議) | 排入 | 4 |
| B | 3, 5 | 否(3 < 4) | 跳過 | 4 |
| C | 0, 6 | 否 | 跳過 | 4 |
| D | 5, 7 | 是 | 排入 | 7 |
| E | 3, 9 | 否 | 跳過 | 7 |
| F | 5, 9 | 否 | 跳過 | 7 |
| G | 6, 10 | 否 | 跳過 | 7 |
| H | 8, 11 | 是 | 排入 | 11 |
結果:A、D、H,共三場會議,這就是最大值。為什麼有效:最早結束的會議留給其他會議的時間最多,所以把任何最佳排程的第一場會議換成它,都不會造成衝突。
Python 實作
def max_meetings(meetings):
"""meetings: list of (name, start, end). Returns the booked names."""
booked, last_end = [], float("-inf")
for name, start, end in sorted(meetings, key=lambda m: m[2]):
if start >= last_end:
booked.append(name)
last_end = end
return booked
requests = [("A", 1, 4), ("B", 3, 5), ("C", 0, 6), ("D", 5, 7),
("E", 3, 9), ("F", 5, 9), ("G", 6, 10), ("H", 8, 11)]
print(max_meetings(requests)) # ['A', 'D', 'H']第二個經典例子是分數背包:當你可以只拿物品的一部分時,依每單位重量的價值由高到低取物品就是最佳解。
def fractional_knapsack(items, capacity):
"""items: list of (value, weight). Returns the best total value."""
total = 0.0
for value, weight in sorted(items, key=lambda i: i[0] / i[1], reverse=True):
take = min(weight, capacity)
total += value * take / weight
capacity -= take
if capacity == 0:
break
return total
print(fractional_knapsack([(60, 10), (100, 20), (120, 30)], 50)) # 240.0貪婪失效時:反例
| 問題 | 誘人的貪婪規則 | 反例 | 正確方法 |
|---|---|---|---|
| 硬幣找零,硬幣面額 1、3、4 | 先拿最大的硬幣 | 金額 6:貪婪得到 4 + 1 + 1(3 枚),最佳解為 3 + 3(2 枚) | 動態規劃 |
| 0/1 背包(只能整件拿) | 每單位重量價值最高者優先 | 容量 50,物品 (60, 10)、(100, 20)、(120, 30):貪婪取 60 + 100 = 160,最佳解為 100 + 120 = 220 | 動態規劃 |
| 區間排程 | 最短的會議優先 | (0, 5)、(4, 7)、(6, 11):貪婪只取 (4, 7),最佳解取另外兩個 | 最早結束者優先 |
| 含負權重邊的最短路徑 | Dijkstra 的最近節點優先 | 任何因負權重邊而使已確定節點變得更便宜的圖 | Bellman-Ford |
時間與空間複雜度
| 演算法 | 時間 | 原因 |
|---|---|---|
| 區間排程 | O(n log n) | 依結束時間排序;掃描為 O(n)。 |
| 分數背包 | O(n log n) | 依單位重量價值排序。 |
| 霍夫曼編碼 | O(n log n) | n − 1 次合併,每次都有堆積操作。 |
| Kruskal 最小生成樹 | O(E log E) | 對邊排序,加上近乎常數時間的 union-find。 |
| Dijkstra 最短路徑 | O((V + E) log V) | 以堆積實作的優先佇列。 |
空間通常是 O(n),用於排序後的副本或堆積。如果輸入已排序,許多貪婪演算法可在 O(n) 內完成。
何時使用貪婪演算法與實際應用
- 當你能證明貪婪選擇性質,或可以接受快速的近似答案時,請使用貪婪演算法;否則請用動態規劃或回溯法。
- 壓縮:霍夫曼編碼反覆合併出現頻率最低的兩個符號,建出最佳前綴碼;DEFLATE(ZIP、gzip、PNG)等格式內部就使用它。
- 網路:最小生成樹(Kruskal、Prim)與最短路徑路由(Dijkstra)。
- 排程:最早截止時間優先,以及預約會議室或機器。
- 近似解:對於集合覆蓋問題,貪婪規則「挑出能覆蓋最多未覆蓋元素的集合」雖然不是最佳解,但保證與最佳解相差不超過對數倍。
- 面試題目:跳躍遊戲、加油站、分配餅乾、無重疊區間、用最少箭數射爆氣球、任務排程器。
常見問題
用簡單的話說,貪婪演算法是什麼?
它是一種永遠選擇當下看起來最好的選項、且從不重新考慮的演算法。只有在這些局部選擇一定能導出全域最佳答案的問題上,它才是正確的。
如何證明貪婪演算法是正確的?
常用的方法是交換論證:從任何一個最佳解出發,證明可以把它的第一個選擇換成貪婪選擇而不會變差,然後對剩下的問題重複這個步驟。
該用貪婪還是動態規劃?
如果覺得某個簡單的規則似乎正確,先試貪婪,再去找反例。如果找得到反例,這個問題就需要動態規劃,它會針對每個子問題比較所有選擇。
Dijkstra 演算法是貪婪演算法嗎?
是。它總是確定離起點最近且尚未確定的節點。這個貪婪選擇之所以正確,是因為邊的權重非負。