点击上方蓝字,和我一起学技术。

今天是算法和数据结构专题的第19篇文章,我们一起来看看最小生成树。

我们先不讲算法的原理,也不讲一些七七八八的概念,因为对于初学者来说,看到这些术语和概念往往会很头疼。头疼也是正常的,因为无端突然出现这么多信息,都不知道它们是怎么来的,也不知道这些信息有什么用,自然就会觉得头疼。这也是很多人学习算法热情很高,但是最后又被劝退的原因。

我们先不讲什么叫生成树,怎么生成树,有向图、无向图这些,先简单点,从最基本的内容开始,完整地将这个算法梳理一遍。

树是什么

首先,我们先来看看最简单的数据结构——树。

树是一个很抽象的数据结构,因为它在自然界当中能找到对应的物体。我们在初学的时候,往往都会根据自然界中真实的树来理解这个概念。所以在我们的认知当中,往往树是长这样的:


上面这张图就是自然界中树的抽象,我们很容易理解。但是一般情况下,我们看到的树结构往往不是这样的,而是倒过来的。也就是树根在上,树叶在下。这样设计的原因很简单,没什么特别的道理,只是因为我们在遍历树的时候,往往从树根开始,从树根往叶子节点出发。所以我们倒过来很容易理解一些,我们把上面的树倒过来就成了这样:


上面的两种画法当然都是正确的,但既然树可以正着放,也可以倒过来放,我们自然也可以将它伸展开来放。比如下面这张图,其实也是一棵树,只是我们把它画得不一样而已。


我们可以想象一下,假如有一只无形的大手抓住了树根将它“拎起来”,那么它自然而然就变成了上面的样子。

然后你会发现,如果真的有这样大手,它不管拎起哪个节点,都会得到一棵树。也就是说,如果树根的位置对我们不再重要的话,树其实就等价于上面这样的图。

那么这样的图究竟是什么图呢?它有什么性质呢?所有的图都能看成是树吗?


显然这三种情况都不是树,第一种是因为图中的边有方向了。有了方向之后,图中连通的情况就被破坏了。在我们认知当中树应该是全连通的,就好像自然界中的一只蚂蚁,可以走到树上任何位置。不能全连通,自然就不是树。情况2也不对,因为有了环,树是不应该有环的。自然界中的树是没有环的,不存在某根树枝自己绕一圈,同样,我们逻辑中的树也是没有环的,否则我们递归访问永远也找不到终点。第三种情况也一样,有些点孤立在外,不能连通,自然也不是树。

那我们总结一下,就可以回答这个问题。树是什么?树就是可以全连通(无向图),并且没有环路的图。

从图到树

从刚才的分析当中,我们得到了一个很重要的结论,树的本质就是图,只不过是满足了一些特殊性质的图。这也是为什么树的很多算法都会被收纳进图论这个大概念当中。

全连通和没有环路这两个性质出发,我们又可以得到一个很重要的结论,对于一棵拥有n个节点的树而言,它的边数是固定的,一定是n-1条边。如果超过n-1条边,那么当中一定存在环路,如果小于n-1条边,那么一定存在不连通的部分。但注意,它只是一个必要条件,不是一个充分条件。也就是说并不是n个点n-1条边就一定是树,这很容易构造出反例。

这个结论虽然很简单,但是很有用处,它可以解决一个由图转化成树的问题。

也就是说当下我们拥有一个复杂图,我们想要根据这个图生成能够连通所有节点的树,这个时候应该怎么办?如果我们没有上面的性质,会有一点无从下手的感觉。但有了这个性质之后,就明确多了。我们一共有两种办法,第一种办法是删减边,既然是一个复杂图,说明边的数量一定超过n-1。那么我们可以试着删去一些边,最后留下一棵树。第二种做法与之相反,是增加边。也就是说我们一开始把所有的边全部撤掉,然后一条一条地往当中添加n-1条边,让它变成一棵树。

我们试着想一下,会发现删减边的做法明显弱于添加边的方法。原因很简单,因为我们每一次在删除边的时候都面临是否会破坏树上连通关系的拷问。比如下图:


