图论算法:最小生成树

目录

生成树

最小生成树

prim算法的实现

prim算法的时间复杂度及其优化?

Kruskal算法的实现

Kruskal算法的时间复杂度及其优化?


生成树

我们说树是一种特殊的图,有n个顶点的树满足一下三个特性中的任意两个(之所以是任意两个是因为由其中两个必定能够推出另外一个):

1.全图必须是连通的

2.全图中只有n-1条边

3.全图中不含环

 生成树则是在一个有V个顶点的无向连通图中,取其中V-1条边,连接其顶点得到的子图。如下所示:

           ​                                     

右图是左图的一颗生成树,但并非我们今天要介绍的最小生成树。

最小生成树

那么什么是最小生成树呢?最小生成树首先肯定是一颗生成树,但它有一个要求:必须是该图所有生成树中边权和最小的那颗。也就是说,最小生成树解决的是如何用最小的代价将N-1条边连接N个点的问题

如何得到最小生成树?

这里介绍两种算法:Prim算法Kruskal算法,它们共同的思想都是贪心,但具体细节差异不小。

prim算法的实现

Prim算法从顶点入手,它将顶点分为了两个集合:U与V。U集合中存放已经加入了最小生成树的点,V集合则是还没有加入最小生成树的点集。设数组dis[i]表示第i个顶点到集合U的距离,首先任意选取一个顶点开始(一般取1号),初始化它的dis值为0,例:dis[1]=0,其它顶点为正无穷。再定义数组vst[i],如果i顶点已经被放入集合U中,则vst[i]=1,否则设为0。

从集合V中选定现在dis值最小的顶点,将其放入集合U,修改与它直接相连且还在集合V中的所有顶点的dis值,设当前选定的顶点为i,它要修改的顶点为j(j可以是多个顶点),如果i到j的距离edge{I,j}.dis小于dis[j],则将dis[j]修改为i到j的距离。不断重复以上步骤,直到所有顶点都在集合U中。

如果不理解上述一大段的文字描述,可以参考下面的图示:

实际举例如下,白点表示在集合U中,绿点是当前要加入集合U的顶点,蓝点表示在集合V中:

dis[1]=0:dis[2]=2 、dis[3]=12、dis[4]=10                                  dis[2]=2:dis[3]=8(8<12)、dis[5]=9

dis[3]=8:dis[4]=6(6<10)、dis[5]=3(3<9)                                    dis[5]=3:dis[4]=6(6<7)

dis[4]=6                                                                                       得到最小生成树

 ​ 

Prim算法的正确性如何理解?

想要不成环地连接N个顶点,需要连接N-1次。我们每一次操作都会连接一个新的顶点,而且所花代价最小(体现在dis值不断更新为最小值上),换一种描述方式就是:每次操作都用了最小的代价,用一条边连接了一个新顶点,该操作将会执行N-1次。若想严格的证明可利用反证法,请感兴趣的读者自行搜索。

prim算法的时间复杂度及其优化?

先来看看朴素算法的流程,设该图有E条边、N个结点,在执行操作前我们要从所有的点中选择dis值最小的点,并且dis值会随着选择点而改变,也就是说我们需要每次都重新搜索一遍所有结点,才能找到改变后的最小dis值,这部分的时间复杂度为O(N),而找到dis值最小的点后就要重新修改与它相连的所有结点的dis值,执行次数最多不会超过N-1,因此时间复杂度为O(N)。以上过程对每个结点都执行了一次,所以总时间复杂度为O( N^2​ )。

#include<cstdio>
#include<cstring> 
using namespace std;
const int INF = 0x7fffffff, N = 505;
//g邻接矩阵存图,dis存放顶点到集合V中顶点的距离,vst存放
int g[N][N], dis[N], vst[N];
int prim(int n){
	int temp, min, ans=0;
	for(int i=1;i<=n;++i)
		dis[i]=INF;
	dis[1]=0;
	for(int i=1;i<=n;++i){
		min=INF; 
        //找到还未选定的顶点中dis最小的那个
		for(int j=1;j<=n;++j)
			if(!vst[j] && min>=dis[j]){
				min=dis[j];
				temp=j;
			}
		vst[temp]=1;//该顶点已被选定,将该顶点的vst标记为1
		ans+=dis[temp];
        //修改相连顶点的dis值
		for(int j=1;j<=n;++j)
			if(!vst[j] && dis[j]>g[temp][j])
				dis[j]=g[temp][j];
	}
	return ans;
}
int main(){
	int m,n;
	scanf("%d%d",&n,&m);
	int x,y,w;
	for(int i=1;i<=n;++i)
		for(int j=1;j<=n;++j)
			g[i][j]=INF;
	for(int i=1;i<=m;++i){
		scanf("%d%d%d",&x,&y,&w);
		g[x][y]=g[y][x]=w;
	}
	memset(vst,0,sizeof(vst));
	int ans=prim(n);
	printf("%d\n",ans);
	return 0;
} 

