虽然名叫“最小生成树”,但这个算法实际是图论分类下的。
我们先来回忆一下树和图的结构都是什么:
树:每一个节点有且仅有一个父节点,拥有数量可以为零的子节点数,便是一个树形结构,如下图:
图:每一个节点都可以和任意个数节点相连,如下图:
但这两种结构并不是水火不相容的结构,我们可以看到如果我们将图结构中的某一些边去掉,就可以将其变成一颗树结构:
那么本篇博客的内容已经呼之欲出了:将一个带权图通过算法进行边的选择,使最后的图成为具有树结构的连通图,且边的权值为最小的树,我们将它成为最小生成树。
最小生成树有两个最常用的算法,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 f[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为最小生成树模板题,其余并查集大部分都为模板题。