【最小生成树】Prim 算法、Kruskal 算法
从定义理解最小生成树
生成树
定义:
一个无向连通图的生成树是包含图中所有顶点的极小连通子图(删去任何一条边都会使其不连通)。若图有 n 个顶点,生成树包含在这 n 个顶点并恰好有 n - 1条边。
特点:
-
不唯一:一个图通常有多棵不同的生成树。
-
无环:生成树是树结构,因此不存在环路。
-
连通性:必须包含原图的所有顶点,且任意两顶点之间有且只有一条路径。
Q:为什么要生成树要连通?
A:不连通的树那不是成森林了吗?
所以我们可以得到:不连通的图没有生成树,但可以有生成森林。
如果一个图由多个连通分量组成,那么它没有生成树,因为无法用一棵树覆盖所有顶点。
但可以为每个连通分量生成一棵树,这些树的集合称为生成森林。
最小生成树(Minimum Spanning Tree, MST)
最小生成树就是所有生成树中边权之和最小的那一棵(或几棵)。
Prim 算法
核心:不断加点
-
初始化:任选一个顶点作为起点,加入生成树。
-
贪心扩展:在每一轮迭代中,选择一条连接生成树与非生成树顶点的最小权边,并将该边的另一顶点加入生成树。加入生成树后更新与该点相连的点到生成树的最短距离。
-
终止条件:所有顶点均被包含在生成树中。
时间复杂度 O(n^2)
模板题
洛谷:P3366 【模板】最小生成树
代码实现
我们知道图的存储有两种方式:邻接矩阵、邻接表(存储方式与树的孩子表示法完全一样)。这里的模板题也分别用这两种方式来实现。
邻接矩阵
O(n^2 + m)
//求最小生成树
//邻接矩阵实现
#include <iostream>
#include <cstring>using namespace std;const int N = 5010, INF = 0x3f3f3f3f;int edge[N][N]; //邻接矩阵edge
int dist[N]; //每个顶点距离生成树的距离
bool st[N]; //标记哪些点已经进入了生成树
int n,m; int prim()
{//dist表示顶点距离生成树的距离,最刚开始都初始化为无穷大 memset(dist,0x3f,sizeof dist);dist[1] = 0;int ret = 0;for(int i=1;i<=n;i++) //循环加入n个点 {//1.找最近点int t = 0; for(int i=1;i<=n;i++) {if(!st[i] && dist[i] < dist[t]) t = i; //找到没有访问的点,并且这个点距离生成树最近 } //判断是否连通 if(dist[t] == INF) return INF; //不连通直接返回INFst[t] = true;ret += dist[t]; //2.更新距离for(int i=1;i<=n;i++){dist[i] = min(dist[i],edge[t][i]); } }return ret;
}int main()
{cin >> n >> m;//初始化edge,这样取最小的时候不会影响结果 memset(edge,0x3f,sizeof edge);//建图for(int i=1;i<=m;i++){int x,y,z; cin >> x >> y >> z;edge[x][y] = edge[y][x] = min(edge[x][y], z); //考虑重边 }int ret = prim(); if(ret == INF) cout << "orz" << endl;else cout << ret << endl;return 0;
}
代码注意事项:
-
memset
是按字节填充内存,而不是按元素类型填充。
memset(dist,0x3f,sizeof dist);
-> dist数组的每个元素(int类型)的每个字节被设为 0x3f ,int类型有四个字节,那么每个元素都是0x3f3f3f3f。 -
0x3f
和0x3f3f3f3f
的值是不相同的。0x3f 的十进制值是 63,0x3f3f3f3f 的十进制值是 1,061,109,567。 -
更新距离从1~n遍历所有顶点的时候,理论上是要判断第i个顶点是不是新来的这个点能走到的点,是才更新距离,不是就不用更新,于是可以用一个 if 判断来处理
if(edge[t][i] != INF)
,于是就更新更短的距离dist[i] = min(dist[i],edge[t][i]);
。但是实际上即使不用判断它们是否相连,直接取最小是不会出错的,因为如果它们不相连,那么edge[t][i]
的值就是INF
,取最小怎么也取不到。 -
我们在读数据的时候就要考虑是否会有重边?这道题没有明确说明,实际上是有的。我们对每次读到的值取最小即可。
edge[x][y] = edge[y][x] = min(edge[x][y], z);
实现过程:
邻接表(vector数组实现)
O(n^2 + 3m)
//邻接表实现
//vector数组实现孩子表示法的邻接表
#include<iostream>
#include<vector>
#include<cstring>using namespace std;typedef pair<int,int> PII;const int N = 5010, INF = 0x3f3f3f3f;vector<PII> edge[N];
int dist[N];
bool st[N];int n,m;int prim()
{memset(dist,0x3f,sizeof dist);dist[1] = 0;int ret = 0;for(int i=1;i<=n;i++) //确定了要添加n个点,就直接用循环 {//1.找距离最短的顶点 int t = 0;for(int i=1;i<=n;i++){if(!st[i] && dist[i] < dist[t]) t = i;}//判断是否连通 if(dist[t] == INF) return INF;st[t] = true;ret += dist[t];//2.更新最短距离 for(auto& p:edge[t]) //循环与t相连的点 {int a = p.first, b = p.second; //当前新加入的点到某个相连的a点的距离b dist[a] = min(dist[a],b); //更新某个相连的a点的最短距离 }} return ret;
}int main()
{cin >> n >> m;//建图 for(int i=1;i<=m;i++) {int x,y,z; cin >> x >> y >> z;edge[x].push_back({y,z});edge[y].push_back({x,z});}int ret = prim();if(ret == INF) cout << "orz" << endl;else cout << ret << endl;return 0;}
注意:在学习的时候一定要上手自己写,我在写的时候就把循环添加 n 个顶点给忘记了。注意我们 prim 算法的第二步贪心扩展中第一句话就是 “在每一轮遍历中” ,我们已经确定了图有 n 个顶点,那么在该图的最小生成树中也必定有 n 个顶点,于是直接写一个循环 n 次来添加所有结点即可。
Kruskal 算法
核心:不断加边
- 先按照边权值从小到大排序
- 每次选出边权值最小的且两端点不连通的一条边,直到所有边都连通。
时间复杂度 O(m*logm)
模板题
依旧是 洛谷:P3366 【模板】最小生成树
详见上
模板代码
kruskal算法相较于prim算法不需要建图。
#include<iostream>
#include<algorithm>
using namespace std;const int N = 5010, M = 2e5 + 10, INF = 0x3f3f3f3f;struct node
{int x,y,z; //x点与y点的边权值为z
}a[M]; //a数组存的是边的信息,数组范围要开的符合边的数量 int fa[N];
int n,m;bool cmp(node& n1, node& n2)
{return n1.z < n2.z;
} int find(int x)
{return x == fa[x] ? x : fa[x] = find(fa[x]);
}int kruskal()
{sort(a + 1, a + 1 + m, cmp); //按权值从小到大排序所有边int ret = 0, cnt = 0; //ret返回长度之和,cnt记录生成树中有多少条边 for(int i=1;i<=m;i++) //遍历m条边信息{int x = a[i].x, y = a[i].y, z = a[i].z; int fx = find(x), fy = find(y);if(fx != fy){ret += z;cnt++; //生成树边数+1 fa[fx] = fy; //标记x点和y点连通,通过并查集实现 }}//判断生成树边数如果等于n-1那么就是正确的生成树 return cnt == n-1 ? ret : INF;
}int main()
{cin >> n >> m;//初始化fa数组for(int i=1;i<=n;i++) fa[i] = i; //是否要考虑重边呢? 有重边又怎样,我又不建图,而且我第一步就是把边从小到大排序,永远都是取最小的边 for(int i=1;i<=m;i++) cin >> a[i].x >> a[i].y >> a[i].z; int ret = kruskal();if(ret == INF) cout << "orz" << endl;else cout << ret << endl;return 0;
}
注意:Kruskal 算法不需要额外处理重边,因为排序后会自动选择权值最小的边。
对比:
- Kruskal的特性决定了它可以为不连通的图生成最小生成森林,因为 Kruskal 按边权排序,逐步选择最小边,不依赖顶点连通性。每次选择边时,仅检查两端点是否属于同一集合。
对于不连通图,Kruskal 会为每个连通分量生成一棵最小生成树,最终得到最小生成森林。 - Prim 从单个顶点出发,逐步扩展生成树,依赖图的连通性。
无法直接处理非连通图,因为它只能从某一个连通分量出发。
例题1
🎯洛谷: P1194 买礼物
分析
首先这道题很明显能看出来是求最小生成树,根据物品之间的购买再购买关系可以得出物品之间是存在边权,Kij 与 Kji 相等又说明了这是一个无向图,题目要求最少要花多少钱就是求最小生成树的一个问题。
我在刚开始思考的时候就看到了与常规求最小生成树不同的地方,
- 第一个就是 边权值 Kij 可能大于 i、j 物品本身的价格,那么这时候我们就没必要通过购买 i 物品再以 Kij 的价格购买 j 物品,这时候应该直接购买 j 物品。对于这个条件的处理是本题的核心。
- 第二个就是 边权值 Kij 可能等于 0 ,这意味着 i,j 之间没有促销关系。但是我们的目的是要买到所有物品,所有这个 j 最终一定会购买,那么就将 边权 Kij 设置为 所有物品的价格 A,如果没有更优的线路到达 j 物品,那么就只能原价购买 j , 所以这样无论如何是不会出错的。
算法选择上:
- 我们可以使用 prim 算法,当必须要选择大于物品价格 A 的边权时,就选择以 A 为边权继续贪心扩展。这一步操作可以直接在读数据的时候就把 大于 A 的边权 以及 等于 0 的边权 全部设置为 A;也可以在 prim 函数处理时,在找当前最小边权时,特判一下当前的最小边权是否大于 A ,如果大于 A 就以 A 为实际边权继续强行扩展顶点。
- 也可以使用 kruskal 算法,在读数据的时候遇到 边权大于 A 的 或者 边权值为 0 的边 直接舍弃即可。因为 kruskal 算法的特性决定了它在非连通图中计算的时候也可以生成最小生成森林,虽然不是最小生成树,但是最小生成森林也具有最小边权和的特性。我们只需要在最小生成森林的总花费基础上加上单独购买每个最小生成树的第一个物品的价格即可。
公式:连通分量个数 = 顶点总数 - 最小生成森林总边数
代码
prim 算法
#include<iostream>
#include<cstring>using namespace std;const int N = 510;int edge[N][N]; //edge[i][j]:i、j两点的边权值
int dist[N]; //每个顶点距离生成树的距离
int st[N];
int n,v; //n是物品数,v是统一价格 //方式一:在函数中特判
int prim1()
{memset(dist,0x3f,sizeof dist);int ret = 0; //最小花费 //任意选择一个点作为起点dist[1] = 0;for(int i=1;i<=n;i++) //循环加入n个点 {//1.选择最近的点int t = 0;for(int i=1;i<=n;i++) {if(!st[i] && dist[i] < dist[t]) t = i;}if(dist[t] <= v) ret += dist[t];else ret += v;//优惠价反而更高或者没有优惠,那么就直接买 st[t] = true;//不用判断连通,本题一定连通//2.更新距离for(int i=1;i<=n;i++){dist[i] = min(dist[i],edge[t][i]);} }return ret;
}int prim2()
{memset(dist,0x3f,sizeof dist);int ret = 0; //最小花费 //任意选择一个点作为起点dist[1] = 0;for(int i=1;i<=n;i++) //循环加入n个点 {//1.选择最近的点int t = 0;for(int i=1;i<=n;i++) {if(!st[i] && dist[i] < dist[t]) t = i;}ret += dist[t];st[t] = true;//不用判断连通,本题一定连通//2.更新距离for(int i=1;i<=n;i++){ dist[i] = min(dist[i],edge[t][i]);} }return ret;
}int main()
{cin >> v >> n;for(int i=1;i<=n;i++) {for(int j=1;j<=n;j++){int x; cin >> x; if(x == 0 || x > v) edge[i][j] = v; //方式二:直接在读数据的时候处理else edge[i][j] = x;}}cout << prim2() + v; return 0;
}
kruskal 算法
#include<iostream>
#include<algorithm>using namespace std;const int N = 124750 + 10;int n,v;
int fa[N];int pos; //帮助存储node
struct node
{int x,y,z;
}e[N];int ret,cnt; //ret是kk()计算的最小花费,cnt是生成森林的总边数 bool cmp(node& a, node& b)
{return a.z < b.z;
}int find(int x)
{return fa[x] == x ? x : fa[x] = find(fa[x]);
}void kk()
{//边权值从小到大排序 sort(e + 1, e + 1 + pos, cmp); for(int i=1;i<=pos;i++) //依次取出最近的边{int x = e[i].x, y = e[i].y, z = e[i].z;int fx = find(x), fy = find(y);if(fx != fy) //保证端点不相连 {ret += z;cnt++;fa[fx] = fy; }}
}int main()
{ cin >> v >> n;for(int i=1;i<=n;i++) fa[i] = i;for(int i=1;i<=n;i++){for(int j=1;j<=n;j++){int x; cin >> x;if(i >= j || x == 0 || x > v) continue;pos++;e[pos].x = i, e[pos].y = j, e[pos].z = x;}}kk();cout << ret + (n - cnt) * v; return 0;
}
例题2
洛谷:P2330 [SCOI2005] 繁忙的都市
分析
这道题与常规的求最小生成树的问法不一样,这道题要求的是多个最小生成树中 最大的道路分值 的最小值。
这里引入一个概念:瓶颈生成树(Bottleneck Spanning Tree, BST)。
瓶颈生成树是指所有生成树中最大边权重最小的生成树。
性质:
- 最小生成树也是瓶颈生成树。任何图的最小生成树自动就是瓶颈生成树,因为MST中最大边权重是所有生成树中最小的。可以用反证法证明。
- 逆命题不成立。并非所有瓶颈生成树都是最小生成树。可能存在多个瓶颈生成树,其中只有某些是MST。
因为瓶颈生成树的目标是 最小化生成树中的最大边权重,而最小生成树的目标是 最小化生成树中所有边的权重之和。
代码
#include<iostream>
#include<algorithm>using namespace std;const int N = 310, M = 8010;struct node
{int x,y,z;
}e[M];int fa[N];
int n,m;
int ret; //最小生成树中的最大分值 int find(int x)
{return fa[x] == x ? x : fa[x] = find(fa[x]);
}bool cmp(node& a, node& b)
{return a.z < b.z;
}void kk()
{sort(e + 1, e + 1 + m, cmp);for(int i=1;i<=m;i++){int x = e[i].x, y = e[i].y, z = e[i].z;int fx = find(x), fy = find(y);if(fx != fy){ret = max(ret,z);fa[fx] = fy;}}
}int main()
{ cin >> n >> m;for(int i=1;i<=m;i++) cin >> e[i].x >> e[i].y >> e[i].z;for(int i=1;i<=n;i++) fa[i] = i;cout << n-1 << " "; //瓶颈生成树的边个数肯定是n-1,直接输出kk();cout << ret << endl;return 0;
}