数据结构与算法专栏 —— C++实现
生成树的定义写在前面:
这一讲来讲一个图中非常重要的内容 —— 最小生成树,在此之前我们先来回顾一下生成树的概念。
一个连通图的生成树是一个极小的连通子图,它包含图中全部的 n 个顶点,但只有构成一棵树的 n-1 条边。说人话就是我要用最少的边将所有结点连接起来,直接上图:
而这个原图的生成树就有一下三个:
由此可以知道对于包含 n 个顶点的无向完全图最多包含 n 的 n-2 次方颗生成树。
最小生成树最小生成树是带权图当中所有边权之和最小的生成树,对于一个无权图它只有生成树而没有最小生成树。
我们还是直接看图理解,假设有这样一个带权无向图:
那么我们可以得到它的最小生成树:
通过计算可以知道,其所有边权之和为 2+1+1+4+5 = 23 ,是所有生成树中最小的一颗,其它生成树的边权之和都要比它的要大。
那么问题来了,我们知道最小生成树是什么了,那如何得到最小生成树呢?
最小生成树算法有很多,但是我们这里只讲最经典的两个,也是面试和笔试中常用的两个算法,克鲁斯卡尔(Kruskal)算法和普里姆(Prim)算法。
克鲁斯卡尔(Kruskal)算法克鲁斯卡尔算法(Kruskal)是⼀种使用贪婪方法的最小生成树算法。它的实现思路是:
1、先将所有边放在一个数组当中,并且按照边权的大小进行从小到大的排序。
2、然后从数组中选一条权值最小的边,如果不能构成回路则加入树中。
3、重复上述操作,直到所有结点都加入到树中。
我们还是直接上图:
第一步:对所有边权进行从小到大排序,开始从头遍历每一条边。
第二步:选择边权最小的边 1 -> 6 ,发现不会成环,添加此边。
第三步:选择边 5 -> 6 ,发现不会成环,添加此边。
第四步:选择边 1 -> 2 ,发现不会成环,添加此边。
第五步:选择边 4 -> 6 ,发现不会成环,添加此边。
第五步:选择边 4 -> 5 ,发现会成环,舍弃此边。
第六步:选择边 2 -> 3 ,发现不会成环,添加此边。
此时所有结点都已经在生成树中,故结束遍历。
接下来我们来看该如何实现上述操作,这里就要用到我们之前学的并查集了,记不清楚的小伙伴可以回顾我之前的文章:
树 - 08 并查集
这里因为涉及到判断是否成环的问题,所以我们要通过并查集来查找每次新加入的边的两个结点是否已经有通路。
克鲁斯卡尔算法实现的核心其实就是并查集,除此之外其实没有什么特别复杂的地方,我们直接来看代码。
全部代码#include普里姆(Prim)算法using namespace std; const int N = 100010, M = 200010, INF = 0x3f3f3f3f; int n, m; int p[N]; //用于存储父结点(并查集) struct Edge { int a, b, w; //自定义排序(按照权值从小) bool operator <(const Edge& E)const { return w < E.w; } }edges[M]; //并查集操作 int find(int x) { if (p[x] != x) p[x] = find(p[x]); return p[x]; } int kruskal() { sort(edges, edges + m); //对权值从小到大进行排序 for (int i = 1; i <= n; i++) p[i] = i; //初始化并查集 //枚举每一条边,满足要求就加入集合中 int res = 0, cnt = 0; for (int i = 0; i < m; i++) { int a = edges[i].a, b = edges[i].b, w = edges[i].w; //查找a和b的父结点,如果其父结点相同则说明两结点已经连通,则直接舍弃该边 a = find(a), b = find(b); if (a != b) { p[a] = b; res += w; cnt++; } } //如果 树中的边数 不等于 结点数量-1 ,说明不存在最小生成树 if (cnt < n - 1) return INF; else return res; } int main() { //假设有n个结点,m条边 cin >> n >> m; //输入每一条边 for (int i = 0; i < m; i++) { int a, b, w; scanf("%d%d%d", &a, &b, &w); edges[i] = { a,b,w }; } int t = kruskal(); //判断该图是否有最小生成树 if (t == INF) puts("impossible"); else cout << t << endl; return 0; }
普里姆算法在找最小生成树时,将顶点分为两类,一类是在查找的过程中已经包含在生成树中的顶点(假设为 A 类),剩下的为另一类(假设为 B 类)。
具体思路如下:
1、我们将所有边放在邻接矩阵中。
2、最开始先创建一个额外的集合,初始化放入一个结点。
3、每次从这个集合中的所有结点与它们相连的结点的边权中,找到边权最小的一条,如果不构成换,则将与之相连的结点加入这个集合当中。
4、重复上述操作,直至所有结点都在树中。
我们还是直接上图:
第一步:存入所有边。并且初始化集合,从结点 1 开始查找。
第二步:集合中相邻边权最小的是边 1 -> 6 ,将结点 6 加入集合中。
第三步:集合中相邻边权最小的是边 6 -> 5 ,将结点 5 加入集合中。
第四步:集合中相邻边权最小的是边 1 -> 2 ,将结点 2 加入集合中。
第五步:集合中相邻边权最小的是边 6 -> 4 ,将结点 4 加入集合中。
第六步:集合中相邻边权最小的是边 4 -> 5 ,但是结点 5 已经存在与集合,故舍弃该边。
第七步:集合中相邻边权最小的是边 2 -> 3 ,将结点 3 加入集合中。
至此,所有结点都已经存在与生成树中,停止遍历。
接下来我们来看看该如何实现上述操作,这里所存储边的方式是我们之前讲过的邻接矩阵,想复习的小伙伴可以跳转到之前的文章中:
图 - 01 图的概述及实现
普利姆算法相对于克鲁斯卡尔算法来说就更加的简单粗暴,直接两层循环。外面一层遍历所有结点,里面一层一方面找到外面结点到集合最短的边,另一方面更新外面结点到集合的路径,我们直接上代码。
全部代码#includeusing namespace std; const int N = 510; int g[N][N]; //存储图 int dt[N]; //存储各个节点到生成树的距离 int st[N]; //节点是否被加入到生成树中 int pre[N]; //节点的前驱节点 int n, m; //n 个节点,m 条边 void prim() { memset(dt, 0x3f, sizeof(dt));//初始化距离数组为一个很大的数(10亿左右) int res = 0; dt[1] = 0;//从 1 号节点开始生成 for (int i = 0; i < n; i++)//每次循环选出一个点加入到生成树 { int t = -1; for (int j = 1; j <= n; j++)//每个节点一次判断 if (!st[j] && (t == -1 || dt[j] < dt[t]))//如果没有在树中,且到树的距离最短,则选择该点 t = j; st[t] = 1;// 选择该点 res += dt[t]; for (int i = 1; i <= n; i++)//更新生成树外的点到生成树的距离 { if (dt[i] > g[t][i] && !st[i])//从 t 到节点 i 的距离小于原来距离,则更新 { dt[i] = g[t][i];//更新距离 pre[i] = t;//从 t 到 i 的距离更短,i 的前驱变为 t. } } } //如果总路径无限大,则说明不存在最小生成树 if (res < 0x3f3f3f3f / 2) cout << res << endl; else cout << "impossible"; } void getPath()//输出各个边 { for (int i = n; i > 1; i--)//n 个节点,所以有 n-1 条边。 cout << i << " " << pre[i] << " " << endl;// i 是节点编号,pre[i] 是 i 节点的前驱节点 } int main() { memset(g, 0x3f, sizeof(g));//各个点之间的距离初始化成很大的数 cin >> n >> m;//输入节点数和边数 while (m--) { int a, b, w; cin >> a >> b >> w;//输出边的两个顶点和权重 g[a][b] = g[b][a] = min(g[a][b], w);//存储权重 } prim();//求最下生成树 //getPath();//输出路径 return 0; }



