【图论】(四)最小生成树与拓扑排序

最小生成树之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最大为10000
    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;

    }

步骤一:选距离生成树最近节点: 每次遍历节点,查找每个节点到生成树的距离,获取到生成树最近节点的编号与距离,因此这里有几个关键的几个变量:

  • 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_MAX

using 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的指向的文件入度-1
			inDegree[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];
	}
	else
		cout << -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的指向的文件入度-1
				inDegree[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];
	}
	else
		cout << -1 << endl;
	
	return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不会编程的小江江

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值