跳转至

第 92 章 最小生成树

配套例题:BISHI107 【模板】最小生成树Ⅰ ‖ 稀疏图:Kruskal、BISHI104 修复公路 来源:S2 Kruskal.cppPrime.cpp;S4 模板.docx「树 → 最小生成树 prime / kruskal / 次小生成树」;S3 day7 前置38-并查集90-图的表示与遍历47-贪心

最小生成树是贪心正确性最漂亮的例子之一:随便按边权从小到大挑, 只要不成环就要,最后一定得到全局最优。这一章讲清楚它为什么对、 两种实现怎么选、以及 Python 下的写法。


92.1 定义

BISHI107 的题面给的定义就很标准:

生成树:对于一张 \(n\) 个节点的图,任选其中 \(n-1\) 条边,使得节点两两连通, 这些边一定会组成一棵树,称为这张图的一棵生成树。

最小生成树(MST):权值和最小的生成树。

三个立刻要明确的前提:

前提 说明
无向图 MST 只对无向图定义。有向图的对应物是「最小树形图」(朱刘算法),不在笔试范围
必须连通 不连通就没有生成树,题目通常要求输出 NO / \(-1\)
边权可以是负数 贪心证明不依赖非负性。BISHI107 的 \(w\) 可以低到 \(-10^9\)

⚠️ 「边权可以是负数」是个高频坑。写成「答案初始化为 0,只累加正边」 或者「跳过负边」都是错的:MST 要求恰好 \(n-1\) 条边把所有点连起来, 负边不但要选,还会优先被选。


92.2 为什么贪心是对的

切割性质(Cut Property)——Prim 的正确性

把点集任意划分成两个非空部分 \(S\)\(V \setminus S\), 横跨这条「切割」的所有边中权值最小的那条(若唯一)一定在每棵 MST 里。

证明(交换论证):设最小横跨边是 \(e\),假设某棵 MST \(T\) 不含 \(e\)。 把 \(e\) 加进 \(T\) 会形成一个环,这个环必然还包含另一条横跨边 \(f\)(要出去就得回来)。 由 \(w(e) \le w(f)\),把 \(f\) 换成 \(e\) 得到的仍是生成树且权和不增, 所以存在含 \(e\) 的 MST;若 \(w(e) < w(f)\) 严格成立,则 \(T\) 不是最优,矛盾。

环性质(Cycle Property)——Kruskal 的正确性

任意一个环上权值最大的边(若唯一)一定不在任何 MST 里。

Kruskal 就是它的直接推论:按权升序考虑边,若当前边的两端已连通, 那么这条边加进去会成环,且它是环上最大的边(因为其余边都更早被选、权更小), 所以可以安全丢弃。

由此得到的三条实用推论

推论 用途
MST 上任意两点的路径,是所有路径中「最大边权最小」的 最小瓶颈路 = MST 上的路径(BISHI104)
所有 MST 的边权多重集完全相同 判「MST 是否唯一」只需看每种权值被选的条数
边权全部互不相同 ⟹ MST 唯一 出题人常用这条来避免多解

92.3 Kruskal

S2 Kruskal.cpp 开头一行注释点明了适用范围:「适用于边较少的图」。

三步:① 所有边按权升序排序;② 依次尝试加入,用并查集判两端是否已连通; ③ 加满 \(n-1\) 条即得 MST。

