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

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


今天是算法和数据结构专题的第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] = 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。

今天的文章就到这里,原创不易,需要你的一个关注,关注我获取更多文章。

在这里插入图片描述

<think>好的,我现在需要给用户详细解释Prim算法,并提供C++模板代码。首先,用户提到正在学习最小生成树,但对Prim算法不太理解,所以需要从基础讲起,确保解释清晰。 首先,我应该回忆Prim算法的核心思想。Prim算法属于贪心算法,通过逐步扩展生成树,每次选择连接树与非树节点的最小边。这一点需要明确,可能用户对贪心策略不太清楚,需要简单说明。 接下来,步骤分解很重要。应该分步骤说明,比如初始化、选择边、更新等。同时,可能需要比较Prim和Kruskal的区别,帮助用户区分两者的不同应用场景,比如稠密图和稀疏图的选择。 然后,数据结构的使用是关键。Prim算法通常用优先队列(堆)来高效选取最小边,或者用普通数组。这里可能需要解释两种实现方式的不同,时间复杂度的差异。用户可能对优先队列的优化不太了解,需要详细说明。 关于时间复杂度,普通实现是O(),适合稠密图;优先队列优化后是O(E log V),适合稀疏图。这部分需要明确说明,让用户根据问题选择合适的实现方式。 模板代码部分,我需要提供一个结构清晰的C++示例。代码应该包括邻接表的表示,优先队列的使用,以及标记数组visited的处理。注释要详细,尤其是关键步骤,比如如何选取最小边,如何更新权值。 同时,要注意代码的可读性,比如变量命名、结构体定义。例如,使用Edge结构体存储边和权重,优先队列的比较方式,防止用户在这里出现常见错误。 用户可能的疑问点包括:为什么需要visited数组?如何避免环的形成?这些在解释时需要提到,Prim算法每次选择连接已选集合和未选集合的最小边,自然避免环,因为不会连接同一集合内的节点。 可能需要举例说明,比如通过一个简单的图,逐步展示Prim的执行过程,但用户没有要求例子,可能暂时不需要,但解释步骤时要具体。 最后,总结Prim的特点,强调其贪心策略和适用场景,帮助用户记忆。检查是否有遗漏的关键点,比如正确性证明,但用户可能只需要应用层面的理解,所以简要提及即可。 现在需要将上述思路整理成自然的中文,分步骤解释,确保逻辑连贯,代码正确并注释清楚。避免使用复杂术语,必要时用比喻或类比帮助理解。</think>Prim算法是一种用于求解加权无向图最小生成树的经典算法。它的核心思想是逐步扩展生成树,每次选择连接已选顶点集和未选顶点集的最小权值边。我将从核心概念、执行步骤、实现细节三个方面为您详细解析: --- ### **一、算法核心思想** 1. 贪心策略:每一步都选择当前可用的最小边 2. 双集合维护:保持已选顶点集合(生成树)和未选顶点集合 3. 安全边选择:确保每次添加的边都是连接两个集合的最小权值边 --- ### **二、算法执行步骤** 1. **初始化**:任选起始顶点,初始化优先队列(存储候选边) 2. **迭代扩展**: - 取出当前权值最小的边(u, v) - 将v加入生成树集合 - 将v的所有邻接边加入候选队列(仅限未访问顶点) 3. **终止条件**:所有顶点都加入生成树 --- ### **三、关键实现细节** 1. **数据结构选择**: - 优先队列(最小堆):高效获取最小边 - 标记数组:记录顶点是否已加入生成树 - 权重数组(可选):记录顶点到生成树的最小距离 2. **时间复杂度**: - 普通实现(邻接矩阵):O() → 适合稠密图 - 优先队列优化(邻接表):O(E log V) → 适合稀疏图 --- ### **四、C++模板代码实现** ```cpp #include <iostream> #include <vector> #include <queue> using namespace std; typedef pair<int, int> pii; // 优先队列元素(权重,顶点) vector<vector<pii>> graph; // 邻接表存储图 int prim(int start, int n) { vector<bool> visited(n, false); priority_queue<pii, vector<pii>, greater<pii>> pq; // 最小堆 int mst = 0; pq.push({0, start}); // 起始点权重为0 while (!pq.empty()) { auto [weight, u] = pq.top(); pq.pop(); if (visited[u]) continue; // 已访问则跳过 visited[u] = true; mst += weight; // 遍历邻接顶点 for (auto &[v, w] : graph[u]) { if (!visited[v]) { pq.push({w, v}); } } } return mst; } int main() { int n = 5; // 顶点数示例 graph.resize(n); // 示例图的边添加(实际使用时根据输入处理) graph[0].push_back({1, 2}); graph[0].push_back({3, 6}); graph[1].push_back({2, 3}); graph[1].push_back({3, 8}); graph[1].push_back({4, 5}); graph[2].push_back({4, 7}); cout << "最小生成树权值和: " << prim(0, n) << endl; return 0; } ``` --- ### **五、算法特点总结** 1. **适用场景**: - 稠密图表现更优 - 需要特定起点生成树时 - 边数量级接近顶点平方时 2. **与Kruskal对比**: - Prim维护连通子树,Kruskal处理森林 - Prim适合邻接矩阵,Kruskal适合边排序 - Prim时间复杂度更稳定,Kruskal代码更简单 建议通过具体示例逐步推演算法执行过程,观察优先队列的变化和顶点标记的更新,可以更直观理解算法的运行机制。实际应用时,根据图的密度选择适当的实现方式。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值