46·图算法高级

Kruskal 最小生成树

kruskalmstgreedyunion-find

第 46 课 - Kruskal 最小生成树

学习目标

  • 理解最小生成树(MST)的概念及其在实际场景中的应用价值
  • 掌握 Kruskal 算法的贪心策略和实现流程
  • 熟悉并查集(Union-Find)数据结构在 Kruskal 算法中的关键作用
  • 能够使用 Kruskal 算法解决带权无向图的最小生成树问题
  • 了解 Kruskal 算法的时间复杂度及其适用场景

核心概念

什么是最小生成树?

最小生成树是连接图中所有顶点的边的子集,需要满足:

  1. 包含图中所有顶点
  2. 边的权重之和最小
  3. 没有环路(是一棵树)

想象你是一个建筑师,需要连接几个村庄修建公路,要求总造价最低。Kruskal 算法就像一个精明的承包商:始终选择当前最便宜的路,只要这条新路不会形成环路。

Kruskal 算法的核心思想

Kruskal 算法采用贪心策略

  1. 将图中所有边按权重从小到大排序
  2. 依次考虑每条边,如果这条边连接的两个顶点不在同一个连通分量中,则选择这条边
  3. 重复第 2 步,直到选择了 n-1 条边(n 为顶点数)

关键问题:如何高效判断两个顶点是否在同一连通分量?答案是并查集数据结构。

并查集在 Kruskal 中的作用

并查集用于高效管理顶点所属的连通分量:

  • find(x): 确定元素 x 属于哪个集合(通常通过返回根节点标识)
  • union(x, y): 合并两个元素所在的集合

在 Kruskal 算法中,每个顶点初始时自成一个集合。当我们考虑一条边 (u, v) 时:

  • 如果 find(u) != find(v),说明 u 和 v 在不同的连通分量中
  • 选择这条边后,执行 union(u, v) 将两个连通分量合并

代码示例

class UnionFind:
    """并查集实现,用于高效管理连通分量"""
    def __init__(self, n):
        # 初始化:每个节点都是自己的父节点(独立集合)
        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(vertices, edges):
    """
    Kruskal 算法求解最小生成树
    
    参数:
        vertices: 顶点数量
        edges: 边的列表,格式为 [(权重, 起点, 终点), ...]
    
    返回:
        mst_edges: 最小生成树的边列表
        total_weight: 最小生成树的总权重
    """
    # 1. 按权重排序所有边(升序)
    sorted_edges = sorted(edges, key=lambda x: x[0])
    
    # 2. 初始化并查集
    uf = UnionFind(vertices)
    
    mst_edges = []
    total_weight = 0
    
    # 3. 按顺序考虑每条边
    for weight, u, v in sorted_edges:
        # 检查这条边是否会导致环路
        if uf.find(u) != uf.find(v):
            # 选择这条边
            mst_edges.append((u, v, weight))
            total_weight += weight
            
            # 合并两个连通分量
            uf.union(u, v)
            
            # 如果已经选择了 n-1 条边,提前结束
            if len(mst_edges) == vertices - 1:
                break
    
    return mst_edges, total_weight


# 测试示例
def main():
    # 创建一个示例图
    # 顶点: 0, 1, 2, 3, 4
    vertices = 5
    
    # 边: (权重, 起点, 终点)
    edges = [
        (2, 0, 1),  # 边 0-1,权重 2
        (3, 0, 4),  # 边 0-4,权重 3
        (5, 1, 4),  # 边 1-4,权重 5
        (1, 1, 2),  # 边 1-2,权重 1
        (4, 2, 4),  # 边 2-4,权重 4
        (6, 2, 3),  # 边 2-3,权重 6
        (7, 3, 4),  # 边 3-4,权重 7
    ]
    
    print("=== Kruskal 最小生成树算法演示 ===")
    print(f"输入图有 {vertices} 个顶点,{len(edges)} 条边")
    
    # 运行 Kruskal 算法
    mst_edges, total_weight = kruskal_mst(vertices, edges)
    
    print("\n=== 结果 ===")
    print("最小生成树包含以下边:")
    for u, v, weight in mst_edges:
        print(f"边 {u} -- {v}, 权重: {weight}")
    
    print(f"\n最小生成树总权重: {total_weight}")
    print(f"共选择了 {len(mst_edges)} 条边")


