假設你是電信的實施工程師,需要為一個鎮的九個村莊架設通信網絡做設計,村莊位置大致如下圖,其中v0~v8是村莊,之間連線的數字表示村與村間的可通達的直線距離,比如v0至v1就是10公里(個別如v0與v6,v6與v8,v5與v7未測算距離是因為有高山或湖泊,不予考慮)。你們領導要求你必須用最小的成本完成這次任務。你說怎么辦?
顯然這是一個帶權值的圖,即網結構。所謂的最小成本,就是n個頂點,用n-1條邊把一個連通圖連接起來,并且使得權值的和最小。在這個例子里,每多一公里就多一份成本,所以只要讓線路連線的公里數最少,就是最少成本了。
我們在講圖的定義和術語時,曾經提到過,一個連通圖的生成樹是一個極小的連通子圖,它含有圖中全部的頂點,但只有足以構成一棵樹的n-1條邊。
找連通網的最小生成樹,經典的有兩種算法,普里姆算法和克魯斯卡爾算法。
普里姆(Prim)算法
為了能講明白這個算法,我們先構造下圖的鄰接矩陣,如下圖的右圖所示。
也就是說,現在我們已經有了一個存儲結構為MGragh的G。G有9個頂點,它的arc二維數組如圖7-6-3的右圖所示。數組中的我們用65535來代表∞。
于是普里姆(Prim)算法代碼如下,左側數字為行號。其中INFINITY為權值極大值,不妨是65535,MAXVEX為頂點個數最大值,此處大于等于9即可。現在假設我們自己就是計算機,在調用MiniSpanTree_Prim函數,輸入上述的鄰接矩陣后,看看它是如何運行并打印出最小生成樹的。
/* Prim算法生成最小生成樹 */
void MiniSpanTree_Prim(MGraph G)
{
int min, i, j, k;
/* 保存相關頂點下標 */
int adjvex[MAXVEX];
/* 保存相關頂點間邊的權值 */
int lowcost[MAXVEX];
/* 初始化第一個權值為0,即v0加入生成樹 */
/* lowcost的值為0,在這里就是此下標的頂點已經加入生成樹 */
lowcost[0] = 0;
/* 初始化第一個頂點下標為0 */
adjvex[0] = 0;
/* 循環除下標為0外的全部頂點 */
for (i = 1; i < G.numVertexes; i++)
{
/* 將v0頂點與之有邊的權值存入數組 */
lowcost[i] = G.arc[0][i];
/* 初始化都為v0的下標 */
adjvex[i] = 0;
}
for (i = 1; i < G.numVertexes; i++)
{
/* 初始化最小權值為∞, */
/* 通常設置為不可能的大數字如32767、65535等 */
min = INFINITY;
j = 1; k = 0;
/* 循環全部頂點 */
while (j < G.numVertexes)
{
/* 如果權值不為0且權值小于min */
if (lowcost[j] != 0 && lowcost[j] < min)
{
/* 則讓當前權值成為最小值 */
min = lowcost[j];
/* 將當前最小值的下標存入k */
k = j;
}
j++;
}
/* 打印當前頂點邊中權值最小邊 */
printf("(%d,%d)", adjvex[k], k);
/* 將當前頂點的權值設置為0,表示此頂點已經完成任務 */
lowcost[k] = 0;
/* 循環所有頂點 */
for (j = 1; j < G.numVertexes; j++)
{
/* 若下標為k頂點各邊權值小于此前這些頂點未被加入生成樹權值 */
if (lowcost[j] != 0 && G.arc[k][j] < lowcost[j])
{
/* 將較小權值存入lowcost */
lowcost[j] = G.arc[k][j];
/* 將下標為k的頂點存入adjvex */
adjvex[j] = k;
}
}
}
}
- 程序開始運行,我們由第4~5行,創建了兩個一維數組lowcost和adjvex,長度都為頂點個數9。
- 第6~7行我們分別給這兩個數組的第一個下標位賦值為0,adjvex[0]=0其實意思就是我們現在從頂點v0開始(事實上,最小生成樹從哪個頂點開始計算都無所謂,我們假定從v0開始),lowcost[0]=0就表示v0已經被納入到最小生成樹中,之后凡是lowcost數組中的值被設置為0就是表示此下標的頂點被納入最小生成樹。
- 第8~12行表示我們讀取圖7-6-3的右圖鄰接矩陣的第一行數據。將數值賦值給lowcost數組,所以此時lowcost數組值為{0,10,65535,65535,65535,11,65535,65535,65535},而adjvex則全部為0。此時,我們已經完成了整個初始化的工作,準備開始生成。
- 第13~36行,整個循環過程就是構造最小生成樹的過程。
- 第15~16行,將min設置為了一個極大值65535,它的目的是為了之后找到一定范圍內的最小權值。j是用來做頂點下標循環的變量,k是用來存儲最小權值的頂點下標。
- 第17~25行,循環中不斷修改min為當前lowcost數組中最小值,并用k保留此最小值的頂點下標。經過循環后,min=10,k=1。注意19行if判斷的lowcost[j]!=0表示已經是生成樹的頂點不參與最小權值的查找。
- 第26行,因k=1,adjvex[1]=0,所以打印結果為(0,1),表示v0至v1邊為最小生成樹的第一條邊。如下圖所示。
第27行,此時因k=1我們將lowcost[k]=0就是說頂點v1納入到最小生成樹中。此時lowcost數組值為{0,0,65535,65535,65535,11,65535,65535,65535}。
第28~35行,j循環由1至8,因k=1,查找鄰接矩陣的第v1行的各個權值,與low-cost的對應值比較,若更小則修改low-cost值,并將k值存入adjvex數組中。因第v1行有18、16、12均比65535小,所以最終lowcost數組的值為:{0,0,18,65535,65535,11,16,65535,12}。adjvex數組的值為:{0,0,1,0,0,0,1,0,1}。這里第30行if判斷的lowcost[j]!=0也說明v0和v1已經是生成樹的頂點不參與最小權值的比對了。
-
再次循環,由第15行到第26行,此時min=11,k=5,adjvex[5]=0。因此打印結構為(0,5)。表示v0至v5邊為最小生成樹的第二條邊,如下圖所示。
image-20200510201940021 -
接下來執行到36行,lowcost數組的值為:{0,0,18,65535,26,0,16,65535,12}。ad-jvex數組的值為:{0,0,1,0,5,0,1,0,1}。12.之后,相信大家也都會自己去模擬了。通過不斷的轉換,構造的過程如下圖中圖1~圖6所示。
image-20200510202041522
有了這樣的講解,再來介紹普里姆(Prim)算法的實現定義可能就容易理解一些。
假設N=(V,{E})是連通網,TE是N上最小生成樹中邊的集合。算法從U={u0}(u0∈V),TE={}開始。重復執行下述操作:在所有u∈U,v∈V-U的邊(u,v)∈E中找一條代價最小的邊(u0,v0)并入集合TE,同時v0并入U,直至U=V為止。此時TE中必有n-1條邊,則T=(V,{TE})為N的最小生成樹。
由算法代碼中的循環嵌套可得知此算法的時間復雜度為O(n2)。
克魯斯卡爾(Kruskal)算法
普里姆(Prim)算法是以某頂點為起點,逐步找各頂點上最小權值的邊來構建最小生成樹的。這就像是我們如果去參觀某個展會,例如世博會,你從一個入口進去,然后找你所在位置周邊的場館中你最感興趣的場館觀光,看完后再用同樣的辦法看下一個。可我們為什么不事先計劃好,進園后直接到你最想去的場館觀看呢?事實上,去世博園的觀眾,絕大多數都是這樣做的。
同樣的思路,我們也可以直接就以邊為目標去構建,因為權值是在邊上,直接去找最小權值的邊來構建生成樹也是很自然的想法,只不過構建時要考慮是否會形成環路而已。此時我們就用到了圖的存儲結構中的邊集數組結構。以下是edge邊集數組結構的定義代碼:
/* 對邊集數組Edge結構的定義 */
typedef struct
{
int begin;
int end;
int weight;
} Edge;
我們將下圖的鄰接矩陣通過程序轉化為下圖的右圖的邊集數組,并且對它們按權值從小到大排序。
于是克魯斯卡爾(Kruskal)算法代碼如下,左側數字為行號。其中MAXEDGE為邊數量的極大值,此處大于等于15即可,MAXVEX為頂點個數最大值,此處大于等于9即可。現在假設我們自己就是計算機,在調用MiniSpanTree_Kruskal函數,輸入下圖右圖的鄰接矩陣后,看看它是如何運行并打印出最小生成樹的。
/* Kruskal算法生成最小生成樹 */
/* 生成最小生成樹 */
void MiniSpanTree_Kruskal(MGraph G)
{
int i, n, m;
/* 定義邊集數組 */
Edge edges[MAXEDGE];
/* 定義一數組用來判斷邊與邊是否形成環路 */
int parent[MAXVEX];
/* 此處省略將鄰接矩陣G轉化為邊集數組edges
并按權由小到大排序的代碼 */
for (i = 0; i < G.numVertexes; i++)
/* 初始化數組值為0 */
parent[i] = 0;
/* 循環每一條邊 */
for (i = 0; i < G.numEdges; i++)
{
n = Find(parent, edges[i].begin);
m = Find(parent, edges[i].end);
/* 假如n與m不等,說明此邊沒有與現有生成樹形成環路 */
if (n != m)
{
/* 將此邊的結尾頂點放入下標為起點的parent中 */
/* 表示此頂點已經在生成樹集合中 */
parent[n] = m;
printf("(%d, %d) %d ", edges[i].begin,
edges[i].end, edges[i].weight);
}
}
}
/* 查找連線頂點的尾部下標 */
int Find(int *parent, int f)
{
while (parent[f] > 0)
f = parent[f];
return f;
}
1.程序開始運行,第5行之后,我們省略掉頗占篇幅但卻很容易實現的將鄰接矩陣轉換為邊集數組,并按權值從小到大排序的代碼,也就是說,在第5行開始,我們已經有了結構為edge,數據內容是下圖的右圖的一維數組edges。
2.第5~7行,我們聲明一個數組parent,并將它的值都初始化為0,它的作用我們后面慢慢說。
3.第8~17行,我們開始對邊集數組做循環遍歷,開始時,i=0。
4.第10行,我們調用了第19~25行的函數Find,傳入的參數是數組parent和當前權值最小邊(v4,v7)的begin:4。因為parent中全都是0所以傳出值使得n=4。
5.第11行,同樣作法,傳入(v4,v7)的end:7。傳出值使得m=7。
6.第12~16行,很顯然n與m不相等,因此parent[4]=7。此時parent數組值為{0,0,0,0,7,0,0,0,0},并且打印得到“(4,7)7”。此時我們已經將邊(v4,v7)納入到最小生成樹中,如下圖所示。
7.循環返回,執行10~16行,此時i=1,edge[1]得到邊(v2,v8),n=2,m=8,par-ent[2]=8,打印結果為“(2,8)8”,此時parent數組值為{0,0,8,0,7,0,0,0,0},這也就表示邊(v4,v7)和邊(v2,v8)已經納入到最小生成樹,如下圖所示。
8.再次執行10~16行,此時i=2,edge[2]得到邊(v0,v1),n=0,m=1,parent[0]=1,打印結果為“(0,1)10”,此時parent數組值為{1,0,8,0,7,0,0,0,0},此時邊(v4,v7)、(v2,v8)和(v0,v1)已經納入到最小生成樹,如下圖所示。
9.當i=3、4、5、6時,分別將邊(v0,v5)、(v1,v8)、(v3,v7)、(v1,v6)納入到最小生成樹中,如下圖所示。此時parent數組值為{1,5,8,7,7,8,0,0,6},怎么去解讀這個數組現在這些數字的意義呢?
從下圖的右下方的圖i=6的粗線連線可以得到,我們其實是有兩個連通的邊集合A與B中納入到最小生成樹中的,如下圖所示。當parent[0]=1,表示v0和v1已經在生成樹的邊集合A中。此時將parent[0]=1的1改為下標,由par-ent[1]=5,表示v1和v5在邊集合A中,par-ent[5]=8表示v5與v8在邊集合A中,par-ent[8]=6表示v8與v6在邊集合A中,par-ent[6]=0表示集合A暫時到頭,此時邊集合A有v0、v1、v5、v8、v6。我們查看parent中沒有查看的值,parent[2]=8表示v2與v8在一個集合中,因此v2也在邊集合A中。再由parent[3]=7、par-ent[4]=7和parent[7]=0可知v3、v4、v7在另一個邊集合B中。
10.當i=7時,第10行,調用Find函數,會傳入參數edges[7].begin=5。此時第21行,parent[5]=8>0,所以f=8,再循環得par-ent[8]=6。因parent[6]=0所以Find返回后第10行得到n=6。而此時第11行,傳入參數edges[7].end=6得到m=6。此時n=m,不再打印,繼續下一循環。這就告訴我們,因為邊(v5,v6)使得邊集合A形成了環路。因此不能將它納入到最小生成樹中,如上圖所示。
11.當i=8時,與上面相同,由于邊(v1,v2)使得邊集合A形成了環路。因此不能將它納入到最小生成樹中,如上圖所示。
12.當i=9時,邊(v6,v7),第10行得到n=6,第11行得到m=7,因此parent[6]=7,打印“(6,7)19”。此時parent數組值為{1,5,8,7,7,8,7,0,6},如下圖所示。
13.此后邊的循環均造成環路,最終最小生成樹即為下圖所示。
好了,我們來把克魯斯卡爾(Kruskal)算法的實現定義歸納一下結束這一節的講解。
假設N=(V,{E})是連通網,則令最小生成樹的初始狀態為只有n個頂點而無邊的非連通圖T={V,{}},圖中每個頂點自成一個連通分量。在E中選擇代價最小的邊,若該邊依附的頂點落在T中不同的連通分量上,則將此邊加入到T中,否則舍去此邊而選擇下一條代價最小的邊。依次類推,直至T中所有頂點都在同一連通分量上為止。
此算法的Find函數由邊數e決定,時間復雜度為O(loge),而外面有一個for循環e次。所以克魯斯卡爾算法的時間復雜度為O(eloge)。
對比兩個算法,克魯斯卡爾算法主要是針對邊來展開,邊數少時效率會非常高,所以對于稀疏圖有很大的優勢;而普里姆算法對于稠密圖,即邊數非常多的情況會更好一些。