C++ | 数据结构与算法 | 最小生成树算法讲解 | Kruskal && Prim

前言

(关于代码实现的图结构,可以看图结构的实现这篇文章)

讲解之前,我们需要先明白连通图是指什么?连通图具有以一个顶点为起点可以到达该图中的任意一个顶点的特性,就算它们不直接相连,但是它们之间至少有一条可以递达的路径。并且连通图是针对无向图的,对于有向图,有个对应的概念——强连通图。而一张连通图的最小连通子图被称为生成树,因此生成树这个概念是对于无向图的,再细看这个概念,什么叫最小连通子图,这个最小体现在哪?一张连通图的连通子图不唯一,只要一张子图满足任意两顶点间连通的条件,它就是连通图的连通子图,从某种意义上说,连通图和连通子图拥有相同的顶点数量,这是一个默认前提,而最小就体现在:用最少的边数将图中的所有顶点连接,若图有n个顶点,那么最少可以用n-1个顶点连通这些顶点在这里插入图片描述
图片来自百度百科,上面这张图中,左上是一张连通图,该图有6个顶点,其他两张图是它的连通子图,它们用5条边将原图的6个顶点连接,使得任意两顶点间都存在一条路径能够递达,满足了连通的特性,所以它们是原图的连通子图,并且用了最少的边连接所有顶点,所以它们又是原图的最小连通子图,也叫生成树。继续补充生成树的概念:连通图的每一棵生成树都是原图的一个极大无环子图,也就是说生成树只要再加一条边,都将形成一条环路。但是只要任意删除一条边,生成树就不再满足连通的特性

这篇博客讲解的是最小生成树,那么这个最小又体现在哪呢?因为生成树也不唯一,从上面的例子就能看出来,我们可以用相同数量的边以不同的方式连接连通图所有的顶点,它们都是最小连通子图,也就是生成树。生成树的最小体现在边的数量最少,而最小生成树的最小体现在边的权值上,即数量相同的边,连接所有的顶点,但是这些边的权值加起来要最小。总结一下最小生成树的特征:

1,用n-1条边连接原图的n个顶点
2.这n-1条边的权值相加得到的值是所有可能中最小的
3.这n-1条边不能构成回路

Kruskal算法

理解了最小生成树的来由,现在的问题是怎样从不唯一的生成树中,找到一棵最小生成树?当然了,最小生成树也是不唯一的,数量相同的边,权值相加难免也会有相同的时候。

Kruskal算法使用了贪心思想,即每次从原图中挑一条权值最小的边,然后挑一条次小的,直到挑了n-1条边。此时最小生成树也就构建完成,这时就有一个问题,如果这n-1条边不能连接n个顶点怎么办?刚才说过了,对于生成树来说,少一条边就无法满足连接所有边的条件,所以我们要挑出n-1条边,不多不少,其次多一条边还会导致图中构成环路。反着理解,我们只要在挑选边的过程中保证每次添加的边不会使图构成环路,那么最后挑出的n-1条边就不会构成环路,并且保证这些边的权值最小,这不就构成了最小生成树吗?所以现在的问题就变成了要怎么保证每次选择的边不会构成环路?我们可以将连通的顶点放在一个集合中,每次选择边时,如果边的两个顶点都在一个集合中,说明这两个顶点已经连通,再选择一条边连接它们,不就构成了环路吗?所以此时不能选这条边,一开始每个顶点都是一个集合,随着边的添加,顶点就逐渐的汇成一个集合,直到最后一条边的添加,两个集合合并成为了一个集合,也就是说该集合中的顶点都是连通的。对于选择不同集合的顶点的子算法,我们使用并查集结构实现,由于上篇博客已经实现了并查集,这里就不再讲解这个结构,但是因为我实现的并查集是针对泛型的,而这里我们只需要针对整数(用数组下标做映射),使用泛型反而有点复杂,所以我把简化后的并查集贴出来

#pragma once
#include <iostream>
#include <unordered_map>
#include <vector>
#include <utility>
using namespace std;

class UnionFindSet
{
public:
	UnionFindSet(size_t n) 
	{
		_ufs.resize(n, -1);
	}

