數據結構與算法-最小生成樹

假設你是電信的實施工程師,需要為一個鎮的九個村莊架設通信網絡做設計,村莊位置大致如下圖,其中v0~v8是村莊,之間連線的數字表示村與村間的可通達的直線距離,比如v0至v1就是10公里(個別如v0與v6,v6與v8,v5與v7未測算距離是因為有高山或湖泊,不予考慮)。你們領導要求你必須用最小的成本完成這次任務。你說怎么辦?

image-20200510194629647

顯然這是一個帶權值的圖,即網結構。所謂的最小成本,就是n個頂點,用n-1條邊把一個連通圖連接起來,并且使得權值的和最小。在這個例子里,每多一公里就多一份成本,所以只要讓線路連線的公里數最少,就是最少成本了。

image-20200510194923661

我們在講圖的定義和術語時,曾經提到過,一個連通圖的生成樹是一個極小的連通子圖,它含有圖中全部的頂點,但只有足以構成一棵樹的n-1條邊。

找連通網的最小生成樹,經典的有兩種算法,普里姆算法和克魯斯卡爾算法。

普里姆(Prim)算法

為了能講明白這個算法,我們先構造下圖的鄰接矩陣,如下圖的右圖所示。

image-20200510200037910

也就是說,現在我們已經有了一個存儲結構為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;                 
             }
         }
     }
 }
  1. 程序開始運行,我們由第4~5行,創建了兩個一維數組lowcost和adjvex,長度都為頂點個數9。
  2. 第6~7行我們分別給這兩個數組的第一個下標位賦值為0,adjvex[0]=0其實意思就是我們現在從頂點v0開始(事實上,最小生成樹從哪個頂點開始計算都無所謂,我們假定從v0開始),lowcost[0]=0就表示v0已經被納入到最小生成樹中,之后凡是lowcost數組中的值被設置為0就是表示此下標的頂點被納入最小生成樹。
  3. 第8~12行表示我們讀取圖7-6-3的右圖鄰接矩陣的第一行數據。將數值賦值給lowcost數組,所以此時lowcost數組值為{0,10,65535,65535,65535,11,65535,65535,65535},而adjvex則全部為0。此時,我們已經完成了整個初始化的工作,準備開始生成。
  4. 第13~36行,整個循環過程就是構造最小生成樹的過程。
  5. 第15~16行,將min設置為了一個極大值65535,它的目的是為了之后找到一定范圍內的最小權值。j是用來做頂點下標循環的變量,k是用來存儲最小權值的頂點下標。
  6. 第17~25行,循環中不斷修改min為當前lowcost數組中最小值,并用k保留此最小值的頂點下標。經過循環后,min=10,k=1。注意19行if判斷的lowcost[j]!=0表示已經是生成樹的頂點不參與最小權值的查找。
  7. 第26行,因k=1,adjvex[1]=0,所以打印結果為(0,1),表示v0至v1邊為最小生成樹的第一條邊。如下圖所示。
image-20200510201546560
  1. 第27行,此時因k=1我們將lowcost[k]=0就是說頂點v1納入到最小生成樹中。此時lowcost數組值為{0,0,65535,65535,65535,11,65535,65535,65535}。

  2. 第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已經是生成樹的頂點不參與最小權值的比對了。

  3. 再次循環,由第15行到第26行,此時min=11,k=5,adjvex[5]=0。因此打印結構為(0,5)。表示v0至v5邊為最小生成樹的第二條邊,如下圖所示。

    image-20200510201940021
  4. 接下來執行到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;

我們將下圖的鄰接矩陣通過程序轉化為下圖的右圖的邊集數組,并且對它們按權值從小到大排序。

image-20200510202630399

于是克魯斯卡爾(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)納入到最小生成樹中,如下圖所示。

image-20200510203052825

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)已經納入到最小生成樹,如下圖所示。

image-20200510203156259

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)已經納入到最小生成樹,如下圖所示。

image-20200510203237568

9.當i=3、4、5、6時,分別將邊(v0,v5)、(v1,v8)、(v3,v7)、(v1,v6)納入到最小生成樹中,如下圖所示。此時parent數組值為{1,5,8,7,7,8,0,0,6},怎么去解讀這個數組現在這些數字的意義呢?

image-20200510203322483

從下圖的右下方的圖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中。

image-20200510203837910

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.此后邊的循環均造成環路,最終最小生成樹即為下圖所示。

image-20200510204123128

好了,我們來把克魯斯卡爾(Kruskal)算法的實現定義歸納一下結束這一節的講解。

假設N=(V,{E})是連通網,則令最小生成樹的初始狀態為只有n個頂點而無邊的非連通圖T={V,{}},圖中每個頂點自成一個連通分量。在E中選擇代價最小的邊,若該邊依附的頂點落在T中不同的連通分量上,則將此邊加入到T中,否則舍去此邊而選擇下一條代價最小的邊。依次類推,直至T中所有頂點都在同一連通分量上為止。

此算法的Find函數由邊數e決定,時間復雜度為O(loge),而外面有一個for循環e次。所以克魯斯卡爾算法的時間復雜度為O(eloge)。

對比兩個算法,克魯斯卡爾算法主要是針對邊來展開,邊數少時效率會非常高,所以對于稀疏圖有很大的優勢;而普里姆算法對于稠密圖,即邊數非常多的情況會更好一些。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,786評論 6 534
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,656評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,697評論 0 379
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,098評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,855評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,254評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,322評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,473評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,014評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,833評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,016評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,568評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,273評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,680評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,946評論 1 288
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,730評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,006評論 2 374