# 最小生成樹-Kruskal介紹、證明與實作 ## 最小生成樹介紹 > 給定一連通帶權無向圖G(V,E),有n條邊,一個生成樹T就是一個有n-1條邊的連通子圖,求出邊權和最小的T。 對於此問題,經典的演算法有Kruskal與Prim兩種演算法,將會以兩篇文章來介紹。 ## Kruskal演算法概述 Kruskal演算法的步驟如下: 1. 將所有邊由小到大排序為$e_1,e_2,...,e_m$。 2. 令$T = \phi$。 3. 對於i從1到m,若$T\cup\{e_i\}$沒有環,將$e_i$加入$T$中 最後的T即是最小生成樹,Kruskal的概念很好理解,即是貪心地拿到不成環的最小權重邊。然而,這個演算法有兩個問題:首先,要如何判斷加了這條邊會成環,再來,為什麼這樣加邊一定是最好的?難道不會被一個小的邊而卡到其他邊嗎? ## 正確性證明 首先我們先解決第二個問題,為了證明演算法的正確性,我們需要引出生成樹的一個性質: > 性質 > 對於兩個生成樹$T_1與T_2和一條e \in T_1 \backslash T_2$,存在$e_2 \in T_2 \backslash T_1$使得$(T_2\backslash \{e_2\})\cup \{e_1\}$依然是生成樹。 白話來說,從$T_1$拿出一條邊加入$T_2$後,一定可以在$T_2$拔掉一條邊使得$T_2$還是生成樹。在加入$e_1$後,$T_2$會形成一個環,此時移除環上任一條邊都可以讓$T_2$有n-1條邊連通,代表$T_2$也是樹了。 接著我們就可以開始證明Kruskal演算法的正確性了: 令Kruskal演算法找到的生成樹為$T$,而最小生成樹為$T^*$,如果有多個最佳解,令$T^*$為與$T$交集最大的一個。如果$T=T^*$就結束了,否則,令$e_i$是編號最小且只出現在$T$的邊,根據上面的性質,存在$e_j \in T^* \backslash T$使得$T^*$把$e_j$換出去再把$e_i$放進來仍是一棵生成樹。 假如$i<j$,那$e_i$的權重$\leq e_j$的權重。由於$T^*$是最小生成樹,這樣做出來的$T' = (T^* \backslash\{e_j\})\cup\{e_i\}$的權重會跟$T^*$一樣(或更小),但是與$T$的交集比$T^*$大,矛盾。 假如$i>j$,由於比$j$前面的邊都在$T$與$T^*$中,根據Kruskal演算法的特性,在遇到$e_j$時就會把$e_j$加入$T$中了,矛盾。 故得證$T=T^*$ ## Kruskal 演算法的實作 接下來我們要解決第一個問題:如何判斷加入邊後會成環?加入邊後會成環用另一個角度思考,就是加入邊的兩端點在加邊前已經連通,所以如果我們能維護點的連通性,就能判斷加邊後是否會成環了。 我們在維護聯通性時,要支援以下操作: * 查詢兩個點是否連通 * 將兩個連通塊合併(加邊) 這樣的問題,可以使用並查集來有效率地解決,關於並查集的介紹,請參考[這份講義](https://hackmd.io/@CLKO/rkRVS_o-4?type=view) 在加邊以前,我們先檢查兩端點是否已經連通,如果尚未連通,則加入此邊,並將兩個連通塊合併。 程式碼如下: ```cpp= #include<bits/stdc++.h> #define ll long long #define MOD 1000000007 #define fastio ios::sync_with_stdio();cin.tie(); using namespace std; const int INF_INT = 2147483647; const ll INF_LL = 9223372036854775807LL; int gcd(int a,int b){return ((b==0)?a:gcd(b,a%b));} int lcm(int a,int b){return a*b/gcd(a,b);} struct edge{ int a,b; ll w; }; bool cmp(edge a,edge b){ return a.w<b.w; } vector<int> Dis;//並查集 int search(int a){ if(Dis[a] == a)return a; else{ Dis[a] = search(Dis[a]); return Dis[a]; } } void Union(int a,int b){ Dis[search(a)] = search(b); } void solve(){ int n,m; cin>>n>>m; vector<edge> E; for(int i=0;i<m;i++){ edge tmp; cin>>tmp.a>>tmp.b>>tmp.w; E.push_back(tmp); } sort(E.begin(),E.end(),cmp);//將邊由小排到大 Dis.resize(n+1); for(int i=1;i<=n;i++)Dis[i] = i;//並查集初始化 ll cnt = 0; int tmp = 0;//用來數加了幾個邊 for(int i=0;i<m&&tmp!=n-1;i++){ if(search(E[i].a)!=search(E[i].b)){//確認是否不連通 tmp++; Union(E[i].a,E[i].b);//合併連通塊 cnt+=E[i].w; } } cout<<cnt<<endl;//輸出最小生成樹的邊權和 } int main(){ fastio; solve(); } ``` ## 複雜度分析 排序邊為$O(\mid E \mid log \mid E \mid)$,並查集查詢與更新均攤後接近$O(1)$,執行$O(\mid E\mid)$次。整個演算法的複雜度為$O(\mid E \mid log \mid E \mid)$。 在完全圖中,Prim的複雜度$O(n^2)$是比Kruskal的$O(n^2 log n)$還要好的。 ## 參考資料 IOICAMP2021講義 https://hackmd.io/@CLKO/rkRVS_o-4?type=view