def kruskal(n, edges):
    """Kruskal 最小生成树。edges = [(w, u, v), ...],点编号 1..n。

    返回 (总权值, 选中的边列表);图不连通时返回 (None, ...)。
    复杂度 O(m log m),瓶颈是排序(C 层 Timsort)。
    """
    edges.sort()                             # ★ w 放元组第一位,直接 sort()
    # DSU(Disjoint Set Union,并查集):唯一的作用是回答「这两点已经连通了吗」
    parent = list(range(n + 1))              # 初始每个点自成一块,父亲是自己
    size = [1] * (n + 1)                     # size[根] = 该块的点数,用于按大小合并

    def find(x):
        """迭代路径压缩:两趟。递归版在 n = 3e5 的退化链上必爆栈。"""
        r = x
        while parent[r] != r:                # 第一趟:一路向上找到根
            r = parent[r]
        while parent[x] != r:                # 第二趟:把路上每个点直接挂到根下
            parent[x], x = r, parent[x]      # 右边先整体求值,再解包赋值
        return r                             # 压缩之后这条链下次查询就是 O(1)

    need = n - 1                             # 还差几条边;n = 1 时开局就是 0
    total = 0
    chosen = []
    for w, u, v in edges:                    # 按权升序考虑每条边(贪心的核心顺序)
        if need == 0:
            break                            # 已经选够,剩下的边不用看
        ru, rv = find(u), find(v)
        if ru == rv:
            continue                         # 同一连通块 -> 加进去会成环,丢弃
                                             # 由环性质,它是那个环上的最大边,丢弃安全
        if size[ru] < size[rv]:              # 按大小合并,让树更浅
            ru, rv = rv, ru                  # 保证 ru 是较大的那块
        parent[rv] = ru                      # 小块挂到大块下面
        size[ru] += size[rv]
        total += w                           # w 可能为负,照加不误
        chosen.append((u, v, w))
        need -= 1
    return (total if need == 0 else None), chosen   # 没选够 n-1 条 = 原图不连通

Python 的三个关键写法

写法 理由
edges 存成 (w, u, v) 后直接 sort() sort(key=lambda e: e[2])\(m\) 次 Python 函数调用,\(m=3\times10^5\) 时差别巨大
find迭代路径压缩 递归在退化链上必爆 C 栈(无报错的段错误),见 38 章
if need == 0: break 选满就停,省掉后面所有 find

性能上界:Kruskal 的瓶颈是排序,而 Python 的 sort() 是 C 层 Timsort, \(3\times10^5\) 条边只要 0.3 秒左右。并查集部分才是纯 Python 循环, 所以要尽量把 find 写得便宜(路径压缩 + 按大小合并)。


92.4 Prim

S2 Prime.cpp 和 S4 的模板都是邻接矩阵 + \(O(n^2)\) 的朴素版:

for(int i=1; i<=n; i++) dis[i]=map[1][i];
vis[1]=1;
for(int i=1; i<=n-1; ++i) {
    int minn=0x3f3f3f3f;
    for(int j=1; j<=n; ++j) if(!vis[j]&&dis[j]<minn) minn=dis[j],pos=j;
    vis[pos]=1; sum+=dis[pos];
    for(int j=1; j<=n; ++j)
        if(!vis[j]&&dis[j]>map[pos][j]) dis[j]=map[pos][j];
}

思想:维护一个已在树中的点集 \(S\)(初始只有 1 号点), dis[j] 表示「\(j\)\(S\) 的最短边权」。每轮取 dis 最小的点加入 \(S\), 再用它更新其余点的 dis。这就是切割性质的直接应用。

注意 Prim 和 Dijkstra 的差别只有一行: Dijkstra 松弛写 dis[v] = min(dis[v], dis[u] + w)(到源点的距离), Prim 写 dis[v] = min(dis[v], w)(到集合的距离)。 结构完全一样,所以 Prim 也能堆优化。

朴素 \(O(n^2)\) Prim(稠密图)

def prim_dense(g, n):
    """朴素 O(n^2) Prim,邻接矩阵版。稠密图 + n <= 1500 时用。

    g[i][j] 是边权,不通为 INF(大整数)。返回 (总权值, 是否连通)。
    """
    INF = 1 << 60
    dis = g[1][:]                            # dis[j] = j 到当前树的最短边
                                             # 树里只有 1 号点,所以初值就是 1 到 j 的边
    dis[1] = 0
    used = bytearray(n + 1)                  # used[j] = 1 表示 j 已经在树里
    used[1] = 1                              # 从 1 号点起步(从哪个点起都一样)
    total = 0
    for _ in range(n - 1):                   # 每轮往树里加一个点,共加 n-1 个
        best = INF
        pos = -1
        for j in range(1, n + 1):            # 找树外最近的点
            if not used[j] and dis[j] < best:
                best = dis[j]
                pos = j
        if pos < 0:
            return None, False               # 树外的点全都够不着 -> 图不连通
        used[pos] = 1                        # 由切割性质,这条最小横跨边一定在 MST 里
        total += best
        gp = g[pos]                          # 绑成局部名
        for j in range(1, n + 1):            # 用新加入的点更新 dis
            if not used[j] and gp[j] < dis[j]:
                dis[j] = gp[j]               # 注意是 min(dis[j], 边权),不加 dis[pos]
    return total, True                       # 与 Dijkstra 的差别就在上面这一行

