CC++ & Algorithm

Python最小生成树 Kruskal 算法

较难4
语言版本:C++Python
概述:把城市之间的道路想象成绳子,Kruskal算法像搭积木一样,每次选最短的绳子连接两个城市,同时避免形成环,最后用最少的绳子把所有城市连起来。

用最少的钱连通所有城市——Python Kruskal 算法详解

想象一下,你是一个城市设计师,要给几个新建的居民区铺设供暖管道。每个居民区之间铺设管道的成本不同,你希望花最少的钱,让所有居民区都能通过管道连通(比如从A区可以通过B区再到C区,不需要A和C之间直接连)。这就是“最小生成树”问题——在图中找到一棵包含所有节点且边权总和最小的树。Kruskal算法就是解决这个问题的经典方法之一,它像搭积木一样,每次挑最便宜、不造成“环路”的边,直到所有点都连起来。

算法核心思想——先排序,再连接,不绕圈

Kruskal算法的核心步骤只有三步:

  1. 排序:把所有的边(连接两个节点的路径)按权重从小到大排序。就像把所有桥的造价从低到高排好队。
  2. 选边:从最便宜的边开始检查。如果这条边的两个端点还没有被连通(不在同一个集合里),就选择它,并标记这两个端点属于同一个连通块;否则跳过它。
  3. 停止:当选择的边数等于节点数减1时,就找到了最小生成树。因为一棵有n个节点的树恰好有n-1条边。

这个过程很像玩“俄罗斯方块”或者“积木连接”:你手里有一堆长短不一的积木条(边),每次拿起最短的那根,如果它能把两个原本不连在一起的积木块粘起来,你就用它;如果它连的两个积木块已经属于同一大块了(再连就会绕成一个环),就扔掉它。直到所有散块都合成一大块,你手里的积木条就是最短路径。

并查集——判断“是否已经连通”的神器

在Kruskal算法中,我们需要快速知道两个节点是否已经在同一个连通块中,并且当选择一条边后,需要把两个连通块合并。这就是**并查集(Union-Find)**的用武之地。它就像一个“帮派登记系统”,每个节点都属于一个“帮派”,帮派有一个“老大”作为代表。我们可以通过找“老大”来判断两个节点是不是一伙的,也可以把两个帮派合并成一个。

并查集的三个核心操作

  • 初始化:每个人最初都是自己的老大。parent数组记录每个人的上级,rank数组记录帮派的“等级”,用于优化合并。
  • 查找(find):找到某个节点的帮派老大(根节点)。路径压缩优化:找老大的路上,顺便把路上的小弟直接挂在老大名下,下次找得更快。
  • 合并(union):把两个帮派合并。先找到各自老大,如果老大不同,就把等级低的帮派合并到等级高的帮派下,保持树的平衡。

生活中的例子

假设班上有5个同学,一开始谁也不认识谁。parent记录每个同学自己(0号同学的老大是0,1号同学的老大是1……)。老师宣布:“现在开始交朋友,每次我念一对同学的名字,如果他们已经是一个朋友圈了,就跳过;否则他们就成为新朋友,合并朋友圈。”
第一次老师念:“小明和小红”。因为小明和小红的老大不同(小明老大是0号,小红老大是1号),于是合并,比如让小红的老大认小明为老大。现在小明和小红在同一朋友圈。
第二次老师念:“小红和小刚”。查一下:小红的老大是0,小刚的老大是2,不同,合并。现在0号帮派包含了小明、小红、小刚。
第三次老师念:“小明和小刚”。查一下:小明老大是0,小刚老大也是0(因为小红合并时,小刚也跟了小明),相同!这说明他们已经是一个朋友圈了,这条边会造成“环”,不能选。
这个机制正是Kruskal所需要的。

Python代码实现并查集

class UnionFind:
    def __init__(self, n):
        # parent: 每个节点的父节点,最初自己指向自己
        self.parent = list(range(n))
        # rank: 每个根节点的“高度”估计值,用于平衡树
        self.rank = [0] * n

    def find(self, x):
        # 查找节点x的根节点,同时做路径压缩
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x, y):
        # 合并x和y所在的集合,若已在同一集合返回False,否则返回True
        root_x = self.find(x)
        root_y = self.find(y)
        if root_x == root_y:
            return False  # 已经连通,不可选
        # 按秩合并:把高度小的树挂到高度大的树下
        if self.rank[root_x] < self.rank[root_y]:
            self.parent[root_x] = root_y
        elif self.rank[root_x] > self.rank[root_y]:
            self.parent[root_y] = root_x
        else:
            self.parent[root_y] = root_x
            self.rank[root_x] += 1  # 高度相等时,合并后高度+1
        return True

Kruskal主算法——搭积木的过程

有了并查集,Kruskal算法就很简单了:

  1. 将所有边按照权重排序(权重就是修路/架桥的成本)。
  2. 初始化并查集,节点从0到n-1。
  3. 依次遍历排好序的边。对每条边(u, v, weight),调用union
    • 如果返回True,说明这条边连接了两个不同的连通块,选它!增加总权重,把它记录下来。
    • 如果返回False,说明会形成环,跳过。
  4. 当选中边的数量达到n-1时,提前结束,因为已经形成了一棵完整的树。

完整可运行的示例代码

下面我们用5个城市(节点0~4)和7条可能的道路(边)来演示。边用元组(权重, 起点, 终点)表示。

