数据结构——图及其C++实现(2)最小生成树Kruskal算法和Prim算法

目录

一、前言

二、最小生成树

1、生成树

2、极大无环子图

3、最小生成树

三、Kruskal算法

1、算法思想

2、算法实现过程 

3、代码实现

4、运行结果

四、Prim算法

1、算法思想

2、代码实现

3、运行结果


一、前言

在上一篇文章中,我们对图的基本概念和图的存储结构及两种遍历方式做了了解,既然已经认识了图,那么对于图中的顶点来说,我们思考当面临“用最小代价把所有点连起来”这类问题时,我们该如何做?接下来我们我们引出关于图的另一个很重要的概念——最小生成树。

二、最小生成树

1、生成树

上篇文章中,我们提到了关于图的生成树是指一个连通图的最小连通子图称作该图的生成树。有n个顶点的连通图的生成树有 n 个顶点和 n-1 条边。

而连通图是指在无向图中,若从顶点v1到顶点v2有路径,则称顶点v1与顶点v2是连通的。如果图中任意一对顶点都是连通的,则称此图为连通图。

如下所示即为图的生成树

知道了生成树,我们接下来看看最小生成树是什么,最小生成树的最小,指的是边的权值之和最小,即在图的所有生成树中边的权值最小的生成树就是最小生成树。生成树可以有很多个,最小生成树也可以有很多个。

2、极大无环子图

连通图中的每一棵生成树,都是原图的一个极大无环子图,即:从其中删去任何一条边,生成树就不在连通;反之,在其中引入任何一条新边(指的是原图中存在但不在生成树中的边),都会形成一条回路。

值得注意的是,极大无环子图与最大无环子图不同,后者指的是在所有可能的无环子图中选择边数最多的那个。找到最大无环子图是一个更复杂的问题,通常需要更复杂的算法来解决。

3、最小生成树

最小生成树的性质:对于任何由n个顶点组成的连通图

  • 生成树必须包含所有 n 个顶点。
  • 生成树必须有恰好 n−1条边(因为树的性质)。
  • 生成树不能有回路(否则就不是树)。
  • 优先使用权重最小的可行边,以确保总权重最小化。

构造最小生成树有两种算法,分别是Kruskal算法Prim算法。

这两种算法都基于贪心策略(Greedy Strategy),即在每一步选择中都采取当前状态下最优的决策(局部最优),最终得到全局最优解(最小生成树)。

三、Kruskal算法

克鲁斯卡尔算法( Kruskal)是一种基于贪心策略的最小生成树(MST)构造算法,由约瑟夫·克鲁斯卡尔于1956年提出。其核心思想是:按权重升序选择边,并避免形成回路,直到连接所有顶点

1、算法思想

任给一个有n个顶点的连通图 N={V,E}, 首先构造一个由这n个顶点组成、不含任何边的图G={V,NULL},其中每个顶点自成一个连通分量(集合),其次不断从E中取出权值最小的一条边(若有多条权值相等任取其一),若该边的两个顶点来自不同的连通分量,则将此边加入到G中。如此重复,直到所有顶点在同一个连通分量上为止。 核心:每次迭代时,选出一条具有最小权值的边,且边的两端点不在同一连通分量(集合)上,则加入生成树。

算法导论中图如下所示

其实就是每次从图中所有的边里面选出权值最小且不会构成环的边,选够n-1条就完成了,这n-1条边构成的生成树就是该图对应的最小生成树。

2、算法实现过程 

首先,我们需要对图中所有边按权重从小到大排序。以方便我们每次选取的都是当前边中权重最小的。

我们借助一个优先级队列并控制它是小堆,先遍历邻接矩阵把所有的边都放到这个优先级队列priority_queue里面,这样我们后续就很方便每次取出最小的边。

需要注意的是选出来的边,我们不能盲目的使用,而要去判断,连接上这条边之后是否会构成环(借助并查集判断,将所有相连边的顶点放到一个集合里面,后续在添加边,判断如果这条边对应的两个顶点在一个集合,就会构成环),如下面例子

gf、fc、ci这三条边是相连的,那这4个顶点就在一个集合里面,现在添加ig的话,ig在一个集合,所以会构成环,不能添加。 如果会构成环的话,就放弃这条边,继续选剩下边里面最小的边如果合适就添加这条边,并且将对应的顶点用并查集合并到一个集合里面,便于后面判断连接新的边是否会构成环。 这样最终我们就可以选出权值之和最小的生成树即最小生成树。

3、代码实现

W Kruskal(Self& minTree)

使用输出型参数,参数 minTree 是一个引用,用于存储生成的最小生成树


