--- tags: 進階班 --- # 最短路徑 shortest path ## 甚麼是最短路徑? 給定一個帶權圖及點 `l, r`,求點 `l` 走到點 `r` 的最小路徑權重和。 設一圖: ![](https://i.imgur.com/Rueki0b.png) 其中要求第 $1$ 點和第 $3$ 點的最短路徑,那麼答案就是: ![](https://i.imgur.com/Kb4YArr.png) $3 + 1 = 4\Rightarrow 4$ ## 演算法:發展期 `shortest path` 一開始時,大家想出的是複雜度較高的算法,但都是後來複雜度較低演算法的基礎。 其中一個大概念是鬆弛 (`Relaxation`) 設有一圖: ![](https://i.imgur.com/AQPz6Fk.png) 我們可以得知:從第 $2$ 點到第 $3$ 點,如果只走一條路,則長度為 $8$。 但若找到一點 $k$,使 $dis(2, k) + dis(k, 3) < dis(2, 3)$,則答案會縮小,更接近最短路徑解。如下圖例子: ![](https://i.imgur.com/iPtTm0N.png) 此技巧即鬆弛,且我們能得知:$min(dis(2, 3))\leq 6$ (因為鬆弛是可以進行很多次的) ### Floyd-Warshall 演算法 這個演算法就是將所有點對其他點都鬆弛過,那麼找到的路徑解即最短路徑。 ```cpp= int n, rd, l, r, v; cin >> n >> rd; vector<vector<int>> dp(n + 1, vector<int>(n + 1, 1e9)); for(int i = 0; i < rd; i++){ cin >> l >> r >> v; dp[l][r] = dp[r][l] = v; //每條路皆雙向 } //以下即 Floyd-Warshall for(int k = 1; i <= n; i++){ for(int i = 1; j <= n; j++){ for(int j = 1; k <= n; k++){ dp[i][j] = min(dp[i][k] + dp[k][j] , dp[i][j]); //窮舉所有鬆弛可能 } } } cin >> l >> r; cout << dp[l][r]; ``` 偽代碼 ```cpp= FOR k <- 1 to N FOR i <- 1 to N FOR j <- 1 to N dis(i, j) <- min(dis(i, k) + dis(k, j), dis(i, j)) ``` `Time Complexity:` $O(N^3)$ 為甚麼迴圈的順序是 `k, i, j` 呢? 看看以下的圖: ![](https://i.imgur.com/EW3fvRd.png) 假設迴圈擺放是 `i, j, k`,那麼 `dp[i][j]` 只會遍歷「連續的 `k` 次」 而不管是第 $3,\;4,\;5$ 點都無法鬆弛 `dp[1][2]` (因為 `dp[1][2]` 是最先被鬆弛的) 這樣就會導致 `dp[1][2]` 有問題。 因此想法應該是對每個點窮舉所有 `dp[i][j]` 鬆弛。 優點:經過一次 `Floyd-Warshall` 後,所有兩點最短距離就都知道了! ### Bellman-Ford 演算法 由 `Floyd-Warshall` 程式碼可知它是對整個圖中的每一點鬆弛。 但我們可以先**得知起始點**,並對「邊」進行鬆弛。 $\Rightarrow$ 一次只能跑出一個點對所有點的最短路徑,`dp[i]` 代表一點 `u` 走到 `i` 點的最短距離 由於一條最短路最多會經過 $n - 1$ 條邊,因此只要對每個邊鬆弛 $n - 1$ 次就好。 因此又誕生一個演算法:`Bellman-Ford` ```cpp= struct road{ int l, r, val; }; int main(){ int n, e, l, r, v; cin >> n >> e; vector<int> dp(n + 1, 1e9); vector<road> rd(e << 1); for(int i = 0; i < e; i++){ cin >> l >> r >> v; rd[i << 1] = {l, r, v}; rd[i << 1 | 1] = {r, l, v}; } cin >> l >> r; dp[l] = 0; for(int i = 0; i < n - 1; i++){ for(int j = 0; j < e * 2; j++){ dp[rd[j].r] = min(dp[rd[j].r], dp[rd[j].l] + rd[j].val); } } for(auto & i : dp) cout << i << ' '; // l 到每個點的距離 return 0; } ``` 偽代碼 ```cpp= STRUCT edge{ point from, to value val } //dis(k) <- the distance from st to k dis(st) <- 0 dis(else) <- INF //from -> to FOR i <- 1 to N //repeat N times FOR e <- all_edge dis(e.to) <- min(dis(e.to), dis(e.from) + e.val) ``` 而觀察以下圖: ![](https://i.imgur.com/odGJat7.png) 從第 $2$ 點到第 $3$ 點的最短路徑是 $2$ 還是 $-1$ 還是 $-4$ ...呢? 由於這種圖(負環)沒有最短路徑,需排除。 而負環會鬆弛超過 $N - 1$ 次,因此可以在 `Bellman-Ford` 演算法結束後判斷。 ```cpp=24 for(int j = 0; j < e * 2; j++){ if(dp[rd[j].r] > dp[rd[j].l] + rd[j].val) cout << "neg cycle\n"; } ``` `Time Complexity:` $O(NE)$ ## 演算法:成熟期 這時,有比較快的演算法出現。 ### SPFA (Shortest Path Faster Algorithm) 從 `Bellman-Ford` 可得知,它是對每個邊都鬆弛。 然而,在求兩點距離時,**一個點被鬆弛過,才可以去鬆弛其他點。** 而可鬆弛的點會從原點開始擴散。 這時實作及概念會有點像 `BFS`。 就是從起點 `i` 擴散,到達每一點 `k` 時一樣要判斷 `dis[k] = min(dis[j] + dis(j, k), dis[k])` 而因為它是 `Bellman-Ford` 演算法的優化版,因此它也可以檢查負環。 ```cpp= #define mem(x) memset(x, 0, sizeof(x)) struct road{ int r, val; }; int main(){ int n, e, l, r, v; cin >> n >> e; vector<int> dp(n + 1, 1e9); vector<road> rd[n + 1]; for(int i = 0; i < e; i++){ cin >> l >> r >> v; rd[l].push_back({r, v}); rd[r].push_back({l, v}); } cin >> l >> r; dp[l] = 0; queue<int> que; que.push(l); bool check[n + 1]; mem(check); int cnt[n + 1]; mem(cnt); while(!que.empty()){ int tmp = que.front(); que.pop(); check[tmp] = 0, cnt[tmp]++; if(cnt[tmp] >= n) {cout << "neg cycle\n"; break;} for(auto & i : rd[tmp]){ if(dp[i.r] > dp[tmp] + i.val){ dp[i.r] = dp[tmp] + i.val; if(!check[i.r]) check[i.r] = 1, que.push(i.r); } } } for(auto & i : dp) cout << i << ' '; return 0; } ``` 幾個實作上的新東西: 1. `check[]` 判斷點不在 `queue` 內才可以做 `BFS` (不然用 `queue` 裡的點做 `BFS` 就好) 2. `cnt[]` 判斷點被丟進 `queue` 幾次,用來檢查負環 3. `struct road` 剩下 `r, val`,因為 `l` 可以用 `vector` 的下標搞定 4. 一個點被鬆弛過,才可以去鬆弛其他點 `Time Complexity:` $O(NE)$ 但有很大的常數優化 (剪枝) --- 最後,還有一個幾乎是最優的演算法。 但它無法檢查負環,也不能解有負環的題目。 不過...要檢查負環的題目數量趨近於 $0$ :poop: ### Dijkstra 演算法 想法:從起點延伸出去的邊中,先把所有邊都丟進一個容器裡(權重設定為 `dis(start, i) + v`), 再選出容器中權重最小的邊,把它接起來,此時權重即為連接到的 `j` 點權重。 接著以 `j` 為原點擴散,把連接到的邊丟進容器,重複動作直到連接至終點。 為了讓容器有「選出最小邊」的功能,因此選用 `priority_queue` ```cpp= #define mem(x) memset(x, 0, sizeof(x)) struct road { int r, val; bool operator<(road rd) const {return val >= rd.val;} //pick the smallest }; int main() { int n, e, l, r, v; cin >> n >> e; vector<int> dis(n + 1, 1e9); vector<road> rd[n + 1]; bool visited[n + 1]; mem(visited); for(int i = 0; i < e; i++){ cin >> l >> r >> v; rd[l].push_back({r, v}); rd[r].push_back({l, v}); } priority_queue<road> pq; cin >> l >> r; int tmp = l; dis[l] = 0; visited[l] = 1; while(tmp != r){ for(auto & i : rd[tmp]) { if(!visited[i.r]) pq.push({i.r, dis[tmp] + i.val}); } road tp_rd; while(visited[tmp]){ tp_rd = pq.top(), pq.pop(); tmp = tp_rd.r; } visited[tmp] = 1, dis[tmp] = tp_rd.val; } for(auto & i : dis) cout << i << ' '; return 0; } ``` ```cpp= dis(st) <- 0 dis(else) <- INF min_dis() // get the min_dis val and position min_dis() add (0, st) WHILE min_dis() != empty now_pos <- min_dis() del min_dis() IF is_vis(now_pos) continue dis(now_pos) <- now_pos.val //vis = true FOR e <- edge(now_pos) // the edges that is from now_pos IF not_vis(e.to) : min_dis() add (now_pos.val + e.val, e.to) ``` `Time Complexity:` $O(NlgE)$ ## 題目練習:DanDanJudge [a577. 避難所](http://203.64.191.163/ShowProblem?problemid=a577) 這題是「兩點最短路」的變化題,因為可能不只一個避難所。 可以把所有避難所的 `dis[i]` 都先設為 $0$, 而因為沒有負環,所以用 `dijkstra` 就好。 :::spoiler `code` ```cpp= #include<bits/stdc++.h> #define mem(x) memset(x, 0, sizeof(x)) #define LL long long using namespace std; struct road { LL r, val; bool operator<(road rd) const {return val >= rd.val;} //pick the smallest }; int main() { cin.tie(nullptr), ios_base::sync_with_stdio(false); int n, m, k, r, lt, rt, v; cin >> n >> m >> k >> r; vector<LL> dis(n + 1, 1LL * 1e15); vector<road> rd[n + 1]; bool visited[n + 1]; mem(visited); for(int i = 0, root; i < k; i++) cin >> root, dis[root] = 0, visited[root] = 1; for(int i = 0; i < m; i++){ cin >> lt >> rt >> v; rd[lt].push_back({rt, v}); rd[rt].push_back({lt, v}); } int visit = n - k - r, tmp; priority_queue<road> pq; for(int i = 1; i <= n; i++){ if(visited[i]){ tmp = i; for(auto & j : rd[i]){ if(!visited[j.r]) pq.push({j.r, j.val}); } } } while(visit--){ for(auto & i : rd[tmp]) { if(!visited[i.r]) pq.push({i.r, dis[tmp] + i.val}); } road tmp_rd; while(visited[tmp]){ tmp_rd = pq.top(), pq.pop(); tmp = tmp_rd.r; } visited[tmp] = 1, dis[tmp] = tmp_rd.val; } cout << dis[tmp] << '\n'; return 0; } ``` ::: ## 技術及經驗總結 其實...記 `dijkstra` 就好,因為其他真的太少用到了 有餘力再去記其他演算法