这是一篇每一个人都能读懂的最小生成树文章(Kruskal)

本文始发于我的公众号:TechFlow,原创不易,求个关注web


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

咱们先不讲算法的原理,也不讲一些七七八八的概念,由于对于初学者来讲,看到这些术语和概念每每会很头疼。头疼也是正常的,由于无故忽然出现这么多信息,都不知道它们是怎么来的,也不知道这些信息有什么用,天然就会以为头疼。这也是不少人学习算法热情很高,可是最后又被劝退的缘由。缓存

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

树是什么

首先,咱们先来看看最简单的数据结构——树。编辑器

树是一个很抽象的数据结构,由于它在天然界当中能找到对应的物体。咱们在初学的时候,每每都会根据天然界中真实的树来理解这个概念。因此在咱们的认知当中,每每树是长这样的:学习

上面这张图就是天然界中树的抽象,咱们很容易理解。可是通常状况下,咱们看到的树结构每每不是这样的,而是倒过来的。也就是树根在上,树叶在下。这样设计的缘由很简单,没什么特别的道理,只是由于咱们在遍历树的时候,每每从树根开始,从树根往叶子节点出发。因此咱们倒过来很容易理解一些,咱们把上面的树倒过来就成了这样:优化

上面的两种画法固然都是正确的,但既然树能够正着放,也能够倒过来放,咱们天然也能够将它伸展开来放。好比下面这张图,其实也是一棵树,只是咱们把它画得不同而已。spa

咱们能够想象一下,假若有一只无形的大手抓住了树根将它“拎起来”,那么它天然而然就变成了上面的样子。设计

而后你会发现,若是真的有这样大手,它无论拎起哪一个节点,都会获得一棵树。也就是说,若是树根的位置对咱们再也不重要的话,树其实就等价于上面这样的图。code

那么这样的图到底是什么图呢?它有什么性质呢?全部的图都能当作是树吗?

显然这三种状况都不是树,第一种是由于图中的边有方向了。有了方向以后,图中连通的状况就被破坏了。在咱们认知当中树应该是全连通的,就好像天然界中的一只蚂蚁,能够走到树上任何位置。不能全连通,天然就不是树。状况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] = 0

    def _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._rank[y]:
            self._father[x] = y
        else:
            self._father[y] = x
            # 若是树深相等,合并以后树深+1
            if 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 = 0
    for u, v, w in edges:
        if disjoinset.same(u ,v):
            continue
        disjoinset.merge(u, v)
        res += w
    print(res)

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

结尾

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

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

今天的文章就到这里,原创不易,须要你的一个关注,扫码关注,获取更多精彩文章。

相关文章
相关标签/搜索