class UnionFind:
    def __init__(self, n):
        # parent: 每个节点的父节点,初时自己指向自己
        self.parent = list(range(n))
        # rank: 每个根节点的高度估计
        self.rank = [0] * n

    def find(self, x):
        # 查找x的根节点(路径压缩)
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x, y):
        # 合并x和y所在的集合,返回是否真正合并
        root_x = self.find(x)
        root_y = self.find(y)
        if root_x == root_y:
            return False
        # 按秩合并
        if self.rank[root_x] < self.rank[root_y]:
            self.parent[root_x] = root_y
        elif self.rank[root_x] > self.rank[root_y]:
            self.parent[root_y] = root_x
        else:
            self.parent[root_y] = root_x
            self.rank[root_x] += 1
        return True

def kruskal_mst(n, edges):
    """
    计算最小生成树
    :param n: 节点个数(0 ~ n-1)
    :param edges: 边列表,每个元素是 (weight, u, v)
    :return: (总权重, 选中的边列表)
    """
    # 按权重从小到大排序
    edges.sort()
    # 创建并查集
    uf = UnionFind(n)
    # 总权重初始为0
    mst_weight = 0
    # 记录选中的边
    mst_edges = []
    # 遍历所有边
    for weight, u, v in edges:
        # 尝试合并u和v,如果成功说明这条边可以选
        if uf.union(u, v):
            mst_weight += weight
            mst_edges.append((u, v, weight))
            # 当选中边数达到 n-1,提前结束
            if len(mst_edges) == n - 1:
                break
    return mst_weight, mst_edges

# ---------- 示例:5个城市修路 ----------
# 节点数量
node_count = 5
# 道路列表:(造价, 城市A, 城市B)
road_list = [
    (10, 0, 1),   # 0-1 造价10
    (15, 0, 2),   # 0-2 造价15
    (5,  1, 2),   # 1-2 造价5
    (12, 1, 3),   # 1-3 造价12
    (8,  2, 3),   # 2-3 造价8
    (7,  2, 4),   # 2-4 造价7
    (3,  3, 4)    # 3-4 造价3
]

total_cost, used_roads = kruskal_mst(node_count, road_list)
print("最小生成树总成本:", total_cost)
print("选中的道路(城市A, 城市B, 造价):", used_roads)

运行结果:

最小生成树总成本: 23
选中的道路(城市A, 城市B, 造价): [(3, 3, 4), (5, 1, 2), (7, 2, 4), (10, 0, 1)]

解释:我们选了造价3、5、7、10的四条路,总成本23,连通了所有5个城市。注意造价8的那条路(2-3)没选,因为当它被检查时,节点2和3已经在同一个连通块里了(通过2-4-3已连通),再选就会形成环。

新手容易犯的错误

  • 忘记排序:直接按输入顺序处理边,结果肯定不是最小生成树。排序是Kruskal算法的前提。
  • 混淆节点编号:并查集中的节点编号必须从0开始连续,如果实际节点是“A、B、C”,要先映射成0、1、2。
  • 忘记检查len(mst_edges) == n-1:虽然最后总会退出循环,但提前终止可以提高效率,否则会继续检查后面的边,虽然不影响结果但浪费。
  • 并查集路径压缩不彻底:如果find函数忘了递归更新parent,会导致多次查找变慢。一定要用self.parent[x] = self.find(self.parent[x])
  • 把边的权重和节点搞反:在变元组中,通常约定(weight, u, v),如果写成(u, v, weight),排序时就会按节点编号排序,完全错误。
  • 未考虑图不连通的情况:Kruskal算法假设图是连通的,如果最后选中的边数小于n-1,说明原图不连通,没有最小生成树。实际编程中应该对这种情况进行报错处理。

相关知识点指引

  • Prim算法:另一种求最小生成树的算法,从任意点开始,每次找权重最小的边向外扩展,适合稠密图。
  • 最短路径算法(Dijkstra、Bellman-Ford):最小生成树关心所有点连通的总代价,而最短路径关心两个特定点之间的最小代价,两者容易混淆。
  • 并查集优化:路径压缩和按秩合并可以使并查集操作近乎常数时间。
  • 图论基础:理解图的表示(邻接矩阵、邻接表)、连通分量、树的性质(n个节点n-1条边)是学习Kruskal的前提。

如果你掌握了Kruskal算法,还可以尝试解决更多实际问题,比如:城市供水管网设计、电路布线、网络布线等——只要把“连接成本”抽象为权重,就能用这个算法找到最省钱的方案。

例题精讲

1单选题

在Kruskal算法中,通常使用哪种数据结构来检测是否形成环?

A
B并查集
C
D哈希表
2单选题

Kruskal算法中,对边按权重排序的时间复杂度是?

AO(V)
BO(E log E)
CO(V+E)
DO(V^2)
3判断题

Kruskal算法一定能得到最小生成树,即使图中存在权值相同的边。

4判断题

Kruskal算法每一步选择当前权值最小的边,并保证不形成环,这是一种贪心策略。

5填空题
def kruskal(n, edges):
    parent = list(range(n))
    rank = [0]*n
    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]
    def union(x, y):
        # 按秩合并的代码略
        pass
    edges.sort(key=lambda e: e[2])
    mst = []
    for u, v, w in edges:
        if ___ :  # 检查u和v是否已连接
            mst.append((u, v, w))
            union(u, v)
        if len(mst) == n-1:
            break
    return mst