Prim 算法:从“点扩张”理解最小生成树

适合信奥 / CSP-J 后期 / CSP-S 入门的图论核心讲解版 · 先懂模型,再手推状态更新,最后读代码。

一、先回答问题

核心结论

Prim 算法的核心思想是“点扩张”。它从一个起点出发,像滚雪球一样维护一个已连通的点集 $S$,每次贪心地挑选连接 $S$ 与外部未选点集 $V-S$ 的最小权值边,拉入一个新点,直至所有点连通。

课堂教学安排

通过“修路连通村庄”的背景切入最小生成树(MST)概念;借由 5 点图互动模拟深刻体会割性质过期边过滤;通过 tab 切换对比稀疏图版(优先队列)与稠密图版(邻接矩阵)模板,并掌握常见建模技巧。

二、核心观察

三大教学难点与本质

1. 为什么要选择“最便宜的跨割边”?(割性质)
如果我们将所有点切成两份,要让全图连通,这两份点之间必须且至少有一条边连接。既然必须连,为了让总权重最小,在这一刻选择最便宜的那条跨界边,绝对是全局最优的安全选择。这就是 Prim 的贪心正确性保障。

2. Prim 与 Dijkstra 的 dist 数组有什么本质区别?
虽然两者代码结构极其相似,但 dist[v] 的含义完全不同:

  • Dijkstra 的 dist[v]:从起点到点 $v$ 的总路径长度(累加值)。
  • Prim 的 dist[v]:点 $v$ 到当前生成树点集 S 的最短单步距离(不累加,只代表接入整棵树的边代价)。

3. 树不唯一,值唯一
当图中有相同权值的边时,选出来的最小生成树边集可能不唯一(比如三角环 1-2, 2-3, 3-1 权值均为 1,选哪两条都行),但是最小生成树的总权值和在同一个图中永远是唯一且确定的

点集扩张 割性质贪心 单步代价更新 总权值和唯一

三、互动演示:手推 Prim 过程

下面我们用一个 5 个点、7 条边的无向图来演示。起点选为 1。你可以点击“下一步”来看清点是如何被一步步拉入生成树的,注意观察 vis 判断如何避开环(过滤过期边)。

2 3 1 4 5 6 7 1 2 3 4 5
当前步骤0
已选点集 S{}
累计树权值0
优先队列 PQ 里的候选边 {w, v}
[(0, 1)]

四、自己选答案

思考题 1

在优先队列版 Prim 算法中,为什么从 PQ 弹出来的边对应的点还要进行 if (vis[u]) continue; 判断?

思考题 2

从“割性质”贪心策略来想,如果一个无向图中有若干条边的权值是相同的,下面哪个结论是正确的?

五、手推结果对比表

步骤 已选点集 S 处理点 u 确定树边与权值 此时优先队列 PQ 状态 说明
0 {} - - [(0, 1)] 初始化起点 1 入队,代价值 0
1 {1} 1 - [(2, 2), (3, 3)] 1号点出队。邻居 2(2)、3(3) 未访问,入队
2 {1, 2} 2 1-2 (权值 2) [(1, 3), (3, 3), (4, 4)] 弹出最小 (2, 2),连 1-2 边。更新邻居 3(1)、4(4) 入队
3 {1, 2, 3} 3 2-3 (权值 1) [(3, 3), (4, 4), (6, 5)] 弹出最小 (1, 3),连 2-3 边。更新邻居 5(6),3-4太长忽略
4 {1, 2, 3} 3 - [(4, 4), (6, 5)] 弹出 (3, 3),但 vis[3] 已经是 true,这属于过期边,跳过
5 {1, 2, 3, 4} 4 2-4 (权值 4) [(6, 5)] 弹出最小 (4, 4),连 2-4 边。更新邻居 5(7)比已存6长,不入队
6 {1, 2, 3, 4, 5} 5 3-5 (权值 6) [] 弹出最小 (6, 5),连 3-5 边。所有点已连通,完成!和为13

六、C++14 参考代码

在信奥考试中,对于稀疏图(点多边少),我们常用“优先队列版”;而对于完全图或稠密图(边很多),“邻接矩阵版”在常数和代码书写上都更有优势。点击切换选项卡来查看并复制代码:

#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
typedef pair<int, int> pii; // {边权, 目标点}

const ll INF = 1e18;

