今天我们介绍图论中的另一部分,最小生成树。
对图的最短路感兴趣的同学可以看:
【图解算法】一次解决最短路径问题
1. 最小生成树简介
- 最小生成树的概念:在连通网的所有生成树中,所有边的代价和最小的生成树,称为最小生成树
用直白的话来说,就是给定一个无向图,在图中选择若干条边把图的所有的节点连接起来,要求边长之和最小。在图论中,叫做最小生成树。
举个例子:
我们余姚在n个城市之间铺设光缆,使它们之间都可以通信。但是铺设光缆的费用很高,且铺设光缆的费用与距离成正比,那么我们应该如何铺设光缆才能使总费用最低呢?
- 常用的计算最小生成树的算法
- prim 算法
- kruskal算法
2. Prim算法
2.1 模板题
2.2 思路模板
每次将距离已经连通部分最近的点 和对应的边 加入连通部分,是连通部分逐渐扩大,最后将整个图连接起来并且边长之和最小。
伪代码:
int dist[n],state[n],pre[n];
dist[1] = 0;
for(i : 1 ~ n)
{
t <- 没有连通起来,但是距离连通部分最近的点;
state[t] = 1;
更新 dist 和 pre;
}
这样讲有点抽象,我们配合图举个例子:
首先,我们设置:
- state状态数组,表示节点是否已经再连通集当中,state[i] 为真,表示已经连通,state[i] 为假,表示还没有连通。初始时,state 各个元素为假。即所有点还没有连通
- dist 距离数组,保存各个点到连通集的距离(注意:不是到原点的距离),dist[i] 表示 i 节点到连通部分的最短距离。初始时,dist 数组的各个元素为无穷大
- pre 数组保存节点的是和谁连通的。pre[i] = k 表示节点 i 和节点 k 之间需要有一条边。初始时,pre 的各个元素置为 -1,这个数组视题目要求考虑要不要写。
假定我们从1号节点开始扩充 连通集,所以 1 号节点与连通部分的最短距离为 0,即disti[1] 置为 0。
遍历 dist 数组,找到一个还没有连通起来,但是距离连通部分最近的点,假设该节点的编号是 i。i节点就是下一个应该加入连通部分的节点,stata[i] 置为 1。
如果我们用灰色表示尚未连通的点,绿色表示在连通集中的点,那么此时距离最小的显然是 1号节点,故state[1]置1.
接着,我们遍历节点1 的 所有可达点j,如果 j 距离连通部分的距离大于 i j 之间的距离,dist[j] > w[i][j](w[i][j] 为 i j 节点之间的距离),则更新 dist[j] 为 w[i][j]。这时候表示,j 到连通部分的最短方式是和 i 相连,因此,更新pre[j] = i。
与节点 1 相连的有 2, 3, 4 号节点。1->2 的距离为 100,小于 dist[2],dist[2] 更新为 100,pre[2] 更新为1。1->4 的距离为 140,小于 dist[4],dist[4] 更新为 140,pre[2] 更新为1。1->3 的距离为 150,小于 dist[3],dist[3] 更新为 150,pre[3] 更新为1。
我们之后,只需要不断重复上两步,直到所有节点都被加入了连通集:
此时dist数组中保存的就是各个节点需要修的路,加起来就是总长度。pre数组中保存了需要选择的边
2.3 代码实现
#include <cstring>
#include <iostream>
#include <algorithm>
using namespace std;
const int N=510;
int g[N][N];
bool st[N];//记录已经在集合中的点
int dist[N];//记录各个点到集合的距离
// int pre[N]; //该模板题没有要求输出所有边,所以不需要定义prev数组
int n,m;
int Prim()
{
int ans=0;
memset(dist,0x3f,sizeof(dist));
dist[1]=0;
for(int i=0;i<n;i++){
int t=-1;
//寻找距离连通集最近的点
for(int j=1;j<=n;j++){
if(!st[j]&&(t==-1||dist[j]<dist[t])){
t=j;
}
}
//判断当前节点是否与集合联通,不连通则说明没有最小生成树
if(i && dist[t] == 0x3f3f3f3f) return 0x3f3f3f3f;
ans+=dist[t];
st[t]=true;
for(int j=1;j<=n;j++){
if(dist[j]>g[t][j]&&!st[j]){
dist[j] = g[t][j];
//pre[j] = t;//从 t 到 j 的距离更短,i 的前驱变为 t.
}
}
}
return ans;
}
//void getPath()//输出各个边
//{
// for(int i = n; i > 1; i--)//n 个节点,所以有 n-1 条边。
//
// {
// cout << i <<" " << pre[i] << " "<< endl;// i 是节点编号,pre[i] 是 i 节点的前驱节//点。他们构成一条边。
// }
//}
int main()
{
cin>>n>>m;
memset(g,0x3f,sizeof g);
while(m--){
int a,b,w;
cin>>a>>b>>w;
g[a][b]=g[b][a]=min(g[a][b],w);
}
int t=Prim();
if (t == 0x3f3f3f3f) puts("impossible");
else cout<<t;
}
2.3 prim 算法的优化
与Dijkstra算法类似,Prim算法也可以使用堆来优化,优化之后时间复杂度由O(n^2)降为O(mlogN).适用于稀疏图,但是稀疏图的时候,其实使用 Kruskal算法更加实用。
所以,这里我们不讲解优化算法,感兴趣的同学可以参照Dijkstra算法的优化来实现。
3. Kruskal算法
3.1 模板题
3.2 思路模板
- 按照边的权值将边进行升序排序,然后从小到大一一判断
- 如果这个边与之前选择的所有边不会组成回路,就选择这条边,反之舍去
- 不断判断,知道具有n个顶点的联通网筛选出来n-1条边为止。
此时筛选出来的边和所有的顶点构成此连通网的最小生成树。
对于判断是否会产生回路,我们使用并查集。
不懂并查集的小伙伴可以看这位大佬的文章,简单易懂:
【算法与数据结构】—— 并查集
- 在初始状态下 各个顶点在不同的集合中
- 遍历每条边的时候,判断该边的两个顶点是否在一个集合中
- 如果边上的这两个顶点在一个集合中,说明两个顶点一定已经连通,那么这条边加上去一定导致环,所以舍去该边。反之如果不在一个集合中,就加入这条边。
我们依然是画图来演示一下:
我们已经将边按照权值做了升序排列
- 选BD边,由于尚未选择任何边组成最小生成树,且 B-D 自身不会构成环路,所以 B-D 边可以组成最小生成树。
2. D-T 边不会和已选 B-D 边构成环路,可以组成最小生成树:
-
A-C 边不会和已选 B-D、D-T 边构成环路,可以组成最小生成树:
-
C-D 边不会和已选 A-C、B-D、D-T 边构成环路,可以组成最小生成树:
-
C-B 边会和已选 C-D、B-D 边构成环路,因此不能组成最小生成树:
- B-T 、A-B、S-A 三条边都会和已选 A-C、C-D、B-D、D-T 构成环路,都不能组成最小生成树。而 S-A 不会和已选边构成环路,可以组成最小生成树。
如图下图 所示,对于一个包含 6 个顶点的连通网,我们已经选择了 5 条边,这些边组成的生成树就是最小生成树。
3.3 代码实现
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 100010;
int p[N];//保存并查集
struct E{
int a;
int b;
int w;
bool operator < (const E& rhs){//通过边长进行排序
return this->w < rhs.w;
}
}edg[N * 2];
int res = 0;
int n, m;
int cnt = 0;
int find(int a){//并查集找祖宗
if(p[a] != a) p[a] = find(p[a]);
return p[a];
}
void klskr(){
for(int i = 1; i <= m; i++)//依次尝试加入每条边
{
int pa = find(edg[i].a);// a 点所在的集合
int pb = find(edg[i].b);// b 点所在的集合
if(pa != pb){//如果 a b 不在一个集合中
res += edg[i].w;//a b 之间这条边要
p[pa] = pb;// 合并a b
cnt ++; // 保留的边数量+1
}
}
}
int main()
{
cin >> n >> m;
for(int i = 1; i <= n; i++) p[i] = i;//初始化并查集
for(int i = 1; i <= m; i++){//读入每条边
int a, b , c;
cin >> a >> b >>c;
edg[i] = {a, b, c};
}
sort(edg + 1, edg + m + 1);//按边长排序
klskr();
if(cnt < n - 1) {//如果保留的边小于点数-1,则不能连通
cout<< "impossible";
return 0;
}
cout << res;
return 0;
}