堆优化 Prim(稀疏图)

from heapq import heappush, heappop


def prim_heap(n, start, to, wt, root=1):
    """堆优化 Prim(CSR 邻接表版)。O(m log m)。

    和 Dijkstra 的唯一区别:入堆的键是**边权本身**,不是累计距离。
    同样用懒删除处理堆里的过期记录。
    """
    used = bytearray(n + 1)                  # used[u] = 1 表示 u 已进树
    total = 0
    cnt = 0                                  # 已进树的点数
    heap = [(0, root)]                       # (把 u 接进树的边权, u);根的接入代价记 0
    while heap:
        w, u = heappop(heap)                 # 全堆最小 = 当前切割上的最小横跨边
        if used[u]:                          # 懒删除:这个点已经进树了
            continue                         # 堆里同一个点会有多份,只认第一份
        used[u] = 1
        total += w                           # 只有真正进树时才把边权计入答案
        cnt += 1
        for i in range(start[u], start[u + 1]):
            v = to[i]
            if not used[v]:
                heappush(heap, (wt[i], v))   # ★ 键是边权本身,不是累计距离
    return (total, True) if cnt == n else (None, False)   # 没收满 n 个点 = 不连通

92.5 Kruskal vs Prim:怎么选

Kruskal 朴素 Prim 堆优化 Prim
复杂度 \(O(m \log m)\) \(O(n^2)\) \(O(m \log m)\)
需要的存图方式 边表(不用建邻接表!) 邻接矩阵 邻接表 / CSR
稀疏图 \(m \approx n\) 最佳 ❌ 浪费 ✅ 可以
稠密图 \(m \approx n^2\) ⚠️ 排序 \(n^2\) 条边 最佳 ❌ 堆里 \(n^2\) 个元素
边直接给出、点数很大 ⚠️
Python 实现难度 最低(排序 + 并查集)
Python 常数 最小(排序在 C 层) 中(纯循环) 大(堆操作)

Python 选手的默认答案是 Kruskal。理由: ① 主要开销 sort() 落在 C 层; ② 只需要边表,省掉建邻接表的时间和内存; ③ 并查集是全竞赛常数最小的数据结构之一。

只有在「\(n \le 1000\) 且给的是邻接矩阵 / 完全图(比如平面上 \(n\) 个点两两连边)」 时才该换朴素 Prim——那时候边数是 \(n^2/2\),排序它反而更慢。


92.6 次小生成树

S4 的模板清单里列了「次小生成树」,但正文是空的(原文标注「暂时没有」)。 这里补上。

两种口径,读题时必须分清:

口径 定义 做法
非严格次小 权和 \(\le\) MST 的次优生成树(可以等于) 枚举非树边,减去路径最大边
严格次小 权和严格大于 MST 的最小生成树 还要维护路径次大

核心定理:次小生成树与 MST 恰好只差一条边

于是算法是:先求 MST,然后枚举每条非树边 \((u,v,w)\), 把它加进 MST 会形成一个环,从环上(也就是 MST 上 \(u \to v\) 的路径上) 删掉一条边,权和变化量是 \(w - (\text{路径上被删边的权})\)。 要让增量最小,就删路径上权值最大的边; 若该最大边恰好等于 \(w\)(增量为 0,得到的是另一棵 MST), 求严格次小时就得退而求其次,删路径上小于 \(w\) 的最大边(即次大边)。

倍增在 MST 上预处理「向上 \(2^k\) 步的路径最大边和次大边」:

# [片段] 次小生成树:MST + 倍增维护路径最大/次大边
# 前置:已用 Kruskal 求出 MST(chosen 列表)与总权 total,且图连通
from collections import deque

LOG = 18                                     # 2^18 > 2.6e5,按 n 调整
NEG = -1 << 60                               # 「不存在」的哨兵

