⚪ 本文内容基于C++和Python讲解最小生成树相关的知识以及相关的算法 |
⚪ 本文原创,转载请联系作者,并注明出处 |
⚪ 由于作者水平有限,可能内容存在谬误,欢迎读者批评指正 |
一、关于树的定义
本定义适用于有根树和无根树
- 森林(forest):每个连通分量(连通块)都是树的图。按照定义,一棵树也是森林。
- 生成树(spanning tree):一个连通无向图的生成子图,同时要求是树。也即在图的边集中选择
- 结点的深度(depth):到根结点的路径上的边数。
- 树的高度(height):所有结点的深度的最大值。
- 无根树的叶结点(leaf node):度数不超过
- 有根树的叶结点(leaf node):没有子结点的结点。
以下定义适用于有根树
- 父亲(parent node):对于除根以外的每个结点,定义为从该结点到根路径上的第二个结点。
根结点没有父结点。 - 祖先(ancestor):一个结点到根结点的路径上,除了它本身外的结点。
根结点的祖先集合为空。 - 子结点(child node):如果
- 是
- 的父亲,那么
- 是
- 的子结点。
子结点的顺序一般不加以区分,二叉树是一个例外。 - 兄弟(sibling):同一个父亲的多个子结点互为兄弟。
- 后代(descendant):子结点和子结点的后代。
或者理解成:如果 - 是
- 的祖先,那么
- 是
- 子树(subtree):删掉与父亲相连的边后,该结点所在的子图。
在本专题中,我们重点讨论有根树中的生成树
二、生成树、生成森林的定义
1.生成树
什么是生成树?
对连通图进行遍历,过程中所经过的边和顶点的组合可看做是一棵普通树,通常称为生成树我们通过距离来直观的展示生成树的含义。
如下图是一张包含5个结点、5条边的无向图
:
我们对这张图进行遍历,根据定义,我们可以得知连通图的生成树必须满足下列两个条件:
- 包含连通图的所有的节点
- 任意两顶点间有且仅有一条通路
由于连通图中,由于任意两顶点之间可能含有多条通路,遍历连通图的方式有多种,往往一张连通图可能有多种不同的生成树与之对应。因此对于本图我们可以得到两颗不同的生成树:
我们定义生成树的权为生成树中所有边权的求和,图中
的权值为
,
的权值为
不妨设
,如果我们想要得到一棵连通所有节点且边权和最小的生成树,显然
符合要求,我们称
为图
的最小生成树。
**Attention!**只有连通图才有生成树,才有最小生成树。而对于非连通图,只存在生成森林。
我们再举一个复杂的图的例子:
本图的最小生成树如右图标红所示。
最小生成树有什么用?
我们举一个现实生活中鲜明生动的例子:修煤气管道(仅仅作示例,实际可能并非如此)
我们拥有一个城镇,城镇的房子之间或有通路,或无通路,现在我们需要修一条煤气管道,由于煤气管道造假高昂,所以要求给出一个方案,保证管道经过每一家每一户而代价最小。经过了对最小生成树的学习,我们不难发现:最优方案便是地图路径连通图的最小生成树。
2.生成森林
生成树是对应连通图而言,而生成森林是对应非连通图而言。
我们知道,非连通图可分解为多个连通分量,而每个连通分量又各自对应多个生成树(至少是 1 棵),因此与整个非连通图相对应的,是由多棵生成树组成的生成森林。
我们给出一张非连通图
,其对应的一种生成森林如右所示:
三、最小生成树算法详解(C++描述、Python描述)
我们在讲解的过程中对于图的储存结构全部采用边集的方式进行储存
1.Kruskal’S Algorithm
1.1 核心思想概述
具体的概括:维护一个森林,查询两个结点是否在同一棵树中,并连接两棵树。
反应到算法上的具体体现:维护一堆集合,查询两个元素是否属于同一集合,合并两个集合。
由于涉及到的集合的查询问题,我们可以采用并查集对查询过程进行优化。
1.2 详解
我们继续使用下面这张示例图来讲解Kruskal算法的原理及过程。
我们首先建图,读入边权信息;然后我们对边集按照边权从大到小的顺序进行排序,得到一个有序的边集数组。我们将每个点视为一个独立的连通分量,遍历边集,每次遍历查询两个结点是否位于同一连通分量中(不成环),对两点选择性进行连接。
我们不难发现,Kruskal算法实际上是个贪心算法的应用。
伪代码描述如下:
具体的执行过程图解:
1.3 C++描述:Kruskal’s Algorithm
1.4 Python描述:Kruskal’s Algorithm
我们使用一个下标为顶点编号的表reps记录各顶点的代表元关系。具体的原理同并查集,参考 裘宗燕 《数据结构与算法-Python语言描述》
1.5 总结
- 算法是一种常见而且好写的最小生成树算法,本质属于贪心算法;
- 基本思想:从小到大加入边
- 适用于稀松图,稠密图应该用
- 算法
- 需要前导知识:并查集、贪心算法、图的储存方式
- 如果使用
- 的排序算法,并且使用
- 或
- 的并查集,就可以得到时间复杂度为
1.6 扩展:证明(引用自OI-WIKI)
思路很简单,为了造出一棵最小生成树,我们从最小边权的边开始,按边权从小到大依次加入,如果某次加边产生了环,就扔掉这条边,直到加入了
证明:使用归纳法,证明任何时候 K 算法选择的边集都被某棵 MST 所包含。
基础:对于算法刚开始时,显然成立(最小生成树存在)。
归纳:假设某时刻成立,当前边集为
,令
为这棵 MST,考虑下一条加入的边
。
如果
属于
,那么成立。
否则,
一定存在一个环,考虑这个环上不属于
的另一条边
(一定只有一条)。
首先,
的权值一定不会比
小,不然
会在
然后,
的权值一定不会比
大,不然
就是一棵比
所以,
包含了
,并且也是一棵最小生成树,归纳成立。
2.Prim Algorithm
2.1 核心思想概述
具体的来说,我们每次需要寻找距离最小的一个结点(与Dijkstra’s Algorithm相似),以及新的边来更新其它结点的距离。在寻找距离最小点的过程中,可以暴力查找,也可以采用堆维护进行优化。
堆优化的方式类似 Dijkstra 的堆优化,但如果使用二叉堆等不支持
时间复杂度估计:
暴力:
。
二叉堆:
。
Fib 堆:
。
与
算法最明显的区别是,
算法初始时拥有一片森林,从点入手对边进行操作;而
算法则是遍历边集,对点进行操作。这两种算法的入手点不同造成了各自擅长应用场景的不同:稀松图采用
算法进行求解更合适、稠密图则更适合使用
算法进行求解。
2.2 详细讲解
我们仍然使用下面这张示例图来讲解Prim算法的原理及过程。请注意,为了帮助理解,我们并非描述算法中的逐步过程,具体逐步过程参见"具体执行过程图解"
(从S = 0出发)
1.预备工作:首先我们将所有的结点分成两个集合A和B,A表示在最小生成树中的点,B则表示不在最小生成树中的点;
2.算法初始化:取任意一个结点(这里取
)加入最小生成树集合
,然后遍历集合
,选取一个点
使得
的权值最小,然后将
加入最小生成树集合
,并将该结点从集合
中移除;
- 目前的出发方案:
这里我们选择边权最小且相等的点
和
;
3.此时我们的出发路径方案有:
因此选择从3出发,到达结点1:
4.同理,继续列出方案方案扫描,选择1~4之间的边进行连接。
此时列举方案并遍历,发现所有的方案都会构成环路,遍历逐层终止并退出,最小生成树求解完毕
伪代码描述:
具体执行过程图解:
2.3 C++描述:Prim Algorithm
2.4 Python描述:Prim Algorithm