圖的最小生成樹
對於一張圖,我們有一個定理:n個點用n-1條邊連線,形成的圖形只可能是樹。我們可以這樣理解:樹的每一個結點都有一個唯一的父親,也就是至少有n條邊,但是根節點要除外,所以就是n-1條邊。還有一種理解:樹裡不存在環,那麼既要連線n個點又不能形成環,只能用n-1條邊。
那麼,對於一張n個點帶權圖,它的生成樹就是用其中的n-1條邊來連線這n個點,那麼最小生成樹就是n-1條邊的邊權之和最小的一種方案,簡單的理解,就是用讓這張圖只剩下n-1條邊,同時這n-1條邊的邊權總和最小。如下圖所示:
紅邊即為此圖的最小生成樹,注意我們目前只討論無向圖的最小生成樹。
求最小生成樹的過程,我們可以理解為建一棵樹。要使邊權總和最小,我們不難想到可以用貪心的思想:讓最小生成樹裡的每一條邊都儘可能小,那麼我們有兩種思路,分別對應著兩種演算法:
(1)Kruskal:
我們不難想到一種貪心的策略:每一條邊的邊權都是小的,那這些邊連線起來的邊權總和一定也是小的。所以,我們不難想到可以先挑選最小的,再挑選次小的、第三小的......直到我們挑了n-1條邊。因此,我們可以將這些邊按照邊權排序,然後開始挑選邊。為什麼是挑選邊呢?不是越小的邊越好嗎,為什麼還要挑?
邊權小固然好,但是不要忘記我們有一個大前提:我們要建的是樹,它裡面不能存在環。也就是說,假如我看到一條邊,但是這條邊連著的兩個點在我建的樹裡已經連通了,那這條邊還需要再加進來嗎?很顯然不用。這樣說可能有點無法理解,我們拿一張圖來具體操作一下:
我們的邊排序後從小到大依次為(結點 結點 邊權):
①1 2 1②1 3 1③4 6 3④5 6 4⑤2 3 6⑥4 5 7⑦3 4 9⑧2 4 11⑨3 5 13
要建的最小生成樹一開始長這樣:
我們的操作就是往這棵樹里加邊,其操作過程如下圖所示:
直到目前為止,一切都很順利。輪到我們的第五條邊2 3 6了。我們發現,2和3已經連通,通過1作為中轉,這時候我們就不能將2 3 6這條邊加入到我們的樹中了。同理,下一條⑥號邊同樣也要跳過,因為4和5已經連通了。接下來,我們加入3 4 9這條邊,如圖所示:
我們發現我們建的樹裡每個結點都已經連線了,剛好用了5條邊,也就是我們說的n-1條邊,演算法結束。
演算法的實現並不難,最為難的是如何判斷兩點是否已經連通。我們可以用深搜或廣搜來解決,但顯然效率極低,因此,我們需要藉助一種強大的資料結構:並查集。並查集的最強大的功能就是可以快速地判斷兩個元素是否在同一集合內(祖先是否相同),所以我們藉助它來判斷兩點是否連通。
主要程式碼:
struct edge
{
int u,v,w;
}a[100001];
//邊集陣列
int boss[10001];//並查集,boss[i]表示i的祖先
int find(int x)
{
if(boss[x]==x)return x;//找到祖先
else
{
boss[x]=find(boss[x]);//路徑壓縮
return boss[x];
}
}
void Kurscal()
{
int i;
for(i=1;i<=n;i++)boss[i]=i;//初始化
//n個結點,每個結點的祖先預設為它自己,也就是每個結點自己一個集合
stable_sort(a+1,a+1+m,cmp);//m條邊,將邊按照邊權從小到大排序
int cnt=0;//當前最小生成樹裡邊的數量
int len=0;//當前最小生成樹邊權總和
for(i=1;i<=m;i++)
{);
int x=find(a[i].u),y=find(a[i].v
//x表示a[i].u的祖先,y表示a[i].v的祖先
if(x!=y)
//說明兩點不在同一集合內,即這兩點不連通
{
boss[x]=y;//標記祖先
cnt++;//邊數增加
len+=a[i].w;//邊權和增加
}
if(cnt==n-1)break;
//如果已經選了n-1條邊,那最小生成樹就建好了
}
}
(2)prim:
除了通過加入邊來建樹,我們還有沒有其它的方法了呢?Kurscal中,我們加入邊,是在我們固定了結點的情況下完成的。也就是,我們這時候不在乎這些結點,我們只在乎連線它們的邊。那,我們可以不可以在乎一下這些結點呢(雨露均沾)?這時候,我們建樹的過程就不是新增邊了,而是新增點。那一開始,我們的樹就應該長這樣(有點尷尬):
我們一開始應該先找一個根結點,這個根結點可以是任何一個結點,因為最後每一個結點都會兩兩連通,哪一個作為根就無所謂了。那麼,每一次我們都要選一個點加入我們的最小生成樹,這個點必須滿足什麼條件呢?它距離當前樹上的與它最近的結點的距離必須是每一個結點距離樹上離它們各自最近的結點的距離中最小的,實際上說的就是每一次要找到一個距離“最小生成樹”最近的結點。我們仍舊以上面的例子來模擬:
首先,1號點為根,這時候距離這棵樹(1號點)最近的是2號點,我們將2號點加入樹:
接下來,距離這棵樹最近的結點是3號點,我們將3號點加入樹:
接下來的操作請讀者自己手動模擬,演算法只有手動模擬了才能身臨其境的感受其思想的真諦(跑題了)
加入點的順序應該是:1->2->3->4->5->6。注意我們每次選點的時候要選的是樹以外的結點,否則一開始就會出現一種非常尷尬的局面:第一輪,找結點,發現根結點到根結點距離為0,選擇根結點;第二輪,發現根結點到根結點距離為0,又選根結點......這就陷入了死迴圈。對於已經在樹裡的結點,我們是沒有必要再去動它了,因為我們的目的就是將所有點插入到樹裡,那你已經在樹裡的我還管你幹什麼?所以我們需要有一個陣列來記錄結點是否已經在樹裡。
在選點的過程中,我們需要按照上面說的一大串話那樣,比較樹外的每一個結點到樹上的每一個結點的距離嗎?我們在那下面說了:“實際上說的就是每一次要找到一個距離“最小生成樹”最近的結點。”我們有沒有辦法來記錄每一個結點到最小生成樹的距離呢?當然有!我們可以開一個陣列dis,有沒有發現,和我們的最短路演算法中的Dijkstra演算法有異曲同工之妙?不懂得可以看一下最短路演算法分析。這兩種演算法都是不斷加入點進行擴充,從而得出整張圖的最短路或最小生成樹。Dijkstra中dis表示的是結點到源點的距離,這裡就是把源點擴大成了一棵樹,其思想並沒有任何改變,我們仍舊可以把那棵樹當作一個點來看待。那麼,在加入點後,我們需要用這個點來重新整理一下其它非樹結點(不在樹上的結點)到樹的距離,這和Dijkstra的鬆弛是一模一樣的!令人讚歎的是,這兩種演算法解決的問題不同,它們的過程竟然完全一樣!
主要程式碼:
struct edge
{
int last,to,len;
}a[100001];
int first[10001],len=0;
//鄰接表
bool f[10001];//記錄是否在樹上
int dis[10001];//記錄結點到樹的距離
void add......//存邊
void prim()
{
int i;
for(i=1;i<=n;i++)dis[i]=999999;//初始化
int cnt=0;//樹內點的數量
int sum=0;//樹內邊權總和
dis[1]=0;
f[1]=1;
cnt=1;
//先確定根結點,一般以1作為根結點
while(cnt<n)//直到n個結點均在樹上
{
int id,minn=1000001;
//id記錄找到的結點的編號,minn是它到樹的距離
for(i=1;i<=n;i++)
if(f[i]==0&&dis[i]<minn)
{
id=i;
minn=dis[i];
}
f[id]=1;
cnt++;
//將這個點加入樹
sum+=dis[id];
//重新整理邊權總和
for(i=first[id];i;i=a[i].last)
//重新整理結點到樹的距離
if(f[a[i].to]==0&&a[i].len<dis[a[i].to])
dis[a[i].to]=a[i].len;
}
}
總結一下兩種演算法:Kurscal演算法是將森林裡的樹逐漸合併,prim演算法是在根結點的基礎上建起一棵樹。
可能有的同學會誤解:dis代表結點到樹的距離,那這個距離一定只包含一條邊嗎?在這裡,距離只能有一條邊。為什麼呢?我們每一次是要往樹里加一個點的,那如果這個距離經過了不止一條邊,那就不滿足我們的需求了。這一點要和Dijkstra區別開,Dijkstra是單純的距離,而prim是隻經過一條邊的距離。這樣的話,即使在存在負邊權,求得的dis不是真正意義上的最短距離,也不會影響我們最終的結果。
我們的過程實際上是每一次新增一個點,然後逐漸建起一棵樹,我們並不是真的希望這個點到我們的樹是最近的,我們只希望這個點加入我們的最小生成樹後可以滿足我們貪心的要求:區域性最優導致整體最優,這個區域性指的是我們最小生成樹的邊權,而並不是真正意義上的距離。這一點一定要好好理解!
同樣,prim演算法也可以堆優化,那麼堆裡存的就是結點的編號和它到樹的距離,和Dijkstra的堆優化基本一樣,希望讀者自己嘗試去實現。
因為Kurscal涉及大量對邊的操作,所以它適用於稀疏圖;普通的prim演算法適用於稠密圖,但堆優化的prim演算法更適用於稀疏圖,因為其時間複雜度是由邊的數量決定的。