if __name__ == "__main__":
    main()

实践练习

练习 1:基础理解

要求:考虑下图的边列表:[(1, A, B), (3, A, C), (2, B, C), (4, B, D), (5, C, D)]

  1. 请手动模拟 Kruskal 算法的执行过程
  2. 写出选择边的顺序
  3. 计算最小生成树的总权重

预期输出

选择顺序: (1, A, B), (2, B, C), (4, B, D)
总权重: 7

练习 2:代码实现

要求:扩展上述代码,添加一个 visualize_mst() 函数,使用简单的文本方式显示图和最小生成树。

提示

  1. 创建一个函数,接受原始边列表和 MST 边列表
  2. 为所有边显示标记,属于 MST 的边用特殊符号标识
  3. 按顶点对展示连接关系

练习 3:实际应用

要求:假设你有 6 个无线网络节点,它们之间的信号强度(权重越小越好)如下:

edges = [
    (10, 0, 1), (15, 0, 2), (20, 1, 2),
    (25, 1, 3), (30, 2, 4), (35, 3, 4),
    (40, 3, 5), (45, 4, 5), (50, 1, 4)
]

使用 Kruskal 算法找到连接所有节点的最优网络拓扑,输出选择的连接和总信号强度。

常见错误

1. 忽略并查集的路径压缩

# 错误写法 - 没有路径压缩
def find(self, x):
    if self.parent[x] != x:
        return self.find(self.parent[x])  # 递归但未压缩
    return x

# 正确写法 - 带路径压缩
def find(self, x):
    if self.parent[x] != x:
        self.parent[x] = self.find(self.parent[x])  # 压缩路径
    return self.parent[x]

2. 边排序不彻底

Kruskal 算法要求严格按权重升序排序,如果权重相等,边的顺序可能影响结果,但不影响正确性。

3. 过早终止循环

一些初学者会在选择了 n-1 条边后立即停止,但忘记检查是否所有顶点都已连通。对于非连通图,最小生成树不存在。

4. 忽略图的连通性

如果图本身不连通,Kruskal 算法会生成最小生成森林(每个连通分量的最小生成树)。需要在算法开始时检查或明确处理这种情况。

5. 并查集合并逻辑错误

# 错误:总是把 y 合并到 x
def union(self, x, y):
    self.parent[find(y)] = find(x)  # 可能导致树过深

# 正确:按秩合并
def union(self, x, y):
    root_x = self.find(x)
    root_y = self.find(y)
    if self.rank[root_x] < self.rank[root_y]:
        self.parent[root_x] = root_y
    else:
        self.parent[root_y] = root_x
        if self.rank[root_x] == self.rank[root_y]:
            self.rank[root_x] += 1

小结

关键要点回顾

  1. Kruskal 算法本质:基于贪心策略,逐步选择不会形成环路的最小权重边
  2. 并查集是关键:高效管理连通分量,判断新边是否会导致环路
  3. 时间复杂度
    • 主要时间消耗在边的排序:O(E log E)
    • 并查集操作:几乎 O(α(V)),α 是反阿克曼函数,实际可视为常数
    • 总复杂度:O(E log E),适合边稀疏的图
  4. 适用场景
    • 边稀疏的图(E 相对较小)
    • 边已经部分排序的图
    • 需要在线处理边流的情况(可稍作修改)
  5. 与 Prim 算法对比
    • Kruskal:全局考虑所有边,适合稀疏图
    • Prim:逐步扩展顶点集合,适合稠密图

思考题

  1. 如果图中所有边的权重都相等,Kruskal 算法会返回什么?
  2. 如何修改 Kruskal 算法来找到最大生成树?
  3. 在什么情况下,Kruskal 算法会失败(找不到生成树)?

下一步学习

下一课我们将学习另一种最小生成树算法——Prim 算法,它采用不同的贪心策略(从顶点出发逐步扩展),并与 Kruskal 算法进行对比分析。理解这两种算法的区别和适用场景,将帮助你在实际问题中选择最合适的解决方案。

练习编辑器

rust
Loading...

继续学习

完成本课后,建议继续学习下一课「Prim 最小生成树」 以巩固所学知识。