--- tags: 成大高階競技程式設計 2021 --- # 2021 Week11 - Graph (MST, SP) 註::bulb: 代表相對少見、較進階或與主題關聯性較低的內容,讀者可自行斟酌觀看。 --- # 最小生成樹(MST, minimum spanning tree) ```graphviz graph { rankdir=LR; node [shape=circle, label=""]; C; B; edge [style=dashed, color=gray]; A -- B [label="8"]; C -- E [label="9"]; D -- F [label="3"]; E -- F [label="7"]; edge [style=bold, color=black]; A -- C [label="4"]; B -- D [label="2"]; C -- D [label="7"]; D -- E [label="6"]; B -- F [label="1"]; } ``` 生成樹,是包含所有點的子圖,並且它是一棵樹; 最小生成樹,是所有生成樹中,邊權重總和最小的。 ## Kruskal's algorithm ```graphviz graph { rankdir=LR; node [shape=circle, label=""]; C; B; edge [style=dashed, color=gray]; D -- F [label="3"]; edge [style=dashed, color=black]; A -- B [label="8"]; C -- E [label="9"]; E -- F [label="7"]; C -- D [label="7"]; D -- E [label="6"]; edge [style="bold, dashed", color=black]; A -- C [label="4"]; edge [style=bold]; B -- D [label="2"]; B -- F [label="1"]; } ``` Kruskal's algorithm 每次在不會形成圈的邊中,選一條最小的,直到形成一棵樹為止。 > Kruskal's algorithm 是一個 greedy algorithm。 > #### 證明 > 令 Kruskal's algorithm 找到的樹為 $T$,而對於其他的生成樹 $U$, > 若 $U$ 的邊 $e$ 不在 $T$ 中,則將 $e$ 加入 $T$ 的話,會形成一個圈, > 圈上一定至少有另一條邊 $f$ 不屬於 $U$, > 而因為 Kruskal's algorithm 將 $f$ 加入 $T$ 而非 $e$,代表 $w_{e}\ge w_{f}$。 > > 我們將 $e$ 換成 $f$,得到另外一棵生成樹 $V=U-e+f$,$W_U\ge W_V$, > 經過多次轉換,$U$ 一定會變為 $T$,$W_U\ge W_V\ge\cdots\ge W_T$, > 所以對於任意生成樹 $U$,$W_U\ge W_T$,得證 $T$ 是最小生成樹。 實作上,要判斷會不會形成圈,也就是判斷連通分量,可以用並查集來記錄。 ```cpp! vector<tuple<int, int, long long>> Kruskal(int n, vector<tuple<long long, int, int>>& edges) { vector<tuple<int, int, long long>> mst{}; DSU dsu{n}; sort(edges.begin(), edges.end()); for (auto& [w, u, v] : edges) if (dsu.find(u) != dsu.find(v)) dsu.unionn(u, v), mst.emplace_back(u, v, w); return mst; } ``` 時間複雜度 $O(|E|\log|E|+|E|\cdot\alpha)=O(|E|\log|E|)$。 <hr class="dashed"> ## Prim's algorithm ```graphviz graph { rankdir=LR; node [shape=circle, label=""]; C; B; edge [style=dashed, color=gray]; D -- F [label="3"]; A -- C [label="4"]; C -- E [label="9"]; edge [color=black]; E -- F [label="7"]; D -- E [label="6"]; A -- B [label="8"]; edge [style="bold, dashed", color=black]; C -- D [label="7"]; edge [style=bold]; B -- D [label="2"]; B -- F [label="1"]; } ``` 跟 Kruskal's algorithm 過程中會形成森林不同,Prim's algorithm 維護一棵樹 $T$, 每次加入符合 $u\in T, v\notin T$ 的邊 $(u,v)$ 中最小邊,同時也是加入一個點 $v$, 當加入所有點後,T 就是最小生成樹。 > Prim's algorithm 也是一個 greedy algorithm。 > #### 證明 > 令 Prim's algorithm 找到的樹為 $T$,而對於其他的生成樹 $U$, > 依照 Prim's algorithm 加入邊的順序,令 $T$ 中第一個不在 $U$ 的邊為 $e$, > 而在這之前被加入的點集合為 $S$, > 因為 $U$ 是樹,$U$ 中還是有一條路徑連接 $e$ 的二個端點, > 而這條路徑上有一條邊 $f$,端點分別屬於 $S$ 以及不屬於 $S$, > 而在 Prim's algorithm 選擇加入 $e$ 時,沒有選擇 $f$,代表 $w_{f}\ge w_{e}$。 > > 我們將 $f$ 換成 $e$,得到另外一棵生成樹 $V=U-f+e$,$W_U\ge W_V$, > 經過多次轉換,$U$ 一定會變為 $T$,$W_U\ge W_V\ge\cdots\ge W_T$, > 所以對於任意生成樹 $U$,$W_U\ge W_T$,得證 $T$ 是最小生成樹。 ```cpp! vector<tuple<int, int, long long>> Prim(const vector<vector<pair<int, long long>>>& adj) { const auto& n{adj.size()}; vector<tuple<int, int, long long>> mst{}; vector<bool> found(n, false); using ti = tuple<long long, int, int>; priority_queue<ti, vector<ti>, greater<ti>> pq{}; found[0] = true; for (auto& [v, w] : adj[0]) pq.emplace(w, 0, v); for (int i{0}; i < n - 1; ++i) { int mn, u, v; do { tie(mn, u, v) = pq.top(), pq.pop(); } while (found[v]); found[v] = true, mst.emplace_back(u, v, mn); for (auto& [x, w] : adj[v]) pq.emplace(w, v, x); } return mst; } ``` 時間複雜度為 $O(|V|+|E|\log|E|)=O(|E|\log|E|)$。 > :bulb:上述做法以邊進行排序,如果改成以點的觀點排序, > 自行實作二元堆積,並透過 [decrease-key](https://en.wikipedia.org/wiki/Binary_heap#Decrease_or_increase_key) 的方式更新, > 時間複雜度可降低為 $O((|V|+|E|)\log|V|)=O(|E|\log{|V|})$。 --- # 最短路徑(SP, shortest path) ```graphviz digraph { rankdir=LR; node [label=""]; edge [style=dashed]; A [label="u"]; F [label="v"]; A -> B [label="3", style=bold]; A -> D [label="8"]; B -> C [label="2", style=bold]; C -> F [label="5"]; D -> C [dir="back", label="1", style=bold]; D -> E [label="2", style=bold]; E -> F [label="1", style=bold]; } ``` $u$ 到 $v$ 的最短路徑是指,以 $u$ 為起點,$v$ 為終點的路徑中,邊的權重總和最小的一條路徑。 ## 單源最短路徑問題(SSSP, single-source shortest path problem) 單源最短路徑問題,是要找出一個起點 $u$,到其他所有點的最短路徑。 ## Bellman-Ford algorithm 之前在 [DP](https://hackmd.io/@XDEv11/NCKU-AdvCP-2021-DP#%E5%96%AE%E6%BA%90%E6%9C%80%E7%9F%AD%E8%B7%AF%E5%BE%91%EF%BC%88Single-source-shortest-path%EF%BC%89) 時講過的方法,沒辦法處理有環的圖,這邊我們定義一個不一樣的問題, 定義 $sp(S,V,i)$ 代表從 $S$ 到 $V$,長度不超過 $i$ 的最短路徑; 如果 $sp(S,V,i)$ 長度小於 $i$,則它就是 $sp(S,V,i-1)$; 否則會是其他點長度不超過 $i-1$ 的最短路徑 $sp(S,U,i-1)$,再加上一條邊 $(U,V)$。 :::success $sp(S,V,i)=\min(sp(S,V,i-1), \min\limits_{(U,V)\in E}(sp(S,U,i-1)+w(U,V)))$ ::: > 沒有負環的話,最短路徑最多包含 $n-1$ 條邊,所以 $sp(S,V,n-1)$ 就是最終的答案了。 ```cpp! vector<long long> Bellman_Ford(const vector<vector<pair<int, long long>>>& adj, int s) { const auto& n{adj.size()}; vector<vector<long long>> sp(n, vector<long long>(n, 100000000)); sp[s][0] = 0; for (int i{1}; i < n; ++i) { for (int v{0}; v < n; ++v) { sp[v][i] = sp[v][i - 1]; for (auto& [u, w] : adj[v]) sp[v][i] = min(sp[v][i], sp[u][i - 1] + w); } } vector<long long> d(n); for (int v{0}; v < n; ++v) d[v] = sp[v][n - 1]; return d; } ``` 時間複雜度為 $O(|V||E|)$ <hr class="thin"> 這樣空間複雜度有點高,我們可以使用之前說過的 rolling dp 的技巧,只留下二行的結果。 > 再偷懶一點,我們只用一行。 ```cpp! vector<long long> Bellman_Ford(const vector<vector<pair<int, long long>>>& adj, int s) { const auto& n{adj.size()}; vector<long long> sp(n, 100000000); sp[s] = 0; for (int i{1}; i < n; ++i) { for (int v{0}; v < n; ++v) { for (auto& [u, w] : adj[v]) sp[v] = min(sp[v], sp[u] + w); } } return sp; } ``` 應該有人發現,這樣我們根本沒有乖乖地用到 $SP(S,U,i-1)$ 的答案, 有些變成了用 $SP(S,U,i-1)$ 去更新; 但演算法的核心價值在於「考慮了所有長度小於 $i$ 的路徑」, 而非「只考慮長度小於 $i$ 的路徑」,所以最終一樣可以得到正確答案。 > 利用鄰點更新最短路徑,這個動作被稱為 [Relaxation](https://en.wikipedia.org/wiki/Relaxation_(approximation))。 <hr class="dotted"> ### Shortest Path Faster Algorithm 上面的做法,可以發現到,很多點的最短路徑根本沒有被更新, 那再一次去嘗試更新鄰點的最短路徑根本是沒有意義的; 所以演算法就可以改成,最短路徑有被更新的點,再去嘗試更新其他點就好。 > 這邊讓它跑 $n$ 輪,第 $n$ 輪時不應該有點再被更新了,否則的話就代表它有負環。 ```cpp! template <typename T> optional<vector<optional<T>>> Bellman_Ford(const vector<vector<pair<int, T>>>& adj, int s) { const auto& n{adj.size()}; vector<optional<T>> d(n, nullopt); d[s] = 0; queue<int> qu{}, qu2{}; vector<bool> in(n, false), in2(n, false); qu.push(s), in[s] = true; for (int i{0}; i < n; ++i) { // at most n-1 edges while (!qu.empty()) { int u{qu.front()}; qu.pop(), in[u] = false; for (auto& [v, w] : adj[u]) if (!d[v] || d[v] > d[u].value() + w) { // relax d[v] = d[u].value() + w; if (!in2[v]) qu2.push(v), in2[v] = true; } } qu.swap(qu2), in.swap(in2); } if (qu.empty()) return d; return nullopt; // if negative cycle } ``` 時間複雜度最差的情況不變,但在隨機的圖下,平均複雜度為 $O(E)$。[^SPFA] <hr class="dashed"> ## Dijkstra's algorithm 一定有些點的最短路徑長度為 $1$,在 Bellman-Ford 演算法中, 這樣的點還是會被更新(與持續嘗試更新其他點),就浪費了很多計算量。 今天假設邊都是非負權重,有一個點集合 $S$, 則 $S$ 中最短路徑最短的點,最短路徑只包含 $V-S$ 中的點, 所以我們就可以持續地找出最短路徑最短的點 $u$, 接著集合就只剩下 $S-u$(子問題),這就是一個 greedy algorithm。 > 因為「$S$ 中最短路徑最短的點,最短路徑只包含 $V-S$ 中的點」, > 等價於我們只需要計算所有點只經過 $V-S$ 的最短路徑,然後找最短的, > 因為如果某個點的最短路徑還要經過 $S$ 中的點,代表它一定不是那個 $u$。 > > 而我們只要每次找到 $u$ 後,去更新其他點的最短路徑, > 就能快速地維護所有點只經過 $V-S$ 的最短路徑。 ```cpp! template <typename T> vector<optional<T>> Dijkstra(const vector<vector<pair<int, T>>>& adj, int s) { const auto& n{adj.size()}; vector<optional<T>> d(n, nullopt); d[s] = 0; vector<bool> found(n, false); using pq_t = pair<T, int>; priority_queue<pq_t, vector<pq_t>, greater<pq_t>> pq{}; pq.emplace(0, s); while (!pq.empty()) { auto [_, u]{pq.top()}; pq.pop(); if (found[u]) continue; found[u] = true; for (auto& [v, w] : adj[u]) if (!d[v] || d[v] > d[u].value() + w) { d[v] = d[u].value() + w; pq.emplace(d[v].value(), v); } } return d; } ``` 複雜度為 $O(|E|\log|E|+|E|\log|E|)=O(|E|\log|E|)=O(|E|\log|V|)$。 > 一個點可能會被前面最短路徑較短的點更新,最多會到 $O(|V|)$ 次, > 但全部的點被更新(被丟進 pq)的次數還是不會超過 $O(|E|)$, > 不然也可以透過 [Decrease key](https://en.wikipedia.org/wiki/Binary_heap#Decrease_or_increase_key) 的方式將 pq 的大小限制在 $O(|V|)$,但差別不大, > 因為$|E|=O(|V|^2)$,因為取對數後,漸進符號(asymptotic notation)是一樣的。 <hr class="dashed"> ## 所有點對最短路徑問題(APSP, all-pairs shortest path problem) 所有點對最短路徑問題,是要找尋任意二點之間的最短路徑。 ## Floyd–Warshall algorithm 定義 $sp(i,j,k)$ 為 $i$ 到 $j$,且中間點只能為 ${0\dots k}$ 的最短路徑, 則 $sp(i,j,k)$ 若有經過 $k$,就是 $sp(i,k,k-1)$,再加上 $sp(k,j,k-1)$。 :::success $sp(i,j,k)=\min(sp(i,j,k-1), sp(i,k,k-1)+sp(k,j,k-1))$ ::: > 一樣透過 rolling dp 的技巧,所以只需要二維的空間。 ```cpp! template <typename T> vector<vector<optional<T>>> Floyd_Warshall(const vector<vector<optional<T>>>& adj) { const auto& n{adj.size()}; auto d{adj}; for (int i{0}; i < n; ++i) d[i][i] = 0; for (int k{0}; k < n; ++k) for (int i{0}; i < n; ++i) for (int j{0}; j < n; ++j) { if (!d[i][k] || !d[k][j]) continue; // no value if (!d[i][j] || d[i][j] > d[i][k].value() + d[k][j].value()) d[i][j] = d[i][k].value() + d[k][j].value(); } return d; } ``` 複雜度為 $O(|V|^3)$。 --- # References * Wikipedia - [Kruskal's algorithm](https://en.wikipedia.org/wiki/Kruskal%27s_algorithm) * Wikipedia - [Prim's algorithm](https://en.wikipedia.org/wiki/Prim's_algorithm) [^SPFA]: Wikipedia - [Shortest Path Faster Algorithm - Running time](https://en.wikipedia.org/wiki/Shortest_Path_Faster_Algorithm#Running_time) <style> hr.thin { height: 0.5px; } hr.dotted { border-top: dotted; height: .0em; color: #777777; background-color: #ffffff; } hr.dashed { border-top: dashed; height: .0em; color: #777777; background-color: #ffffff; } /* Gradient transparent - color - transparent */ hr.gradient { border: 0; height: 1px; background-image: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); } /* Flaired edges, by Tomas Theunissen */ hr.flaired { overflow: visible; height: 30px; border-style: solid; border-color: black; border-width: 1px 0 0 0; border-radius: 20px; background-color: #ffffff; } hr.flaired:before { /* Not really supposed to work, but does */ display: block; content: ""; height: 30px; margin-top: -31px; border-style: solid; border-color: black; border-width: 0 0 1px 0; border-radius: 20px; } </style>