图算法专题(三)【最小生成树】

最小生成树

    • 最小生成树及其性质
      • 性质:
    • prim算法
      • 基本思想
      • 具体实现
      • 伪代码
      • 算法代码
        • 邻接矩阵版
        • 邻接表版
      • 举例
    • kruskal算法
      • 基本思想
      • 伪代码
      • 算法代码
      • 举例
    • 如何选择是用prim还是kruskal算法

最小生成树及其性质

  最小生成树(MST)是在一个给定的无向图G(V,E)中求一棵树T,使得这棵树拥有图G中的所有顶点,且所有边都是来自图G中的边,并且满足整棵树的边权之和最小。

性质:

  1. 最小生成树的边数=顶点数-1,且树内没有环。
  2. 对给定的图G(V,E),其最小生成树可以不唯一,但是其边权之和一定是唯一的。
  3. 最小生成树的根节点可以是这棵树上的任意一个结点,只需要以给出的结点作为根结点来求解最小生成树即可。

  求解最小生成树一般有两种算法,即prim算法与kruskal算法,都使用了贪心的策略。

prim算法

基本思想

  对图G(V,E)设置集合S,存放已被访问的顶点,然后每次从集合V-S中选择与集合S的最短距离最小的一个顶点(记为u),访问并加入集合S。之后,令顶点u为中介点,优化所有从u能到达的顶点v与集合S之间的最短距离。这样的操作执行n次(n为顶点个数),直到集合S已包含所有顶点。

  对图G(V,E)设置集合S来存放已被访问的顶点,然后执行n次以下步骤:(n为顶点个数)

  1. 每次从集合V-S中选择与集合S最近的一个顶点(记为u),访问u并将其加入集合S,同时把这条离集合S最近的边加入最小生成树中。
  2. 令顶点u作为集合S与集合V-S连接的接口,优化从u能到达的未访问顶点v与集合S的最短距离。

具体实现

  prim算法需要实现两个关键的概念,即集合S的实现、顶点Vi(0<=i<=n-1)与集合S的最短距离。

  1. 集合S的实现方法和Dijkstra中相同,即使用一个bool型数组vis[ ]表示顶点是否已被访问。其中vis[i] == true 表示顶点Vi已被访问。
  2. 不妨令int型数组d[ ]来存放顶点Vi(0<=i<=n-1)与集合S的最短距离。初始时除了起点s的d[s]赋为0,其他顶点都赋为一个很大的数来表示INF,即不可达。
    如果仅是求最小边权之和,那么在prim算法中就可以随意指定一个顶点为初始点。

伪代码

//G为图,一般设为全局变量;数组d为顶点与集合S的最短距离
Prim(G,d[]){
	初始化;
	for(循环n次){
		u = 使d[u]最小的还未被访问的顶点的标号;
		记u已被访问;
		for(从u出发能到达的所有顶点v){
			if(v未被访问 && 以u为中介点使得v与集合S的最短距离d[v]更优){
				将G[u][v]赋值给v与集合S的最短距离d[v];
			}
		}
	}
}

算法代码

邻接矩阵版

const int MAXV = 1000; //最大顶点数
const int INF = 0x3fffffff; //设INF为一个很大的数

int n, G[MAXV][MAXV]; //n为顶点数,MAXV为最大顶点数
int d[MAXV]; //顶点与集合S的最短距离
bool vis[MAXV] = {false}; //标记数组

int prim(){ //默认0号为初始点,函数返回最小生成树的边权之和 
	fill(d,d+MAXV,INF); //fill函数将整个d数组赋为INF 
	d[0] = 0; //只有0号顶点到集合S的距离为0,其余全为INF	
	int ans =0; //存放最小生成树的边权之和
	for(int i =0;i<n;i++){ //循环n次 
		int u = -1, MIN = INF;
		for(int j = 0;j<n;j++){
			if(vis[j] == false && d[j] < MIN){
				u = j;
				MIN = d[j];
			}
		}
		//找不到小于INF的d[u],则剩下的顶点和集合S不连通 
		if(u == -1) return -1;
		vis[u] == true; //标记u为已访问 
		ans += d[u]; //将与集合S距离最小的边加入最小生成树
		for(int v = 0;v<n;v++){
			//v未访问&& u能到达v && 以u为中介点可以使v离集合S更近
			if(vis[v] == false && G[u][v] !=INF && G[u][v]<d[v]){
				d[v] = G[u][v]; //将G[u][v]赋值给d[v] 
			} 
		} 
	}
	return ans; //返回最小生成树的边权之和 
} 

