cv.cm

編碼 · 演算法

貪婪演算法教學:原理、程式碼與範例

閱讀約 6 分鐘

簡答

貪婪演算法一步步建構解,每一步都選擇當下看起來最好的選項,且不會回頭修改先前的選擇。它快速又簡單,但只對具有貪婪選擇性質的問題才正確,也就是局部最佳的選擇一定屬於某個最佳解。經典且正確的貪婪演算法包括區間排程、霍夫曼編碼、Dijkstra 演算法,以及 Kruskal 與 Prim 的最小生成樹演算法。

貪婪演算法解決什麼問題

貪婪演算法針對最佳化問題:選出最多的活動、用最少的硬幣、建出最便宜的網路、把檔案壓縮成最少的位元。貪婪演算法不會探索每種組合,而是把選項排序或排定優先順序,並在每一步承諾選擇最佳的那個。這通常只需 O(n log n) 時間,往往就是排序的成本。

困難之處不在程式碼,而在證明。許多看似自然的貪婪規則其實是錯的,所以你必須證明該規則正確,或拿它和暴力解法互相比對測試。

用白話說明原理

  1. 找出一個能為選項排名的規則,例如「最早結束者優先」或「最輕的邊優先」。
  2. 依這個規則將選項排序,或把它們放進優先佇列。
  3. 逐一檢視選項,凡是仍符合限制條件的就選取。
  4. 絕不撤銷已做的選擇。

貪婪演算法要正確,必須同時具備兩個性質。貪婪選擇性質:存在一個最佳解包含第一個貪婪選擇。最佳子結構:做出該選擇後,剩下的問題是同一問題的較小實例。常用的證明方式是交換論證:取任何一個最佳解,證明把其中的某個選擇換成貪婪選擇並不會使結果變差。

範例:區間排程

你有一間會議室和這些申請(開始, 結束):A (1, 4)、B (3, 5)、C (0, 6)、D (5, 7)、E (3, 9)、F (5, 9)、G (6, 10)、H (8, 11)。要排入盡可能多的互不重疊的會議。正確的貪婪規則是:在所有於上一場已排入會議結束後才開始的會議中,永遠挑結束最早的那場。

會議(依結束時間排序)開始, 結束是否在上次結束之後開始?決定上次結束時間
A1, 4是(尚未排入任何會議)排入4
B3, 5否(3 < 4)跳過4
C0, 6否跳過4
D5, 7是排入7
E3, 9否跳過7
F5, 9否跳過7
G6, 10否跳過7
H8, 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 演算法是貪婪演算法嗎?

是。它總是確定離起點最近且尚未確定的節點。這個貪婪選擇之所以正確,是因為邊的權重非負。

全部編碼教學