學習最小生成樹算法之前我們先來了解下 下面這些概念:
樹(Tree):如果一個無向連通圖中不存在回路,則這種圖稱為樹。
生成樹 (Spanning Tree):無向連通圖G的一個子圖如果是一顆包含G的所有頂點的樹,則該子圖稱為G的生成樹。
生成樹是連通圖的極小連通子圖。這里所謂極小是指:若在樹中任意增加一條邊,則將出現一條回路;若去掉一條邊,將會使之變成非連通圖。
最小生成樹(Minimum Spanning Tree,MST):或者稱為最小代價樹Minimum-cost Spanning Tree:對無向連通圖的生成樹,各邊的權值總和稱為生成樹的權,權最小的生成樹稱為最小生成樹。
構成生成樹的准則有三條:
<1> 必須只使用該網絡中的邊來構造最小生成樹。
<2> 必須使用且僅使用n-1條邊來連接網絡中的n個頂點
<3> 不能使用產生回路的邊。
構造最小生成樹的算法主要有:克魯斯卡爾(Kruskal)算法和普利姆(Prim)算法他們都遵循以上准則。
接下分別討論一下這兩種算法以及判定最小生成樹是否唯一的方法。
克魯斯卡爾算法
克魯斯卡爾算法的基本思想是以邊為主導地位,始終選擇當前可用(所選的邊不能構成回路)的最小權植邊。所以Kruskal算法的第一步是給所有的邊按照從小到大的順序排序。這一步可以直接使用庫函數qsort或者sort。接下來從小到大依次考察每一條邊(u,v)。
具體實現過程如下:
<1> 設一個有n個頂點的連通網絡為G(V,E),最初先構造一個只有n個頂點,沒有邊的非連通圖T={V,空},圖中每個頂點自成一格連通分量。
<2> 在E中選擇一條具有最小權植的邊時,若該邊的兩個頂點落在不同的連通分量上,則將此邊加入到T中;否則,即這條邊的兩個頂點落到同一連通分量 上,則將此邊舍去(此后永不選用這條邊),重新選擇一條權植最小的邊。
<3> 如此重復下去,直到所有頂點在同一連通分量上為止。
下面是偽代碼:
1 // 把所有邊排序,記第i小的邊為e[i] (1<=i<=m)m為邊的個數 2 // 初始化MST為空 3 // 初始化連通分量,使每個點各自成為一個獨立的連通分量 4 5 for (int i = 0; i < m; i++) 6 { 7 if (e[i].u和e[i].v不在同一連通分量) 8 { 9 // 把邊e[i]加入MST 10 // 合並e[i].u和e[i].v所在的連通分量 11 } 12 }
上面的偽代碼,最關鍵的地方在於“連通分量的查詢和合並”,需要知道任意兩個點是否在同一連通分量中,還需要合並兩個連通分量。
這個問題正好可以用並查集完美的解決(不得不佩服前輩們聰明才智啊!)
並查集(Union-Find set)這個數據結構可以方便快速的解決這個問題。基本的處理思想是:初始時把每個對象看作是一個單元素集合;然后依次按順序讀入聯通邊,將連通邊中的兩個元素合並。在此過程中將重復使用一個搜索(Find)運算,確定一個集合在那個集合中。當讀入一個連通邊(u,v)時,先判斷u和v是否在同一個集合中,如果是則不用合並;如果不是,則用一個合並(Union)運算把u、v所在集合合並,使得這兩個集合中的任意兩個元素都連通。因此並查集在處理時,主要用到搜索和合並兩個運算。
為了方便並查集的描述與實現,通常把先后加入到一個集合中的元素表示成一個樹結構,並用根結點的序號來表示這個集合。因此定義一個parent[n]的數組,parent[i]中存放的就是結點i所在的樹中結點i的父親節點的序號。例如,如果parent[4]=5,就是說4號結點的父親結點是5號結點。約定:如果i的父結點(即parent[i])是負數,則表示結點i就是它所在的集合的根結點,因為集合中沒有結點的序號是負的;並且用負數的絕對值作為這個集合中所含結點的個數。例如,如果parent[7]=-4,說明7號結點就是它所在集合的根結點,這個集合有四個元素。初始時結點的parent值為-1(每個結點都是根結點,只包含它自己一個元素)。
實現Kruskal算法數據結構主要有3個函數。
1 void UFset() // 初始化 2 { 3 for (int i = 0; i < n; i ++) 4 parent[i] = -1; 5 } 6 int Find(int x) // 查找並返回結點x所屬集合的根結點 7 { 8 int s; // 查找位置 9 for (s = x; parent[s]>=0; s = parent[s]); // 注意這里的 ; 10 while (s != x) // 優化方案 -- 壓縮路徑,使后續的查找 11 { 12 int tmp = parent[x]; 13 parent[x] = s; 14 x = tmp; 15 } 16 return s; 17 } 18 // R1和R2是兩個元素,屬於兩個不同的集合,現在合並這兩個集合 19 void Union (int R1, int R2) 20 { 21 // r1位R1的根結點,r2位R2的根結點 22 int r1 = Find(R1), r2 = Find(R2); 23 int tmp = parent[r1] + parent[r2]; // 兩個集合的結點個數之和(負數) 24 // 如果R2所在樹結點個數 > R1所在樹結點個數 25 // 注意parent[r1]和parent[r2]都是負數 26 if(parent[r1] > parent[r2]) // 優化方案 -- 加權法則 27 { 28 parent[r1] = r2; // 將根結點r1所在的樹作為r2的子樹(合並) 29 parent[r2] = tmp; // 跟新根結點r2的parent[]值 30 } 31 else 32 { 33 parent[r2] = r1; // 將根結點r2所在的樹作為r1的子樹(合並) 34 parent[r1] = tmp; // 跟新根結點r1的parent[]值 35 } 36 }
接下來對 Find 函數和 Union 函數的實現過程作詳細解釋。
Find 函數:在 Find 函數中如果僅僅靠一個循環來直接得到結點所屬集合的根結點的話,通過多次的 Union 操作就會有很多結點在樹的比較深層次中,再查找起來就會很費時。可以通過壓縮路徑來加快后續的查找速度:增加一個 While 循環,每次都把從結點 x 到集合根結點的路徑上經過的結點直接設置為根結點的子女結點。雖然這增加了時間,但以后的查找會更快。如圖 3.4 所示,假設從結點 x = 6 開始壓縮路徑,則從結點 6 到根結點1 的路徑上有 3 個結點:6、10、8,壓縮后,這 3 個結點都直接成為根結點的子女結點,如圖(b)所示。
並查集:Find函數中的路徑壓縮
Union 函數:兩個集合並時,任一方可做為另一方的子孫。怎樣來處理呢,現在一般采用加權合並,把兩個集合中元素個數少的根結點做為元素個數多的根結點的子女結點。這樣處理有什么優勢呢?直觀上看,可以減少樹中的深層元素的個數,減少后續查找時間。
例如,假設從 1 開始到 n,不斷合並第 i 個結點與第 i+1 個結點,采用加權合並思路的過程如下圖所示(各子樹根結點上方的數字為其 parent[ ]值)。這樣查找任一結點所屬集合的時間復雜度幾乎都是 O(1)!!!
並查集:加權合並
不用加權規則可能會得到下圖所示的結果。這就是典型的退化樹(只有一個葉結點,且每個非葉結點只有一個子結點)現象,再查找起來就會很費時,例如查找結點 n 的根結點時復雜度為 O(n)。
並查集:合並時不加權的結果。
例 利用 Kruskal 算法求無向網的最小生成樹,並輸出依次選擇的各條邊及最終求得的最小生成樹的權。
假設數據輸入時采用如下的格式進行輸入:首先輸入頂點個數 n 和邊數 m,然后輸入 m 條邊的數據。每條邊的數據格式為:u v w,分別表示這條邊的兩個頂點及邊上的權值。頂點序號從 1開始計起。
分析:
在下面的代碼中,首先讀入邊的信息,存放到數組 edges[ ]中,並按權值從小到大進行排序。
Kruskal( )函數用於實現 :首先初始化並查集,然后從 edges[ ]數組中依次選用每條邊,如果這條邊的兩個頂點位於同一個連通分量,則要棄用這條邊;否則合並這兩個頂點所在的連通分量。
代碼如下:

1 #include <stdio.h> 2 #include <string.h> 3 #include <algorithm> 4 #define MAXN 11 //頂點個數的最大值 5 #define MAXM 20 //邊的個數的最大值 6 using namespace std; 7 8 struct edge //邊 9 { 10 int u, v, w; //邊的頂點、權值 11 }edges[MAXM]; //邊的數組 12 13 int parent[MAXN]; //parent[i]為頂點 i 所在集合對應的樹中的根結點 14 int n, m; //頂點個數、邊的個數 15 int i, j; //循環變量 16 void UFset( ) //初始化 17 { 18 for( i=1; i<=n; i++ ) 19 parent[i] = -1; 20 } 21 int Find( int x ) //查找並返回節點 x 所屬集合的根結點 22 { 23 int s; //查找位置 24 for( s=x; parent[s]>=0; s=parent[s] ); 25 while( s!=x ) //優化方案―壓縮路徑,使后續的查找操作加速。 26 { 27 int tmp = parent[x]; 28 parent[x] = s; 29 x = tmp; 30 } 31 return s; 32 } 33 34 //將兩個不同集合的元素進行合並,使兩個集合中任兩個元素都連通 35 void Union( int R1, int R2 ) 36 { 37 int r1 = Find(R1), r2 = Find(R2); //r1 為 R1 的根結點,r2 為 R2 的根結點 38 int tmp = parent[r1] + parent[r2]; //兩個集合結點個數之和(負數) 39 //如果 R2 所在樹結點個數 > R1 所在樹結點個數(注意 parent[r1]是負數) 40 if( parent[r1] > parent[r2] ) //優化方案――加權法則 41 { 42 parent[r1] = r2; 43 parent[r2] = tmp; 44 } 45 else 46 { 47 parent[r2] = r1; 48 parent[r1] = tmp; 49 } 50 } 51 bool cmp( edge a, edge b ) //實現從小到大排序的比較函數 52 { 53 return a.w <= b.w; 54 } 55 void Kruskal( ) 56 { 57 int sumweight = 0; //生成樹的權值 58 int num = 0; //已選用的邊的數目 59 int u, v; //選用邊的兩個頂點 60 UFset( ); //初始化 parent[]數組 61 for( i=0; i<m; i++ ) 62 { 63 u = edges[i].u; v = edges[i].v; 64 if( Find(u) != Find(v) ) 65 { 66 printf( "%d %d %d\n", u, v, edges[i].w ); 67 sumweight += edges[i].w; num++; 68 Union( u, v ); 69 } 70 if( num>=n-1 ) break; 71 } 72 printf( "weight of MST is %d\n", sumweight ); 73 } 74 int main( ) 75 { 76 int u, v, w; //邊的起點和終點及權值 77 scanf( "%d%d", &n, &m ); //讀入頂點個數 n 78 for( int i=0; i<m; i++ ) 79 { 80 scanf( "%d%d%d", &u, &v, &w ); //讀入邊的起點和終點 81 edges[i].u = u; edges[i].v = v; edges[i].w = w; 82 } 83 sort(edges,edges+m,cmp); 84 Kruskal(); 85 return 0; 86 }