	size_t find_root(size_t index)   // 查找index下标的根元素,返回其下标
	{
		size_t root = index;
		while (_ufs[root] >= 0)     // 根元素在并查集中的值是负数
		{
			root = _ufs[root];
		}

		while (_ufs[index] >= 0)
		{
			size_t parent = _ufs[index];  // 先保存其双亲,以对其双亲也进行路径压缩
			_ufs[index] = root;           // 路径压缩
			index = parent;
		}

		return root;
	}


	bool set_test(size_t data1, size_t data2)         // 判断两个元素是否在同一集合中
	{
		return find_root(data1) == find_root(data2); // 返回两元素的根元素下标是否相等
	}

	// 连接两个集合
	void set_union(size_t data1, size_t data2)
	{
		// 找到它们的根元素下标
		size_t root1 = find_root(data1);
		size_t root2 = find_root(data2);

		if (root1 != root2) // 不同集合才能合并
		{
			// 假设root1为根元素的集合元素个数更多,如果它的元素更少,交换
			// 注意根元素在并查集中存储的是负数
			if (_ufs[root1] > _ufs[root2])
			{
				swap(root1, root2);
			}

			_ufs[root1] += _ufs[root2]; // 数值的累加,维护集合的个数
			_ufs[root2] = root1;        // 将小集合作为大集合的子集,保存大集合根元素的下标
		}
	}

	size_t set_size() // 返回并查集中树的个数
	{
		// 遍历数组,只有有值小于0就说明它是一个根元素,树的个数加1
		size_t ret = 0;
		for (size_t i = 0; i < _ufs.size(); ++i)
		{
			if (_ufs[i] < 0)
			{
				ret++;
			}
		}
		return ret;
	}
private:
	vector<int> _ufs;				// 保存元素之间关系的数组
};

有了并查集,我们只需要在选择边时,判断边的两个顶点是否处于同一集合中,如果处于同一集合,不选择这条边。如果不处于,选择这条边并将这两个顶点放入同一集合,关于集合的操作我们通过并查集提供的接口实现。

而对于每次的选择,我们要怎么快速的选到最小权值的边呢?由于顶点之间的关系都是用邻接矩阵或者邻接表保存的(具体实现可以看这篇博客),这样的结构只适合用来查询,想要找出其中最小权值的边就会比较复杂,所以这里得额外地再使用一个结构,保存边的信息(起点和终点,权值),用来排序的数组也好,直接使用优先级队列创建小堆也行。这里我就使用优先级队列,排序其实也行,这个看具体使用场景。

struct edge
{
	size_t _srci;
	size_t _deti;
	W _w;

	edge(size_t srci, size_t deti, W w)
		:_srci(srci)
		, _deti(deti)
		, _w(w)
	{}

	bool operator>(const edge& x)
	{
		return _w > x._w;
	}
};

typedef graph<V, W, MAX_W, Direction> self;
const W& kruskal(self& min_tree) // 接收一个graph类型的对象引用,将其修改为最小生成树
{
	// 先初始化优先级队列
	priority_queue<edge, vector<W>, greater<W>> minque;
	// 遍历邻接矩阵,将图的边保存到优先级队列中
	// 但是要注意,由于生成树是无向图,所以只需要遍历一半的矩阵,另一半是重复的
	for (size_t i = 0; i < _matrix.size(); ++i)
	{
		// 注意j < i,控制只遍历一半的矩阵
		for (size_t j = 0; j < i; ++j)
		{
			if (_matrix[i][j] != MAX_W)
			{
				minque.push(edge(i, j, _matrix[i][j]));
			}
		}
	}
}

先给出一部分实现,因为优先级队列需要传仿函数,缺省的仿函数是less,建立的是大堆,其仿函数的实现大概是这样的

class <template T>
struct less
{
	bool operator()(const T& left, const T& right)
	{
		return left < right;
		// 如果是greater类
		// return left > right;
	}
};

可以看到它重载了()操作符,priority_queue使用时会用less类创建一个对象,比如

less compare;

遇到需要比较的情况,比如要比较left和right两个数,就使用compare对象

compare(left, right);