现在考虑如何优化。由于图的结点数固定,而prim算法是基于结点遍历的,所以这部分循环肯定没有优化空间了。那么,能够优化的部分就只有每次选出dis值最小的顶点这步操作了,我们很容易将其抽象成这样一个问题:存在集合S,里面的元素可能会随时增加、删除、修改,而我们需要做到随时返回集合内的最小值,而这个问题则可以使用二叉堆轻松解决,这里求的是最小值,所以我们需要建立一个小根堆。如果使用C++的STL库,可以直接利用优先队列priority_queue解决此问题而不需手写堆(参考这篇博文:优先队列的使用)。当然,如果直接使用优先队列,你会发现存在一些问题,例如:从二叉堆取出了最小的dis值,却不知道它属于哪个顶点、要修改之前顶点的dis值时发现它们存入了优先队列使我们不能找到它们、优先队列是默认大根堆的,而prim算法要求的却是小根堆。

幸运的是,这几种情况都是存在解决方案的,先看第一个问题:不知道找出的dis值属于哪个顶点,是因为将dis数组存入优先队列时不能存入下标,既然这样就将dis定义成一个结构体数组,结构体内存储dis值与它对应的顶点,这样取出时就能知道它的顶点信息了。当然,还有更简单的方式,使用STL中的pair,pair允许将一对元素打包成一个,譬如pair<int,int> p就能一个变量存放两个元素,这样第一个元素存放dis值,第二个元素存放它对应的顶点就ok了。

再看第二个问题:没办法找到已经存入优先队列的结点,是因为优先队列只允许我们访问优先级最高的元素。既然如此,那我们不去找它们不就行了?哎——不去找它那要怎么修改呢?答案是,尽管我们已经使用了pair来保存dis值,但我们仍保留dis数组,修改时修改dis数组,而存入时则利用pair存入优先队列。不过这又带来了一个问题:既然不是修改而是添加新数据,那原来的数据因为无法找到所以也无法删除,这又要怎么办呢?答案是,不用管它。为什么?想想什么时候dis会修改,只有当这个顶点的dis变得更小时才会修改,所以后续存入的该顶点的dis一定比之前存入优先队列的dis要小,取出时一定先取出后放入的数据。至于前放入的数据,不要忘了我们还有一个数组vst呢!如果后续的时候我们取到了前放入顶点的数据,由于这个顶点已经被取过了,它的vst值被标记为1,将直接出队且不进行后续步骤,因此对最终的答案是不会产生影响的。

最后一个问题则比较好解决,方案有很多,如果是自定义结构体的,可以直接重载运算符;如果是使用pair的可以用这种方式定义优先队列:priority_queue< pair<int,int>, vector<int>, greater<int> > q; 当然,还有最简单的一种方法,存入时直接将dis取负值就行了。

好了,这样我们就能使用优先队列来优化Prim算法了。不过,最后Prim还有一处可以优化:可以将邻接矩阵的存图方式改为邻接表存图,这不是必须的,但却是很有必要的,顶点过多时,使用邻接矩阵的空间复杂度将难以承受,尽管邻接表将使代码编写变得复杂,但仍需掌握。以下代码使用了结构体+vector实现邻接表,这样在题目有多组输入时处理更加方便。

