目录
最小生成树
基础概念
图论中的最小生成树指的是:包含原图的所有节点(假设图的节点数为n,最小生成树的边数则为n-1),且所用边权值最小的一条路径。
ps:图中可能存在重边和自环,边权可能为负数
应用场景
要在n个城市之间铺设光缆,主要目标是要使这 n 个城市的任意两个之间都可以通信,但铺设光缆的费用很高,且各个城市之间铺设光缆的费用不同,因此另一个目标是要使铺设光缆的总费用最低。这就需要找到带权的最小生成树。
其他不同的表达方式:城市群之间修建公路/铁路,局部区域岛屿联通修桥......
常用Prim/Kruskal这两种算法解决最小生成树问题。
辨析最小生成树与最短路径
最小生成树:把连通的图的所有顶点连起来路径之和最小的问题,即生成树路径总权值之和最小。
最短路:把两点之间路径最短。最短路只需要将连接起点到终点,其路径并不一定经过所有点,即图中可以有孤立点。而最小生成树要连接图中的每一个点,不能有孤立点。
Prim算法
概念
Prim算法 : 把所有点到 已连通集合 的距离dis设成∞ ,每次找到距离最小的点,加入到连通集合中,并用该点距离进行松弛操作,更新所有点到集合距离 dis[i]=min(dis[i],g[t][i])
即:从图中任意找一个起点,每次循环均要找到当前距连通集合最近的点,直到所有点都加入连通集合。
代码模板
模板题:Acwing 858 该代码与朴素Dijkstra的板子相似,流程与思想也比较接近
// 稠密图用邻接矩阵
int g[N][N];
// 记录点至连通集合的距离
int dist[N];
// 结点是否在连通集合中
bool state[N];
// 与迪杰算法的区别 D中的松弛操作是更新到起始点的距离
// 而Prim是更新到集合S的距离
int prim(){
memset(dist,0x3f,sizeof dist);
int res = 0;
// 当i=0时 第一次循环 所取的t点一定为1
dist[1] = 0;// 起点的dist一定为0 从而无需在循环里特判
for(int i = 0; i < n; i++){
int t = -1;
//*1 寻找已连通集合之外 距连通集合最近的点t
for(int j = 1; j <= n; j++){
if(!state[j] && (t == -1 || dist[t] > dist[j])){
t = j;
}
}
// *2 状态更新,加入结果
// 当距离为INF,表示图中有不与集合连通的孤立点,此时肯定没有最小生成树
if(dist[t] == INF) return INF;
res += dist[t];
state[t] = true;
// *3 g[t][j]即为j至 刚加入连通集合中的点t 的距离 根据此进行松弛操作
for(int j = 1; j <= n; j++){
dist[j] = min(dist[j],g[t][j]);
}
}
return res;
}
辨析Prim与Dijkstra
参考资料:AcWing 858. prim 与dijkstra的区别
相同点:算法时间复杂度均为O(n^2)
与朴素版的三部曲流程与思想基本一致
区别
Prim算法 : 把所有点到 已连通集合 的距离dis设成∞ ,每次找到未加入集合的距离最小的点t,加入到连通集合中,并用该点距离进行松弛操作,更新所有点到集合距离 dis[i]=min(dis[i],g[t][i])
即:从图中任意找一个起点,每次循环均要找到当前距连通集合最近的点,直到所有点都加入连通集合。
dijkstra:把所有点到 起点 距离dis设成∞ ,每次找到未遍历过的距离最小的点t 加入路径中 修改状态,并用该点距离进行松弛操作,更新所有点到起点距离 dis[i]=min(dis[i],w+g[t][i]);
即:基于题意确定起点,每次确定距离最近的点,直到终点
唯一区别就是,dijkstra 更新的是到起点的距离,prim更新的是到连通集合的距离。
在代码实现层面,即state状态数组与dist距离数组的意义不同,2*步骤更新状态的具体操作不同
并查集
在了解Kruskal算法之前,我们需要先补充其核心思想所用到的并查集
顾名思义,并查集是一种大大提升两个集合间的合并与集合中的元素查询的一种数据结构
模板题:Acwing 836 Acwing 837 连通块中点的数量
练习题:lc 547. 省份数量
思路:
参考资料:AcWing 836. 基础_并查集_合并集合
1. 初始化:
// 每个节点的father数组
int p[N];//p[i]--节点i所在集合的根节点(即祖宗节点)
for(int i = 1;i <= n; i++){
p[i] = i;// 初始化 令当前节点的父节点均为自己
}
示意图(来源于上面的参考资料)如下:
2.查找 + 路径压缩:
// 返回x的祖宗节点 + 路径压缩
// 把整条查找路径上所有节点的父节点都变成了祖宗节点
int find(int x){
// 递归终止条件:祖先节点的父节点是自己本身
if(p[x] != x){
// 将x的父亲置为x父亲的祖先节点,实现路径的压缩
p[x] = find(p[x]);
}
return p[x];
}
因为一次查找可以递归将整条查找路径上所有节点的路径压缩,将压缩路径的消耗平均至每个节点,查询的时间复杂度接近O(1)
3. 合并操作
if(op == 'M'){
// 合并操作-令a的祖宗节点的父节点 为 b的祖宗节点
p[find(a)] = find(b);
}
假设有以下两个集合
合并1,5所在的集合
find(1) = 3 find(5) = 4 p[find(1)] = find(5) –> p[3] = 4
如下图所示
Kruskal算法
概念
Kruskal算法将一个连通块当做一个集合。(参考资料:最小生成树详解(模板 + 例题)_潘小蓝)
①.Kruskal首先将所有的边按从小到大顺序排序(一般使用快排),并认为每一个点都是孤立的,分属于n个独立的集合。然后按从小到大顺序枚举每一条边。时间复杂度:O(mlogm)
②.如果这条边连接着两个不同的集合,那么就把这条边加入最小生成树,这两个不同的集合就合并成了一个集合(使用并查集)。如果这条边连接的两个点属于同一集合,就跳过本次操作。直到选取了n-1条边为止。时间复杂度:O(m)
代码模板
模板题:Acwing 859
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10,M = 2 * N;
int n,m;
int res = 0,cnt = 0;
int p[N];
// krus使用并查集 并不需要将图完整存储
// 只需将每条边孤立地存储即可
struct Edge{
int a,b,w;
}edges[M];
bool cmp(Edge a,Edge b){
return a.w < b.w;
}
int find(int x){// 并查集找祖宗
if(p[x] != x)
p[x] = find(p[x]);
return p[x];
}
void kruskal(){
// 1* 对边进行排序
sort(edges,edges + m,cmp);
// 2* 使用并查集 按边权从小至大合并集合
for(int i = 0; i < m; i++){
// 从小到大遍历
int a = edges[i].a, b = edges[i].b, w = edges[i].w;
a = find(a),b = find(b);
if(a != b){
p[a] = b;// 合并集合
res += w;
cnt++;// 记录已处理的边数
}
}
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for(int i = 0; i < m; i++){
int a,b,w;
cin >> a >> b >> w;
edges[i] = {a,b,w};
}
// 点的并查集初始化
for(int i = 1; i <= n; i++) p[i] = i;
kruskal();
if(cnt == n-1){
cout << res;
}else{
cout <<"impossible";
}
}
两种方法的辨析
稠密图——优先选择Prim方法,时间复杂度为O(n^2),一般采用 邻接矩阵 进行存储边.
稀疏图——优先选择Kruskal,遍历每条边来决定是否合并集合,使时间复杂度O(mlogm)主要受边数的影响。一般采用邻接表进行存储边之间的关系(更简洁方便的是采用结构体的方式,只需将每条边的起点、终点、权值孤立地存储即可)。