# 1) 用 MST 的边建树(CSR 或 list of list 都行),BFS 求 depth / parent
# 2) 倍增表:up[k][v] = v 向上 2^k 步的祖先
#            mx[k][v] = 这段路径上的最大边权
#            se[k][v] = 严格小于 mx 的最大边权(次大)
def merge(a1, a2, b1, b2):
    """合并两段的 (最大, 次大)。次大必须严格小于最大。"""
    mx = a1 if a1 > b1 else b1               # 两段最大值里再取大的
    cands = [x for x in (a1, a2, b1, b2) if x < mx]   # 只留严格小于 mx 的候选
    se = max(cands) if cands else NEG        # 四个值全相等时次大不存在,返回哨兵
    return mx, se

# 3) 查询:把 u、v 提到同深度再一起上跳,沿途 merge,得到路径 (最大, 次大)
# 4) 枚举每条非树边 (u, v, w):
#       mx, se = path_max(u, v)
#       if w > mx:   cand = total + w - mx          # 严格次小
#       elif se > NEG and w > se: cand = total + w - se
#    取所有 cand 的最小值

复杂度\(O(m \log m + n \log n + m \log n)\)

Python 的现实性判断:倍增表是 \(18 \times n\) 的三张表。 \(n = 10^5\) 时是 \(5.4\times10^6\) 个表项,光建表就要 5 秒以上,内存也上百 MB。 结论:次小生成树在 Python 下的现实规模是 \(n \le 2\times10^4\)。 更大规模要么换语言,要么用「按秩合并的可撤销并查集 + 离线」之类的技巧规避倍增表。 倍增的通用讨论见 45-倍增


92.7 MST 的常见变形

变形 做法
最小瓶颈生成树 就是 MST(最大边最小)。「最早何时全连通」类题(BISHI104)直接跑 Kruskal 取最大边
最大生成树 边权降序排序,Kruskal 照跑
必须包含某些边 先把这些边无条件合并,再对剩下的跑 Kruskal
MST 是否唯一 对每种权值,统计「这批边加进来时减少了几个连通块」,若某种权值存在多种选法则不唯一
两点间最小瓶颈路 建出 MST 后,答案 = MST 上两点路径的最大边(倍增 / 树剖,见 94 章
动态加边维护 MST 每加一条边,在环上删最大边(LCT 领域,超纲)
点数少、边是坐标算出来的 完全图 \(m = n^2/2\),用朴素 Prim

Borůvka 算法(每轮为每个连通块找一条最小出边)值得知道名字: 它是唯一天然可并行的 MST 算法,也是「最小异或生成树」等 Trie 类题的基础。 笔试范围内用不到。


92.8 例题

BISHI107 【模板】最小生成树Ⅰ ‖ 稀疏图:Kruskal(较难)

\(n, m \le 3\times10^5\) 的无向赋权图(可能不连通、可能有重边、无自环), \(-10^9 \le w \le 10^9\)。图不连通输出 NO,否则输出 MST 边权和。 时限:C/C++ 5 秒,其他语言 10 秒。 题面见 BISHI107 原题(牛客)。 题解见 solutions/BISHI107.py(已用官方样例验证)。

标题就写明了「稀疏图:Kruskal」。\(m\)\(n\) 同阶,Kruskal 是最优选择。

import sys


def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])

    edges = [None] * m                       # 预分配,避免 append 扩容
    p = 2
    for i in range(m):
        u = int(data[p]); v = int(data[p + 1]); w = int(data[p + 2]); p += 3
        edges[i] = (w, u, v)                 # 权值放首位,sort() 即按权升序
    edges.sort()                             # 唯一的排序,C 层 Timsort

    parent = list(range(n + 1))              # 并查集:初始每个点自成一块
    size = [1] * (n + 1)                     # 按大小合并,避免树退化成链

    def find(x):
        r = x
        while parent[r] != r:                # 先一路向上找到块的根
            r = parent[r]
        while parent[x] != r:                # 迭代路径压缩
            parent[x], x = r, parent[x]      # 把沿途的点全部改挂到根上
        return r

    need = n - 1                             # 还差几条边;n = 1 时开局就是 0
    total = 0
    for w, u, v in edges:                    # 贪心:按权从小到大依次尝试
        if need == 0:
            break                            # 已经选够 n-1 条,后面的边不必再看
        ru, rv = find(u), find(v)
        if ru == rv:
            continue                         # 已连通,这条边会成环
        if size[ru] < size[rv]:
            ru, rv = rv, ru                  # 让 ru 指向较大的块
        parent[rv] = ru                      # 合并两块
        size[ru] += size[rv]
        total += w                           # w 可能为负,照加不误
        need -= 1
    sys.stdout.write(("%d\n" % total) if need == 0 else "NO\n")   # 没选够 = 不连通