由于compare对象实现了()的重载,所以程序会调用这个重载函数,返回left < right的比较情况,如果这两个操作数是内置类型,int,char,那么left < right这个表达式是成立的,但是两个操作数是自定义类型呢?这个比较计算就无法进行,因此我们需要在自定义类型中对<操作符进行重载,以支持这个比较计算。所以我在edge中实现了operator>以支持edge类型的比较计算。以下是Kruskal的全部实现

struct edge
{
	size_t _srci;
	size_t _deti;
	W _w;

	edge(size_t srci, size_t deti, W w)
		:_srci(srci)
		, _deti(deti)
		, _w(w)
	{}

	bool operator>(const edge& x) const
	{
		return _w > x._w;
	}
};

typedef graph<V, W, MAX_W, Direction> self;
W kruskal(self& min_tree) // 接收一个graph类型的对象引用,将其修改为最小生成树
{
	// 先初始化优先级队列
	priority_queue<edge, vector<edge>, greater<edge>> minque;
	// 遍历邻接矩阵,将图的边保存到优先级队列中
	// 但是要注意,由于生成树是无向图,所以只需要遍历一半的矩阵,另一半是重复的
	for (size_t i = 0; i < _matrix.size(); ++i)
	{
		// 注意j < i,控制只遍历一半的矩阵
		for (size_t j = 0; j < i; ++j)
		{
			if (_matrix[i][j] != MAX_W)
			{
				minque.push(edge(i, j, _matrix[i][j]));
			}
		}
	}

	// 最小生成树的初始化,初始化矩阵,顶点集合与映射表
	min_tree._vertex = _vertex;
	min_tree._index_map = _index_map;
	min_tree._matrix.resize(_vertex.size());
	for (size_t i = 0; i < _vertex.size(); ++i)
	{
		min_tree._matrix[i].resize(_vertex.size(), MAX_W);
	}

	// 并查集的初始化
	UnionFindSet ufs(_vertex.size()); 
	// 返回值创建
	W ret = W();

	// 当并查集中只有一个集合,说明所有的顶点都连接在了一起
	// 此时结束循环,最小生成树构建完成
	while (ufs.set_size() != 1)
	{
		// 取出最小的边
		edge min_edge = minque.top();
		minque.pop();
		// 判断边的顶点是否处于同一集合中
		if (ufs.set_test(min_edge._srci, min_edge._deti) == false)
		{
			// 若不在一个集合中,添加这条边然后将两顶点放入同一集合
			min_tree._add_edge(min_edge._srci, min_edge._deti, min_edge._w);
			ufs.set_union(min_edge._srci, min_edge._deti);
			ret += min_edge._w;
		
			// for test
			cout << _vertex[min_edge._srci] << "->" << _vertex[min_edge._deti] << ':' << _matrix[min_edge._srci][min_edge._deti] << endl;
		}
	}
	return ret;
}

其中使用到了上篇博客模拟实现的图结构,但是有一个接口发生了变化add_edge,该接口接收两个顶点的值,与边的权值,将顶点的值转换成下标后将两点连接,由于Kruskal算法中已经算出了顶点映射的下标,所以这里不用调该接口,我们将其结构进一步封装,得到一个接收顶点下标的接口_add_edge,使程序调用该接口就好了

void _add_edge(size_t srci, size_t deti, const W& w)
{
	// 检查邻接矩阵是否初始化,因为默认构造函数并不会初始化矩阵
	if (_matrix.size() != _vertex.size())
	{
		_matrix.resize(_vertex.size());
		for (size_t i = 0; i < _matrix.size(); ++i)
		{
			// 用最大值初始化矩阵
			_matrix[i].resize(_vertex.size(), MAX_W);
		}
	}

	_matrix[srci][deti] = w;
	// 如果是无向图,镜像也要添加边
	if (Direction == false)
	{
		_matrix[deti][srci] = w;
	}
}

// 边的添加
void add_edge(const V& src, const V& det, const W& w)
{
	// 需要进入邻接表,将顶点转换成下标
	size_t src_index = get_index(src);
	size_t det_index = get_index(det);

	// 顶点存在的判断
	if (src_index == -1 || det_index == -1)
	{
		throw invalid_argument("顶点不存在");
	}

	_add_edge(src_index, det_index, w);
}