size_t n = _vertexs.size();

minTree._vertexs = _vertexs;
minTree._indexMap = _indexMap;
minTree._matrix.resize(n);
for (size_t i = 0; i < n; ++i)
{
	minTree._matrix[i].resize(n, MAX_W);
}

初始化部分,获取图中顶点的数量,将原图的顶点集合和索引映射复制到最小生成树中,初始化最小生成树的邻接矩阵将所有边的权重初始化为MAX_W(表示无穷大,即无边)


priority_queue<Edge, vector<Edge>, greater<Edge>> minque;
for (size_t i = 0; i < n; ++i)
{
    for (size_t j = 0; j < n; ++j)
    {
        if (i < j && _matrix[i][j] != MAX_W)
        {
            minque.push(Edge(i, j, _matrix[i][j]));
        }
    }
}

利用优先级队列处理边,创建一个小堆minque,用于按权重排序所有边。


int size = 0;//累计已经选择的边数
W totalW = W();//累计最小生成树的总权重
//这行代码保证了无论 W 是什么类型,totalW 都以合理的默认值开始累加权重,是编写模板代码时的安全初始化方式。
UnionFindSet ufs(n);//并查集,用于检测是否成环

//从当前优先级队列里面拿出最小权重的边
while (!minque.empty())
{
    Edge min = minque.top();
    minque.pop();
}

//利用并查集判断是否在同一集合里(构成环)
if (!ufs.InSet(min._srci, min._dsti))
{
    minTree._AddEdge(min._srci, min._dsti, min._w);//如果不成环则添加边到最小生成树中
    ufs.Union(min._srci, min._dsti);//合并两个顶点的集合
    ++size;
    totalW += min._w;
}
else
{
	//cout << "构成环:";
	//cout << _vertexs[min._srci] << "->" << _vertexs[min._dsti] << ":" << min._w << endl;
}

if (size == n - 1)
{
	return totalW;
}
else
{
	return W();
}
  • 如果成功选择了n-1条边,返回总权重
  • 否则返回默认值(表示图不连通,无法形成生成树)

4、运行结果

        const char str[] = "abcdefghi";
		Graph<char, int> g(str, strlen(str));
		g.AddEdge('a', 'b', 4);
		g.AddEdge('a', 'h', 8);
		//g.AddEdge('a', 'h', 9);
		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('g', 'i', 6);
		g.AddEdge('h', 'i', 7);

如图

 

四、Prim算法

 接下来看生成最小生成树的第二个算法——Prim(普利姆)算法。

1、算法思想

Prim 算法的工作原理与 Dijkstra 的最短路径算法相似(后面讲到)。Prim 算法所具有一个性质是集合 A 中的边总是构成一棵树。如下图所示,这棵树从一个任意的根结点 r 开始,一直长大到覆盖 V 中的所有结点时为止。算法每一步在连接集合 A 和 A 之外的结点的所有边中,选择一条轻量级边加入到 A 中。这条规则所加入的边都是对 A 安全的边。因此,当算法终止时,A 中的边形成一棵最小生成树。本策略也属于贪心策略,因为每一步所加入的边都必须是使树的总权重增加量最小的边。

 在上图执行 Prim 算法的过程。初始的根结点为 a。加阴影的边和黑色的结点都属于树 A。在算法每一步,树中的结点就决定了图的一个切割,横跨该切割的一条轻量级边被加入到树中。例如,在图中的第 2 步,该算法可以选择将边 (b, c) 加入到树中,也可以选择将边 (a, h) 加入到树中,因为这两条边都是横跨该切割的轻量级边。

通俗地讲,Prim算法实际上是这样的,它与 Kruskal算法相比较两者都是采用了局部的贪心策略,但是 Kruskal算法在每次选择边的时候都是在整个图的边中去选择一条最小权重的边,即在选择完成之后,当前的选择的边与之前已经选择的边可以不在同一棵树中。而Prim算法则是在之前已经选择完成的树的基础上选择与该树相邻的边的最小权重边。

既然已经有了 Kruskal算法了,Prim算法存在的意义又是什么呢?与 Kruskal算法相比有什么优势呢?它这样去选边,选出来的边是不会构成环的。如果把当前选择的树看作一个集合,将当前树之外的部分看作另一个集合,其实就是每次选择的时候都是在另外一个集合拿顶点,所以根本就不会构成环,不像 Kruskal算法一样每次还都需要用并查集判断是否构成环。

2、代码实现

W Prim(Self& minTree, const W& src)

传入输出型参数 minTree,和开始建树的起始顶点,返回值是最后生成的最小生成树的总权重。