邻接表版

const int MAXV = 1000; //最大顶点数
const int INF = 0x3fffffff; //设INF为一个很大的数

struct Node{
	int v,dis; //v为边的目标顶点,dis为边权 
};
vector<Node> Adj[MAXV]; //图G,Adj[u]存放从顶点u出发可以到达的所有顶点 
int n;  //n为顶点数
int d[MAXV]; //顶点与集合S的最短距离
bool vis[MAXV] = {false}; //标记数组

int prim(){
	fill(d,d+MAXV,INF);
	d[0] = 0;
	int ans = 0;
	for(int i =0;i<n;i++){//循环n次 
		int u =-1, MIN = INF;
		for(int j =0;j<n;j++){
			if(vis[j] == false && d[j]<MIN){
				u = j;
				MIN = d[j]; 
			}
		}
		if(u == -1) return -1;
		vis[u] = true;
		ans += d[u];
		for(int j = 0;j< Adj[u].size();j++){
			int v = Adj[u][j].v; //通过邻接表直接获得u能到达的顶点v 
			if(vis[v] == false && Adj[u][j].dis<d[v]){
				d[v] = Adj[u][j].dis;
			}
		}
	}
	return ans;
}

举例

问题:给定n个顶点和m条边,接着给出m行数据均形如:0 1 4(0->1的边权为4),输出最小生成树的边权之和。

#include
using namespace std;
const int MAXV = 1000;
const int INF =0x3fffffff;

int n,m,G[MAXV][MAXV];  //n为顶点数,MAXV为最大顶点数 
int d[MAXV]; //顶点与集合S的距离 
bool vis[MAXV] = {false}; //标记数组

int prim(){//默认0位初始点 
	fill(d,d+MAXV,INF);
	d[0] = 0;
	int ans = 0;
	for(int i =0;i<n;i++){ //循环n次 
		int u =-1, MIN =INF;
		for(int j =0;j<n;j++){
			if(vis[j] == false && d[j]<MIN){
				u = j;
				MIN = d[j];
			}
		}
		if(u == -1) return -1;
		vis[u] == true;
		ans += d[u];
		for(int v = 0;v<n;v++){
			if(vis[v] == false&& G[u][v] !=INF&& G[u][v]<d[v]){
				d[v] = G[u][v];
			}
		}
	}
	return ans;
} 

int main(){
	int u,v,w;
	scanf("%d%d",&n,&m);//输入顶点数,边数 
	fill(G[0],G[0]+MAXV*MAXV,INF); //初始化图G 
	for(int i =0;i<m;i++){
		scanf("%d%d%d",&u,&v,&w);//输入u,v,边权 
		G[v][u] = G[u][v] = w; //无向图 
	}
	int ans = prim();//prim算法入口
	print("%d\n",ans);
	return 0; 
}

kruskal算法

  kruskal算法采用了边贪心的策略。

基本思想

  在初始状态时隐去了图中的所有边,这样图中每个顶点都自成一个连通块。之后执行下面的步骤:

  1. 对所有的边按边权从小到大进行排序。
  2. 按边权从小到大测试所有边,如果当前测试边所连接的两个顶点不在同一个连通块中,则把这条测试边加入当前最小生成树中;否则,将边舍弃。
  3. 执行步骤2,直到最小生成树中的边数=总顶点数-1,或是测试完所有边时结束。而当结束时如果最小生成树的边数<总顶点数-1,说明该图不连通。

  简单来说,就是每次选择图中最小边权的边,如果边两端的顶点在不同的连通块中,就把这条边加入最小生成树中。

伪代码

int kruskal(){
	令最小生成树的边权之和为ans、最小生成树的当前边数Num_Edge;
	将所有边按边权从小到大排序;
	for(从小到大枚举所有边){
		if(当前测试边的两个端点在不同的连通块中){
			将该测试边加入最小生成树中;
			ans += 测试边的边权;
			最小生成树的当前边数Num_Edge加1;
			当边数Num_Edge等于顶点数减1时结束循环;
		}
	}
	return ans;
}

