1. 程式人生 > >資料結構 第17講 溝通無限校園網——最小生成樹(kruskal演算法)

資料結構 第17講 溝通無限校園網——最小生成樹(kruskal演算法)

本內容來源於本人著作《趣學演算法》,線上章節:http://www.epubit.com.cn/book/details/4825

構造最小生成樹還有一種演算法,Kruskal演算法:設G=(VE)是無向連通帶權圖,V={1,2,…,n};設最小生成樹T=(VTE),該樹的初始狀態為只有n個頂點而無邊的非連通圖T=(V,{}),Kruskal演算法將這n個頂點看成是n個孤立的連通分支。它首先將所有的邊按權值從小到大排序,然後只要T中選中的邊數不到n−1,就做如下的貪心選擇:在邊集E中選取權值最小的邊(ij),如果將邊(ij)加入集合TE中不產生迴路(圈),則將邊(i

j)加入邊集TE中,即用邊(ij)將這兩個連通分支合併連線成一個連通分支;否則繼續選擇下一條最短邊。把邊(ij)從集合E中刪去。繼續上面的貪心選擇,直到T中所有頂點都在同一個連通分支上為止。此時,選取到的n−1條邊恰好構成G的一棵最小生成樹T

那麼,怎樣判斷加入某條邊後圖T會不會出現迴路呢?

該演算法對於手工計算十分方便,因為用肉眼可以很容易看到挑選哪些邊能夠避免構成迴路(避圈法),但使用計算機程式來實現時,還需要一種機制來進行判斷。Kruskal演算法用了一個非常聰明的方法,就是運用集合避圈:如果所選擇加入的邊的起點和終點都在T的集合中,那麼就可以斷定一定會形成迴路(圈)。其實就是我們前面提到的“避圈法”:邊的兩個結點不能屬於同一集合。

步驟1:初始化。將圖G的邊集E中的所有邊按權值從小到大排序,邊集TE={ },把每個頂點都初始化為一個孤立的分支,即一個頂點對應一個集合。

步驟2:在E中尋找權值最小的邊(ij)。

步驟3:如果頂點ij位於兩個不同連通分支,則將邊(ij)加入邊集TE,並執行合併操作,將兩個連通分支進行合併。

步驟4:將邊(ij)從集合E中刪去,即E=E−{(ij)}。

步驟 5:如果選取邊數小於n−1,轉步驟2;否則,演算法結束,生成最小生成樹T

2.完美圖解

G =(VE)是無向連通帶權圖,如圖2-98所示。

圖2-98 無向連通帶權圖G

(1)初始化

將圖G的邊集E中的所有邊按權值從小到大排序,如圖2-99所示。

..\17-0245 圖\02107.tif

圖2-99 按邊權值排序後的圖G

邊集初始化為空集,TE={ },把每個結點都初始化為一個孤立的分支,即一個頂點對應一個集合,集合號為該結點的序號,如圖2-100所示。

圖2-100 每個結點初始化集合號

(2)找最小

E中尋找權值最小的邊e1(2,7),邊值為1。

(3)合併

結點2和結點7的集合號不同,即屬於兩個不同連通分支,則將邊(2,7)加入邊集TE,執行合併操作(將兩個連通分支所有結點合併為一個集合);假設把小的集合號賦值給大的集合號,那麼7號結點的集合號也改為2,如圖2-101所示。

..\17-0245 圖\02109.tif

圖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所示。

..\17-0245 圖\02111.tif

圖2-103 最小生成樹求解過程

(8)找最小

E中尋找權值最小的邊e4(4,7),邊值為9。

(9)合併

結點4和結點7集合號不同,即屬於兩個不同連通分支,則將邊(4,7)加入邊集TE,執行合併操作將兩個連通分支所有結點合併為一個集合;假設我們把小的集合號賦值給大的集合號,那麼4、5號結點的集合號都改為2,如圖2-104所示。

..\17-0245 圖\02112.tif

圖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所示。

..\17-0245 圖\02113.tif

圖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所示。

..\17-0245 圖\02114.tif

圖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==