int main() {
    // 提升 I/O 效率
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    vector<vector<pii>> g(n + 1);

    for (int i = 0; i < m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        // 无向图,双向建边
        g[u].push_back({v, w});
        g[v].push_back({u, w});
    }

    vector<bool> vis(n + 1, false);
    // 优先队列声明:
    // 第1参数:元素类型 pii 即 pair<int,int>
    // 第2参数:底层容器 vector<pii>
    // 第3参数:比较函数 greater<pii> 产生最小堆(小顶堆)
    // 【记忆口诀】:C++ 优先队列中,比较函数返回 true 代表优先级低(在下面)。
    // greater 在 a > b 时返回 true,所以大的数在下面,即小的数在最上面(小顶堆)。
    // pair 比较时默认先比较 first 元素(边权)
    priority_queue<pii, vector<pii>, greater<pii>> pq;

    // 从 1 号点开始,起点接入树的代价为 0
    pq.push({0, 1});

    ll ans = 0;  // 记录最小生成树总权值
    int cnt = 0; // 记录已加入生成树的点数

    while (!pq.empty()) {
        int w = pq.top().first;
        int u = pq.top().second;
        pq.pop();

        // 关键一步:如果该点已在树中,说明这条边是过期的,跳过防环
        if (vis[u]) continue;

        // 确认把 u 归入最小生成树点集
        vis[u] = true;
        ans += w;
        cnt++;

        // 遍历所有从 u 出发的边,尝试更新邻居
        for (auto e : g[u]) {
            int v = e.first;
            int cost = e.second;

            if (!vis[v]) {
                pq.push({cost, v});
            }
        }
    }

    // 若连通的点数小于 n,说明图不连通,无法形成最小生成树
    if (cnt < n) {
        cout << "orz\n";
    } else {
        cout << ans << '\n';
    }

    return 0;
}
核心代码阅读提示
  • 注意 vis[u] 的标记时机,它只在点从堆中弹出且确定计入生成树时才置为 true,而不是在被邻居推入堆时。这就保证了我们每次加进去的都是当前最短边。
  • 关于 priority_queue<pii, vector<pii>, greater<pii>> 声明中的三个参数:C++ 优先队列默认是大顶堆,若要使用小顶堆(让最小边最先弹出),必须显式写出这三个参数,并使用 greater 作为第三个参数。
  • 【比较器记忆口诀】:在 C++ 优先队列的比较函数中,返回 true 代表在下面(优先级低)。因为 greater 比较器在 a > b 时返回 true,表示大数 a 优先级低、被放在下面,因此小数 b 就会在堆顶,形成小顶堆。这与 std::sort(返回 true 在前面/左边)刚好相反!

七、洛谷经典例题精讲

在信奥图论题中,除了标准的模板题,还有许多以 MST 为底层的变形建模。点击下面各卡片可查看建模分析与 C++ 代码。

例题 1:洛谷 P3366【模板】最小生成树 (标准模板 & 连通性判断)

题目要点:给出一个无向图,输出最小生成树的总权值,如果图不连通则输出 orz

建模解析:这是最基础的 MST。用稀疏图邻接表构建无向图,用优先队列实现 Prim,最后通过计数变量 cnt 判断是否加满了 n 个点。若 cnt < n 说明图是不连通的(即图中有部分村庄无法修路到达),输出 orz

#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
typedef pair<int, int> pii;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    vector<vector<pii>> g(n + 1);

    for (int i = 0; i < m; i++) {
        int x, y, z;
        cin >> x >> y >> z;
        g[x].push_back({y, z});
        g[y].push_back({x, z});
    }

    vector<bool> vis(n + 1, false);
    // 最小堆声明,利用 greater 使得较小值在堆顶
    // 口诀:比较返回 true 代表在下面。a > b 为 true,大值在下面,即为小顶堆。
    priority_queue<pii, vector<pii>, greater<pii>> pq;

    pq.push({0, 1});

    ll ans = 0;
    int cnt = 0;

    while (!pq.empty()) {
        int w = pq.top().first;
        int u = pq.top().second;
        pq.pop();

        if (vis[u]) continue;

        vis[u] = true;
        ans += w;
        cnt++;

        for (auto e : g[u]) {
            int v = e.first;
            int cost = e.second;

            if (!vis[v]) {
                pq.push({cost, v});
            }
        }
    }

    if (cnt < n) cout << "orz\n";
    else cout << ans << '\n';

    return 0;
}
例题 2:洛谷 P1546 最短网络 Agri-Net (矩阵读入 & 稠密图)

题目要点:给定 $N$ 个农场($N \le 100$),读入是一个 $N \times N$ 的连通矩阵,表示任意两个农场之间连接的光纤费用。求将所有农场连通的最少费用。

建模解析:点数很小(最大 100),但给的是完整的邻接矩阵,代表这是一个完全图(稠密图)。这种情况下使用邻接矩阵版 Prim ($O(N^2)$)是最简单、最不容易出错的选择,代码非常直观,免去了邻接表建边的繁琐步骤。

