雖然名叫“最小生成樹”,但這個算法實際是圖論分類下的。
我們先來回憶一下樹和圖的結構都是什麼:
樹:每一個節點有且僅有一個父節點,擁有數量可以爲零的子節點數,便是一個樹形結構,如下圖:
圖:每一個節點都可以和任意個數節點相連,如下圖:
但這兩種結構並不是水火不相容的結構,我們可以看到如果我們將圖結構中的某一些邊去掉,就可以將其變成一顆樹結構:
那麼本篇博客的內容已經呼之欲出了:將一個帶權圖通過算法進行邊的選擇,使最後的圖成爲具有樹結構的連通圖,且邊的權值爲最小的樹,我們將它成爲最小生成樹。
最小生成樹有兩個最常用的算法,Kruskal算法以及Prim算法。
Prim算法
prim算法的思想與dijkstra算法中的一部分有一定相似性,因爲我把它提到了kruskal算法前。
與dijkstra算法的操作相似,prim算法的思想是維護一個dis數組,通過dis數組找到距離已生成一部分的最小生成樹最近的一個頂點,將其加入到最小生成樹中,逐漸將所有點都鏈接到最小生成樹中。
該算法大概有以下步驟:
1、掃描dis數組,找到距離當前已生成的最小生成樹最近的一個未被加入最小生成樹的頂點;
2、將該頂點加入到生成樹中;
3、掃描新加入頂點的所有邊,更新dis數組。
通過不斷循環以上步驟,最終可以將所有的點都加入到生成樹中,最終找到該圖的最小生成樹。
以下是基於鄰接矩陣的最小生成樹prim算法的模板
#define INF 0x3f3f3f3f
int book[1005],Map[1005][1005],dis[1005];
int n,m;
void prim(){
memset(dis,0x3f,sizeof(dis));
memset(book,0,sizeof(book));
dis[1] = 0;
book[1] = 1;
for(int i = 1;i<=n;i++)
dis[i] = Map[1][i];
for(int i = 1;i<n;i++){
int Min = INF,k = 0;
for(int j = 1;j<=n;j++){ //查找距離已生成樹最近的點
if(!book[j]&&dis[j]<Min){
Min = dis[j];
k = j;
}
}
book[k] = 1; //將該點標記爲以加入生成樹
for(int j = 1;j<=n;j++){ //更新dis數組
if(!book[j]){
dis[j] = min(Map[k][j],dis[j]);
}
}
}
}
這裏使用效率較低的鄰接矩陣而不使用鄰接表,是因爲prim算法本身效率並不高,在對使用鄰接表儲存效率更高的稀疏圖時prim的性價比非常的低。因此,prim主要用於稠密圖,這時使用鄰接矩陣存儲受益更高。
Kruskal算法
Kruskal算法應該是更加常用的一種算法,不僅大部分時候效率更高,算法也更好理解。在正式進入Kruskal算法之前,我先來說一說它的前置算法:並查集。
並查集
首先我們需要了解並查集的作用是什麼:合併和查詢若干個不重疊的集合,具體到圖論中就是合併和查詢若干個互不聯通的點集,其中每個集合中的所有點都是連通的。
操作
具體來說,並查集包括這兩種操作:
1、get,查詢一個元素屬於哪一個集合;
2、merge,把兩個集合合併成爲一個集合。
結構
爲了實現這兩種操作,首先我們爲每一個集合選擇一個固定的元素,作爲整個集合的“代表”元素。其次,我們需要找到一種方式表示每一個元素的“代表”是誰。在這裏我們選擇的是使用樹形結構儲存每個集合,樹上的每一個節點都是一個元素,樹根則是集合的代表元素,而整個並查集實際上是一個森林,也就是樹的集合。
爲什麼選擇樹形結構呢?我們可以從已經確定的並查集的結構入手:每一個集合都有一個代表元素,和集合內其他實際從屬於代表元素的每個元素。這種結構與樹形結構的一個根及若干葉子節點的結構非常相似,因此在實現並查集的時候我們往往選擇樹形結構。
在實現時,我們通常使用一個數組f來記錄這個森林,用f[i]來保存元素i的父節點,並令樹根的父節點爲自己。這樣,在合併兩個樹的時候,只需要令其中一個樹根爲另一樹根的子節點即可(在這裏我們通常使用相同的順序合併一前一後兩棵樹,如f[root1] = root2)。但如果不做任何優化,那麼每次查詢元素所屬的樹的時候,就需要沿着樹型結構不斷遞歸訪問父親節點,直到到達樹根,這樣的效率非常的慢,所以我們需要引進一種優化方式:路徑壓縮。
路徑壓縮
應該注意到,我們並不關心每一個子節點到父節點之間的路徑到底是如何的,我們只關心每個子節點的樹根是哪個元素,那麼在並查集中的查詢操作中,如下兩顆樹是完全等價的:
爲了讓每一個葉子節點可以直接指向樹根而不額外耗費時間,我們可以在進行查詢操作的時候,將訪問過的每個節點都直接指向樹根,即把上圖中左邊那棵樹變成右邊那顆,這種優化方式被稱爲路徑壓縮。採用這種優化方式的並查集,平均每次查詢操作的複雜度爲O(logN)。
順便如果對這塊還是不太明白,可以用這個網站模擬一下(https://visualgo.net/en/ufds)
模板
int f[N];
void init(){ //初始化
for(int i = 1 ;i <=n ;i++ ){
f[i] = i;
}
}
int getf(int v){ //查詢
if(f[v] == v) return v;
else{
f[v] = getf(f[v]);
return f[v];
}
}
void merge(int x,int y){ //合併
int root1 = getf(x),root2 = getf(v);
if(root != root2){
f[root1] = root2;
}
}
kruskal
回到kruskal算法。kruskal算法的原理在於,從未被選中的邊中找到一條權值最小的邊,並且該邊的兩端點不屬於同一顆生成樹,那麼就將該邊加入生成樹中。這樣選邊,可以保證我們每次加入邊後得到的生成樹一定是最小生成樹。而每次選邊時,判斷該邊的兩端點是否屬於同一顆樹,就可以使用並查集來進行查詢。具體來說,kruskal算法大概有如下流程:
1、初始化並查集;
2、讀入所有邊,將所有邊按照邊權從小到大排序;
3、從小到大掃描所有邊,若該邊兩頂點屬於同一集合(連通),則繼續掃描下一條邊;否則,合併兩頂點所在的集合,並將該邊加入到答案中。
相比prim算法,kruskal算法更適合求稀疏圖的最小生成樹,而且在遇到一些邊權無法計算的情況,可以通過邊的頂點進行排序、入樹,上週六聯想杯的比賽中D題就是這樣的一道題,有興趣的同學可以去看一看(鏈接)。
下面是最小生成樹的模板題代碼:
#include <iostream>
#include <cstring>
#include <algorithm>
#include <cstdio>
#include <vector>
#include <queue>
using namespace std;
int n,m;
int f[1005];
struct node{
int x,y,w;
}road[10005];
void init(){
for(int i = 1;i<=n+1;i++)
f[i] = i;
return;
}
int getf(int v){
if(f[v] == v){
return v;
} else{
f[v] = getf(f[v]);
return map[v];
}
}
int sum;
void merge(int i){
int t1 = getf(road[i].x);
int t2 = getf(road[i].y);
if(t1!=t2){
f[t2] = t1;
sum+=road[i].w; //這道題因爲要算最小生成樹的權值和,所以需要把他加上去
}
}
bool cmp(struct node x,struct node y){
return x.w<y.w;
}
int main() {
while(scanf("%d %d",&n,&m)!=EOF){
init();
//並查集初始化
for(int i = 0;i<m;i++){
scanf("%d %d %d",&road[i].x,&road[i].y,&road[i].w);
}
sum = 0;
//按照邊權排序
sort(road,road+m,cmp);
//求最小生成樹
for(int i = 0;i<m;i++){
merge(i);
}
cout<<sum<<endl;
}
return 0;
}
最後附上oj的練習題;也可以參照stepbystep。
其中oj2144爲最小生成樹模板題,其餘並查集大部分都爲模板題。