#include<cstdio>
#include<cstring> 
#include<queue>
using namespace std;
const int N = 105;
//邻接表 
struct Edge{
	int to,dis;
};
vector<Edge> head[N];
//添边 
void addEdge(int from,int to,int dis){
	Edge edge;
	edge.to=to;
	edge.dis=dis;
	head[from].push_back(edge);
}
//prim算法的辅助数组 
int dis[N], vst[N]; 
priority_queue< pair<int,int> > q;
int prim(){
	int ans=0;
	dis[1]=0;
	q.push(make_pair(-dis[1],1));
	while(q.size()){
		int i=q.top().second;
		q.pop();		
		if(vst[i])	continue;
		vst[i]=1;
		ans+=dis[i];
		for(int j=0;j<head[i].size();++j){
			Edge edge=head[i][j];
			int x=edge.to;
			if(dis[x]>edge.dis){
				dis[x]=edge.dis;
				q.push(make_pair(-dis[x],x));
			}
		}
	}
	return ans;
}
//预处理 
void pretreatment(int n){
	for(int i=1;i<=n;++i) head[i].clear(); 
	memset(vst,0,sizeof(vst));
	memset(dis,0x7f,sizeof(dis));
}
int main(){
	int n,m;
	while (~scanf("%d", &n),n){
		pretreatment(n); 
		int u,v,w;
		m=n*(n-1)/2;
		//邻接表建图 
		for(int i=1;i<=m;++i){
			scanf("%d%d%d",&u,&v,&w);
			addEdge(u,v,w);
			addEdge(v,u,w);//无向图 
		}
		printf("%d\n",prim());
	}
	return 0;
} 

Kruskal算法的实现

Kruskal算法相比于Prim算法,它的思路就更容易理解了:它从边出发,首先将所有边按边权大小从小到大排序,然后依次选边,若添加了边后形成环则舍弃该边,直至选出N-1条边。

                                                                                                        选择边(1,2)

选择边(3,5)                                                                                 选择边(3,4)

选择边(4,5)后成环,舍弃                                                         选择边(2,3),得到最小生成树

Kruskal算法的正确性?

Kruskal算法是显而易见的,想让总和最小,当然每次都选择最小的值,不合要求的值舍掉即可,所以相比之下Kruskal算法更符合我们常见的思维习惯。

Kruskal算法的时间复杂度及其优化?

首先是边排序的时间复杂度:ElogE(E为边数),然后是从E条边中选出N-1条边,注意这里最多可能会执行E次而不是N-1次,因为不是每次选边都是符合条件的!我们还需要判断选边后是否成环,简单的做法就是每次都检测当前图中是否含环,可以深搜来做,时间复杂度为O(E+N),执行E次就是O(E^{2}+EN​),所以总时间复杂度为O(ElogE+E^{2}+EN​),怎么看都应该是有点大的,所以需要优化。

在什么地方可以优化呢?排序肯定是不行了的,理论上快排已经是最优选择了,那么就只有在判环上入手了。于是,我们选择用并查集来优化判环,不熟悉并查集的读者可以参考我的这篇博客:并查集。并查集判环的思想十分简单,即每选择一条边就将它的两个端点加入到并查集中,在后续的选边前,先判断它的两个端点是否在同一集合中,若在,则说明形成了环,不能添加该边。假设单次查询并查集的时间复杂度为α(n),最多操作了E次,这样总的时间复杂度为O(Eα(n)),加上排序的时间,最终的时间复杂度为O(ElogE+Eα(n)),如果并查集再使用路径压缩优化,查找一次的时间复杂度能优化到接近常数,所以可以近似得到O(ElogE)的时间复杂度。

#include<cstdio>
#include<iostream>
#include<algorithm>
using namespace std;
const int N = 10010;
struct Edge{
	int from, to;
	int dis;
	bool operator < (const Edge e) const{
		return dis < e.dis;
	}
} e[N],temp[N]; //temp记录最终生成的最小生成树 
int father[N];
void merge(int x, int y){
	father[y]=x;
}
int find(int x){ //路径压缩
	return x==father[x]?x : father[x]=find(father[x]);
}
int main(){
	int n, m; //n个结点,m条边
	cin>>n>>m;
	for(int i=1;i<=m;++i)
		cin>>e[i].from>>e[i].to>>e[i].dis;
	for(int i=1;i<=n;++i)
		father[i]=i;
	sort(e+1,e+m+1);
	int ans = 0, cnt = 0; //ans记录最小生成树的权值和,cnt记录当前已经加入的边数 
	for(int i=1;i<=m;++i){
	    int f1=find(e[i].from);
	    int f2=find(e[i].to);
	    if(f1!=f2){
	        temp[cnt].from=e[i].from;
	        temp[cnt].to=e[i].to;
	        ans += e[i].dis;
	        merge(f1,f2);
	        cnt++;
	    }
	    if(cnt==n-1) break;
	}
	if(cnt<n-1) cout<<"Impossible\n";
	else{
	    for(int i=0;i<cnt;++i)
	        cout<<temp[i].from<<" "<<temp[i].to<<"\n";
	    cout<<ans<<"\n";	
	} 
	return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值