如果我们一旦删去了AB这条边,那么一定会破坏整个结构的连通性。我们要判断连通关系,最好的办法就是我们先删除这条边,然后试着从A点出发,看看能否到达B点。如果可以,那么则认为这条边可以删除。如果图很大的话,每一次删除都需要遍历整张图,这会带来巨大的开销。并且每一次删除都会改变图的结构,很难缓存这些结果。

因此,删除边的方式并不是不可行,只是复杂度非常高,正因此,目前比较流行的两种最小生成树的算法都是利用的第二种,也就是添加边的方式实现的。

到这里,我们就知道了,所谓的最小生成树算法,就是从图当中挑选出n-1条边将它转化成一棵树的算法。

解决生成问题

我们先不考虑边上带权重的情况,我们假设所有边都是等价的,先来看看生成问题怎么解决,再来进行优化求最小。

如果采用添加边的方法,面临的问题和上面类似,当我们选择一条边的时候,我们如何判断这条边是有必要添加的呢?这个问题需要用到树的另外一个性质。

由于没有环路,树上任意两点之间的路径,有且只有一条。因为如果存在两点之间的路径有两条,那么必然可以找到一个环路。它的证明很简单,但是我们很难凭自己想到这个结论。有了这个结论,就可以回答上面的那个问题,什么样的边是有必要添加的?也就是两个点之间不存在通路的时候。如果两个点之间已经存在通路,那么当前这条边就不能添加了,否则必然会出现环。如果没有通路,那么可以添加。

所以我们要做的就是设计一个算法,可以维护树上点的连通性

但是这又带来了一个新的问题,在树结构当中,连通性是可以传递的。两个点之间连了一条边,并不仅仅是这两个点连通,而是所有与这两个点之间连通的点都连通了。比如下图:


这张图当中A和B连了一条边,这不仅仅是A和B连通,而是左半边的集合和右半边集合的连通。所以,虽然A只是和B连通了,但是和C也连通了。AC这条边也一样不能被加入了。也就是说A和B连通,其实是A所在的集合和B所在的集合合并的过程。看到集合的合并,有没有一点熟悉的感觉?对嘛,上一篇文章当中我们讲的并查集算法就是用来解决集合合并和查询问题的。那么,显然可以用并查集来维护图中这些点集的连通性。

如果对并查集算法有些遗忘的话,可以点击下方的传送门回顾一下:

四十行代码搞定经典的并查集算法

利用并查集算法,问题就很简单了。一开始所有点之间都不连通,那么所有点单独是一个集合。如果当前边连通的两个点所属于同一个集合,那么说明它们之间已经有通路了,这条边不能被添加。否则的话,说明它们不连通,那么将这条边连上,并且合并这两个集合。

于是,我们就解决了生成树这个问题。

从生成树到最小生成树

接下来,我们为图中的每条边加上权重,希望最后得到的树的所有权重之和最小。

比如,我们有下面这张图,我们希望生成的树上所有边的权重和最小


观察一下这张图上的边,长短不一。根据贪心算法,我们显然希望用尽量短的边来连通树。所以Kruskal算法的原理非常简单粗暴,就是对这些边进行长短排序,依次从短到长遍历这些边,然后通过并查集来维护边是否能够被添加,直到所有边都遍历结束。

可以肯定,这样生成出来的树一定是正确的,虽然我们对边进行了排序,但是每条边依然都有可能会被用上,排序并不会影响算法的可行性。但问题是,这样贪心出来的结果一定是最优的吗?

这里,我们还是使用之前讲过的等价判断方法。我们假设存在两条长度一样的边,那么我们的决策是否会影响最后的结果呢?

两个完全相等的边一共只有可能出现三种情况,为了简化图示,我们把一个集合看成是一个点。第一种情况是这两条边连通四个不同的集合:


那么显然这两条边之间并不会引起冲突,所以我们可以都保留。所以这不会引起反例。

第二种情况是这两条边连通三个不同的集合:


这种情况和上面一样,我们可以都要,并不会影响连通情况。所以也不会引起反例。

最后一种是这两条边连通的是两个集合,也就是下面这样。


在这种情况下,这两条件之间互相冲突,我们只能选择其中的一条。但是显然,不论我们怎么选都是一样的。因为都是连接了这两个连通块,然后带来的价值也是一样的,并不会影响最终的结果