#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const ll INF = 1e18;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

    vector<vector<ll>> g(n + 1, vector<ll>(n + 1));

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            cin >> g[i][j];
        }
    }

    vector<bool> vis(n + 1, false);
    vector<ll> dist(n + 1, INF);

    dist[1] = 0;

    ll ans = 0;

    for (int i = 1; i <= n; i++) {
        int u = -1;

        for (int j = 1; j <= n; j++) {
            if (!vis[j] && (u == -1 || dist[j] < dist[u])) {
                u = j;
            }
        }

        vis[u] = true;
        ans += dist[u];

        for (int v = 1; v <= n; v++) {
            if (!vis[v] && g[u][v] < dist[v]) {
                dist[v] = g[u][v];
            }
        }
    }

    cout << ans << '\n';

    return 0;
}
例题 3:洛谷 P2872 Building Roads S (坐标完全图 & 已有边权处理)

题目要点:给定平面上 $N$ 个村庄的坐标,任意两点之间都可以连边,边权为欧几里得距离。此外,图里已经有 $M$ 条修好的路,求再新建一部分路使得全部村庄连通的最小长度。

建模解析

  • 坐标点连边:每个村庄都可以与其他任意村庄连边,所以这是一个 $O(N^2)$ 的稠密完全图,推荐使用邻接矩阵版 Prim。边权可以通过两点坐标 $dis = \sqrt{(x_1-x_2)^2 + (y_1-y_2)^2}$ 计算。
  • 已有道路处理:题目说已经有 $M$ 条路存在,不需要新增花费。因此,我们可以将这 $M$ 条已有道路的边权直接设为 0(即 g[u][v] = g[v][u] = 0),然后再运行标准的 MST 算法。这样,算法在扩张时会优先免费选用这些已有的道路!

#include <bits/stdc++.h>
using namespace std;

const double INF = 1e18;

struct Point {
    double x, y;
};

// 计算两点之间的距离
double dis(Point a, Point b) {
    double dx = a.x - b.x;
    double dy = a.y - b.y;
    return sqrt(dx * dx + dy * dy);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    vector<Point> p(n + 1);
    for (int i = 1; i <= n; i++) {
        cin >> p[i].x >> p[i].y;
    }

    // 用 INF 初始化邻接矩阵
    vector<vector<double>> g(n + 1, vector<double>(n + 1, INF));
    for (int i = 1; i <= n; i++) {
        g[i][i] = 0;
    }

    // 1. 初始化任意两点间的距离为边权
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            g[i][j] = dis(p[i], p[j]);
        }
    }

    // 2. 将已有道路的费用置为 0
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        g[u][v] = g[v][u] = 0;
    }

    vector<bool> vis(n + 1, false);
    vector<double> dist(n + 1, INF);

    dist[1] = 0;
    double ans = 0;

    // 3. 运行邻接矩阵版的 Prim 算法
    for (int i = 1; i <= n; i++) {
        int u = -1;
        for (int j = 1; j <= n; j++) {
            if (!vis[j] && (u == -1 || dist[j] < dist[u])) {
                u = j;
            }
        }

        vis[u] = true;
        ans += dist[u];

        for (int v = 1; v <= n; v++) {
            if (!vis[v] && g[u][v] < dist[v]) {
                dist[v] = g[u][v];
            }
        }
    }

    // 保留两位小数输出
    cout << fixed << setprecision(2) << ans << '\n';

    return 0;
}

八、避坑指南:信奥中常见的 MST 错误

易错点 1

无向图忘记双向建边
在邻接表建边时,如果是无向图,一定要正反加两次边!只写 g[u].push_back({v, w}) 不写反向边会导致图的连通方向受阻,无法连通。

易错点 2

邻接矩阵的重边覆盖写错
在用邻接矩阵存图时(例如有平行重边),读入数据必须使用 g[u][v] = min(g[u][v], w),否则直接赋值 g[u][v] = w 会导致之前更小的边被大边给覆盖了,从而算出错误的树权值。

易错点 3

连通性判断被忽略
若原图不连通,图里是不会存在最小生成树的。使用优先队列版 Prim 时,必须使用 cnt 计数,如果最后 cnt < n,代表并没有把全部点接进来,一定要特殊输出 orz-1,不能直接输出 ans

易错点 4

和 Dijkstra 算法的更新写混
这是最严重的逻辑错误。在 Prim 松弛邻居时,更新条件是:g[u][v] < dist[v](邻居到最新加入点 $u$ 的单步距离是否小于其到生成树的距离);而 Dijkstra 是:dist[u] + g[u][v] < dist[v](累加值松弛)。写错会导致算法退化成错误的逻辑。

九、课后巩固与思考练习

进阶拓展

尝试利用本节所学进行建模拓展:

  • 超级源点模型P1550 Watering Hole G(打井与铺管道连通,思考如何虚构一个 0 号井源点)。
  • 最小生成森林模型P1195 口袋的天空(将村庄连成 $K$ 个部落,思考为什么只需要选 $N-K$ 条边)。