在这里插入图片描述
最后是测试程序,先给出一个连通图,我们要找出该连通图的最小生成树
在这里插入图片描述

#include "Graph.hpp"
#include "UnionFindset.hpp"

int main()
{
	matrix::graph<char, int, INT_MAX, false> g;
	g.add_tex('a');
	g.add_tex('b');
	g.add_tex('c');
	g.add_tex('d');
	g.add_tex('e');
	g.add_tex('f');
	g.add_tex('g');
	g.add_tex('h');
	g.add_tex('i');

	g.add_edge('a', 'b', 4);
	g.add_edge('a', 'h', 8);
	g.add_edge('b', 'h', 11);
	g.add_edge('b', 'c', 8);
	g.add_edge('h', 'i', 7);
	g.add_edge('i', 'c', 2);
	g.add_edge('h', 'g', 1);
	g.add_edge('i', 'g', 6);
	g.add_edge('c', 'f', 4);
	g.add_edge('g', 'f', 2);
	g.add_edge('c', 'd', 7);
	g.add_edge('d', 'f', 14);
	g.add_edge('d', 'e', 9);
	g.add_edge('e', 'f', 10);

	matrix::graph<char, int, INT_MAX, false> min_tree;
	size_t ret = g.kruskal(min_tree);
	cout << "最小生成树的权值:" << ret << endl;
	return 0;
}

在这里插入图片描述
(图片来自《算法导论》)我在添加边时将这条边的起点,终点以及权值进行了打印,读者可以通过打印的顺序与图片进行对照,看看两者构建的最小生成树是否相同

可以看到最小生成树不是唯一的,《算法导论》给出的结果选择了a->h这条边,而我们的算法选择了b->c这条边,虽然边不一样,但是权值是相同的,不影响程序最后的结果。经过测试Kruskal算法实现完成

Prim算法

与Kruskal的思想类似,它们都是贪心思想,不同的是两者贪心的范围不同,Kruskal的贪心是从全局入手,每次选择的都是整张图中权值最小的边,而Prim呢?Prim从局部入手,这个局部就体现在Prim需要接收一个顶点值,作为算法的起点,从这个顶点入手,从依附于该顶点的边中选择权值最小的那个。所以说Prim的贪是着眼于当前,从当前拥有的顶点出发,从依附于它们的边中选择权值最小的那个,而Kruskal的贪则是着眼于全局,从所有的未选择的边中选择权值最小的那个,这就是两个算法的本质区别。

因为Prim是从依附于当前顶点的边中选择权值最小的那个,那么Prim所拥有的顶点与依附于该顶点的边就需要被记录,这里可以使用一个bool数组记录已经拥有的顶点,可以用优先级队列存储依附于当前顶点的边。一开始,以调用者传入的顶点为起点,将顶点值转换成数组下标,修改bool数组中该下标的bool值为true,表示当前连接了这个顶点,然后遍历邻接矩阵(邻接表)找出依附于该顶点的边并放入优先级队列中,与Kruskal一样,优先级队列是一个小堆,此时Prim的初始化工作完成。要注意的是:虽然无向图的边没有方向,但是我们可以人为的指定方向(注意这只是为了方便算法的实现),以拥有的顶点为起点,向外的顶点(未拥有的顶点)为终点。

初始化完成后,我们选择堆顶的边(局部权值最小),添加到最小生成树上,但是要注意保证权值最小的同时,这条边的终点不能是我们已经拥有的顶点,否则将构成环路。边的起点是我们已经拥有的顶点,边的终点是我们未拥有的顶点,在这个前提下保证边的权值是最小的。就这样我们从一个顶点开始一步步的向外连接其他顶点,直到所有顶点被连接,此时的边的数量为n-1,根据这个条件我们结束循环,最小生成树的构建也随之完成了