算法代码

  由于需要判断边的两个端点是否在不同的连接块中,因此边的两个端点的编号是需要的,而算法需要的边权也必须要有。因此可以定义一个结构体,在里面存放边的两个端点编号和边权:

struct edge{
	int u,v;//边的两个端点编号
	int cost;//边权
}E[MAXV]; //最多有MAXV条边

  在解决了边的定义后,需要写一个排序函数来让数组E按边权从小到大排序,因此不妨定义sort的cmp函数:

bool cmp(edge a, edge b){
	return a.cost < b.cost;
}

  如果把每个连通块当做一个集合,那么就可以把 “如何判断两个端点是否在不同的连通块中” 转换为判断两个端点是否在同一个集合中,因此使用并查集。并查集可以通过查询两个结点所在集合的根节点是否相同来判断它们是否在同一个集合,而合并功能恰好可以解决 “如何将测试边加入最小生成树中” 的问题,即把测试边的两个端点所在集合合并。
  假设题目中顶点编号的范围是 [1,n],因此代码如下:

int father[MAXV];//并查集数组
int findFather(int x){ //并查集查询函数,下标范围改变时需要更改 
	if(father[x] == x){//如果数组下标等于该值,则代表该数为根结点 
		return x;
	}else{
		father[x] = findFather(father[x]); //递归,直到找到该数的根节点
		return father[x]; 
	}
}
//kruskal函数返回最小生成树的边权之和,参数n为顶点个数,m为图的边数
int kruskal(int n,int m){
	//ans为所求边权之和,Num_edge为当前生成树的边数
	int ans = 0, Num_Edge = 0;
	for(int i =1;i<=n;i++){//假设题目中顶点范围是1,n 
		father[i] =i;//并查集初始化 
	} 
	sort(E,E+m,cmp);//所有边按边权从小到大排序 
	for(int i =0;i<m;i++){ //枚举所有的边 
		int faU = findFather(E[i].u);
		int faV = findFather(E[i].v);
		if(faU != faV){ //不在一个集合中 
			father[faU] = faV;//合并集合(把测试边放入最小生成树中) 
			ans += E[i].cost; //边权之和增加测试边的边权 
			Num_Edge += 1; //当前生成树的边数+1 
			if(Num_Edge == n-1) break; //边数=顶点数-1时结束 
		}
	}
	if(Num_Edge != n-1) return -1;//无法连通时返回-1   
	else return ans; //返回最小生成树的边权之和 
} 

由于kruskal算法的复杂度主要来源于对于边进行排序,因此适合顶点数较多、边数较少的情况。

举例

如上问题,给定n个顶点和m条边,接着给出m行数据均形如:0 1 4(0->1的边权为4),输出最小生成树的边权之和。

注意此时的顶点的编号范围是 [0,n-1]

#include
using namespace std;
const int MAXV = 1000;
const int INF = 0x3fffffff;
struct edge{
	int u,v;
	int cost;
}E[MAXV];
bool cmp(edge a,edge b){
	return a.cost<b.cost;
}
//查并集部分
int father[MAXV];
int findFather(int x){
	if(x == father[x]){
		return x;
	}else{
		father[x] = findFather(father[x]);
		return father[x];
	}
} 
//kruscal部分,返回最小生成树的边权和
int kruscal(int n,int m){
	int ans = 0,Num_Edge = 0;
	for(int i =0;i<n;i++){ //顶点编号范围为[0,n-1] 
		father[i] = i;
	}
	sort(E,E+m,cmp);
	for(int i =0;i<m;i++){//遍历所有的边 
		int faU = findFather(E[i].u);
		int faV = findFather(E[i].v);
		if(faU != faV){
			father[faU] = faV;
			ans += E[i].cost;
			Num_Edge += 1;
			if(Num_Edge == n-1) break;
		}
	}
	if(Num_Edge != n-1) return -1;
	else return ans;
}
int main(){
	int n,m;
	scanf("%d%d",&n,&m);//顶点数,边数 
	for(int i =0;i<m;i++){
		scanf("%d%d%d",&E[i].u,&E[i].v,&E[i].cost);//两个端点编号、边权 
	}
	int ans = kruscal(n,m);//kruskal算法入口
	printf("%d\n",ans);
	return 0; 
} 

如何选择是用prim还是kruskal算法

  如果是稠密图(边多),使用prim算法;如果是稀疏图(边少),使用kruskal算法。

你可能感兴趣的:(模板)