main()

复杂度:排序 \(O(m\log m) \approx 3\times10^5 \times 18\)(C 层), 并查集近似线性。\(m = 3\times10^5\) 在 10 秒时限下很轻松。

三个坑

  1. 边权可以是负数(样例 2 里有 \(-12\))。这不影响 Kruskal 的正确性—— 贪心的证明不依赖非负性——但答案可能是负数, 所以不能写「答案初始化为 0 且只加正数」之类的偷懒逻辑;
  2. \(n = 1\) 时答案是 0,且图算连通。上面代码里 need = n - 1 = 0, 循环第一次就 break,输出 0 ✓。写成「必须选够 \(n-1\) 条边否则 NO」时, 要保证 \(n=1\) 落在「选够了」这一侧;
  3. 重边,Kruskal 天然处理(同根跳过),不必预处理去重。

为什么不用 Prim? \(m = 3\times10^5\)\(n = 3\times10^5\),是典型稀疏图。 堆优化 Prim 的堆里会塞进 \(6\times10^5\) 个元组,比 Kruskal 的一次排序慢得多; 朴素 Prim 是 \(O(n^2) = 9\times10^{10}\),直接出局。

Python 现实性判断\(3\times10^5\) 条边的元组构造 + sort() 约 1 秒, 主循环是 \(3\times10^5\) 轮 × 2 次 find(每次几步 while),约 \(3\times10^6\) 次迭代, 约 2 秒。10 秒时限留有余量。若要再压:把 find 内联进主循环 (见 38 章 的扁平数组版)。

BISHI104 修复公路(中等)

\(N \le 10^3\) 个城市、\(M \le 10^5\) 条双向公路,第 \(i\) 条连接 \(x_i, y_i\) 且在第 \(t_i\) 秒修完(\(1 \le t \le 10^5\))。 问最早何时任意两个城市都能通车;全部修完仍不连通则输出 \(-1\)。 题面见 BISHI104 原题(牛客)。 题解见 solutions/BISHI104.py(已用官方样例验证)。

这题是「最小瓶颈生成树」的模板:答案就是 MST 中的最大边权

理由:要让所有城市连通,必须选出一棵生成树; 完工时刻 = 这棵树里最晚修完的那条路; 要最小化「最大边」,就是最小瓶颈生成树 = MST(92.2 的推论一)。 而 Kruskal 按权升序加边,最后一次成功合并时的边权正是 MST 的最大边—— 所以根本不用跑完,第 \(N-1\) 次成功合并时就能立刻输出。

import sys


def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    if n == 1:                               # ★ 只有一个城市,天然连通
        sys.stdout.write("0\n")
        return

    edges = [None] * m
    p = 2
    for i in range(m):
        x = int(data[p]); y = int(data[p + 1]); t = int(data[p + 2]); p += 3
        edges[i] = (t, x, y)                 # t 放首位,直接 sort() 即按时间升序
    edges.sort()                             # 按修完时刻从早到晚考虑每条公路

    parent = list(range(n + 1))              # 并查集:维护「哪些城市已经互相通车」
    size = [1] * (n + 1)

    def find(x):
        r = x
        while parent[r] != r:                # 一路向上找根
            r = parent[r]
        while parent[x] != r:                # 迭代路径压缩,两趟写法
            parent[x], x = r, parent[x]
        return r

    need = n - 1                             # 连通 n 个城市恰好需要 n-1 次成功合并
    for t, x, y in edges:
        rx, ry = find(x), find(y)
        if rx == ry:
            continue                         # 这两城已通车,本条路不减少连通块数
        if size[rx] < size[ry]:
            rx, ry = ry, rx
        parent[ry] = rx
        size[rx] += size[ry]
        need -= 1                            # 只对「成功合并」计数,重边不会误计
        if need == 0:                        # 第 n-1 次成功合并 = 全图连通
            sys.stdout.write("%d\n" % t)     # 当前这条边的时间就是答案
            return                           # 它正是 MST 的最大边(最小瓶颈)
    sys.stdout.write("-1\n")                 # 所有边用完仍没连通