当我们把所有情况列举出来之后,我们就可以明确,在这个问题当中贪心法是可行的,并不会引起反例,所以我们可以放心大胆地用。

实际问题与代码实现

明白了算法原理之后,我们来看看这个算法的实际问题。其实这个算法在现实当中的使用蛮多的,比如自来水公司要用水管连通所有的小区。而水管是有成本的,那么显然自来水公司希望水管的总长度尽量短。比如山里的村庄通电,要用尽量少的电缆将所有村庄连通,这些类似的问题其实都可以抽象成最小生成树来解决。当然现实中的问题可能没有这么简单,除了考虑成本和连通之外,还需要考虑地形、人文、社会等其他很多因素。

最后,我们试着用代码来实现一下这个算法。

class DisjointSet:def __init__(self, element_num=None):
        self._father = {}
        self._rank = {}# 初始化时每个元素单独成为一个集合if element_num is not None:for i in range(element_num):
                self.add(i)def add(self, x):# 添加新集合# 如果已经存在则跳过if x in self._father:return 
        self._father[x] = x
        self._rank[x] = 0def _query(self, x):# 如果father[x] == x,说明x是树根if self._father[x] == x:return x
        self._father[x] = self._query(self._father[x])return self._father[x]def merge(self, x, y):if x not in self._father:
            self.add(x)if y not in self._father:
            self.add(y)# 查找到两个元素的树根
        x = self._query(x)
        y = self._query(y)# 如果相等,说明属于同一个集合if x == y:return# 否则将树深小的合并到树根大的上if self._rank[x]             self._father[x] = yelse:
            self._father[y] = x# 如果树深相等,合并之后树深+1if self._rank[x] == self._rank[y]:
                self._rank[x] += 1# 判断是否属于同一个集合def same(self, x, y):return self._query(x) == self._query(y)# 构造数据
edges = [[1, 2, 7], [2, 3, 8], [2, 4, 9], [1, 4, 5], [3, 5, 5], [2, 5, 7], [4, 5, 15], [4, 6, 6], [5, 6, 8], [6, 7, 11], [5, 7, 9]]if __name__ == "__main__":
    disjoinset = DisjointSet(8)# 根据边长对边集排序
    edges = sorted(edges, key=lambda x: x[2])
    res = 0for u, v, w in edges:if disjoinset.same(u ,v):continue
        disjoinset.merge(u, v)
        res += w
    print(res)

其实主要都是利用并查集,我们额外写的代码就只有几行而已,是不是非常简单呢?

结尾

相信大家也都感觉到了Kruskal算法的原理非常简单,如果你是顺着文章脉络这样读下来,相信一定会有一种顺水推舟,一切都自然而然的感觉。也正是因此,它非常符合直觉,也非常容易理解,一旦记住了就不容易忘记,即使忘记了我们也很容易自己推导出来。这并不是笑话,有一次我在比赛的时候临时遇到了,当时许久不写Kruskal算法,一时想不起来。凭着仅有的一点印象,硬是在草稿纸上推导了一遍算法。

在下一篇文章当中我们继续研究最小生成树问题,一起来看另外一个类似但不相同的算法——Prim。

今天的文章就到这里,原创不易,需要你的一个关注,你的举手之劳对我来说很重要。

