【数据结构】基础:图的最小生成树(附C++源代码)
摘要:将会在数据结构专题中开展关于图论的内容介绍,其中包括四部分,分别为图的概念与实现、图的遍历、图的最小生成树以及图的最短路径问题。本文主要介绍Kruskal算法(克鲁斯卡尔)与Prim算法(普里姆),二者都是通过贪心策略完成对最小生成树的生成的,需要掌握二者的思想与实现。
文章目录
- 【数据结构】基础:图的最小生成树(附C++源代码)
- 前言
- 1. 图的实现方式
- 2. 并查集
- 一、概述
- 二、Kruskal算法(克鲁斯卡尔)
- 2.1 算法思想
- 2.2 具体实例
- 2.3 代码实现
- 2.4 测试用例
- 三、Prim算法(普里姆)
- 3.1 算法实现
- 3.2 具体实例
- 3.3 代码实现
- 3.4 测试用例
前言
1. 图的实现方式
本文中图的实现方法为邻接矩阵法,以下是对其类的基本描述,若需查看更加具体的内容,可以参考博客图的概念与基本实现。其重点可以概括为:
- Direction:表示是否为有向图
- _vertexs:记录了对应检索下的顶点元素
- _vIndexMap:记录了检索与顶点的对应关系
- _matrix:表示邻接矩阵
具体代码如下:
template<class V, class W, bool Direction = false, W MAX_WEIGHT = INT_MAX>
class Graph {
typedef Graph<V, W, Direction, MAX_WEIGHT> Self;
private:
vector<V> _vertexs; // 顶点集合
map<V, int> _vIndexMap; // 顶点检索
vector<vector<W>> _matrix; // 邻接矩阵
public:
Graph() = default;
Graph(const V* vertexs,size_t vertexSize) {
_vertexs.reserve(vertexSize);
for (size_t i = 0; i < vertexSize; i++) {
_vertexs.push_back(vertexs[i]);
_vIndexMap[vertexs[i]] = i;
}
// 格式化
_matrix.resize(vertexSize);
for (auto& e : _matrix) {
e.resize(vertexSize, MAX_WEIGHT);
}
//for (size_t i = 0; i < _matrix.size(); i++) {
// _matrix[i][i] = 0;
//}
}
size_t GetVertexIndex(const V& v) {
auto ret = _vIndexMap.find(v);
if (ret != _vIndexMap.end()) {
return ret->second;
}
else {
throw invalid_argument("不存在的顶点");
return -1;
}
}
void AddEdge(const V& src, const V& dest, const W& weight) {
size_t srcIndex = GetVertexIndex(src);
size_t destIndex = GetVertexIndex(dest);
AddEdgeByIndex(srcIndex, destIndex, weight);
}
void AddEdgeByIndex(size_t srcIndex, size_t destIndex, const W& weight){
_matrix[srcIndex][destIndex] = weight;
if (Direction == false) {
_matrix[destIndex][srcIndex] = weight;
}
}
};
2. 并查集
本文会使用到并查集这一数据结构,并查集是一种树型的数据结构,用于处理一些不相交集合的合并及查询问题。并查集的思想是用一个数组表示了整片森林(parent),树的根节点唯一标识了一个集合,我们只要找到了某个元素的的树根,就能确定它在哪个集合里。
本文对于并查集而言并非重点,因此不过多介绍。本文所使用的并查集时通过数组vector
与map
实现,实现了以下内容:
vector
通过中元素若为负数表示为根,数目绝对值为集合元素的个数,正数表示存在根的检索。map
记录了顶点元素与数组索引的关系- 完成函数封装有
Union
合并、InSet
检查是否在同一集合
完整代码示例如下:
#pragma once
#include <vector>
#include <map>
#include <iostream>
using namespace std;
template<class T>
class UnionFindSet {
private:
vector<int> _set;
map<int, T> _indexMap;
public:
/// <summary>
/// 创建数组
/// 加入索引与数组内容
/// </summary>
/// <param name="vectorInsert"></param>
/// <param name="size"></param>
UnionFindSet(vector<T> vectorInsert, int size)
:_set(size, -1)
{
for (size_t i = 0; i < size; i++) {
cout << i<< ": " <<vectorInsert[i] << endl;
_indexMap.insert(pair<T,int>(vectorInsert[i], i));
}
}
/// <summary>
/// 寻根的索引,找出对应索引进行对于根的查找
/// 当寻根后,可以将父亲节点与祖先与根连接,降低树的高度
/// </summary>
/// <param name="key">键值</param>
/// <returns></returns>
size_t FindRootIndex(T key) {
int index = _indexMap[key];
while (_set[index] >= 0) {
index = _set[index];
}
// 降低树的高度
int root = index;
int curIndex = _indexMap[key];
while (_set[curIndex] >= 0) {
int parentIndex = _set[curIndex];
_set[curIndex] = root;
curIndex = parentIndex;
}
return root;
}
/// <summary>
/// 合并两个集合 找出两个根索引,将矮的树插入到另一棵树中
/// </summary>
/// <param name="key1"></param>
/// <param name="key2"></param>
void Union(T key1,T key2) {
int rootIndex1 = FindRootIndex(key1);
int rootIndex2 = FindRootIndex(key2);
if (rootIndex1 == rootIndex2) {
return;
}
if (abs(_set[rootIndex1]) < abs(_set[rootIndex2]))
swap(rootIndex1, rootIndex2);
_set[rootIndex1] += _set[rootIndex2];
_set[rootIndex2] = rootIndex1;
}
bool InSet(T key1, T key2){
return FindRootIndex(key1) == FindRootIndex(key2);
}
/// <summary>
/// 返回并查集数目
/// </summary>
/// <returns></returns>
size_t SetCount() {
size_t count = 0;
for (size_t i = 0; i < _set.size(); ++i){
if (_set[i] < 0)
count++;
}
return count;
}
};
一、概述
连通图中的每一棵生成树,都是原图的一个极大无环子图,即:
- 从其中删去任何一条边,生成树就不在连通;
- 反之,在其中引入任何一条新边,都会形成一条回路。
若连通图由n个顶点组成,则其生成树必含n个顶点和n-1条边,因此构造最小生成树的准则有三条:
- 只能使用图中的边来构造最小生成树
- 只能使用恰好n-1条边来连接图中的n个顶点
- 选用的n-1条边不能构成回路
构造最小生成树的方法:Kruskal算法和Prim算法。这两个算法都采用了逐步求解的贪心策略。所谓贪心算法,是指在问题求解时,总是做出当前看起来最好的选择。也就是说贪心算法做出的不是整体最优的的选择,而是某种意义上的局部最优解。贪心算法不是对所有的问题都能得到整体最优解。
二、Kruskal算法(克鲁斯卡尔)
2.1 算法思想
基本思想:任给一个有n个顶点的连通网络N={V,E},首先构造一个由这n个顶点组成、不含任何边的图G={V,NULL},其中每个顶点自成一个连通分量,其次不断从E中取出权值最小的一条边(若有多条任取其一),若该边的两个顶点来自不同的连通分量,则将此边加入到G中。如此重复,直到所有顶点在同一个连通分量上为止。
核心:每次迭代时,选出一条具有最小权值,且两端点不在同一连通分量上的边,加入生成树。
2.2 具体实例
以下截取算法导论中的实例进行说明:不断选取最小的边(依次为:h->g
、 i->c
、 g->f
…),当可能形成环时对其不进行选择(如:i ->g
),最终形成最小生成树
2.3 代码实现
具体实现:
- 构造最小生成树的图,其中顶点容器与检索树和原来的图相同,而邻接矩阵设置为全不可达,即不含任意边
- 设置优先级队列,将各边推入小堆中
- 设置并查集,将访问后的节点聚集在一个集合中,若在后续对边的访问中,两个节点都已访问,则表示已形成环,否则可以添加该边到最小生成树中
- 在小堆取栈顶元素,判断是否符合最小生成树要求,直至边被取完为止
- 判断形成最下生成树的边的个数,来判断是否生成成功最小生成树
W Kruskal(Self& minTree) {
size_t n = _vertexs.size();
// 初始化最小树的数据结构
minTree._vertexs = this->_vertexs;
minTree._vIndexMap = this->_vIndexMap;
minTree._matrix.resize(n);
for (size_t i = 0; i < n; i++) {
minTree._matrix[i].resize(n, MAX_WEIGHT);
}
// 优先级队列:升序排序,排序对比看权值
priority_queue<Edge, vector<Edge>, greater<Edge>> minQueue;
// 加入边
for (size_t i = 0; i < n; i++) {
for (size_t j = 0; j < n; j++) {
if (Direction == false) {
//无向连通图
if (i < j && _matrix[i][j] != MAX_WEIGHT){
minQueue.push(Edge(i, j, _matrix[i][j]));
}
}
else {
//有向连通图
if (_matrix[i][j] != MAX_WEIGHT) {
minQueue.push(Edge(i, j, _matrix[i][j]));
}
}
}
}
// 统计边
int count = 0;
// 统计权值
W totalWeight = W();
// 并查集:将访问过的顶点作为一个集合,否则会出现环的情况
UnionFindSet<V> ufs(_vertexs, _vertexs.size());
// 对于优先级队列取边,查看是否构成环
while (!minQueue.empty()) {
Edge minEdge = minQueue.top();
minQueue.pop();
// 两个顶点不在通过一个集合中
if (!ufs.InSet(_vertexs[minEdge._srcIndex], _vertexs[minEdge._destIndex])) {
cout << _vertexs[minEdge._srcIndex] << "->" << _vertexs[minEdge._destIndex]
<< ":" << minEdge._weight << endl;
minTree.AddEdgeByIndex(minEdge._srcIndex, minEdge._destIndex, minEdge._weight);
// 将两个点放入已访问的集合中
ufs.Union(_vertexs[minEdge._srcIndex], _vertexs[minEdge._destIndex]);
count++;
totalWeight += minEdge._weight;
}
else {
cout << "构成环:";
cout << _vertexs[minEdge._srcIndex] << "->" << _vertexs[minEdge._destIndex] << ":" << minEdge._weight << endl;
}
}
// 查看是否成功构成了n-1条边
if (count == n - 1)
return totalWeight;
else
return W();
}
2.4 测试用例
该图为算法导论中提供的案例,在上文中已经画出
void TestGraphMinTree()
{
const char str[] = "abcdefghi";
Graph<char, int> g(str, strlen(str));
g.AddEdge('a', 'b', 4);
g.AddEdge('a', 'h', 8);
g.AddEdge('b', 'c', 8);
g.AddEdge('b', 'h', 11);
g.AddEdge('c', 'i', 2);
g.AddEdge('c', 'f', 4);
g.AddEdge('c', 'd', 7);
g.AddEdge('d', 'f', 14);
g.AddEdge('d', 'e', 9);
g.AddEdge('e', 'f', 10);
g.AddEdge('f', 'g', 2);
g.AddEdge('g', 'h', 1);
g.AddEdge('i', 'g', 6);
g.AddEdge('h', 'i', 7);
Graph<char, int> kminTree;
cout << "Kruskal:" << g.Kruskal(kminTree) << endl;
kminTree.Print();
cout << endl << endl;
}
三、Prim算法(普里姆)
3.1 算法实现
基本思想:任给一个有n个顶点的连通网络N={V,E},首先构造一个由这n个顶点组成、不含任何边的图G={V,NULL},将顶点分为两组,分别为已访问以及未访问,通过从已访问的点中筛选出与未访问的点邻接的最小权值的边,将其作为最小子树的边,加入到G中,如此重复知道边的数量为n-1条,否则构成失败。
3.2 具体实例
以下截取算法导论中的实例进行说明:最初状态中只有a点被访问,其邻接的边的另一顶点未被访问,且权值最小,该边为a-b
。则加入改变,访问b点并加入对应的邻边到优先级队列中,再次从优先级队列中选边,重复该过程。
3.3 代码实现
- 初始化最小生成树的数据结构,并创建两个数组记录两个集合,分别记录是否访问,并完成相应的初始化设置
- 设置优先队列,储存已访问过的点的临边
- 从优先级队列中,不断获取最小边,判断是否成环(标准为边的两点中只有一个被访问过),若成环选择下一条边,否则选择该边,并将其端点临边加入到优先级队列中
- 判断是否生成树生成成功
具体代码:
W Prim(Self& minTree, const W& src) {
// 获取src的index
size_t srcIndex = GetVertexIndex(src);
size_t n = _vertexs.size();
// 初始化最小生成树的数据结构
minTree._vertexs = this->_vertexs;
minTree._vIndexMap = this->_vIndexMap;
minTree._matrix.resize(n);
for (size_t i = 0; i < n; i++) {
minTree._matrix[i].resize(n, MAX_WEIGHT);
}
// 创建两个数组记录两个集合,分别为是否访问
vector<bool> vistedV(n, false);
vector<bool> unvistedV(n, true);
// 对于起点,已经访问
vistedV[srcIndex] = true;
unvistedV[srcIndex] = false;
// 优先队列:已访问过的点的临边
priority_queue<Edge, vector<Edge>, greater<Edge>> minQueue;
for (size_t i = 0; i < n; ++i){
if (_matrix[srcIndex][i] != MAX_WEIGHT){
minQueue.push(Edge(srcIndex, i, _matrix[srcIndex][i]));
}
}
size_t count = 0;
W totalWeight = W();
while (!minQueue.empty()) {
// 最小边
Edge minEdge = minQueue.top();
minQueue.pop();
// 看是否都是已访问过的点,是的话就形成了环
if (vistedV[minEdge._destIndex] == true) {
cout << "构成环:";
cout << _vertexs[minEdge._srcIndex] << "->"
<< _vertexs[minEdge._destIndex] << ":" << minEdge._weight << endl;
}
else {
minTree.AddEdgeByIndex(minEdge._srcIndex, minEdge._destIndex, minEdge._weight);
count++;
vistedV[minEdge._destIndex] = true;
unvistedV[minEdge._destIndex] = false;
totalWeight += minEdge._weight;
if (count == n - 1)
break;
// 访问后向优先队列添加临边
for (size_t i = 0; i < n; ++i){
if (_matrix[minEdge._destIndex][i] != MAX_WEIGHT && unvistedV[i] == true)
{
minQueue.push(Edge(minEdge._destIndex, i, _matrix[minEdge._destIndex][i]));
}
}
}
}
if (count == n - 1)
return totalWeight;
else
return W();
}
3.4 测试用例
该图为算法导论中提供的案例,在上文中已经画出
void TestGraphMinTree(){
const char str[] = "abcdefghi";
Graph<char, int> g(str, strlen(str));
g.AddEdge('a', 'b', 4);
g.AddEdge('a', 'h', 8);
g.AddEdge('b', 'c', 8);
g.AddEdge('b', 'h', 11);
g.AddEdge('c', 'i', 2);
g.AddEdge('c', 'f', 4);
g.AddEdge('c', 'd', 7);
g.AddEdge('d', 'f', 14);
g.AddEdge('d', 'e', 9);
g.AddEdge('e', 'f', 10);
g.AddEdge('f', 'g', 2);
g.AddEdge('g', 'h', 1);
g.AddEdge('i', 'g', 6);
g.AddEdge('h', 'i', 7);
Graph<char, int> pminTree;
cout << "Prim:" << g.Prim(pminTree, 'a') << endl;
pminTree.Print();
cout << endl;
}
补充:
- 代码将会放到:C++/C/数据结构代码链接 ,欢迎查看!
- 欢迎各位点赞、评论、收藏与关注,大家的支持是我更新的动力,我会继续不断地分享更多的知识!