最小生成树与拓扑排序
- 最小生成树之prim(P算法)
- 相关概念
- 结题思路
- 拓展
- 最小生成树之kruska(K算法)
- 过程模拟
- 程序实现
- 拓展
- 拓扑排序
- 背景与思路
- 模拟过程
- 程序实现
最小生成树之prim(P算法)
相关概念
P算法是用于求最小生成树的算法。
-
最小生成树是所有节点的最小连通子图, 即:以最小的成本(边的权值)将图中所有节点链接到一起。
-
图中有 n 个节点,那么一定可以用 n - 1 条边将所有节点连接到一起。
- 那么在这个图中,如何选取 n-1 条边 使得 图中所有节点连接到一起,并且边的权值和最小呢?
输入描述: 第一行包含两个整数V 和 E,V代表顶点数,E代表边数 。顶点编号是从1到V。例如:V=2,一个有两个顶点,分别是1和2。
接下来共有 E 行,每行三个整数 v1,v2 和 val,v1 和 v2 为边的起点和终点,val代表边的权值。
输出描述: 输出联通所有节点的最小路径总距离
输入示例:
7 11
1 2 1
1 3 1
1 5 2
2 6 1
2 4 2
2 3 2
3 4 1
4 5 1
5 6 2
5 7 1
6 7 1
输出示例:
6
prim算法 是从节点的角度 采用贪心策略 每次寻找距离 最小生成树最近的节点 并加入到最小生成树中。
prim算法核心就是三步,称为prim三部曲,非常重要
prim三部曲,非常重要
prim三部曲,非常重要
在prim算法中,有一个数组特别重要,这里我起名为:minDist。
“每次寻找距离 最小生成树最近的节点 并加入到最小生成树中”,那么如何寻找距离最小生成树最近的节点呢?
minDist数组 用来记录 每一个节点距离最小生成树的最近距离
为了方便数组下标和节点对应,minDist数组下标从 1 开始计数,下标0 就不使用了
结题思路
第一步:初始状态
- 还没有最小生成树,默认每个节点距离最小生成树是最大的,这样后面我们在比较的时候,发现更近的距离,才能更新到 minDist 数组上。
- minDist 数组 里的数值初始化为 最大数,因为本题 节点距离不会超过 10000,所以 初始化最大数为 10001。
如图:
第二步
-
第一步:选距离生成树最近节点: 选择距离最小生成树最近的节点,加入到最小生成树,刚开始还没有最小生成树,所以随便选一个节点加入就好,为了方便选择1
-
第二步:最近节点加入生成树: 此时 节点1 已经算最小生成树的节点。
-
第三步:更新非生成树节点到生成树的距离(即更新minDist数组): 更新所有节点距离最小生成树的距离,如图:
此时所有非生成树的节点距离 最小生成树(节点1)的距离都已经跟新了 。
- 节点2 与 节点1 的距离为1,比原先的 距离值10001小,所以更新minDist[2]。
- 节点3 和 节点1 的距离为1,比原先的 距离值10001小,所以更新minDist[3]。
- 节点5 和 节点1 的距离为2,比原先的 距离值10001小,所以更新minDist[5]。
第三步
- 第一步:选距离生成树最近节点: 选取一个距离 最小生成树(节点1) 最近的非生成树里的节点,节点2,3,5 距离 最小生成树(节点1) 最近,选节点 2(其实选 节点3或者节点2都可以,距离一样的)加入最小生成树。
- 第二步:最近节点加入生成树: 节点1 和 节点2,已经算最小生成树的节点。
- 第三步:更新非生成树节点到生成树的距离(即更新minDist数组): 接下来,我们要更新节点距离最小生成树的距离,如图
此时所有非生成树的节点距离 最小生成树(节点1、节点2)的距离都已经跟新了 。
- 节点3 和 节点2 的距离为2,和原先的距离值1 小,所以不用更新。
- 节点4 和 节点2 的距离为2,比原先的距离值10001小,所以更新minDist[4]。
- 节点5 和 节点2 的距离为10001(不连接),所以不用更新。
- 节点6 和 节点2 的距离为1,比原先的距离值10001小,所以更新minDist[6]。
第四步
-
第一步:选距离生成树最近节点: 选择一个距离 最小生成树(节点1、节点2) 最近的非生成树里的节点,节点3,6 距离 最小生成树(节点1、节点2) 最近,选节点3 (选节点6也可以,距离一样)加入最小生成树
-
第二步:最近节点加入生成树: 此时 节点1 、节点2 、节点3 算是最小生成树的节点。
-
第三步:更新非生成树节点到生成树的距离(即更新minDist数组): 接下来更新节点距离最小生成树的距离,如图:
所有非生成树的节点距离 最小生成树(节点1、节点2、节点3 )的距离都已经跟新了 。
- 节点 4 和 节点 3的距离为 1,和原先的距离值 2 小,所以更新minDist[4]为1。
上面为什么我们只比较 节点4 和 节点3 的距离呢?
因为节点3加入 最小生成树后,非 生成树节点 只有 节点 4 和 节点3是链接的,所以需要重新更新一下 节点4距离最小生成树的距离,其他节点距离最小生成树的距离 都不变。
程序实现
图的存储: 这里采用邻接矩阵进行对图的存储
int v, e;int x, y, k;cin >> v >> e;// 填一个默认最大值,题目描述val最大为10000vector<vector<int>> grid(v + 1, vector<int>(v + 1, 10001));while (e--) {cin >> x >> y >> k;// 因为是双向图,所以两个方向都要填上grid[x][y] = k;grid[y][x] = k;}
步骤一:选距离生成树最近节点: 每次遍历节点,查找每个节点到生成树的距离,获取到生成树最近节点的编号与距离,因此这里有几个关键的几个变量:
minDist[]
:标记不在生成树中的节点到生成树的距离isInTree
:标记该节点是否在生成树中cur
:记录每一轮到生成树最小节点的标号minDis
:记录每一轮节点到生成树的最小距离
加入生成树节点的条件:
- 本来就不在生成树中
- 到生成树的距离 minDist[j] 最小
// 标记节点是否在生成树中
vector<bool> isInTree(v+1, false);
// 记录非生成树中的节点到生成树的最短距离
vector<int> minDist(v+1, 10001);// 步骤一、选距离生成树最近节点
int cur = -1; // 待加入到生成树中的节点
int minDis = INT_MAX; // 记录本轮离生成树最近距离
for(int j = 1; j <= v; j++)
{// 加入生成树的条件:// 1. 节点不在生成树中// 2. 距离最小生成树最近的节点if(!isInTree[j] && minDist[j] < minDis){minDis = minDist[j];cur = j; }
}
例如,如下情况:
对于上述情况,节点1,2,3在生成树中,minDist[4] 和 minDist[6]属于minDist[j],内的最小值,所以cur = 4,将节点4加入生成树。(其实加入6也可以,这取决于程序中的 minDist[j] < minDis
处是否取等号)
步骤二:最近节点加入生成树: 就程序而言,加入生成树,就是将最近的节点的生成树标志位立为 true
// 步骤二、最近节点加入生成树
isInTree[cur] = true;
步骤三:更新非生成树节点到生成树的距离: 因为新的节点 cur 加入到了生成树中,那么不在生成树中的节点到最小生成树的距离(即minDist数组需要更新,其中需要更新的节点有以下条件:
- 节点不在生成树中(在生成树中的当然不用跟新了)
- 与cur相连的某节点的权值 比 之前 minDist[j]更小了。
// 第三步、更新非生成树节点到生成树的距离
// 那么所有节点到 最小生成树的距离(即minDist数组)需要更新一下
// 由于cur节点是新加入到最小生成树,那么只需要关心与 cur 相连的 非生成树节点 的距离
// 是否比 原来 非生成树节点到生成树节点的距离更小了呢
for(int j = 1; j <= v; j++)
{// 更新节点的要求:// 1. 节点不在生成树中// 2. 与cur相连的某节点的权值 比 该某节点距离最小生成树的距离小if(!isInTree[j] && grid[cur][j] < minDist[j]){minDist[j] = grid[cur][j];}
}
模拟实例如下情况,如图:
-
此时生成树中:节点1、2、3
-
minDist数组:其中minDist[5] 表示节点5到生成树中节点1的距离
-
查找最近的节点:节点4
-
加入新的节点:节点4
-
新的生成树:节点1、2、3、4
-
此时由于节点4的加入,节点4到节点5的距离比之前节点5到节点1的距离小
grid[cur][5] < minDist[5]
,说明点5到最小生成树的距离变小了,需要更新minDist[5],更新minDist数组为:
完整程序:
#include <iostream>
#include <vector>
#include <climits> // IINT_MAXusing namespace std;int main()
{int v,e;int x,y,k;cin >> v >> e;vector<vector<int>> grid(v+1, vector<int>(v+1, 10001));while(e--){cin >> x >> y >> k;// 因为是双向图,所以两个方向都要填上grid[x][y] = k;grid[y][x] = k;}// 标记节点是否在生成树中vector<bool> isInTree(v+1, false);// 记录非生成树中的节点到生成树的最短距离vector<int> minDist(v+1, 10001);// 只需要循环 n-1次,建立 n - 1条边,就可以把n个节点的图连在一起for(int i = 1; i < v; i++){// 步骤一、选距离生成树最近节点int cur = -1; // 待加入到生成树中的节点int minDis = INT_MAX; // 记录本轮离生成树最近距离for(int j = 1; j <= v; j++){// 加入生成树的条件:// 1. 节点不在生成树中// 2. 距离最小生成树最近的节点if(!isInTree[j] && minDist[j] < minDis){minDis = minDist[j];cur = j; }}// 步骤二、最近节点加入生成树isInTree[cur] = true;// 第三步、更新非生成树节点到生成树的距离// cur节点加入之后, 最小生成树加入了新的节点,// 那么所有节点到 最小生成树的距离(即minDist数组)需要更新一下// 由于cur节点是新加入到最小生成树,那么只需要关心与 cur 相连的 非生成树节点 的距离 // 是否比 原来 非生成树节点到生成树节点的距离更小了呢for(int j = 1; j <= v; j++){// 更新节点的要求:// 1. 节点不在生成树中// 2. 与cur相连的某节点的权值 比 该某节点距离最小生成树的距离小if(!isInTree[j] && grid[cur][j] < minDist[j]){minDist[j] = grid[cur][j];}}}// 统计结果int result = 0;// 不计第一个顶点,因为统计的是边的权值,v个节点有 v-1条边for (int i = 2; i <= v; i++) { result += minDist[i];}cout << result << endl;
}
拓展
如果让打印出来 最小生成树的每条边呢? 或者说 要把这个最小生成树画出来呢?
使用一维数组记录是有向边,不过我们这里不需要记录方向,所以只关注两条边是连接的就行。
parent数组初始化代码:
vector<int> parent(v + 1, -1);
在更新 minDist数组 的时候,去更新parent数组来记录一下对应的边
for (int j = 1; j <= v; j++) {if (!isInTree[j] && grid[cur][j] < minDist[j]) {minDist[j] = grid[cur][j];parent[j] = cur; // 记录最小生成树的边 (注意数组指向的顺序很重要)}
}
代码中 为什么 只能 parent[j] = cur
而不能 parent[cur] = j
:
-
如果写成 parent[cur] = j,在 for 循环中,有多个 j 满足要求, 那么 parent[cur] 就会被反复覆盖,因为 cur 是一个固定值。
-
举个例子,cur = 1, 在 for循环中,可能 就 j = 2, j = 3,j =4 都符合条件,那么本来应该记录 节点1 与 节点 2、节点3、节点4相连的。
-
如果 parent[cur] = j 这么写,最后更新的逻辑是 parent[1] = 2, parent[1] = 3, parent[1] = 4, 最后只能记录 节点1 与节点 4 相连,其他相连情况都被覆盖了。
-
如果这么写 parent[j] = cur, 那就是 parent[2] = 1, parent[3] = 1, parent[4] = 1 ,这样 才能完整表示出 节点1 与 其他节点都是链接的,才没有被覆盖。
-
主要问题也是我们使用了一维数组来记录,如果使用二维数组就不存在这种情况。
最小生成树之kruska(K算法)
K算法也是一种常见的求解最小生成树的方法,prim 算法是维护节点的集合,而 Kruskal 是维护边的集合。
kruscal的思路:
- 边的权值排序,因为要优先选最小的边加入到生成树里
- 遍历排序后的边
- 如果边首尾的两个节点在同一个集合,说明如果连上这条边图中会出现环
- 如果边首尾的两个节点不在同一个集合,加入到最小生成树,并把两个节点加入同一个集合
过程模拟
依然以示例中,如下这个图来举例。
将图中的边按照权值有小到大排序,这样从贪心的角度来说,优先选 权值小的边加入到 最小生成树中。
排序后的边顺序为:
[(1,2) (4,5) (1,3) (2,6) (3,4) (6,7) (5,7) (1,5) (3,2) (2,4) (5,6)]
开始从头遍历排序后的边
-
选边(1,2),节点1 和 节点2 不在同一个集合,所以生成树可以添加边(1,2),并将 节点1,节点2 放在同一个集合。
-
选边(4,5),节点4 和 节点 5 不在同一个集合,生成树可以添加边(4,5) ,并将节点4,节点5 放到同一个集合。
-
断两个节点是否在同一个集合,就看图中两个节点是否有绿色的粗线连着就行
-
选边(1,3),节点1 和 节点3 不在同一个集合,生成树添加边(1,3),并将节点1,节点3 放到同一个集合。
-
选边(2,6),节点2 和 节点6 不在同一个集合,生成树添加边(2,6),并将节点2,节点6 放到同一个集合。
-
选边(3,4),节点3 和 节点4 不在同一个集合,生成树添加边(3,4),并将节点3,节点4 放到同一个集合。
-
选边(6,7),节点6 和 节点7 不在同一个集合,生成树添加边(6,7),并将 节点6,节点7 放到同一个集合
-
选边(5,7),节点5 和 节点7 在同一个集合,不做计算。
-
选边(1,5),两个节点在同一个集合,不做计算。
-
后面遍历 边(3,2),(2,4),(5,6) 同理,都因两个节点已经在同一集合,不做计算。
但在代码中,将两个节点加入同一个集合,判断两个节点是否在同一个集合,这就涉及到之前的并查集模板了
程序实现
#include <iostream>
#include <vector>
#include <algorithm>using namespace std;//带权值的边类型 包括起点,终点以及权值
struct Edge
{int v1;int v2;int val;
};
//按边的权值排序仿函数
bool cmp(Edge& edge1, Edge& edge2)
{return edge1.val < edge2.val;
}// 并查集功能模板
int n = 10001; // 边树
vector<int> fa(10001);//初始化
void init()
{for(int i = 1; i <= n; i++)fa[i] = i;
}
//查找祖先
int find(int i)
{if(fa[i] == i)return i;else{fa[i] = find(fa[i]);return fa[i];}
}
//加入集合
void join(int u, int v)
{int u_fa = find(u);int v_fa = find(v);if(u_fa == v_fa)return;fa[v_fa] = u_fa;
}
//判断是否在一个集合
bool isSame(int u, int v)
{int u_fa = find(u);int v_fa = find(v);return u_fa == v_fa;
}int main()
{int v, e;cin >> v >> e;//边存储int x, y, k;vector<Edge> edges; // 边容器while(e--){cin >> x >> y >> k;edges.push_back({x,y,k});}// 将边按权值从小到大排序sort(edges.begin(), edges.end(), cmp);// 并查集初始化init();// 遍历排序后的每条边vector<Edge> result; // 存储最小生成树的边for(auto edge: edges){// 加入这条边成环if(isSame(edge.v1, edge.v2))continue;else{join(edge.v1, edge.v2);result.push_back(edge);}}// 计算生成树大小int res = 0;for(auto edge: result)res += edge.val;cout << res << endl;
}
拓展
输出最小生成树的边
因为K算法本身就是保存的边,因此在判断加入的2个点不在一个集合的时候,将对应的边保存在结果集中即可。
当判断两个节点不在同一个集合的时候,这两个节点的边就加入到最小生成树, 所以添加边的操作在这里:
// 遍历排序后的每条边
vector<Edge> result; // 存储最小生成树的边
for(auto edge: edges)
{// 加入这条边成环if(isSame(edge.v1, edge.v2))continue;else{join(edge.v1, edge.v2); // 两个节点加入到同一个集合result.push_back(edge); // 记录最小生成树的边}
}
Kruskal 与 prim 算法的区别:
- Kruskal 与 prim 的关键区别在于,prim维护的是节点的集合,而 Kruskal 维护的是边的集合。
- 如果 一个图中,节点多,但边相对较少,那么使用Kruskal 更优。
- 而 prim 算法是对节点进行操作的,节点数量越少,prim算法效率就越优。
拓扑排序
卡码网:117. 软件构建
拓扑排序:将图的点按先后顺序进行排序,
示例1:
则拓扑排序有:
a e b c d
a b e c d
a b c e d
以上三种排序中任意一种即可。
示例2:
输出示例:
0 1 2 3 4
顺序除了示例中的顺序,还存在
0 2 4 1 3
0 2 1 3 4
等等合法的顺序。
示例3:
输出:
-1
不能成功处理(相互依赖,存在环),输出 -1。
背景与思路
拓扑排序的背景
- 大学排课,例如 先上A课,才能上B课,上了B课才能上C课,上了A课才能上D课,等等一系列这样的依赖顺序。 问给规划出一条 完整的上课顺序
- 概括来说,给出一个 有向图,把这个有向图转成线性的排序 就叫拓扑排序
- 当然拓扑排序也要检测这个有向图 是否有环,即存在循环依赖的情况,因为这种情况是不能做线性排序的。
- 所以拓扑排序也是图论中判断有向无环图的常用方法。
拓扑排序的思路
实现拓扑排序的算法有两种:卡恩算法(BFS) 和 DFS
所以当我们做拓扑排序的时候,应该优先找 入度为 0 的节点,只有入度为0,它才是出发节点。
拓扑排序的过程,其实就两步:
- 找到入度为0 的节点,加入结果集
- 将该节点从图中移除
循环以上两步,直到 所有节点都在图中被移除。
模拟过程
用本题的示例2来模拟这一过程:
1. 找到入度为0 的节点,加入结果集 2. 将该节点从图中移除
1. 找到入度为0 的节点,加入结果集 2. 将该节点从图中移除
这里节点1 和 节点2 入度都为0, 选哪个都行,所以这也是为什么拓扑排序的结果是不唯一的。
1. 找到入度为0 的节点,加入结果集 2. 将该节点从图中移除
节点2 和 节点3 入度都为0,选哪个都行,这里选节点2
后面的过程一样的,节点3 和 节点4,入度都为0,选哪个都行。
最后结果集为: 0 1 2 3 4 。当然结果不唯一的。
判断有环
如果有 有向环怎么办呢?例如这个图:
那么如果我们发现结果集元素个数 不等于 图中节点个数,我们就可以认定图中一定有 有向环!
这也是拓扑排序判断有向环的方法。
程序实现
(1)为了每次可以找到所有节点的入度信息,我们要在初始化的时候,就把每个节点的入度 和 每个节点的依赖关系做统计。
int n, m;
cin >> n >> m;int s, t;
vector<int> inDegree(n, 0); // 记录每个节点的入度
vector<int> result; // 记录结果排序集
unordered_map<int, vector<int>> umap; // 记录依赖关系
while(m--)
{cin >> s >> t;inDegree[t]++; // s->t t的入度++umap[s].push_back(t); // 记录节点s指向哪些节点 建立依赖
}
(2)找入度为0 的节点,我们需要用一个队列放存放。
因为每次寻找入度为0的节点,不一定只有一个节点,可能很多节点入度都为0,所以要将这些入度为0的节点放到队列里,依次去处理。
//找入度为0 的节点,我们需要用一个队列放存放。
//因为每次寻找入度为0的节点,不一定只有一个节点,可能很多节点入度都为0,
//所以要将这些入度为0的节点放到队列里,依次去处理。
queue<int> que;
for(int i = 0; i < n; i++)
{if(inDegree[i] == 0)que.push(i);
}
(3)开始从队列里遍历入度为0 的节点,将其放入结果集。
while(que.size())
{int cur = que.front(); // 当前选中的节点que.pop();result.push_back(cur);// 将该节点从图中移除 }
(4)删除入度为0的节点以及相连的边
删除节点目的是为了删除相连的边,删除相连的边是为了所连接的节点的入度 减一,从而继续判断留下节点的度数是否有为0,有则加入队列,作为下一轮起点
模拟过程第一步:开始时候节点1和节点2的度数为1,当删除节点0后,节点1和节点2的度数变成0,加入队列。
//队列不为空 存在入度0的节点
while(que.size())
{int cur = que.front(); // 当前选中的节点que.pop();result.push_back(cur); //当前节点加入结果集vector<int> nodes = umap[cur]; //获取当前节点所连接的节点// cur后续有连接的节点if(nodes.size()){//顺序处理这些连接的节点for (int i = 0; i < nodes.size(); i++) {// cur的指向的文件入度-1inDegree[nodes[i]]--;// 如果入度减到0, 加入队列if(inDegree[nodes[i]] == 0)que.push(nodes[i]);}}
}
(5)结果输出(判断是否成环)
if( n == result.size()){for(int i = 0; i < n-1;i++)cout << result[i] << " ";cout << result[n-1];}elsecout << -1 << endl;
完成程序实现:
#include <iostream>
#include <unordered_map>
#include <vector>
#include <queue>using namespace std;int main()
{int n, m;cin >> n >> m;int s, t;vector<int> inDegree(n, 0); // 记录每个节点的入度vector<int> result; // 记录结果排序集unordered_map<int, vector<int>> umap; // 记录依赖关系while(m--){cin >> s >> t;inDegree[t]++; // s->t t的入度++umap[s].push_back(t); // 记录节点s指向哪些节点 建立依赖}//找入度为0 的节点,我们需要用一个队列放存放。//因为每次寻找入度为0的节点,不一定只有一个节点,可能很多节点入度都为0,//所以要将这些入度为0的节点放到队列里,依次去处理。queue<int> que;for(int i = 0; i < n; i++){if(inDegree[i] == 0)que.push(i);}//队列不为空 存在入度0的节点while(que.size()){int cur = que.front(); // 当前选中的节点que.pop();result.push_back(cur); //当前节点加入结果集vector<int> nodes = umap[cur]; //获取当前节点所连接的节点// cur后续有连接的节点if(nodes.size()){//顺序处理这些连接的节点for (int i = 0; i < nodes.size(); i++) {// cur的指向的文件入度-1inDegree[nodes[i]]--;// 如果入度减到0, 加入队列if(inDegree[nodes[i]] == 0)que.push(nodes[i]);}} }if( n == result.size()){for(int i = 0; i < n-1;i++)cout << result[i] << " ";cout << result[n-1];}elsecout << -1 << endl;return 0;
}