W Prim(self& min_tree, const V& start)
{
	// 先将顶点值转换为下标
	size_t starti = get_index(start);
	if (starti == -1)
	{
		throw invalid_argument("顶点不存在");
	}

	// 最小生成树的初始化,初始化矩阵,顶点集合与映射表
	min_tree._vertex = _vertex;
	min_tree._index_map = _index_map;
	min_tree._matrix.resize(_vertex.size());
	for (size_t i = 0; i < _vertex.size(); ++i)
	{
		min_tree._matrix[i].resize(_vertex.size(), MAX_W);
	}

	// bool数组的构建与初始化
	size_t vertex_size = _vertex.size();
	vector<bool> vertex_had(vertex_size, false);
	// 起点的标记
	vertex_had[starti] = true;

	// 创建优先级队列,存储当前可以选择的边
	priority_queue<edge, vector<edge>, greater<edge>> min_que;
	// 优先级队列初始化
	for (size_t i = 0; i < vertex_size; ++i)
	{
		// 将依附于顶点的边存储到优先级队列
		if (_matrix[starti][i] != MAX_W)
		{
			min_que.push(edge(starti, i, _matrix[starti][i]));
		}
	}
	// 至此初始化完成,要开始循环以构建最小生成树了
	
	// 一些关键变量的创建
	size_t finish = 0;
	W ret = W();
	// 当还未连接所有顶点时,重复循环
	while (finish != vertex_size - 1)
	{
		// 拿到最小权值边
		edge min_edge = min_que.top();
		min_que.pop();
		// 判断可以选择的最小权值边是否构成了环路
		if (!vertex_had[min_edge._deti])
		{
			// 未构成环路,为最小生成树添加这条边
			min_tree._add_edge(min_edge._srci, min_edge._deti, min_edge._w);
			vertex_had[min_edge._deti] = true;
			// 循环变量的维护
			finish++;
			// 返回值的维护,权值的记录
			ret += min_edge._w;

			// for test
			cout << _vertex[min_edge._srci] << "->" << _vertex[min_edge._deti] << ':' << _matrix[min_edge._srci][min_edge._deti] << endl;
		}
		// 若构成环路,直接进行下次最小权值边的选择
		else
		{
			continue;
		}
		// 添加依附于新拥有顶点的边
		// 注意添加的边的起点是新拥有的顶点,终点是未拥有的顶点
		for (size_t i = 0; i < vertex_size; ++i)
		{
			if (_matrix[min_edge._deti][i] != MAX_W && !vertex_had[i])
			{
				min_que.push(edge(min_edge._deti, i, _matrix[min_edge._deti][i]));
			}
		}
	} // end of while
	return ret;
}

以上是Prim算法的实现,由于之前已经讲解过了大致的逻辑,并且代码也有详细的注释,这里就不再赘述其中的细节,直接进行程序的测试

#include "Graph.hpp"
#include "UnionFindset.hpp"

int main()
{
	matrix::graph<char, int, INT_MAX, false> g;
	g.add_tex('a');
	g.add_tex('b');
	g.add_tex('c');
	g.add_tex('d');
	g.add_tex('e');
	g.add_tex('f');
	g.add_tex('g');
	g.add_tex('h');
	g.add_tex('i');

	g.add_edge('a', 'b', 4);
	g.add_edge('a', 'h', 8);
	g.add_edge('b', 'h', 11);
	g.add_edge('b', 'c', 8);
	g.add_edge('h', 'i', 7);
	g.add_edge('i', 'c', 2);
	g.add_edge('h', 'g', 1);
	g.add_edge('i', 'g', 6);
	g.add_edge('c', 'f', 4);
	g.add_edge('g', 'f', 2);
	g.add_edge('c', 'd', 7);
	g.add_edge('d', 'f', 14);
	g.add_edge('d', 'e', 9);
	g.add_edge('e', 'f', 10);

	matrix::graph<char, int, INT_MAX, false> min_tree;
	size_t ret = g.Prim(min_tree, 'a');
	cout << "最小生成树的权值:" << ret << endl;
		
	return 0;
}

在这里插入图片描述

与Kruskal算法举的例子一样,这次我们用Prim算法求其最小生成树,读者可以通过打印的顺序体会Prim算法的实现过程。


至此最小生成树的两种算法讲解完成,虽然实现过程略有些复杂,但是两种的指导思想——贪心,却很好理解,所以在复杂算法的实现之前,我们需要考虑清楚算法的大概实现逻辑,将贪心的思想具体化,最后才是写代码完成细节的实现

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值