最小代價生成樹
目前為止的學習,我們能夠看到現實中的很多問題都和圖結構息息相關,因為現實中的關系往往不是一對一或一對多的關系,而是多對多的關系。例如有這樣一個場景,假設我要在 n 個城市之間架設通信聯絡網,首先我們先明確一個前提:任意兩個城市之間都需要架設通信網絡?答案是否定的,因為這么做勞民傷財的,其實兩個城市之間的互聯可以通過多條鏈路連通,並不需要一定是直達的。
根據這一點,我們就能夠明白,連通 n 個城市需要 n - 1 條鏈路。接下來考慮下一個問題,不同城市間可能存在着多條路徑,路徑有長短,也就是說假設的 n - 1 條鏈路會因為城市間距離的不同而產生成本上的差異。因此我們需要考慮如何選擇出這樣 n - 1 條路徑,使得線路假設的成本最小。
在一個連通圖中的所有生成樹中,存在各邊代價之和最小的生成樹,則稱該生成樹為該聯通網的最小代價生成樹。在上面的例子中可以用聯通網表示,在一個圖結構中的頂點可以用於表示 n 個城市,邊表示頂點之間的路徑,用權表示城市之間的距離,這個使我們判斷成本的重要參考。
MST
MST 性質是一些構造最小生成樹算法使用的原理,因此先學習 MST 性質是下文的預備知識。
性質
假設 N = (V,E) 是一個連通網,U 是頂點集 V 的一個非空子集。若 (u,v) 是一條具有最小生成樹權值(代價)的邊,其中 u ∈ U,v ∈ V - U,則必存在一棵包含邊 (u,v) 的最小生成樹。
需要注解的是,在生成樹構造時,圖結構中的 n 個頂點分屬於 2 個集合:
- 已落在生成樹上的頂點集:U
- 尚未落在生成樹上的頂點集:V - U
那么我們就需要在所連通的 U 中頂點和 V - U 中頂點的邊中選取權值最小的邊。
證明
反證法:假設網 N 的任何一棵最小生成樹都不包含 (u,v)。設 T 是連通網上的一棵最小生成樹,當將邊 (u,v) 加入到 T 中時,由生成樹的定義,T 中必存在一條包含 (u,v) 的回路。另一方面,由於 T 是生成樹,則在 T 上必存在另一條邊 (u',v'),其中 u' ∈ U,v'∈ Y - U,且 u 和 u' 之間、v 和 v' 之間均有路徑相通。刪去邊 (u',v'),便可消除上述回路,同時得到另一棵生成樹 T'。因為 (u,v) 的權值不高於 (u',v'),則 T 的權值亦不高於 T,T’ 是包含 (u',v') 的一棵最小生成樹。由此和假設矛盾。 ——《數據結構(C語言版)》
Prim 算法(加點法)
該算法時間復雜度為 O(n2),與圖結構的邊數無關,適合稠密圖。由於直接講算法難以理解,所以我先模擬一下算法。
算法模擬
所謂加點法就是每次操作的都是點啦。假設有如下圖結構,從頂點 0 出發構造最小生成樹。
首先頂點 0 選擇它能夠延伸出去的最短路徑,那就是 0->1,因此我們把頂點 1 加入到點集中。
接着頂點 0,1 選擇它能夠延伸出去的最短路徑,那就是 0->3,因此我們把頂點 3 加入到點集中。
接着頂點 0,1,3 選擇它能夠延伸出去的最短路徑,那就是 1->2,因此我們把頂點 2 加入到點集中。
接着頂點 0,1,3,2 選擇它能夠延伸出去的最短路徑,那就是 2->5,因此我們把頂點 5 加入到點集中。(由於我們要保證選取的邊不能構成環,因此不能選擇已經在點集中的 0->2 路徑。)
最后頂點 0,1,3,2,5 選擇它能夠延伸出去的最短路徑,那就是 5->4,因此我們把頂點 4 加入到點集中。到此所有的點都被添加到生成樹中了,構造完畢。
算法流程
假設 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 為止。
- 每次選擇最小邊的時候,可能存在多條同樣權值的邊可選,不要猶豫任選其一就行。
算法實現
結構設計
當我使用鄰接矩陣來存儲時,由於需要描述 U 到 V-U 具有最小權值的邊,因此需要兩個數組來輔助:
- lowcost 數組:存儲最小邊上的權值;
- adjvex 數組:存貯最小邊在 U 中的頂點。
算法步驟
首先將初始頂點 u 加入 U 中,對其每一個頂點 vj,將 lowcost 數組和 adjvex 數組初始化為到 u 的邊信息。
接着循環 n - 1 次,每次循環進行如下操作:
- 從各個邊中選出最小的邊 lowcost[k] 選出;
- 將 k 頂點加入 U 中;
- 更新剩余每組最小邊的信息,對於 V-U 中的邊,若存在新的邊權值比現有邊更小,則更新 lowcost 數組為新的權值。
代碼實現
#define INFINITY 65535
void MiniSpanTree_Prim(MGraph G)
{
int min;
int i,j,k;
int adjvex[MAXV]; //保存相關頂點下標
int lowcost[MAXV]; //保存相關頂點間邊的權值
lowcost[0] = 0; //v0 加入生成樹
adjvex[0] = 0; //初始化第一個頂點下標為 0
for(i = 1; i < G.n; i++) //初始化,循環除下標 0 外的所有頂點
{
lowcost[i] = G.edges[0][i]; //將 v0 頂點存在邊的權值存入 lowcost
adjvex[i] = 0; //初始化都為 v0 的下標
}
for(i = 1; i < G.n; i++)
{
min = INFINITY; //初始化最小權值為 ∞
j = 1;
k = 0;
while(j < G.n) //循環全部頂點
{
if(lowcost[j] != 0 && lowcost[j] < min)
{ //若權值不為 0 且小於 min
min = lowcost[j]; //令當前權值為最小值
k = j; //當前最小值的下標拷貝給 k
}
j++;
}
cout << adjvex[k] << k; //輸出當前頂點邊中權值最小邊
lowcost[k] = 0; //將頂點權值設為 0 表示添加入生成樹
for(j = 1; j < G.n; j++)
{
if(lowcost[j] != 0 && G.edges[k][j] < lowcost[j])
{ //若下標為 k 的頂點各邊權值小於當前頂點未被加入生成樹權值
lowcost[j] = G.edges[k][j]; //將最小權值存入 lowcost
adjvex[j] = k; //將下標為 k 的頂點存入 adjvex
}
}
}
}
Kruskal 算法(加邊法)
假若以“堆”結構來存放邊進行堆排序,對於包含 e 條邊的網,上述算法排序的時間復雜度為 O(e㏒2e)。只要采取合適的數據結構,算法的時間復雜度為 O(e㏒2e)。與普里姆算法相比,該算法更適合於求稀疏圖的最小生成樹。由於直接講算法難以理解,所以我先模擬一下算法。
算法模擬
所謂加邊法就是每次操作的都是點啦,假設有如下圖結構。
在所有邊挑選出一條最短的邊為 (0,1),添加邊。
對於還未被添加的邊,挑選出一條最短的邊為 (0,3),添加邊。
對於還未被添加的邊,挑選出一條最短的邊為 (1,2),添加邊。
對於還未被添加的邊,挑選出一條最短的邊為 (4,5),添加邊。
對於還未被添加的邊,挑選出一條最短的邊為 (0,2),但是添加了這條邊之后會形成回路,因此不能添加。
繼續挑選出一條最短的邊為 (2,5),添加邊,到此所有的點都被被連通,構造完畢。
算法流程
假設 N = (V,E) 是連通網,將 N 中的邊按權值從小到大的順序排列。
- 初始狀態只有 n 個頂點而沒有邊的非連通圖 T = (V,{}),圖中每個頂點都自成一個連通分量;
- 在 E 中選擇權值最小的邊,若該邊依附的頂點落在 T 中不同的連通分量上(不形成回路),則將此邊加入到 T 中,否則不添加而選擇下一條權值最小的邊;
- 重復步驟 2,直至 T 中的所有頂點都在同一連通分量上為止。
算法實現
結構設計
由於我們的視角放在“邊”上,因此我們選擇邊集數組來存儲圖結構,為我們提取邊的信息提供方便。同時為了判斷是否形成回路,我們可能需要結合並查集來判斷。
算法步驟
首先將邊集數組中的元素按權值從小到大排序。
接着依次檢查邊集數組的所有邊,做如下操作:
- 從邊集數組中選擇一條邊 (U1,U2);
- 在頂點數組中分別查找 v1 和 v2 所在的連通分量 vs1 和 vs2,進行判斷。若 vs1 和 vs2 不相等,表明所選的兩個頂點分別屬於不同的連通分量,則合並連通分量 vs1 和 vs2。若相等,表明所選的兩個頂點屬於同一個連通分量屬於同一個連通分量,舍棄這個邊,選擇下一條權值最小的邊。
代碼實現
void MiniSpanTree_Kruskal(MGraph G)
{
int i,n,m;
Edge edges[MAXE]; //定義邊集數組
int parent[MAXV]; //判斷回路的並查集
for(i = 0; i < G.n; i++)
{
n = Find(parent, edges[i].begin); //“查”操作
m = Find(parent, edges[i].end);
if(m != n) //若 n 和 m 不相等,說明回路不存在
{
parent[n] = m; //將此邊結尾頂點放入下標為起點的 parent 中
cout << edges[i].begin << edges[i].end << edges[i].weight << endl;
}
}
}
int Find(int *parent, int f) //“查”操作
{
while(parent[f] > 0)
f = parent[f];
return f;
}
實例:公路村村通
情景需求
測試樣例
輸入樣例
6 15
1 2 5
1 3 3
1 4 7
1 5 4
1 6 2
2 3 4
2 4 6
2 5 2
2 6 6
3 4 6
3 5 1
3 6 1
4 5 10
4 6 8
5 6 3
輸出樣例
12
情景分析
這個情景思路很明確,就是讓我們找最小生成樹,然后求一下最小生成樹中權值的和。需要注意的是輸入數據不足以保證暢通,這句話的意思是說給出的所有頂點可能會因為缺邊導致無法連通,因此我們就需要對這種情況進行判斷。
代碼實現
Prim 算法
與上文的代碼幾乎無異,需要添加一個判斷圖是否連通的功能而已。判斷方法是看看每一輪循環 k 值有沒有被修改,如果沒有被修改就證明圖的連接被切斷,也就是不連通,直接 return 就行了。
int MiniSpanTree_Prim(MGraph G)
{
int min;
int i, j, k;
int adjvex[MAXV]; //保存相關頂點下標
int lowcost[MAXV]; //保存相關頂點間邊的權值
int cost = 0;
lowcost[1] = 0; //v1 加入生成樹
adjvex[1] = 0; //初始化第一個頂點下標為 1
for (i = 2; i <= G->n; i++) //初始化,循環除下標 1 外的所有頂點
{
lowcost[i] = G->edges[1][i]; //將 v1 頂點存在邊的權值存入 lowcost
adjvex[i] = 1; //初始化都為 v1 的下標
}
for (i = 2; i <= G->n; i++)
{
min = INFINITY; //初始化最小權值為 ∞
j = 1;
k = 0;
while (j <= G->n) //循環全部頂點
{
if (lowcost[j] != 0 && lowcost[j] < min)
{ //若權值不為 0 且小於 min
min = lowcost[j]; //令當前權值為最小值
k = j; //當前最小值的下標拷貝給 k
}
j++;
}
if (k != 0) //若 k 值被修改,證明成功添加了點
{
cost += min; //計算費用
lowcost[k] = 0; //將頂點權值設為 0 表示添加入生成樹
}
else //若 k 值沒被修改,證明圖不連通,斷了聯系
{
return -1;
}
for (j = 2; j <= G->n; j++)
{
if (lowcost[j] != 0 && G->edges[k][j] < lowcost[j])
{ //若下標為 k 的頂點各邊權值小於當前頂點未被加入生成樹權值
lowcost[j] = G->edges[k][j]; //將最小權值存入 lowcost
adjvex[j] = k; //將下標為 k 的頂點存入 adjvex
}
}
}
return cost;
}
實例:通信網絡設計
情景需求
測試樣例
輸入樣例
5 4
1 2 3
1 3 11
2 3 8
4 5 9
輸出樣例
NO!
1 part:1 2 3
2 part:4 5
情景分析
這個情景思路也很明確,就是讓我們找最小生成樹,然后求一下最小生成樹中權值的和。不過,我們可能會遇到像樣例那樣缺乏數據支撐的情況,也就是圖被分割成了好幾部分,這個情景對找到圖被分割的各個部分提出了要求。
如何實現這個功能?我們聯想到並查集,這是因為並查集這個結構能夠描述一堆數據元素所在的集合關系,我們可以用並查集來確定一下圖結構中被分為了幾個部分。若所有點都屬於同一個集合中,說明圖是連通的,可以找最小生成樹,若並查集中存在超過 1 個集合,說明圖被分割成了好幾部分,那就要分別輸出各部分。
兩種算法都可以實現功能,我選擇 Prim 算法,不過我只有確定並查集中僅存在一個集合我才啟動算法。
建圖算法
我需要構造一個並查集來確定有多少個集合,因此我可以在建立鄰接矩陣的時候順手建好並查集。對於並查集不是很熟悉的朋友可以看我的另一篇博客並查集(Union Find)。
偽代碼
代碼實現
void CreateMGraph(MGraph& g, int n, int e, int parent[])
{
int i, j;
int point1, point2;
int money;
g = new GraphNode;
for (i = 1; i <= n; i++)
{
parent[i] = i;
for (j = 1; j <= n; j++)
{
if (i == j)
g->edges[i][j] = 0;
else
g->edges[i][j] = INFINITY;
}
}
g->n = n;
g->e = e;
for (i = 0; i < e; i++)
{
cin >> point1 >> point2 >> money;
g->edges[point1][point2] = g->edges[point2][point1] = money;
Union(parent, point1, point2);
}
}
主函數
在主函數需要先判斷並查集中是否僅存在一個集合,若僅存在一個集合才啟動 Prim 算法。
偽代碼
代碼
int main()
{
MGraph g;
int n, e;
int count = 0;
int parent[MAXV];
int i, j;
int fre = 1;
cin >> n >> e;
CreateMGraph(g, n, e, parent);
for (int i = 1; i <= n; i++)
{
if (parent[i] == i)
{
count++;
}
}
if (count == 1)
{
cout << "YES!\n" << "Total cost:" << MiniSpanTree_Prim(g) << endl;
}
else
{
cout << "NO!" << endl;
for ( i = 1; i <= n; i++)
{
if (parent[i] == i)
{
cout << fre << " part:" << i;
for ( j = i + 1; j <= n; j++)
{
if (parent[j] == i)
{
cout << " " << j;
}
}
fre++;
cout << endl;
}
}
}
return 0;
}
破圈法
避圈法和破圈法
一個有 n 個結點的連通圖的生成樹是原圖的極小連通子圖,且包含原圖中的所有 n 個結點,並且有保持圖連通的最少的邊,在一個連通圖中的所有生成樹中,存在如此各邊代價之和最小的生成樹,則稱該生成樹為該聯通網的最小代價生成樹。
我們熟知的 Prim 算法和 Kruskal 算法可以用來找到最小生成樹,不過這兩種算法的思想其實是一致的:一開始我只有有限的信息,無論是所有頂點做加邊法,還是所有的邊做加點法,一開始的信息都是不完整的。其實無論是加點還是加邊,本質上是從最小的權的邊開始,添加的是在未選中的邊中的最小權值邊,原理都是使用了 MST 性質去做的。在做 Prim 算法和 Kruskal 算法時我們比較關心每添加了一次邊就檢查看看有沒有出線回路,因為生成樹是不能有回路。那也就是說我要避免回路的出現,在重復添加最小權值邊時就能保證將最小生成樹添加出來,這種手法就被稱為避圈法。
現在我們來考慮另一種思路,避圈法是把最小生成樹建出來,現在我有全套的信息,我要怎么把最小生成樹找出來?由於最小生成樹是 n 個頂點 n - 1 條邊,因此我需要將邊的數量減少到 n - 1 條,一旦我滿足了這點就可以認為是找到了最小生成樹。所以現在的問題是怎么找需要刪除的邊?回顧一下什么叫生成樹,生成樹是沒有回路的,那也就是說我只需要把邊給刪除掉,使得該結構不存在回路即可。當然你可能要問了直接隨意刪除不就好了嗎?這樣剩余 n - 1 條邊絕對是不存在回路的。但是這么做可能會導致本來我的生成樹是可以保證連通的,但是就被拆成了多個部分,這個不是我們願意發生的事情。所以刪除邊的時候都需要驗證這條邊的有或無是否導致回路的存在。
還有一個問題是,如果我漫無目的地刪除邊,那么這樣得到的生成樹未必不是最小生成樹。這個問題解決還是比較容易的,我直接去找現有的權值最大邊即可,然后判斷回路存在后直接把最長邊。這樣每次操作的都是最長邊,剩余的邊就會是短邊,對應的生成樹就是最小生成樹。
模擬生成
我們還是拿這個圖結構來看,首先對於破圈法來說,我擁有全套信息。
接下來我去找權值最大的邊,應該是 3-5,判斷一下有這條邊將存在回路,刪除。
繼續尋找權值最大的邊,應該是 1-4,判斷一下有這條邊將存在回路,刪除。
繼續尋找權值最大的邊,應該是 2-5,判斷一下有這條邊對生成樹存在回路是沒有影響的。因為現在回路存在於 0,1,2 這 3 個點(用橙色標出) 2-5 的存在與否並不影響,因此保留。
繼續尋找權值最大的邊,應該是 0-2,判斷一下有這條邊將存在回路,刪除。
此時邊的數量已經縮小至 n - 1,說明構造完畢。
參考資料
《大話數據結構》—— 程傑 著,清華大學出版社
《數據結構(C語言版|第二版)》—— 嚴蔚敏 李冬梅 吳偉民 編著,人民郵電出版社
並查集(Union Find)