size_t srci = GetVertexIndex(src);//找到起始顶点在图中的索引
size_t n = _vertexs.size();//图中的顶点数


//初始化最小生成树的顶点集合、索引关系和邻接矩阵
minTree._vertexs = _vertexs;
minTree._indexMap = _indexMap;
minTree._matrix.resize(n);
for (size_t i = 0; i < n; ++i) {
    minTree._matrix[i].resize(n, MAX_W);
}

初始化


vector<bool> X(n, false);  // 已选顶点集合(初始全未选)
vector<bool> Y(n, true);   // 未选顶点集合(初始全选)
X[srci] = true;            // 将起始顶点加入已选集合
Y[srci] = false;           // 从Y中移除起始顶点

将整个的顶点集合划分为两部分,已经选择了的和还没有选择的,这样接下来选择的时候只需要从还没选择的集合里面拿顶点放进已经选的顶点的集合中即可。

  • X:记录已加入MST的顶点(true表示已选)。
  • Y:记录未加入MST的顶点(true表示未选)。
  • 初始化时,只有起始顶点srcX中,其余在Y中。

priority_queue<Edge, vector<Edge>, greater<Edge>> minq;
for (size_t i = 0; i < n; ++i) {
    if (_matrix[srci][i] != MAX_W) {
        minq.push(Edge(srci, i, _matrix[srci][i]));
    }
}

同样需要一个优先级队列来对未选择顶点中的边的权值做排序,每次就方便能拿到最小权重的边了。

  • 使用**最小堆(优先队列)**存储当前可选的边(按权值从小到大排序)。
  • 将起始顶点srci的所有邻接边加入队列(权值不为MAX_W的边)。

size_t size = 0;       // 已选边数
W totalW = W();         // 总权值(初始化为默认值,如0)
while (!minq.empty()) {
    Edge min = minq.top();  // 取出当前最小权值边
    minq.pop();
if (X[min._dsti]) {
    // 忽略该边(避免形成环)
}
else {
    minTree._AddEdge(min._srci, min._dsti, min._w);  // 加入MST
    X[min._dsti] = true;     // 标记为已选
    Y[min._dsti] = false;    // 从Y中移除
    ++size;                  // 已选边数+1
    totalW += min._w;        // 累加权值
    if (size == n - 1) break; // 已选n-1条边,结束
    for (size_t i = 0; i < n; ++i) {
        if (_matrix[min._dsti][i] != MAX_W && Y[i]) {
            minq.push(Edge(min._dsti, i, _matrix[min._dsti][i]));
        }
      }
    }
if (size == n - 1) {
    return totalW;  // 返回MST的总权值
} else {
    return W();     // 图不连通,返回默认值(如0)
}

构建最小生成树 

  • 每次从队列中取出权值最小的边min
  • 如果目标顶点min._dsti已在X中,选择该边会形成环,直接跳过。
  • 将有效边min加入minTree
  • 更新顶点集合XY
  • 如果已选边数达到n-1(MST的性质),提前终止循环。
  • 将新加入顶点min._dsti的所有邻接边(且目标顶点在Y中)加入队列。
  • 如果成功选出n-1条边,返回总权值totalW
  • 否则说明图不连通,返回默认值(如0)。

3、运行结果

还是使用和上面测试Kruskal算法时一样的图

        const char str[] = "abcdefghi";
		Graph<char, int> g(str, strlen(str));
		g.AddEdge('a', 'b', 4);
		g.AddEdge('a', 'h', 8);
		//g.AddEdge('a', 'h', 9);
		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('g', 'i', 6);
		g.AddEdge('h', 'i', 7);
		Graph<char, int> pminTree;
		cout << "Prim:" << g.Prim(pminTree, 'a') << endl;
		pminTree.Print();
		cout << endl;

		for (size_t i = 0; i < strlen(str); ++i)
		{
			cout << "Prim:" << g.Prim(pminTree, str[i]) << endl;
		}

迭代过程: 

    • 选最小边:a-b:4 → 加入 b
    • 选最小边:a-h:8 → 加入 h
    • 选最小边:h-g:1 → 加入 g
    • 选最小边:g-f:2 → 加入 f
    • 选最小边:f-c:4 → 加入 c
    • 选最小边:c-i:2 → 加入 i
    • 选最小边:c-d:7 → 加入 d
    • 选最小边:d-e:9 → 加入 e
  1. 总权值:4+8+1+2+4+2+7+9 = 37
  • 多次输出 Prim:37,说明无论从哪个顶点(a-i)开始执行Prim算法,最终生成的MST总权值均为 37(最多就是多个最小生成树的边不同)。这验证了MST的总权值具有唯一性

感谢阅读!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值