# 最小生成樹 Minimum Spanning Tree
在一些路網的構建當中,我們時常期望可以找出一個最簡的路網,使資料可以互相傳遞,而且花費最小。要實現這樣的想法,就會用到最小生成樹的概念
## 生成樹 Spanning Tree
### 定義
若一棵樹 $T$ 是一張連通圖 $G$ 的子圖,且 $V(T) = V(G)$ (即 $T$ 擁有所有 $G$ 的節點),則稱 $T$ 為一棵生成樹
<center class = "center">
<img src="https://hackmd.io/_uploads/B1NKHX98kl.png" style="width: 340px">
<img src="https://hackmd.io/_uploads/Hy6qrXcLJg.png" style="width: 350px">
</center>
<p class="text-center">
下圖就是上圖的生成樹
</p>
### 性質
因為生成樹 $T$ 是一棵樹,必須符合樹的性質:
1. $|E| = |V| - 1$
2. 無環
3. 為一張連通圖
4. 任兩節點 $u, v\in V(T)$ 只會有一條唯一的路徑 $p$
由於一張圖會有多個子圖,而 $T$ 也是連通圖 $G$ 的子圖,因此 $T$ 可能會有很多種
## 常見的生成樹例子
### 深度優先搜尋樹 DFS Tree
[深度優先搜尋 (DFS)](https://hackmd.io/@ShanC/bfs_dfs) 會訪問圖上的所有節點。事實上將走訪路徑保留後,可以連接所有節點形成一顆生成樹
### 廣度優先搜尋樹 BFS Tree
相同地,BFS 也後產生一顆生成樹。此生成樹會有一個性質 : 根節點所有其他點的距離為最短
## 最小生成樹 Minimum Spanning Tree
通常 Minimum Spanning Tree 會簡寫成 MST
### 樹的權重
一棵樹的權重,就是所有邊權重的和
設一棵樹 $T = (V, E)$,則用數學式可表達成 : $\sum_{v\in V} w(v)$
### 定義
最小生樹就是所有生成樹當中樹的權重最小的那一棵
根據生成樹的性質,最小生成樹也可能有多種可能
<center class = "half">
<img src="https://hackmd.io/_uploads/SJ79LQ98yl.png" style="width: 350px">
<img src="https://hackmd.io/_uploads/HJmiUX98Je.png" style="width: 350px">
</center>
<p class="text-center">
右圖就是左圖的最小生成樹
</p>
## Prim 演算法
### 想法
Prim 演算法是一個尋找 MST 的貪心演算法。核心概念是想維護一個集合 $S$,使 $S$ 最終包含連通圖中所有節點
設一張連通圖 $G=(V, E)$
1. 在最開始找個起點 $t\in V$ 裝入 $S$ 中
2. 重複將連接集合 $S$ 與 $V-S$ 的邊中最小權重的那一條所連接的節點 $v\in V-S$ 併入 $S$,直到 $S = V$ 時就停止
### 實作程式碼 (原始版)
- $\text{g[ ]}$ : 鄰接陣列
- $\text{vis[ i ]}$ : 紀錄節點 $\text{i}$ 是否在集合 $S$ 中
- $\text{key[ i ]}$ : 暫存與節點 $\text{i}$ 所聯的邊權
- $\text{f[ i ]}$ : 紀錄每個節點 $\text{i}$ 的父親是誰
- $\text{n}$ : 節點的數量
- $\text{m}$ : 邊的數量
```cpp
typedef pair<int, int> pii;
vector<pii> g[MAXN]; // 儲存鄰接陣列: g[u] = {v, w}
int n, m; // 節點數、邊數
int prim(int start){ // 0-based
vector<bool> vis(n, false);
vector<int> key(n, INT_MAX);
vector<int> f(n);
f[start] = -1; // 起點沒有父親
key[start] = 0;
for(int i = 0; i < n - 1; i++){ // 最多只會走 n - 1 條邊
int mn = INT_MAX, idx = 0;
for(int j = 0; j < n; j++){
if(!vis[j] && mn > key[j])
mn = key[j], idx = j;
}
vis[idx] = true;
int u = idx;
for(pii j : g[u]){ // 找所有與 u 相鄰的節點
int v = j.first, w = j.second;
if(!vis[v] && w < key[v]){
f[v] = u;
key[v] = w;
}
}
}
int ans = 0;
for(int i = 0; i < n; i++)
ans += key[i];
return ans;
}
```
### 實作程式碼 (以 priority queue 優化)
由於整個算法使用 priority queue 寫,感覺會很像 BFS
- $\text{g[ ]}$ : 鄰接陣列
- $\text{vis[ i ]}$ : 紀錄節點 $\text{i}$ 是否在集合 $S$ 中
- $\text{n}$ : 節點的數量
- $\text{m}$ : 邊的數量
```cpp
typedef pair<int, int> pii;
int n, m; // 節點數、邊數
vector<pii> g[MAXN]; // 儲存鄰接陣列: g[u] = {v, w}
int prim(int start){
priority_queue<pii, vector<pii>, greater<pii>> pq;
pq.push({0, start}); // 將開始的節點放入 pq
vector<bool> vis(m, false);
int ans = 0;
while(!pq.empty()){
int u = pq.top().second;
int w = pq.top().first;
pq.pop();
if(vis[u])
continue;
vis[u] = true;
ans += w;
for(pii i : g[u]){
int v = i.first, w = i.second;
if(!vis[v])
pq.push({w, v});
// 因為是以權重優先,所以反著放入 pq
}
}
return ans;
}
```
### 時間複雜度
#### 原始版
總時間複雜度: $O(n^2)$,因為雙重迴圈
#### priority queue 優化版
- $\text{pq.push()}$ : $O(log~m)$
- $\text{pq.pop()}$ : $O(log~m)$
- 遍歷所有節點 (很像 BFS): $O(n + m)=O(m)$,因為邊數通常比較多,所以取個上限
總時間複雜度: $O(m~log~m)$
### 缺點
挑選不同的起點會影響整個演算法的運作
## Kruskal 演算法
### 想法
Kruskal 演算法是一個尋找 MST 的貪心演算法。核心概念是把邊依照權重由小排到大,再使用[併查集](https://hackmd.io/@ShanC/dsu)維護集合,使其中一個集合最終包含連通圖中所有節點
設一張連通圖 $G=(V, E)$
1. 設 $i = 1$
2. 檢查第 $i$ 小的邊兩終點是否在同一個集合
3. 如果不是在同一個集合就將兩集合取聯集
4. $i$++,回到 1. 直到將所有邊都遍歷
```py
sort E with the weight in ascending order
w = 0 # the weight of the spanning tree
for each e in E:
if e.u and e.v are not in the same set:
union the set of e.u and the set of e.v
w += e.weight
```
### 實作程式碼
併查集因為之前提過,為了版面大小,這裡就省略實作細節
```cpp
int n, m; // 節點數、邊數
struct Edge{
int u, v, w;
bool operator<(const Edge a){ // 定義運算子
return w < a.w; // 排序時依照邊權由小排到大
}
};
vector<Edge> edges; // 用 vector 存邊
int kruskal(){
int ans = 0; // 紀錄答案
dsu.init(n); // 將併查集初始化
sort(edges.begin(), edges.end()); // 排序
for(int i = 0; i < m; i++){
Edge e = edges[i];
if(dsu.find(e.u) != dsu.find(e.v)){ // 兩終點在不同集合
ans += e.w; // 更新答案
dsu.merge(e.u, e.v); // 取聯集
if(dsu.sz[dsu.find(e.u)] == n)
break; // 如果確定全部節點都找完了,就離開迴圈
}
}
return ans;
}
```
### 時間複雜度
- 併查集初始化: $O(n)$
- 查詢所在集合: $O(\alpha(n))\approx O(1)$
- 合併: $O(\alpha(n)) \times 2 + O(1)\approx O(1)$
- 排序所有邊: $O(m~log~m)$
總時間複雜度: $O(m~log~m)$
由於邊數最多 $\cfrac{n(n - 1)}{2}$ 條,因此總時間複雜度也可以改寫成:
$O(m~log~n^2)=O(2m~log~n)=O(m~log~n)$
## 證明 Prim 演算法與 Kruskal 演算法的正確性
講到這裡,不知有沒有人好奇為什麼這兩種演算法是正確的,難道就不會產生不是生成樹的結果嗎? 接下來要來證明這兩種演算法為什麼是對的
### 尋找 MST 的通用演算法
首先必須來將 Prim 演算法與 Kruskal 演算法一般化:
先定義 $A$ 是某個最小生成樹的子集合
```python
find-a-MST(G):
A := ∅
while A does not form a spanning tree:
find an edge (u, v) that is safe for A
A := A ∪ {(u, v)}
return A
```
以上這段程式每一步都會找出一條邊 $(u, v)$,使 $A \cup \{(u, v)\}$ 也是某棵 MST 的子集合。我們可以將這種不違反「$A$ 是某個最小生成樹的子集合」性質的邊 $(u, v)$ 稱為安全邊
### 名詞定義
對於一無向圖 $G=(V, E)$
- 割線 (cut): $V$ 做分割,以 $(S, V - S)$ 來表示
- 跨越 (cross): 如果一條邊的兩終點分別在 $S$ 與 $V - S$ 之間,就說那條邊跨越割線 $(S, V - S)$
- 尊重 (respect): 如果 $A$ 沒有跨越割線的邊,則稱割線尊重 $A$
- 輕邊 (light edge): 跨越割線的邊之中,最輕的那一條稱為輕邊
### 定理: 安全性
#### 敘述
設 $G=(V, E)$ 是連通的無向圖,且有一個權重函數 $w: E \to \mathbb{R}$。設 $A$ 是 $E$ 的子集合,且 $A$ 屬於 $G$ 的某個 MST。設 $(S, V - S)$ 是在 $G$ 中尊重 $A$ 的任何一條割線,且 (u, v) 是跨越 $(S, V - S)$ 的輕邊,則$(u, v)$ 對 $A$ 來說是安全的
<center>
<img src="https://hackmd.io/_uploads/HJ4yN6c8yl.png" style="margin: 0 auto; display: block; width: 600px">
</center>
#### 證明
設 $T$ 是一個包含 $A$ 的 MST,則可能會有以下狀況:
- Case1 : 如果 $(u, v)\in T$,則這個東西沒問題
- Case2 : $(u, v)\notin T$,則需要找到另一棵 MST $T'$,使 $A\cup\{(u, v)\} \subseteq T'$
以下說明 $T'$ 是一棵 MST:
因為 $T$ 是一棵 MST,必存在 $u,v$-路徑 $p$,使路徑上至少有一條邊跨越 $(S, V-S)$。設 $(x, y)\in p$ 為任一條這種跨越 $(S, V-S)$ 的邊。因為割線尊重 $A$,所以 $(x, y)$ 不在 $A$ 裡面。將 $(x, y)$ 移除,再將 $(u, v)$ 接回去會產生一顆新的 MST $T'$。總結來說就是以下
- $T'=T - \{(x, y)\}~\cup~\{(u, v)\}$,這說明 $T$ 是一棵生成樹
接下來,又因為 $(u,v)$ 是一條跨越 $(S, V-S)$ 的輕邊,因此 $w(u, v) \leq w(x, y)$,可推得以下結論
- $w(T')=w(T) - w(x, y) + w(u, v)\leq w(T)$,選擇 $(u,v)$ 之後的樹權會比選 $(x, y)$ 的樹權來的小
最後需要證明 $(u, v)$ 是安全邊:
因為 $A\subseteq T$ 且 $(x, y)\notin A$,所以 $A\subseteq T-\{(x, y)\}\subseteq T'$。因為 $T'$ 是 MST,所以 $(u, v)$ 是安全的
由以上證明,可以說明演算法是正確的
## 題目練習
[CSES Road Reparation](https://cses.fi/problemset/task/1675) (裸題)
[Zerojudge a129. 最小生成樹](https://zerojudge.tw/ShowProblem?problemid=a129) (裸題)
[Zerojudge e509. Conscription](https://zerojudge.tw/ShowProblem?problemid=e509) (最大生成樹??)
[Zerojudge f506. 11747 - Heavy Cycle Edges](https://zerojudge.tw/ShowProblem?problemid=f506)
[AtCoder ABC_235 E - MST + 1](https://atcoder.jp/contests/abc235/tasks/abc235_e?lang=en) (改一下原本的演算法,動動腦)
[AtCoder ABC_065 D - Built?](https://atcoder.jp/contests/abc065/tasks/arc076_b) (因為貪心的緣故,基本上只需要連最近的 x 座標距離及最近的 y 座標距離)
[Zerojudge c495. 四、次小生成樹(SBMST)](https://zerojudge.tw/ShowProblem?problemid=c495) (需要用到最低共同祖先的原理)
[Zerojudge k715. 糧食便道 (Supply)](https://zerojudge.tw/ShowProblem?problemid=k715)
[Zerojudge q839. 4. 分組遊戲](https://zerojudge.tw/ShowProblem?problemid=q839) (Kruskal 到第 $k-1$ 小的邊)
----
## 參考資料
- Introduction to Algorithms, Fourth Edition
- [spanning tree - 台師大演算法筆記](https://web.ntnu.edu.tw/~algo/SpanningTree.html)
- [geeksforgeeks Prim’s Algorithm for Minimum Spanning Tree (MST)](https://www.geeksforgeeks.org/prims-minimum-spanning-tree-mst-greedy-algo-5/)
- [Graph Theory & DSU - 海大競程](https://hackmd.io/@LeeShoWhaodian/2024I2CP_DSUandGraph#/3)
----
> [ShanC 程式競賽筆記](https://hackmd.io/@ShanC/B1ouGxqcC)
> 作者: ShanC
> 更新: 2025/1/7