main()

复杂度 \(O(M \log M)\),瓶颈是排序。\(M = 10^5\) 轻松。

三个坑

  1. \(N = 1\) 时答案是 0(一个城市天然连通,不需要任何公路)。 不特判的话 need 初始就是 0,循环里永远触发不了 need == 0, 会错误输出 \(-1\)「规模为 1」的退化情况是模板题最常见的隐藏用例
  2. 计数用「成功合并的次数」而不是「扫过的边数」——重边和自环会让后者失真;
  3. 排序按 \(t\) 升序。写成 sort(key=lambda e: e[0]) 也对, 但把 \(t\) 放元组第一维直接 sort() 更快(省掉 \(10^5\) 次 lambda 调用)。

和二分答案的关系:这题也能「二分时间 \(T\) + 只用 \(t \le T\) 的边跑并查集验证连通」, 复杂度 \(O(M \log M \cdot \alpha)\)但没必要——Kruskal 一遍扫过去就够了。 不过这个「二分 + 验证」的思路在瓶颈值不是边权(比如是某个函数)时是唯一出路, 见 44-二分

这题也是并查集章的例题38-并查集)。 两章的视角不同:38 章讲的是「并查集怎么用」,本章讲的是 「为什么答案等于 MST 的最大边」。同一份代码,两层理解。


92.9 本章速查

要点 结论
MST 定义 无向连通图上权和最小的 \(n-1\) 条边
不连通 没有生成树,按题意输出 NO / \(-1\)
边权可为负 贪心照样正确,别跳过负边
切割性质 任意切割的最小横跨边一定在 MST 里 → Prim
环性质 任意环上的最大边一定不在 MST 里 → Kruskal
推论 1 MST = 最小瓶颈生成树(最大边最小)
推论 2 所有 MST 的边权多重集相同
推论 3 边权互不相同 ⟹ MST 唯一
Python 默认选 Kruskal 排序在 C 层,只要边表,并查集常数极小
Prim 的场景 \(n \le 1500\)稠密图 / 完全图(邻接矩阵)
Kruskal 排序 \(w\) 放元组第一位 sort(),别用 key=lambda
并查集 find 必须迭代路径压缩 + 按大小合并
提前结束 选够 \(n-1\) 条就 break
\(n = 1\) 答案 0 且视为连通——必查的退化情况
次小生成树 MST + 枚举非树边 + 倍增求路径最大/次大边
严格 vs 非严格次小 严格的要维护「严格小于最大值」的次大边
规模 Python 现实性
Kruskal,\(m \le 3\times10^5\) ✅ 排序 ~1 秒 + 并查集 ~2 秒
Kruskal,\(m \le 10^6\) ⚠️ 排序还行,并查集主循环 \(10^7\) 次,危险
朴素 Prim,\(n \le 1000\) \(10^6\) 次迭代
朴素 Prim,\(n \le 2000\) ⚠️ \(4\times10^6\) 次,勉强
堆优化 Prim,\(m \le 2\times10^5\) ⚠️ 比 Kruskal 慢,除非题目只给邻接表
次小生成树(倍增),\(n \le 2\times10^4\) ⚠️ 倍增表 \(18n\) 项已是上限
次小生成树,\(n \ge 10^5\) ❌ 建表就超时
看到什么 → 想到 MST
「用最小代价把所有点连起来」
「最早何时全部连通」→ Kruskal 取最大边(BISHI104)
「任意两点路径的最大边最小」→ 最小瓶颈路 = MST 上的路径
「必须修某几条路」→ 先合并这些边再跑 Kruskal
「代价是坐标距离」→ 完全图 → 朴素 Prim