資料結構 第17講 溝通無限校園網——最小生成樹(kruskal演算法)
本內容來源於本人著作《趣學演算法》,線上章節:http://www.epubit.com.cn/book/details/4825
構造最小生成樹還有一種演算法,Kruskal演算法:設G=(V,E)是無向連通帶權圖,V={1,2,…,n};設最小生成樹T=(V,TE),該樹的初始狀態為只有n個頂點而無邊的非連通圖T=(V,{}),Kruskal演算法將這n個頂點看成是n個孤立的連通分支。它首先將所有的邊按權值從小到大排序,然後只要T中選中的邊數不到n−1,就做如下的貪心選擇:在邊集E中選取權值最小的邊(i,j),如果將邊(i,j)加入集合TE中不產生迴路(圈),則將邊(i
那麼,怎樣判斷加入某條邊後圖T會不會出現迴路呢?
該演算法對於手工計算十分方便,因為用肉眼可以很容易看到挑選哪些邊能夠避免構成迴路(避圈法),但使用計算機程式來實現時,還需要一種機制來進行判斷。Kruskal演算法用了一個非常聰明的方法,就是運用集合避圈:如果所選擇加入的邊的起點和終點都在T的集合中,那麼就可以斷定一定會形成迴路(圈)。其實就是我們前面提到的“避圈法”:邊的兩個結點不能屬於同一集合。
步驟1:初始化。將圖G的邊集E中的所有邊按權值從小到大排序,邊集TE={ },把每個頂點都初始化為一個孤立的分支,即一個頂點對應一個集合。
步驟2:在E中尋找權值最小的邊(i,j)。
步驟3:如果頂點i和j位於兩個不同連通分支,則將邊(i,j)加入邊集TE,並執行合併操作,將兩個連通分支進行合併。
步驟4:將邊(i,j)從集合E中刪去,即E=E−{(i,j)}。
步驟 5:如果選取邊數小於n−1,轉步驟2;否則,演算法結束,生成最小生成樹T。
2.完美圖解
設G =(V,E)是無向連通帶權圖,如圖2-98所示。
圖2-98 無向連通帶權圖G
(1)初始化
將圖G的邊集E中的所有邊按權值從小到大排序,如圖2-99所示。
圖2-99 按邊權值排序後的圖G
邊集初始化為空集,TE={ },把每個結點都初始化為一個孤立的分支,即一個頂點對應一個集合,集合號為該結點的序號,如圖2-100所示。
圖2-100 每個結點初始化集合號
(2)找最小
在E中尋找權值最小的邊e1(2,7),邊值為1。
(3)合併
結點2和結點7的集合號不同,即屬於兩個不同連通分支,則將邊(2,7)加入邊集TE,執行合併操作(將兩個連通分支所有結點合併為一個集合);假設把小的集合號賦值給大的集合號,那麼7號結點的集合號也改為2,如圖2-101所示。
圖2-101 最小生成樹求解過程
(4)找最小
在E中尋找權值最小的邊e2(4,5),邊值為3。
(5)合併
結點4和結點5集合號不同,即屬於兩個不同連通分支,則將邊(4,5)加入邊集TE,執行合併操作將兩個連通分支所有結點合併為一個集合;假設我們把小的集合號賦值給大的集合號,那麼5號結點的集合號也改為4,如圖2-102所示。
圖2-102 最小生成樹求解過程
(6)找最小
在E中尋找權值最小的邊e3(3,7),邊值為4。
(7)合併
結點3和結點7集合號不同,即屬於兩個不同連通分支,則將邊(3,7)加入邊集TE,執行合併操作將兩個連通分支所有結點合併為一個集合;假設我們把小的集合號賦值給大的集合號,那麼3號結點的集合號也改為2,如圖2-103所示。
圖2-103 最小生成樹求解過程
(8)找最小
在E中尋找權值最小的邊e4(4,7),邊值為9。
(9)合併
結點4和結點7集合號不同,即屬於兩個不同連通分支,則將邊(4,7)加入邊集TE,執行合併操作將兩個連通分支所有結點合併為一個集合;假設我們把小的集合號賦值給大的集合號,那麼4、5號結點的集合號都改為2,如圖2-104所示。
圖2-104 最小生成樹求解過程
(10)找最小
在E中尋找權值最小的邊e5(3,4),邊值為15。
(11)合併
結點3和結點4集合號相同,屬於同一連通分支,不能選擇,否則會形成迴路。
(12)找最小
在E中尋找權值最小的邊e6(5,7),邊值為16。
(13)合併
結點5和結點7集合號相同,屬於同一連通分支,不能選擇,否則會形成迴路。
(14)找最小
在E中尋找權值最小的邊e7(5,6),邊值為17。
(15)合併
結點5和結點6集合號不同,即屬於兩個不同連通分支,則將邊(5,6)加入邊集TE,執行合併操作將兩個連通分支所有結點合併為一個集合;假設我們把小的集合號賦值給大的集合號,那麼6號結點的集合號都改為2,如圖2-105所示。
圖2-105 最小生成樹求解過程
(16)找最小
在E中尋找權值最小的邊e8(2,3),邊值為20。
(17)合併
結點2和結點3集合號相同,屬於同一連通分支,不能選擇,否則會形成迴路。
(18)找最小
在E中尋找權值最小的邊e9(1,2),邊值為23。
(19)合併
結點1和結點2集合號不同,即屬於兩個不同連通分支,則將邊(1,2)加入邊集TE,執行合併操作將兩個連通分支所有結點合併為一個集合;假設我們把小的集合號賦值給大的集合號,那麼2、3、4、5、6、7號結點的集合號都改為1,如圖2-106所示。
圖2-106 最小生成樹
(20)選中的各邊和所有的頂點就是最小生成樹,各邊權值之和就是最小生成樹的代價。
3.偽碼詳解
(1)資料結構
int nodeset[N];//集合號陣列
struct Edge {//邊的儲存結構
int u;
int v;
int w;
}e[N*N];
(2)初始化
void Init(int n)
{
for(int i = 1; i <= n; i++)
nodeset[i] = i;//每個結點賦值一個集合號
}
(3)對邊進行排序
bool comp(Edge x, Edge y)
{
return x.w < y.w;//定義優先順序,按邊值進行升序排序
}
sort(e, e+m, comp);//呼叫系統排序函式
(4)合併集合
int Merge(int a, int b)
{
int p = nodeset[a];//p為a結點的集合號
int q = nodeset[b]; //q為b結點的集合號
if(p==q) return 0; //集合號相同,什麼也不做,返回
for(int i=1;i<=n;i++)//檢查所有結點,把集合號是q的全部改為p
{
if(nodeset[i]==q)
nodeset[i] = p;//a的集合號賦值給b集合號
}
return 1;
}
4.實戰演練
//program 2-8
#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 100;
int nodeset[N];
int n, m;
struct Edge {
int u;
int v;
int w;
}e[N*N];
bool comp(Edge x, Edge y)
{
return x.w < y.w;
}
void Init(int n)
{
for(int i = 1; i <= n; i++)
nodeset[i] = i;
}
int Merge(int a, int b)
{
int p = nodeset[a];
int q = nodeset[b];
if(p==q) return 0;
for(int i=1;i<=n;i++)//檢查所有結點,把集合號是q的改為p
{
if(nodeset[i]==q)
nodeset[i] = p;//a的集合號賦值給b集合號
}
return 1;
}
int Kruskal(int n)
{
int ans = 0;
for(int i=0;i<m;i++)
if(Merge(e[i].u, e[i].v))
{
ans += e[i].w;
n--;
if(n==1)
return ans;
}
return 0;
}
int main()
{
cout <<"輸入結點數n和邊數m:"<<endl;
cin >> n >> m;
Init(n);
cout <<"輸入結點數u,v和邊值w:"<<endl;
for(int i=0;i<m;i++)
cin >> e[i].u>> e[i].v >>e[i].w;
sort(e, e+m, comp);
int ans = Kruskal(n);
cout << "最小的花費是:" << ans << endl;
return 0;
}
5.演算法複雜度分析
(1)時間複雜度:演算法中,需要對邊進行排序,若使用快速排序,執行次數為e*loge,演算法的時間複雜度為O(e*loge)。而合併集合需要n−1次合併,每次為O(n),合併集合的時間複雜度為O(n2)。
(2)空間複雜度:演算法所需要的輔助空間包含集合號陣列 nodeset[n],則演算法的空間複雜度是O(n)。
6.演算法優化拓展
該演算法合併集合的時間複雜度為O(n2),我們可以用並查集(見附錄E)的思想優化,使合併集合的時間複雜度降為O(e*logn),優化後的程式如下。
//program 2-9
#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 100;
int father[N];
int n, m;
struct Edge {
int u;
int v;
int w;
}e[N*N];
bool comp(Edge x, Edge y) {
return x.w < y.w;//排序優先順序,按邊的權值從小到大
}
void Init(int n)
{
for(int i = 1; i <= n; i++)
father[i] = i;//頂點所屬集合號,初始化每個頂點一個集合號
}
int Find(int x) //找祖宗
{
if(x != father[x])
father[x] = Find(father[x]);//把當前結點到其祖宗路徑上的所有結點的集合號改為祖宗集合號
return father[x]; //返回其祖宗的集合號
}
int Merge(int a, int b) //兩結點合併集合號
{
int p = Find(a); //找a的集合號
int q = Find(b); //找b的集合號
if(p==q) return 0;
if(p > q)
father[p] = q;//小的集合號賦值給大的集合號
else
father[q] = p;
return 1;
}
int Kruskal(int n)
{
int ans = 0;
for(int i=0;i<m;i++)
if(Merge(e[i].u, e[i].v))
{
ans += e[i].w;
n--;
if(n==