cv.cm

編碼 · 演算法

Bellman-Ford 演算法教學:原理、程式碼與時間複雜度

閱讀約 6 分鐘

簡答

Bellman-Ford 演算法可找出從單一起點到其他所有節點的最短路徑,即使部分邊的權重為負也沒問題。它會對所有邊進行 V − 1 輪鬆弛,之後所有最短距離都是正確的。再多做一輪就能偵測負環:如果還有任何距離可以被改善,就代表圖中存在總權重為負的環。時間複雜度為 O(V × E)。

Bellman-Ford 解決什麼問題

Dijkstra 演算法很快,但要求邊權重非負。有些問題天生就有負權重:退款、匯率兌換中的獲利,或可能低於零的能量成本。Bellman-Ford 能正確處理這類圖,而且當圖中有負環(可以無限繞圈、讓成本越來越低的迴圈)導致不存在最短路徑時,它也會告訴你。

這個演算法以 Richard Bellman 和 Lester Ford Jr. 命名,兩人在 1950 年代分別發表了相關版本。

用白話說明原理

  1. 把起點的距離設為 0,其他所有節點設為無限大。
  2. 重複 V − 1 次:對每條邊 u → v(權重為 w),如果 dist[u] + w < dist[v],就更新 dist[v]。
  3. 再對所有邊多做一輪。如果仍有距離被改善,就回報存在負環。

為什麼是 V − 1 輪?沒有環的最短路徑最多使用 V − 1 條邊。第 k 輪結束後,最短路徑至多有 k 條邊的節點都已得到正確距離,所以 V − 1 輪就涵蓋了所有簡單路徑。

逐步範例

有向圖有 4 個節點,邊為 S→A(4)、S→B(5)、B→A(−3)、A→C(3)。每一輪都依這個順序處理各條邊。

輪次SABC
起始0∞∞∞
102(經由 B:5 − 3)55(經由 A:2 + 3)
20255(沒有變化:提前結束)

在第 1 輪中,A 先透過 S→A 變成 4,再經由 B→A 改善為 2,而 C 用的是改善後的值。Dijkstra 演算法會在看到 B 之前就把 A 確定為 4,這正是負權重邊造成的錯誤。

Python 實作

Bellman-Ford 直接處理邊的串列,寫起來很短:

def bellman_ford(nodes, edges, start):
    """edges: list of (u, v, weight). Returns dist, or raises on a negative cycle."""
    INF = float("inf")
    dist = {n: INF for n in nodes}
    dist[start] = 0
    for _ in range(len(nodes) - 1):
        changed = False
        for u, v, w in edges:
            if dist[u] != INF and dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                changed = True
        if not changed:
            break                         # already optimal
    for u, v, w in edges:
        if dist[u] != INF and dist[u] + w < dist[v]:
            raise ValueError("graph contains a negative cycle")
    return dist
nodes = ["S", "A", "B", "C"]
edges = [("S", "A", 4), ("S", "B", 5), ("B", "A", -3), ("A", "C", 3)]
print(bellman_ford(nodes, edges, "S"))  # {'S': 0, 'A': 2, 'B': 5, 'C': 5}

加入一條權重為 −6 的邊 C → B,會形成環 B → A → C → B,總權重為 −3 + 3 − 6 = −6,因此額外的那一輪就能偵測到它:

try:
    bellman_ford(nodes, edges + [("C", "B", -6)], "S")
except ValueError as e:
    print(e)  # graph contains a negative cycle

dist[u] != INF 這個檢查會略過從尚未到達的節點出發的邊。在 Python 中,float("inf") + w 仍是無限大,所以這個檢查主要是省下運算。在以很大的整數代表無限大的語言中,它能避免溢位和錯誤的更新。

時間與空間複雜度

情況時間原因
最差O(V × E)V − 1 輪,每輪鬆弛全部 E 條邊。
最佳(提前結束)O(E)如果起點之後的第一輪沒有任何變化,迴圈就會停止。
空間O(V)除了邊串列之外,只需要距離(以及可選的父節點)對應表。

在 1,000 個節點、10,000 條邊的圖上,最多約需 1,000 萬次鬆弛,所以當權重皆非負時,Dijkstra 快得多。

變化版本與常見錯誤

  • 提前結束:當完整一輪都沒有任何變化時就停止。這樣做是安全的,實務上常能大幅加速。
  • SPFA(shortest path faster algorithm):使用佇列,只對距離有變動的節點重新鬆弛其外向邊。通常很快,但最差情況仍是 O(V × E)。
  • 限制跳數的路徑:只執行恰好 k 輪,並使用上一輪距離的副本,就能得到最多 k 條邊的最便宜路徑。這可以解決「k 次轉機內的最便宜航班」。
  • 找出環本身:維護父節點對應表;當額外那一輪更新了節點 v,就沿著父節點走 V 步,必定會落在環內,再繼續走直到回到同一個節點。

何時使用與實際應用

  • 當邊權重可能為負,或必須偵測負環時,請使用 Bellman-Ford;否則優先選擇 Dijkstra。
  • 距離向量路由協定(例如 RIP)使用的是分散式 Bellman-Ford 的概念:每個路由器根據鄰居的估計值,不斷更新自己的距離估計。
  • 匯率套利:把權重設為 -log(rate),負環就代表一連串的兌換最後得到比起始更多的錢。
  • Johnson 演算法先執行一次 Bellman-Ford 對圖重新加權,之後就能從每個節點執行 Dijkstra,求出所有節點對的最短路徑。
  • 差分約束(形如 x - y <= c 的不等式系統)可以轉換成最短路徑問題求解;出現負環就代表無解。

常見問題

為什麼 Bellman-Ford 要執行 V − 1 次迭代?

沒有環的最短路徑最多包含 V − 1 條邊,而每一輪都會確定多一條邊的路徑的距離。除非有負環,否則 V − 1 輪之後所有最短路徑都已找到。

Bellman-Ford 如何偵測負環?

在 V − 1 輪之後,它會再對所有邊多做一輪。如果仍有距離可以被縮短,就表示某條路徑能不斷變得更便宜,這只有在存在可到達的負環時才可能發生。

Bellman-Ford 比 Dijkstra 快嗎?

不會。Bellman-Ford 是 O(V × E),而使用堆積的 Dijkstra 是 O((V + E) log V)。只有在需要負權重或偵測負環時才使用 Bellman-Ford。

Bellman-Ford 是動態規劃嗎?

是。第 k 輪利用第 k − 1 輪的結果,計算最多使用 k 條邊的最佳距離,這就是以邊數為變數的動態規劃遞迴式。

全部編碼教學