证明kruskal算法求解图的最小生成树具有贪心选择性质_将并查集应用在图论中的最小生成树算法——Kruskal...相关推荐

  1. 【并查集】【图论】【最小生成树】剑鱼行动(ssl 1618)

    剑鱼行动 ssl 1618 题目大意: 求一个平面直角坐标系中的最小生成树 原题: 题目描述 给出N个点的坐标,对它们建立一个最小生成树,代价就是连接它们的路径的长度,现要求总长度最小.N的值在100 ...

  2. 弗洛伊德(Floyd)算法求解图的最短路径

    弗洛伊德(Froyd)算法用于求解所有顶点到所有顶点的的最短路径.时间复杂度为O(n^3). 正如我们所知道的,Floyd算法用于求最短路径.Floyd算法可以说是Warshall算法的扩展,三个fo ...

  3. Dijkstra算法求解图中最短路径距离

    前言:这里是自学内容,讲解的是用python来实现Dijkstra算法,算是入门求解图中最短路径问题的典型案例. 算法简介: 迪杰斯特拉(Dijkstra)算法是一个按照路径长度递增的次序产生的最短路 ...

  4. 图 相关算法~从头学算法【广搜、 深搜、 拓扑排序、 并查集、 弗洛伊德算法、迪杰斯特拉算法】

    图的相关主流算法主要有: 广度优先搜索 深度优先搜索 拓扑排序 并查集 多源最短路径(弗洛伊德算法) 单源最短路径(迪杰斯特拉算法) 其中呢,最基本的是前两种,也就是平时常用的广搜和深搜,本文中将概要 ...

  5. java 最少使用(lru)置换算法_「Redis源码分析」Redis中的LRU算法实现

    如果对我的文章感兴趣.希望阅读完可以得到你的一个[三连],这将是对我最大的鼓励和支持. LRU是什么 LRU(least recently used)是一种缓存置换算法.即在缓存有限的情况下,如果有新 ...

  6. android studio插入数据表中没有_学Java能拿高薪吗 Java中常见排序算法有哪些

    学Java能拿高薪吗?Java中常见排序算法有哪些?作为老牌编程语言,Java拥有广阔的市场占有率,几乎90%以上的大中型互联网应用系统在服务端开发都会首选Java.为了加入到Java这一高薪行业,很 ...

  7. 动态图连通性(线段树分治+按秩合并并查集)

    在考场上遇到了这个的板子题,,,所以来学习了一下线段树分治 + 带撤销的并查集. 题目大意是这样的:有m个时刻,每个时刻有一个加边or撤销一条边的操作,保证操作合法,没有重边自环,每次操作后输出当前图 ...

  8. 挑战程序设计竞赛(算法和数据结构)——14.1互质的集合(并查集)的JAVA实现

    题目与思路: 代码: import java.util.Scanner; import java.util.Vector;public class DisjointSet {public static ...

  9. 算法设计与分析 实验二 贪心算法

    实验2.<贪心算法实验> 一.实验目的 了解贪心算法思想 掌握贪心法典型问题,如背包问题.作业调度问题等. 二.实验内容 编写一个简单的程序,实现单源最短路径问题. 编写一段程序,实现找零 ...

最新文章

  1. 反激式开关电源变压器设计
  2. 一致性哈希算法——算法解决的核心问题是当slot数发生变化时,能够尽量少的移动数据...
  3. nvidia-smi命令显示NVIDIA-SMI has failed because it couldn‘t communicate with the NVIDIA driver
  4. Python pip 国内镜像大全及使用办法
  5. 用PHP控制您的浏览器cache
  6. property java用法_Java Properties getProperty(key)用法及代码示例
  7. excel运行python_使用PyXLL在Excel中执行Python脚本
  8. 微信公众号发送模板消息 -- PHP后台
  9. 信令传送协议-SCTP协议解析
  10. 计算机按键截图,电脑按什么键自由截图(电脑截屏的快捷键是什么)
  11. 射击类游戏--射击辅助线的实现
  12. 在 pygame 中好好玩玩精灵,滚雪球学 Python 游戏番
  13. E500 键盘粘键,倒水滑落键盘
  14. Vue2.0 内置指令directives 与全局配置过滤filters
  15. tomcat安装配置.md
  16. 二叉树的后序非递归遍历(巧妙思想)
  17. Selenium安装流程
  18. okd下gitlab首次启动没有重置密码如何登陆
  19. java业界新闻语音播报
  20. 获取当天,本周,本月,本季度,本半年,本年时间

热门文章

  1. 纪念下数据路上遇到的贵人
  2. Travis CI 一些专用术语介绍
  3. 如何修改 pdf 文件默认的显示图标
  4. SAP UI5 视图控制器 View Controller 的生命周期方法 - Lifecycle methods
  5. 写给即将离开校园准备进入 SAP 研究院实习的朋友
  6. 从调试器里观察到的Observable对象反推出其赋值的源代码位置
  7. 使用jsp打印HTTP请求头部所有字段的值
  8. 如何以sandbox模式测试开发好的Fiori Launchpad插件
  9. role cache - set data user parameter - /UI2/CACHE_DISABLE
  10. pageSet的底层数据库存储逻辑