# Bellman-Ford Algorithm Bellman-Ford 演算法算是比較早期的演算法,而最短路徑問題基本上發展到 Dijkstra 就已經是最佳解了。我們至今還是會用 Bellman-Ford 演算法是因為他可以處理我們無法處理的兩個問題 : 負權邊、找負環 負權邊的問題[上次](https://hackmd.io/@ShanC/Dijkstra)講過了,最短路徑問題[這篇](https://hackmd.io/@ShanC/Shortest-Path)也大概聊過。來說說優點好了,Bellman-Ford 演算法因為本質上是走 walk,而不是 simple path,只要還能鬆弛就能繼續 walk 下去,因此可以幫我們測環 ## 邊陣列 Edge Array 這個東西我在[這篇](https://hackmd.io/@ShanC/basic-graph-theory-4)有講過了,但這邊才正式用到,因此再說一次 ### 無權邊 ```cpp= pair<int, int> edges[MXN]; // 這裡儲存邊的兩個終點 for(int i = 0; i < m; i++){ // 輸入可以這樣處理 cin >> edges[i].first >> edges[i].second; } ``` ### 帶權邊 ```cpp= struct Edge{ // 儲存兩終點 u, v 與邊的權重 w int u, v, w; }edge[MXN]; for(int i = 0; i < m; i++){ // 輸入可以這樣處理 cin >> edge[i].u >> edge[i].v >> edge[i].w; } ``` ## 鬆弛 Relax 由 $\delta$ 的定義,設一起點 $s$,對於任何邊 $(u, v)$ 而言,必須符合三角不等式 : $$ \delta(s, v)\leq \delta(s, u) + w(u, v) $$ 在單源最短路徑的演算法中,若我們搜尋到的邊不符合三角不等式,我們就需要把它替換掉,就如同我們求最小值的技巧一樣 ```cpp /* 此為 Pseudocode */ /* d 存兩點之間的最短距離, w 存邊權 */ Relax(u, v, w) if d[s][u] > d[u][v] + w[u][v] : d[s][u] = d[u][v] + w[u][v] ``` 這個東西是最短路徑的精華 ## Bellman-Ford 演算法 ### 想法 給一張無負環的圖 $G=(V, E)$、一個邊權函數 $w: E\to \mathbb{Z}$ 與一個起點 $s\in V$。Bellman-Ford 主要的想法是利用鬆弛的機制,每一輪將每條邊都鬆弛。只要鬆弛 $|V|-1$ 輪,那麼距離陣列 $\text{dis}[~]$ 紀錄的就會是最短距離 ### 範例 給定以下這張圖,除了起點以外,其他點的 `d[]` 都初始化為 $\infty$ <center> ![shapes at 26-01-29 09.12.17](https://hackmd.io/_uploads/BkeRVNdLbg.png) </center> 從圖中可以發現 $\infty\to\infty$ 是不可能被鬆弛到的,因為都是無限大。僅有從起點出發的邊可以被鬆弛到 <center> ![shapes at 26-01-29 09.12.24](https://hackmd.io/_uploads/SyQxBVu8-x.png) </center> 接下來注意到右邊那個點,他會被兩條邊鬆弛,最後結果為 $\min\{2+4,4+2,\infty\}=6$。而下面的點在這輪也可以被鬆弛,值為 $\min\{4,2-1\}=1$ <center> ![shapes at 26-01-29 09.22.30](https://hackmd.io/_uploads/HJ1F8VuUWe.png) </center> 最尾端的點接下來也可以被鬆弛為 $11$。然後會發現因為 $\min\{1+2, 6\}=3$,所以右邊那個點可以被鬆弛 <center> ![shapes at 26-01-29 09.22.36](https://hackmd.io/_uploads/H1nVPEd8Zl.png) </center> 由於數值有改變的關係,最後尾端那個點也可以被鬆弛變成 $\min\{11, 3+5\}=8$ <center> ![shapes at 26-01-29 09.22.40](https://hackmd.io/_uploads/ByKSvE_L-g.png) </center> 會發現第 $4$ 輪之後,已經沒有可以鬆弛的邊了 這裡要稍微提醒一下,在例子中,每次鬆弛都是以上一個階段的結果去計算 ### 維護最短路徑 這裡 $n=|V|$、$m=|E|$。設第 $t$ 輪鬆弛後,從 $s$ 到其他點 $v$ 的最短距離為 $\text{dis}[t][v]$。那麼其實可以當作 DP 轉移式: $$ \text{dis}[t][v]= \begin{cases} \infty &\text{, if } t=0 \text{ and } v\neq s\\ 0 &\text{, if } t=0 \text{ and } v=s\\ \min\limits_{(u,v)\in E}\{\text{dis}[t-1][u]+w(u,v)\} &\text{, if } t\ge 1 \end{cases} $$ ### 程式實作 1 根據以上的定義與想法,可以得到以下程式碼: ```cpp= for (int t = 0; t < n; t++) { for (int i = ; i < m; i++) { dis[t][i] = INF; // 很大的數字 } } dis[0][s] = 0; for (int t = 1; t < n; t++){ // 注意: 這邊只需要跑 n-1 次,而不是 n 次 for (int i = 0; i < m; i++){ int u = edge[i].u, v = edge[i].v, w = edge[i].w; if (dis[t - 1][u] + w < dis[t][v]) dis[t][v] = dis[t - 1][u] + w; } } ``` - 時間: $O(nm)$ - 空間: $O(nm)$ ### 程式實作 2 由於當我們跑到第 $t$ 輪時,實際會取用到的資料只有第 $t-1$ 輪的資料,前面 $1\sim t-2$ 都用不到,所以其實可以運用 DP 的 rolling 技巧將空間降一個維度 ```cpp= int dis[2][MXN]; for (int i = 0; i < n; i++) dis[0][i] = dis[1][i] = INF; dis[0][s] = 0; for (int t = 1; t < n; t++) { int cur = t % 2; int prev = (t - 1) % 2; // 先繼承上一輪的最短路徑結果 for (int i = 0; i < n; i++) dis[cur][i] = dis[prev][i]; for (int i = 0; i < m; i++) { int u = edge[i].u, v = edge[i].v, w = edge[i].w; if (dis[prev][u] + w < dis[cur][v]) { dis[cur][v] = dis[prev][u] + w; } } } ``` - 時間: $O(nm)$ - 空間: $O(2n)$ ### 程式實作 3 其實我們可以通通寫在同一個一維陣列當中。這樣連續覆寫可能會造成連續走很多步,但是這頂多讓某些節點更快速的找到最佳解,所以沒差 ```cpp= for (int t = 1; t < n; t++){ // 注意: 這邊只需要跑 n-1 次,而不是 n 次 for (int i = 0; i < m; i++){ int u = edge[i].u, v = edge[i].v, w = edge[i].w; if (dis[u] + w < dis[v]) dis[v] = dis[u] + w; } } ``` - 時間: $O(nm)$ - 空間: $O(n)$ 這種寫法才是真正會用到的程式碼 ### 優化 有一種可能就是這張圖其實在第 $n - 1$ 輪鬆弛之前就已經完全鬆弛完。如果真發生這種事,那麼就有好幾輪是浪費的。因此我們可以設一個變數 `relax = true`,來判斷該輪是否還有邊還在鬆弛 ```cpp= bool relax = true; for(int t = 1; t < n && relax; t++){ relax = false; for (int i = 0; i < m; i++){ int u = edge[i].u, v = edge[i].v, w = edge[i].w; if (dis[u] + w < dis[v]) { dis[v] = dis[u] + w; relax = true; } } } ``` 雖然最差情況下時間複雜度相同,但是在某些 case 確實可以加速一下 ## 測負環 Negative Cycle Detection ### 想法 不難觀察到,如果存在負環,那麼就可以無限地鬆弛下去,屆時所有距離都是 $-\infty$。這顯然不是我們想要到,因此要找出負環 <center> ![image](https://hackmd.io/_uploads/rk4ZoH_IZl.png =200x) </center> 根據最短路徑樹的性質,找出所有點的最短路徑只需要 $n-1$ 輪。若是到第 $n$ 輪還能鬆弛,就到表有環 ### 程式實作 用一個變數 `negcycle` 來檢查即可 ```cpp= for (int t = 1; t < n; t++){ for (int i = 0; i < m; i++){ int u = edge[i].u, v = edge[i].v, w = edge[i].w; if (dis[u] + w < dis[v]) dis[v] = dis[u] + w; } } bool negcycle = false; // 測負環 for (int i = 0; i < m; i++){ // 第 n 輪鬆弛 int u = edge[i].u, v = edge[i].v, w = edge[i].w; if (dis[u] + w < dis[v]) // 還能鬆弛 negcycle = true; } ``` ## 備註 - 雖然第 $n$ 輪鬆弛代表存在負環,但是究竟是哪邊有負環,我們並不知道,需要額外再處理。如果是不存在負環的圖,那麼每條邊被鬆弛達到最短後就不會再鬆弛。但如果存在負環的話,在我們鬆弛到負環之後,該負環與後面的節點都會一直被鬆弛 - Bellman-Ford 演算法可以處理的問題是沒有負環的最短路徑 - Bellman-Ford 演算法允許負權邊 - 好像有很多人在差不多同一個時代提出差不多的演算法,但不知道為什麼最後是以 Bellman 跟 Ford 兩人命名 ## 題目練習 [CSES Cycle Finding](https://cses.fi/problemset/task/1197) [CSES High Score](https://cses.fi/problemset/task/1673) [AtCoder Beginner Contest 061D - Score Attack](https://atcoder.jp/contests/abc061/tasks/abc061_d?lang=en) ---- ## 參考資料 - Introduction to Algorithms, Fourth Edition - [海大 I2CP - Shortest Path](https://hackmd.io/@LeeShoWhaodian/HyT4ib5qJg#/) - [CP-Algorithms - Bellman-Ford Algorithm](https://cp-algorithms.com/graph/bellman_ford.html) - [FDHSCPP110 - 最短路徑 shortest path](https://hackmd.io/@fdhscpp110/shortest_path) - [Aaron - Bellman-Ford algorithm](https://medium.com/algorithm-solving/bellman-ford-algorithm-3cd327630e2d) - [Wikipedia - Bellman–Ford algorithm](https://en.wikipedia.org/wiki/Bellman%E2%80%93Ford_algorithm) (這裡的範例gif檔還蠻不錯的,可以看看) ---- > [ShanC 程式競賽筆記](https://hackmd.io/@ShanC/B1ouGxqcC) > 作者: ShanC > 更新: 2026/1/29