CSP-S 提高级课程中心 / 算法进阶 / 第32讲
CSP-S 提高级 · 算法进阶 · s5-10

最小生成树:Prim 与 Kruskal

用 n−1 条边把所有点连起来、总代价最小 —— 两种贪心算法,一条"排序加并查集"、一条"点集扩张加 lowcost",稀疏图与稠密图各有一把好刀。

1 个大纲知识点 Kruskal O(m log m) Prim O(n²) / O(m log n) 交换论证 正确性直觉 12 道洛谷原题
本讲 · 第 32/52 讲最小生成树:Prim和Kruskal
模块 01

情景导航

从一份"村村通网的账本"出发,把最小生成树的目标、约束和两种贪心策略一次讲清楚。

故事村村通网的账本

某个乡镇要搞"村村通网":一共有 n 个村子,任意两个村子之间都可以架设一条光缆,但造价各不相同 —— 有的隔着一条河,贵;有的只隔一片田,便宜。乡长的要求很简单:让所有村子都能互相通信,总造价最低。

请你先回答第一个问题:最少需要架设几条光缆?答案是 n − 1 条。因为 n 个点要用边连成一个整体,至少 n − 1 条边;而 n − 1 条边恰好能把它们连成一棵"树",任何一条多余的边都会造成环路、白花钱。

第二个问题:怎么挑这 n − 1 条边?承包商老张的做法是"先把所有可能的线路按造价从低到高排个队,从最便宜的看起:只要这条线路连接的是两个还没打通的区域,就签下它;如果它连接的两个村子本来就在同一片已打通的区域里(会形成环),就跳过。" —— 这叫 Kruskal 算法,它的目光始终盯在"边"上。

另一位工程师老李的做法不同:他从乡政府所在的村子开始,把"已经连进来的村子"画成一个圈,然后每次从圈内到圈外挑一条最便宜的线路,把圈外的那个村子拉进来,圈子越来越大,直到圈住所有村子 —— 这叫 Prim 算法,它的目光始终盯在"点"上。

两人算出的总造价完全一样(都是最小生成树),但工作量不同:村子多、线路稀疏时老张更快;村子少、线路密得像张网时老李更划算。

最小生成树 = 用最少的边连起所有点,且总权值最小。

💡 故事背后的算法思想

最小生成树是贪心算法在图上最漂亮的胜利之一:每一步都做"局部最优"的选择(当前最短的边 / 当前最短的跨界边),最终却能得到全局最优解。

两种算法的"贪心方式"不同,但都建立在同一个数学性质上:割性质 —— 对于图的任意一种把点分成两半的划分(割),横跨这个割的最小边一定属于某棵最小生成树。Kruskal 与 Prim 只是从不同角度反复应用这一性质。

记住一句话:Kruskal 选边,Prim 选点;稀疏图用 Kruskal,稠密图用 Prim。

①生活类比
⚡

电网架设

给所有村庄通电,电线越短越省钱;不能有环路(环路里的线是浪费)。

→ 最小生成树
🛣️

cheapest 修路方案

让 n 个城市互相可达,只修 n−1 条路,选最便宜的一组。

→ n−1 条边约束
🧩

把积木拼块合并

先把最小的两块粘起来,再粘下一块 —— 每次粘的都是"当前最便宜且不在同一块里的两块"。

→ Kruskal 排序 + 并查集
💧

水洼向外蔓延

从一处水洼开始,每次蔓延到"最近的一格",水洼越铺越大直到覆盖整块地。

→ Prim 点集扩张
②核心定义

生成树(Spanning Tree):连通无向图 G 的一棵包含全部 n 个顶点、恰好 n − 1 条边的无环连通子图。并不是所有图都有生成树 —— 只有当图本身连通时才有。

最小生成树(MST, Minimum Spanning Tree):在带权连通无向图中,边权之和最小的生成树。注意:最小生成树可能不唯一(权值相同),但最小权值和是唯一的。

割(Cut)与割性质:把顶点集合分成两部分 S 与 V−S 的划分叫割,横跨割的边叫割边。割性质:对任意割,权值最小的割边一定存在于某棵最小生成树中。这就是两种贪心算法的正确性来源。

Kruskal 算法:把所有边按权值升序排序,依次考虑每条边,若它的两个端点尚未连通(不构成环)就加入答案,直到选够 n − 1 条边。

Prim 算法:任选一个起点加入"已选点集",维护每个未选点到点集的最短距离 lowcost,每次把 lowcost 最小的点拉进点集,并用它去松弛其他未选点,重复 n − 1 次。

📌 本讲核心知识地图

板块核心内容对应大纲
定义与性质生成树 n−1 条边、连通无环;MST 权值和唯一【6】最小生成树
Kruskal边排序 + 并查集判环,O(m log m)【6】Prim和Kruskal等算法
Prim点集扩张 + lowcost,朴素 O(n²)、堆优化 O(m log n)【6】Prim和Kruskal等算法
正确性割性质 + 交换论证(局部换成更优边不劣化)贪心算法
扩展与应用次小生成树、瓶颈生成树、连成 k 个连通块算法综合
③核心口诀
🔑 两只手

Kruskal 抓边:边排序、并查集、不成环就选;
Prim 抓点:起点入集、每次拉最近的邻居进来。

🔑 选型

稀疏图(m 接近 n)→ Kruskal,O(m log m) 稳;
稠密图(m 接近 n²)→ Prim 朴素,O(n²) 快过 O(n²log n)。

🔑 判无解

选不满 n − 1 条边说明图不连通,MST 不存在!

模块 02

知识探究

先讲清楚"生成树"到底是什么,再逐个拆解 Kruskal 与 Prim 的两个核心机器:并查集、lowcost 数组。

1生成树与最小生成树 基础
1
🌐
包含全部点

n 个顶点一个不少

2
🪢
恰好 n−1 条边

边数固定,多一条必成环

3
🔗
连通且无环

任意两点之间有唯一路径

4
💰
权值和最小

这就是"最小"生成树

🔍 三个必须想清楚的问题

问题一:为什么恰好是 n − 1 条边?一个连通图若有 n 个点、n − 1 条边且无环,就是树;若边数少于 n − 1,则一定不连通。所以 n − 1 是"连通"所需的最少边数,也正是树的边数。

问题二:MST 唯一吗?不一定。如果图中存在权值相等的边,就可能有多棵不同的最小生成树,但它们的权值和必然相同。竞赛题目问的都是权值和。

问题三:什么时候不存在?当原图不连通时,不存在生成树。此时算法会"选不满 n − 1 条边",要按题目要求输出无解信息(例如 orz)。

🌲 一个生成树示例

原图(4 个点、5 条边):

边1-2 : 2
边1-3 : 2
边1-4 : 3
边2-3 : 4
边3-4 : 3

最小生成树(3 条边):

1 — 2 : 2
1 — 3 : 2
1 — 4 : 3
总权值 = 2 + 2 + 3 = 7,这就是 P3366 样例的答案
性质表述用途
割性质任意割的最小割边属于某棵 MSTKruskal 与 Prim 的正确性根基
环性质任意环上的最大边不属于任何 MSTKruskal 判环、次小生成树推导
瓶颈性质MST 就是瓶颈生成树使"最大边最小"的问题
唯一性所有边权互不相同时 MST 唯一判断题常用结论
2Kruskal:边排序 + 并查集 稀疏图首选
C++Kruskal 算法流程(骨架)
1. 把所有 m 条边存进数组,按权值 升序 排序        // 目光盯在"边"上
2. 初始化并查集:每个点各自一个集合
3. 依次扫描每条边 (u, v, w):
       if (find(u) != find(v)) {               // 两点尚未连通 → 不会成环
           合并 find(u) 与 find(v);
           总代价 += w;  选中边数++;
           if (选中边数 == n - 1) break;    // 已经连成一棵树了
       }
4. 若选中边数 < n - 1,说明原图不连通,无解

🔍 为什么"不成环就选"是对的?

排序后扫描时,考虑的每条边都是"当前所有尚未被处理的边里最小的"。设当前已选的边集把点分成了若干连通块,那么这条边横跨了两个不同的连通块 —— 这正是一个"割"。由割性质,该割的最小割边属于某棵最小生成树,所以放心选它。

反之,如果两个端点已经连通,说明加入它会形成环;由环性质,环上最大边不属于任何 MST(而它正是环上最大的,因为我们按升序处理),所以必须跳过。

并查集的作用:在近乎 O(1) 的时间内回答"这两个点现在连通吗",以及"把这两块合并"。路径压缩 + 按秩合并后,单次操作近乎常数。

▶ Kruskal 逐边扫描演示(4 点 5 边)
0边按权值排序:1-2:2、1-3:2、1-4:3、3-4:3、2-3:4。并查集初始为 {1}{2}{3}{4},总代价 = 0,已选 0 条。
1看边 1-2(权 2):find(1) ≠ find(2),两点不在同一集合 → 选中。合并成 {1,2},总代价 = 2,已选 1 条。
2看边 1-3(权 2):find(1) 是块 {1,2}、find(3) 是块 {3},不同 → 选中。合并成 {1,2,3},总代价 = 4,已选 2 条。
3看边 1-4(权 3):find(1) 在 {1,2,3}、find(4) 在 {4},不同 → 选中。合并成 {1,2,3,4},总代价 = 7,已选 3 条 = n−1,提前结束。
4如果继续看边 3-4(权 3):find(3) 与 find(4) 已在同一集合 → 会成环,跳过。这就是并查集判环的价值。
5结论:最小生成树 = {1-2, 1-3, 1-4},总权值 7。若不提前 break,扫描完 m 条边也可,时间复杂度不变(都是排序主导)。
关键记忆点:升序扫描 + 不成环就选 + 选够 n−1 条就停。
环节做法复杂度
存边结构体数组 {u, v, w},无向图存一份即可O(m)
排序按 w 升序(可用 sort + 重载比较)O(m log m)
判环与合并并查集 find / merge(路径压缩 + 按秩合并)近乎 O(α(n)) 每次
总计排序是瓶颈O(m log m)
3Prim:点集扩张与 lowcost 数组 稠密图首选

Prim 的思路可以想象成"水洼蔓延":先把一个点圈进点集 S,然后不断问"从 S 到外面的每个点,各自最近的一条边有多长",把最近的那个点拉进来,直到 S 包含所有点。

🔍 三个数组的含义(朴素版核心)

vis[i]:点 i 是否已经加入点集 S(布尔)。

lowcost[i]:未加入点 i 到点集 S 的最短边权。这是 Prim 的灵魂数组。

mst[i]:让 lowcost[i] 取到最小值的那条边的另一个端点(可选,用于输出方案)。

每轮做两件事:① 在所有 vis[i] 为假的点中找 lowcost 最小的那个,把它拉进 S(代价累加、边数 +1);② 用新点去松弛所有未加入的点:lowcost[j] = min(lowcost[j], w(新点, j))。

与 Dijkstra 的区别(极高频混淆点):Dijkstra 的 dis[j] 是"从起点到 j 的最短路径长度",松弛时累加 dis[u] + w;Prim 的 lowcost[j] 只是"到点集的一条单边权",松弛时直接取 min(lowcost[j], w),不累加。一字之差,含义完全不同。

1 2 3 4 5 2 1 2 5 3 蓝 = 已在点集 S 内 粉 = 尚未加入(候选)
Prim 从 1 号点出发,依次吸收 3(权 1)、2(权 2)、4(权 2)、5(权 3)
扩张过程(对应左侧图)
  1. S = {1}。lowcost = [−, 2, 1, ∞, ∞] → 选 3,代价 +1。
  2. S = {1,3}。松弛后 lowcost = [−, 2, −, 2, 7] → 选 2,代价 +2。
  3. S = {1,2,3}。松弛后 lowcost = [−, −, −, 2, 3](用 2 号点把 4 号点从 5 降到 2)→ 选 4,代价 +2。
  4. S = {1,2,3,4}。lowcost = [−,−,−,−, 3] → 选 5,代价 +3。
  5. 共选 4 条边,总权值 = 1 + 2 + 2 + 3 = 8。
C++Prim 朴素版 · O(n²)(邻接矩阵)
// n 个点,邻接矩阵 g[i][j](不连通为 INF)
const int INF = 0x3f3f3f3f;
int n, g[1005][1005], lowcost[1005];
bool vis[1005];

long long prim() {
    for (int i = 1; i <= n; i++) lowcost[i] = INF;
    lowcost[1] = 0;                       // 从 1 号点开始,自己到点集距离为 0

    long long total = 0;
    for (int round = 1; round <= n; round++) {
        int u = -1;
        for (int i = 1; i <= n; i++)           // 找未加入点中 lowcost 最小的
            if (!vis[i] && (u == -1 || lowcost[i] < lowcost[u])) u = i;

        if (u == -1 || lowcost[u] == INF) break;  // 图不连通
        vis[u] = true;
        total += lowcost[u];                  // 注意:不加 g[?][u],加的就是 lowcost 本身

        for (int v = 1; v <= n; v++)         // 用 u 松弛其他未加入的点
            if (!vis[v] && g[u][v] < lowcost[v]) lowcost[v] = g[u][v];
    }

    for (int i = 1; i <= n; i++) if (!vis[i]) return -1;  // 有漏点 → 不连通
    return total;
}
✅ Prim 朴素 O(n²)

只依赖邻接矩阵,代码短、常数小。稠密图(m ≈ n²)时优于 Kruskal,因为 n² 比 m log m = n² log n 小。

✅ Prim 堆优化 O(m log n)

把小根堆里的 (lowcost, 点) 作为候选,类似 Dijkstra。稀疏图时与 Kruskal 同阶,但常数略大。

❌ 用错数组含义

把 lowcost 按 Dijkstra 的 dis 来松弛(累加 dis[u] + w)→ 求出的是"最短路径树",不是最小生成树。

C++Prim 堆优化 · O(m log n)(链式前向星 + 小根堆)
struct Edge { int to, w, nxt; } e[MAXM * 2];
int head[MAXN], cntE = 0;
void add(int u, int v, int w) {
    e[++cntE] = {v, w, head[u]}; head[u] = cntE;
}

typedef pair<int,int> PII;    // (到点集的距离, 点编号)
priority_queue<PII, vector<PII>, greater<PII>> pq;

long long prim() {
    vector<bool> vis(n + 1, false);
    pq.push({0, 1});               // 从 1 号点出发
    long long total = 0;
    int picked = 0;

    while (!pq.empty() && picked < n) {
        PII cur = pq.top(); pq.pop();
        int d = cur.first, u = cur.second;
        if (vis[u]) continue;          // 过期候选,跳过
        vis[u] = true;
        total += d; picked++;
        for (int i = head[u]; i; i = e[i].nxt) {
            int v = e[i].to;
            if (!vis[v]) pq.push({e[i].w, v});   // 直接压入单边权,不累加
        }
    }
    return picked == n ? total : -1;
}

堆优化的写法与 Dijkstra 几乎相同,唯一的差别就是入堆的是"单边权"而不是"累加后的路径长"。

4两个算法的贪心正确性(交换论证) 证明直觉

很多同学会心里打鼓:只用"局部最优"的贪心,凭什么保证全局最优?下面给出提高组必须掌握的证明直觉 —— 交换论证。

📐 割性质(Cut Property)的证明思路

命题:把顶点分成两个非空集合 S 与 V−S(叫一个"割"),设 e 是横跨这个割的最小权边,则存在一棵最小生成树包含 e。

证明(交换论证):取任意一棵最小生成树 T。

① 若 e ∈ T,已经成立。

② 若 e ∉ T:由于 T 连通,在 T 中从 e 的一个端点到另一个端点有唯一路径。这条路径必然要"跨过割"至少一次,所以路径上存在某条边 e' 也横跨这个割。

③ 在 T 中用 e 替换 e',得到的 T' = T − e' + e 仍然是连通的、恰好 n−1 条边的图,即仍是一棵生成树。

④ 因为 e 是割上最小边,所以 w(e) ≤ w(e'),于是 w(T') ≤ w(T)。而 T 已经是最小生成树,说明 w(T') = w(T),T' 也是最小生成树,且包含 e。证毕。

✂️ Kruskal 的贪心如何套用割性质
  • 按权值升序扫描,当前边 e = (u, v) 是两个不同连通块之间的最小边。
  • 把"含 u 的连通块"作为 S,"其余部分"作为 V−S,则 e 就是该割的最小边。
  • 由割性质,e 属于某棵 MST → 放心选。
  • 若两端点已连通,加它会成环;由环性质,环上最大边不属于 MST,而它正是环上最大 → 必须跳过。
💧 Prim 的贪心如何套用割性质
  • 每次把当前点集 S 与"未加入的点"看作一个割。
  • lowcost 最小的那个点对应的边,正是横跨该割的最小边。
  • 由割性质,这条边属于某棵 MST → 拉进来。
  • 把新点并入 S,割变了,重新计算 lowcost,继续。
🔑 一句话总结

两种算法的每一步,都是在"某个割"上拿走了"该割的最小边"。割性质保证了这一步不会走错,于是贪心的累积结果就是全局最优。

对比项KruskalPrim(朴素)
贪心对象边(全局最小边)点(到点集的最近点)
数据结构边数组 + 并查集邻接矩阵 + lowcost
时间复杂度O(m log m)O(n²)
适用图稀疏图(m 远小于 n²)稠密图(m 接近 n²)
能否处理不连通能,选不满 n−1 条即无解能,出现 lowcost = INF 即无解
能否处理负权能(边权可负,排序仍正确)能(取最小值,不受负权影响)
5次小生成树 扩展概念

有时题目会问"第二小的生成树"。这是一个典型的"枚举删边 + 求最小"问题。

🔍 思路:先求 MST,再枚举加边删边

第1步:先求出最小生成树 T,设其总权为 W。

第2步:考虑所有不在 T 中的边 e = (u, v, w)。把它加进 T 会形成一个唯一的环。

第3步:为了让新图重新变回树,必须从环上去掉一条边。为了总权最小,应当去掉环上除 e 以外权值最大的那条边 maxEdge —— 于是得到一棵新树,权值为 W + w − maxEdge。

第4步:对所有非树边取这个值的最小值,就是答案(若要求严格次小,还要保证 w > maxEdge,否则去掉的应是"第二大的边")。

复杂度:枚举 m 条边,每条边要查询树上 u→v 路径的最大边。用 LCA + 倍增维护路径最值,可以把总复杂度压到 O(m log n),这是提高组/省选的常规做法。

① 求 MST得到 T 与 W
② 加非树边e 形成一个环
③ 去最大边W + w − maxEdge
④ 取最小所有候选中的最小
C++次小生成树 · 暴力版框架(n ≤ 100 可用)
/* 思路:先 Kruskal 求 MST,再枚举非树边做"加边删边" */
long long W;                       // MST 总权值
vector<int> tree[MAXN];            // 只含树边的邻接表
int maxEdgeOnPath, tx, ty;         // u→v 路径上的最大边权及其端点

void dfsPath(int u, int fa, int target) {
    if (u == target) { maxEdgeOnPath = 0; return; }
    // 在树上找 u→target 的路径,记录路径上的最大边(此处用简单 DFS)
    for (int v : tree[u]) if (v != fa) dfsPath(v, u, target);
}

long long secondMST() {
    long long ans = LLONG_MAX;
    for (auto& ed : edges) {
        if (ed.inTree) continue;                  // 只看非树边
        int mw = 0;                            // 路径 (u,v) 上的最大树边权
        for (int k = 0; k < 17; k++)              // 倍增查询路径最大值
            mw = max(mw, queryMax(ed.u, ed.v, k));
        if (ed.w > mw) ans = min(ans, W + ed.w - mw);   // 严格次小
    }
    return ans;
}

严格次小生成树(洛谷 P4180)是省选难度题,本讲只需理解"加边 → 成环 → 删最大边"的思想,不必手写倍增。

6常见应用与瓶颈生成树
🔧 四类典型应用
  • 最少代价连通:n 个点用最少的边连成一个整体(电网、管网、公路)。
  • 连成 k 个连通块:先做 Kruskal 到只剩 k 个块就停(P1195 口袋的天空)。
  • 瓶颈生成树:使"树上最大边权最小",直接取 MST(性质保证)。
  • 虚拟源点:给每个点加一条"到超源"的边,把点权问题转成边权问题(P1550 打井)。
🌉 瓶颈生成树是什么
  • 定义:在所有生成树中,使最大边权最小的那棵。
  • 关键结论:最小生成树一定是瓶颈生成树(反之不成立)。
  • 直观理解:MST 每一步都选当前最小割边,不会出现"被迫走了很贵的一条边"。
  • 应用:让"最差的一段路最短"(如最大载重货车路径)。
🔑 遇到"最大边尽量小"

不要慌,先求 MST —— 它天然满足"最大边最小"。要输出路径上的最大边,再配个 LCA 倍增即可。

案例案例1:P3366 最小生成树 · Kruskal 完整实现 模板题

题目:给出一个 n 个点、m 条边的无向带权图,求最小生成树的边权和。若图不连通输出 orz。

🔍 详细分析过程

第1步:识别模型 —— "把所有点连起来且总代价最小"就是标准 MST。n ≤ 5000、m ≤ 2×105,边数远大于点数,是稀疏图,选 Kruskal。

第2步:存边 —— 无向图每条边存一次即可(结构体 {u, v, w}),因为 Kruskal 不关心方向。

第3步:排序 —— 按 w 升序。这一步是复杂度瓶颈,O(m log m)。

第4步:并查集 —— find 用路径压缩,merge 用按秩(或按大小)合并。判定条件就是 find(u) != find(v)。

第5步:提前结束 —— 已选边数达到 n−1 就 break,能省下后面的时间。

第6步:无解判定 —— 循环结束若已选边数少于 n−1,说明图不连通,输出 orz。

复杂度:O(m log m + m α(n)) ≈ O(m log m),1s 内轻松通过。

C++P3366 · Kruskal 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 5005, MAXM = 200005;

struct Edge {
    int u, v, w;
    bool operator<(const Edge& o) const { return w < o.w; }   // 按权值升序
} e[MAXM];

int fa[MAXN], rk[MAXN];              // 并查集:父节点 + 秩

int find(int x) {
    while (fa[x] != x) { fa[x] = fa[fa[x]]; x = fa[x]; }   // 路径压缩(迭代版)
    return x;
}

bool merge(int a, int b) {
    a = find(a); b = find(b);
    if (a == b) return false;                 // 已连通,会成环
    if (rk[a] < rk[b]) swap(a, b);              // 按秩合并
    fa[b] = a;
    if (rk[a] == rk[b]) rk[a]++;
    return true;
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n, m; cin >> n >> m;
    for (int i = 1; i <= m; i++) cin >> e[i].u >> e[i].v >> e[i].w;
    for (int i = 1; i <= n; i++) fa[i] = i;

    sort(e + 1, e + m + 1);                  // 第 1 步:边排序

    long long total = 0;
    int picked = 0;
    for (int i = 1; i <= m && picked < n - 1; i++) {
        if (merge(e[i].u, e[i].v)) {             // 第 2 步:不成环就选
            total += e[i].w;
            picked++;
        }
    }

    if (picked < n - 1) cout << "orz" << "\n";  // 图不连通
    else cout << total << "\n";
    return 0;
}
运行结果
输入: 4 5
      1 2 2
      1 3 2
      1 4 3
      2 3 4
      3 4 3
输出: 7

(选中的边:1-2(2)、1-3(2)、1-4(3),恰好 3 = n-1 条)

💡 想一想

题目里 n ≤ 5000 而 m ≤ 200000,m/n 达到 40,属于稀疏图。如果把点数和边数换成 n = 2000、m = 2×106(接近完全图),排序 200 万条边仍然可行;但若 n 只有 300 而边是"完全图"(m ≈ 45000),Prim 朴素 O(n²) = 9×104 会明显快过 Kruskal 的 O(m log m) ≈ 7×105。

案例案例2:Prim 朴素版完整实现(稠密图) O(n²)

题目:给定 n 个点(n ≤ 1000)与一个 n×n 的邻接矩阵 g[i][j](0 表示自己,−1 表示不连通),求最小生成树的边权和;若不连通输出 -1。

🔍 详细分析过程

第1步:为什么用 Prim —— 邻接矩阵本身就是 O(n²) 的存储,矩阵里带 0 的边可能非常多(接近完全图)。用 Prim 朴素 O(n²) 与矩阵规模同阶,是这种情况下最快的选择。

第2步:初始化 —— lowcost[i] = INF 全部置为无穷;把起点 1 的 lowcost 设为 0,表示"起点到自己所在点集的距离为 0",这样第一轮就会先把 1 号点拉进来。

第3步:每轮选点 —— 在线性扫描中找未访问且 lowcost 最小的点。注意除了"未被访问"外,还要判断 lowcost 是否为 INF(不连通则跳出)。

第4步:累加与松弛 —— total += lowcost[u],然后用 g[u][v] 去更新其他未访问点。

第5步:连通性判断 —— 所有点访问完后检查 vis,有漏点就输出 −1。

复杂度:外层 n 轮,内层两次 O(n) 扫描 → O(n²)。n = 1000 时约 106 次操作。

C++Prim 朴素版 · 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1005;
const int INF = 0x3f3f3f3f;

int n, g[MAXN][MAXN];
int lowcost[MAXN];        // 未加入点 i 到点集 S 的最短单边权
bool vis[MAXN];

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++) {
            cin >> g[i][j];
            if (g[i][j] == -1) g[i][j] = INF;     // -1 表示不连通
        }

    for (int i = 1; i <= n; i++) lowcost[i] = INF;
    lowcost[1] = 0;                            // 从 1 号点开始

    long long total = 0;
    for (int round = 1; round <= n; round++) {
        int u = 0;                             // 找 lowcost 最小的未访问点
        for (int i = 1; i <= n; i++)
            if (!vis[i] && (u == 0 || lowcost[i] < lowcost[u])) u = i;

        if (u == 0 || lowcost[u] == INF) break;     // 图不连通

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

    int cnt = 0;
    for (int i = 1; i <= n; i++) if (vis[i]) cnt++;
    if (cnt < n) cout << -1 << "\n";
    else cout << total << "\n";
    return 0;
}
运行结果
输入: 5
      0  4  1 -1 -1
      4  0  2  5 -1
      1  2  0  2  7
     -1  5  2  0  3
     -1 -1  7  3  0
输出: 8

(选边过程:1-3 权 1 → 3-2 权 2 → 3-4 权 2 → 4-5 权 3,合计 8)
案例案例3:P1195 口袋的天空 —— Kruskal 变体 连通块计数

题目:有 n 朵云、m 条可以相连的边(每条有代价),要把它们连成 k 个连通块(即"连成 k 个云朵集合"),求最小总代价。若做不到输出 No Answer。

🔍 关键转化:把"选够 n−1 条"改成"连够 n−k 条"

思路:初始时有 n 个连通块。每选中一条有效边,连通块数量就减 1。要从 n 个块变成 k 个块,恰好需要选中 n − k 条边。

所以:把 Kruskal 的终止条件从"picked == n−1"改成"picked == n−k"即可,其余完全不变。

无解条件:如果扫描完所有边后连通块个数仍大于 k,说明边不够用,输出 No Answer。

另一种等价直觉:Kruskal 就是"从最小的边开始不断合并两个块"。合并到只剩 k 个块时停止,此时用的边数最少、代价也最小。

复杂度:仍然是排序主导,O(m log m)。

C++P1195 · 连成 k 个连通块 · 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1005, MAXM = 10005;

struct Edge {
    int u, v, w;
    bool operator<(const Edge& o) const { return w < o.w; }
} e[MAXM];

int fa[MAXN];
int find(int x) { while (fa[x] != x) { fa[x] = fa[fa[x]]; x = fa[x]; } return x; }

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n, m, k; cin >> n >> m >> k;
    for (int i = 1; i <= m; i++) cin >> e[i].u >> e[i].v >> e[i].w;
    if (k > n) { cout << "No Answer" << "\n"; return 0; }   // 块数不能超过点数

    for (int i = 1; i <= n; i++) fa[i] = i;
    sort(e + 1, e + m + 1);

    long long total = 0;
    int blocks = n;                        // 当前连通块个数,初始为 n
    for (int i = 1; i <= m && blocks > k; i++) {
        int a = find(e[i].u), b = find(e[i].v);
        if (a != b) {
            fa[a] = b;
            total += e[i].w;
            blocks--;                        // 合并两块,块数减一
        }
    }

    if (blocks > k) cout << "No Answer" << "\n";   // 边不够,达不到 k 块
    else cout << total << "\n";
    return 0;
}
运行结果
输入: 5 5 2
      1 2 1
      2 3 1
      3 4 1
      4 5 1
      1 5 10
输出: 3

(选边 1-2(1)、2-3(1)、3-4(1),块数从 5 降到 2,
  代价 1+1+1 = 3;剩下 {5} 单独成块)

💡 想一想

为什么这里不需要 k 个块各自尽量"紧"?因为 Kruskal 是按边权升序合并的:每一次合并用的都是"当前最便宜的那条跨界边"。所以不管最终分成几块,合并过程始终最小化总代价。

如果把要求改成"连成恰好 k 个块且每块点数尽量均匀",那就不是 MST 问题了 —— 它会变成 NP 难的划分问题。这也说明:题目问什么,决定了算法选什么。

模块 03

实践应用

三道完整题目,覆盖 Kruskal、Prim 与"连成 k 个连通块"三类考法。

题1最小生成树(Kruskal 实现) 模板

题目描述

给出 n 个点、m 条无向带权边(可能有重边与自环),求最小生成树的边权和。若不连通输出 orz。

输入格式:第一行两个整数 n, m;接下来 m 行每行三个整数 u, v, w,表示 u 与 v 之间有一条权值为 w 的边。

输出格式:一行,最小生成树边权和,或 orz。

输入样例:
4 5
1 2 2
1 3 2
1 4 3
2 3 4
3 4 3

输出样例:7

数据范围:1s / 256MB,1 ≤ n ≤ 5000,1 ≤ m ≤ 200000,0 ≤ w ≤ 104。

🔍 两种方法对比

方法一:Kruskal推荐 —— 排序 m 条边 O(m log m),配合并查集判环。稀疏图首选。

方法二:Prim 朴素此处吃亏 —— 邻接矩阵要开 5000×5000 的 int 数组,约 100MB,勉强能过但风险大;O(n²) = 2.5×107 也在可接受范围,但内存不友好。

结论:"n 小 m 大"必选 Kruskal;"n 小且图接近完全图"(m 接近 n²/2)才轮到 Prim。

重边与自环处理:自环会直接构成环,Kruskal 里 find(u) == find(v) 自动跳过;重边会一起参与排序,只保留最有用的那条,不影响正确性。

C++题1 参考代码 · Kruskal
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 5005, MAXM = 200005;
struct Edge { int u, v, w; } e[MAXM];
bool cmp(const Edge& a, const Edge& b) { return a.w < b.w; }

int fa[MAXN], rk[MAXN];
int find(int x) { while (fa[x] != x) { fa[x] = fa[fa[x]]; x = fa[x]; } return x; }

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n, m; cin >> n >> m;
    for (int i = 1; i <= m; i++) cin >> e[i].u >> e[i].v >> e[i].w;
    for (int i = 1; i <= n; i++) fa[i] = i;
    sort(e + 1, e + m + 1, cmp);

    long long total = 0;
    int picked = 0;
    for (int i = 1; i <= m && picked < n - 1; i++) {
        int a = find(e[i].u), b = find(e[i].v);
        if (a == b) continue;
        if (rk[a] < rk[b]) swap(a, b);
        fa[b] = a; if (rk[a] == rk[b]) rk[a]++;
        total += e[i].w;
        picked++;
    }
    if (picked < n - 1) cout << "orz" << "\n";
    else cout << total << "\n";
    return 0;
}
运行结果
输入: 4 5
      1 2 2
      1 3 2
      1 4 3
      2 3 4
      3 4 3
输出: 7

输入: 4 2
      1 2 1
      3 4 1
输出: orz
(只有 2 条有效边,选不满 n-1=3 条,图不连通)

💡 引入

思考:如果题目变成"最大生成树"(要求总权值最大),代码要改几处?答案:只需把排序改成降序,其余完全不动。这就是贪心算法的漂亮之处 —— 方向一改,Kruskal 秒变"最大生成树算法"。

题2同一张图,Prim 与 Kruskal 双解对照 双写法

题目描述

给定 5 个点与 7 条边的无向带权图,求最小生成树的边权和。要求分别用 Prim(朴素版)与 Kruskal 写出两种实现。

输入格式:第一行 n, m;接下来 m 行 u, v, w。

输出格式:一行一个整数,最小生成树边权和。

输入样例:
5 7
1 2 4
1 3 1
2 3 2
2 4 5
3 4 2
3 5 7
4 5 3

输出样例:8

数据范围:1s / 256MB,1 ≤ n ≤ 1000,1 ≤ m ≤ 105,0 ≤ w ≤ 104。

🔍 两种方法对比

方法一:Kruskal代码短 —— 排序 7 条边后依次扫描,选 1-3(1)、2-3(2)、3-4(2)、4-5(3),合计 8。

方法二:Prim 朴素版不加排序 —— 从 1 出发:先拉 3(权 1),再用 3 松弛:2 变成 2、4 变成 2、5 变成 7;拉 2(权 2);用 2 松弛 4:min(2, 5) 不变;拉 4(权 2);用 4 松弛 5:min(7, 3) = 3;拉 5(权 3)。合计 1+2+2+3 = 8。

关键观察:两种算法选出的边完全一样 —— 因为本题边权各不相同,最小生成树唯一。若出现等权边,两者可能选出不同的树,但权值和必然相同。

时间复杂度:Kruskal O(m log m);Prim 朴素 O(n²)。本题 m = 7 很小,两者都是瞬间完成。

C++题2 参考代码 · Prim 朴素版(邻接矩阵)
#include <bits/stdc++.h>
using namespace std;

const int INF = 0x3f3f3f3f, MAXN = 1005;
int n, m, g[MAXN][MAXN], lowcost[MAXN];
bool vis[MAXN];

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    cin >> n >> m;
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++) g[i][j] = INF;
    for (int i = 1; i <= m; i++) {
        int u, v, w; cin >> u >> v >> w;
        g[u][v] = min(g[u][v], w);           // 处理重边,取较小者
        g[v][u] = min(g[v][u], w);
    }

    for (int i = 1; i <= n; i++) lowcost[i] = INF;
    lowcost[1] = 0;

    long long total = 0;
    for (int r = 1; r <= n; r++) {
        int u = 0;
        for (int i = 1; i <= n; i++)
            if (!vis[i] && (u == 0 || lowcost[i] < lowcost[u])) u = i;
        if (lowcost[u] == INF) { cout << -1; return 0; }
        vis[u] = true; total += lowcost[u];
        for (int v = 1; v <= n; v++)
            if (!vis[v] && g[u][v] < lowcost[v]) lowcost[v] = g[u][v];
    }
    cout << total << "\n";
    return 0;
}
运行结果
输入: 5 7
      1 2 4
      1 3 1
      2 3 2
      2 4 5
      3 4 2
      3 5 7
      4 5 3
输出: 8

(Prim 选边:1-3(1) → 2-3(2) → 3-4(2) → 4-5(3),与 Kruskal 结果一致)

💡 引入

注意 Prim 里累加的是 lowcost[u],不是 g[prev][u]。为什么要这样写?因为 lowcost[u] 已经记录了"u 到点集最短的那条边的权",累加它就等于累加"本次接入 S 所付出的一条边"。写成 g[prev][u] 在常规实现里也常等价,但在"起点 lowcost = 0"的写法下容易出错。

建议统一记牢:Prim 加 lowcost,Dijkstra 加 g[prev][u] + dis[prev],两句话区分两块代码。

题3连成 k 个连通块的最小代价 变形

题目描述

有 n 个点与 m 条可选的带权无向边。要求从中选一些边,使得所有点被划分成恰好 k 个连通块,且选中边的总权值最小。若无法做到输出 No Answer。

输入格式:第一行三个整数 n, m, k;接下来 m 行 u, v, w。

输出格式:一行,最小总代价或 No Answer。

输入样例:
5 5 2
1 2 1
2 3 1
3 4 1
4 5 1
1 5 10

输出样例:3

数据范围:1s / 256MB,1 ≤ k ≤ n ≤ 1000,1 ≤ m ≤ 104。

🔍 思路与正确性

核心转化:初始有 n 块,每合并一次少一块。要从 n 块变成 k 块,需要合并 n − k 次,也就是选中 n − k 条边。

算法:边按权升序排序,维护"当前块数 blocks",每成功合并一次 blocks 减一;当 blocks 等于 k 时停止。若扫完所有边 blocks 仍大于 k,输出 No Answer。

正确性直觉:整个过程与 Kruskal 完全一致(每一步都是当前最小割边),只是提前停止的位置不同。由于"合并 n−k 次"是必须的,而 Kruskal 的前 n−k 次合并恰好是代价最小的 n−k 次合并,所以答案最优。

边界:若 k 大于 n,直接 No Answer;若 k = 1,退化为标准 MST;若 k = n,答案为 0(一条边都不选)。

C++题3 参考代码 · Kruskal 连通块计数变体
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1005, MAXM = 10005;
struct Edge { int u, v, w; } e[MAXM];
bool cmp(const Edge& a, const Edge& b) { return a.w < b.w; }
int fa[MAXN];
int find(int x) { while (fa[x] != x) { fa[x] = fa[fa[x]]; x = fa[x]; } return x; }

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n, m, k; cin >> n >> m >> k;
    if (k > n) { cout << "No Answer"; return 0; }
    for (int i = 1; i <= m; i++) cin >> e[i].u >> e[i].v >> e[i].w;
    for (int i = 1; i <= n; i++) fa[i] = i;
    sort(e + 1, e + m + 1, cmp);

    long long total = 0;
    int blocks = n;
    for (int i = 1; i <= m && blocks > k; i++) {
        int a = find(e[i].u), b = find(e[i].v);
        if (a == b) continue;
        fa[a] = b; total += e[i].w; blocks--;
    }
    if (blocks > k) cout << "No Answer" << "\n";
    else cout << total << "\n";
    return 0;
}
运行结果
输入: 5 5 2
      1 2 1
      2 3 1
      3 4 1
      4 5 1
      1 5 10
输出: 3

输入: 5 5 1
      1 2 1
      2 3 1
      3 4 1
      4 5 1
      1 5 10
输出: 4
(k = 1 时退化为标准 MST,选 1-2、2-3、3-4、4-5 共 4 条边)

💡 引入

思考:若要求"恰好 k 个连通块,且每个块内点数不超过 3",问题会变成什么?答案是它不再是 MST 问题了(变成带容量约束的划分问题,属于 NP 难)。约束一多,贪心的正确性就不再成立 —— 这也是判断"能不能用 MST"的关键:只关心"连通"与"总代价最小"两个目标时才可以。

模块 04

总结提升

①核心要点与万能模板
要点一句话记住易错提醒
生成树条件n 个点、n−1 条边、连通无环n−1 条边必须真的连通,不能有孤岛
MST 唯一性边权互不相同时唯一权值和总是唯一,方案未必唯一
Kruskal 三步排序 → 并查集判环 → 选够 n−1 停并查集要初始化;路径压缩别写错
Prim 核心lowcost = 到点集的单边最短距离松弛是 min,绝不累加
累加什么Kruskal 加边权;Prim 加 lowcost[u]Prim 写成 g[prev][u] 易在起点处出错
正确性来源割性质(最小割边属于某棵 MST)环性质:环上最大边不属于 MST
无解判定选不满 n−1 条边 → 图不连通别忘了输出题目指定的无解串
典型变形连成 k 块:合并 n−k 次即停k 大于 n 直接无解
C++万能模板 · Kruskal + Prim 一起带走
/* ================= Kruskal(稀疏图) ================= */
struct Edge { int u, v, w; };
Edge e[MAXM];
int fa[MAXN];

int find(int x) { while (fa[x] != x) { fa[x] = fa[fa[x]]; x = fa[x]; } return x; }

long long kruskal(int n, int m) {
    for (int i = 1; i <= n; i++) fa[i] = i;
    sort(e + 1, e + m + 1, [](const Edge& a, const Edge& b) { return a.w < b.w; });

    long long total = 0;
    int picked = 0;
    for (int i = 1; i <= m; i++) {
        int a = find(e[i].u), b = find(e[i].v);
        if (a == b) continue;               // 成环,跳过
        fa[a] = b;
        total += e[i].w;
        if (++picked == n - 1) break;      // 连成一棵树,提前结束
    }
    return picked == n - 1 ? total : -1;  // -1 表示不连通
}

/* ================= Prim(稠密图,O(n^2)) ================= */
int g[MAXN][MAXN], lowcost[MAXN];
bool vis[MAXN];

long long prim(int n) {
    for (int i = 1; i <= n; i++) { lowcost[i] = INF; vis[i] = false; }
    lowcost[1] = 0;

    long long total = 0;
    for (int r = 1; r <= n; r++) {
        int u = 0;
        for (int i = 1; i <= n; i++)
            if (!vis[i] && (u == 0 || lowcost[i] < lowcost[u])) u = i;
        if (u == 0) return -1;                 // 不连通
        vis[u] = true;
        total += lowcost[u];
        for (int v = 1; v <= n; v++)
            if (!vis[v] && g[u][v] < lowcost[v]) lowcost[v] = g[u][v];
    }
    return total;
}
②拓展案例
拓展1虚拟源点

P1550 风格的"打井"问题:每个点可以选择自己打井(有点权),也可以连管道(有边权)

思路:给每个点加一条"到虚拟源点 0"的边,边权等于该点自己打井的费用。于是"要么打井、要么接管道"就统一成了"每个点必须通过某条边接通" —— 直接跑 MST 即可。

// 虚拟源点技巧:把"点权"转成"边权"
int n; cin >> n;
int cnt = 0;
for (int i = 1; i <= n; i++) {
    int c; cin >> c;                    // 第 i 个点自己打井的费用
    e[++cnt] = {0, i, c};                 // 连到虚拟源点 0
}
for (int i = 1; i <= n; i++)
    for (int j = 1; j <= n; j++) {
        int w; cin >> w;                // i 与 j 之间铺管道的费用
        if (i < j) e[++cnt] = {i, j, w};
    }
// 现在有 n+1 个点(0..n),求 MST 的总权值即可
cout << kruskal(n + 1, cnt) << "\n";

运行结果(示例):示例输入 —— 3 个点,打井费用分别为 5、4、4;管道费用 1-2 为 2、2-3 为 3、1-3 为 2。加上虚拟源点 0 后共有 4 个点、6 条边,MST 选中 1-2(2)、1-3(2)、0-2(4),总代价 8。含义是:2 号点自己打井(花 4),再铺管道 2-1(2)与 1-3(2),合计 8。

要点:"虚拟源点"是把点权转边权的通用技巧,还可用于"必须选至少一个""每个点有容量"等多种变形。注意虚拟源点本身也要被 MST 连进来,所以答案必然包含"至少打一口井"的费用。

拓展2瓶颈路径

货车运输:求两点之间所有路径中"最大边权最小"的那条路径的最大边权

思路(方法一):二分答案 + 并查集。二分最大边权 x,只保留权值不超过 x 的边,判断 s 与 t 是否连通。若连通则答案不超过 x。复杂度 O(m log maxW)。

思路(方法二):先求最大生成树(按边权降序跑 Kruskal),则任意两点在树上的唯一路径,其最大边权就是答案。再用 LCA 倍增查询路径最大边。复杂度 O(m log m + q log n)。

/* 方法一:二分 + 并查集,好写且不易错 */
bool check(int mid, int s, int t) {
    for (int i = 1; i <= n; i++) fa[i] = i;
    for (int i = 1; i <= m; i++)
        if (e[i].w <= mid) {
            int a = find(e[i].u), b = find(e[i].v);
            if (a != b) fa[a] = b;
        }
    return find(s) == find(t);
}

int solve(int s, int t) {
    int lo = 0, hi = MAXW, ans = -1;
    while (lo <= hi) {
        int mid = (lo + hi) / 2;
        if (check(mid, s, t)) { ans = mid; hi = mid - 1; }
        else lo = mid + 1;
    }
    return ans;
}

运行结果(示例):4 个点、边 1-2(3)、2-3(5)、3-4(2)、1-4(1),查询 1 到 3。两条路径:1-2-3 的最大边为 5,1-4-3 的最大边为 max(1,2)=2,取最小者 → 输出 2。

结论:"最大边最小"与"最小边最大"这两类问题,都能用生成树 + 二分/倍增解决,是提高组的高频套路。

拓展3严格次小生成树

P4180 严格次小生成树(思路版)

思路:先求 MST(Kruskal,同时标记树边)。然后枚举每条非树边 e=(u,v,w):在 MST 上找到 u 到 v 路径上的最大边 max1 与严格次大边 max2(倍增维护两个值)。

替换规则:若 w > max1,则新树权 = W + w − max1;若 w == max1(等权边,换成 max1 会得到与原树等权的树,不"严格"),则必须换用次大边:新树权 = W + w − max2。所有候选取最小即为严格次小生成树的权值。

// 倍增数组:mx1[u][k] 表示 u 向上跳 2^k 步路径上的最大边
//            mx2[u][k] 表示同一路径上的严格次大边
int mx1[MAXN][18], mx2[MAXN][18], up[MAXN][18], dep[MAXN];

// 合并两个区间的最大值与次大值:标准套路(分类讨论 + max/min)
void merge(int& m1, int& m2, int a1, int a2) {
    int v[4] = {m1, m2, a1, a2};
    sort(v, v + 4, greater<int>());
    unique(v, v + 4, [](int x, int y) { return x == y; });  // 去掉重复值
    m1 = v[0]; m2 = v[1];
}

long long best = LLONG_MAX;
for (auto& ed : edges) {
    if (ed.inTree) continue;
    int m1 = 0, m2 = 0;
    queryPath(ed.u, ed.v, m1, m2);            // LCA 倍增,得到路径上的最大 / 次大边
    if (ed.w > m1) best = min(best, W + ed.w - m1);
    else if (ed.w == m1 && m2 > 0) best = min(best, W + ed.w - m2);
}
cout << best << "\n";

运行结果(示例):示例输入 4 4 与边 1-2(1)、2-3(2)、3-4(3)、4-1(1)。MST 权值 W = 4(选 1-2、4-1、2-3)。唯一非树边是 3-4(3),它在树上的路径 3-2-1-4 的最大边为 2,于是新树权值 = 4 + 3 − 2 = 5,这就是严格次小生成树的答案(该图的生成树权值从小到大为 4、5、6、6)。

提示:本题是省选难度,本讲只要求理解"加非树边 → 成环 → 换掉环上最大的边"这一核心思想。

③思考与启发

🤔 思考一:如果所有边权都相同,MST 会退化成什么?

此时任何一棵生成树的权值和都一样,MST 就变成了"随便找一棵生成树"。

查看参考解答

答案:边权全相同时,问题退化为"求生成树数量"或"只要能连通就行"。此时 Kruskal 会按输入顺序选择,Prim 会从起点开始随便扩展 —— 任意一棵生成树都是最小生成树,权值都等于 (n−1)×w。

启发:这说明 MST 问题的"难"完全来自边权的差异。当问题失去权值信息,贪心的选择就不再重要,能过验证的只有"连通性"。

要点:贪心算法的价值体现在"有权值可比"的前提下;权值全同,贪心无所作为。

🤔 思考二:Kruskal 与 Prim 为什么能得出相同结果?

一个从边出发,一个从点出发,看起来风马牛不相及。

查看参考解答

答案:因为它们都在反复应用同一个数学性质 —— 割性质。

Kruskal 每次取的是"当前所有候选边中最小的",它必定是某个割的最小割边;Prim 每次取的是"当前点集与外部之割的最小割边"。两者都保证了"选的边属于某棵 MST",因此最终结果权值相同。

进一步:如果边权有重复,两者选出的树可能不同,但因为每一步都合法,最终权值和必定相同。这也是"MST 权值唯一"的直观解释。

要点:不同的算法路径,可以通向同一个最优解 —— 只要都用到了同一个正确的贪心依据。

🤔 思考三:为什么带负权边时两种算法都还成立,而 Dijkstra 却不行?

这是一个非常值得对比的细节。

查看参考解答

答案:MST 关心的是"选哪些边",而每条边是否入选只取决于它的排序位置与两端是否连通,与"累加和"无关。负权边只会更早被选中,不会破坏割性质,所以 Kruskal / Prim 都成立。

对比 Dijkstra:Dijkstra 的正确性依赖于"最短路的子路径也是最短的"(即已确定的点不会再被更新)。负权边会让这个性质崩溃:后面出现的负边可以让已确定的最短路变得更短,因此 Dijkstra 在负权图上会给出错误答案。这正是下一讲要讲的重点。

要点:同一张图上的不同问题,对"负权"的容忍度完全不同 —— 记忆时一定要把"算法"和"问题"绑在一起记。
模块 05

学习检测

先找错,再背口诀 —— 把最小生成树的坑消灭在考试之前。

⚡高频错误诊断
易错1

并查集忘记初始化

❌ 错误
写完 find / merge 就直接扫边,忘了 for (i=1;i<=n;i++) fa[i]=i;

后果:所有点的父亲都是 0(全局数组初值),find 全部返回 0,于是所有边都被判成"成环"而跳过,答案变成 0 或选不满。
✅ 正确
在读边之后、排序之前,先把每个点初始化成独立集合。习惯做法:把 init 写成一个小函数,调用一次。
口诀:并查集第一步,每个点先做自己的爹!
易错2

Prim 把 lowcost 当成 dis 来松弛(累加)

❌ 错误
写成 lowcost[v] = min(lowcost[v], lowcost[u] + g[u][v]);

后果:这算的是"从起点到 v 的最短路径",得到的是最短路径树而非最小生成树,答案偏大。
✅ 正确
写成 lowcost[v] = min(lowcost[v], g[u][v]);

lowcost 的含义是"v 到点集的单条边中最短的",所以只比较单边权。
口诀:Prim 只比单边权,不累加!
易错3

忘记判断图不连通

❌ 错误
Kruskal 扫完所有边就直接输出 total,没有检查 picked < n-1。

后果:图不连通时输出了一个假的"最小生成树"权值,应该输出 orz 却输出了数字。
✅ 正确
Kruskal:循环后判断 picked 是否等于 n−1;Prim:判断是否所有点都被访问,或有 lowcost 为 INF 的点。
口诀:选不满 n−1 条,必定不连通,先判无解再输出!
易错4

边数组开太小 / 权值用 int 溢出

❌ 错误
按点数开边数组(e[MAXN]),或者用 int 累计总权值。

后果:RE(数组越界)或答案溢出为负数。n = 104、w = 109 时总权可达 1013,远超 int。
✅ 正确
边数组按边数开(e[MAXM],一般 2×105 以上);总权值用 long long 累计。
口诀:边开 MAXM,和用 long long,越界溢出都不怕!
易错5

把"最小生成树"当成"最短路树"

❌ 错误
题目问"连接所有点总代价最小",却用 Dijkstra 求了单源最短路树。

后果:两者是完全不同的问题。例如三角形三边 1、1、10,"最短路树"会取 1 和 10,和是 11;MST 取 1 和 1,和是 2。
✅ 正确
看清关键词:"所有点连通 / 总边权和最小" → MST;"从某点到其他点的距离" → 最短路。
口诀:连所有点选 MST,问某点到谁都选最短路!
📋速查表
算法核心步骤复杂度适用
Kruskal排序边 → 并查集判环 → 选 n−1 条O(m log m)稀疏图(m 远小于 n²)
Prim 朴素n 轮,每轮选 lowcost 最小的点并松弛O(n²)稠密图(m 接近 n²)
Prim 堆优化小根堆存 (单边权, 点),弹出即合并O(m log n)稀疏图,代码较长
连成 k 块Kruskal 合并到 blocks == k 就停O(m log m)变形题(P1195)
最大生成树Kruskal 把排序改成降序O(m log m)"收益最大"类题
次小生成树枚举非树边,换掉环上最大边O(m log n)(配倍增)严格次小需额外处理等权
判断条件对应做法
"n 个点全部连通,代价最小"MST(Kruskal / Prim)
"选 n−1 条边"与"无环"同时满足生成树
要求"最大边尽量小"先求 MST(瓶颈性质)
要求"第二小的总权值"MST + 加边删边
每个点可以"自建"或"连接"虚拟源点转成边权
📌笔记卡
🧠 必记概念
  • 生成树 = n 点 + n−1 边 + 连通无环
  • MST 权值和唯一,方案可能不唯一
  • 割性质:最小割边属于某棵 MST
  • 环性质:环上最大边不属于 MST
  • lowcost = 到点集的单边最短距离
  • MST 一定是瓶颈生成树
⌨️ 必背模板
  • Kruskal:sort + find/merge + picked
  • 并查集:路径压缩循环写法
  • Prim:lowcost + vis,松弛取 min
  • 累加:Kruskal 加 e[i].w,Prim 加 lowcost[u]
  • 提前停:picked == n−1 就 break
  • 无解:picked < n−1 → 不连通
⚠️ 考场提醒
  • 并查集一定要先初始化
  • 边数组按边数开,别按点数开
  • 总权值用 long long
  • 重边取较小值,自环直接忽略
  • 看清无解输出的具体字符串
  • 稠密图优先考虑 Prim 朴素
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念题

一个连通的 n 点图,它的生成树一定有几条边?如果图有 5 个点、7 条边,最多能找出多少棵不同的生成树?

查看参考答案

思路:生成树的边数固定为 n − 1;不同生成树的数量与图的结构有关,需要用基尔霍夫矩阵树定理计算(提高组只要求会数简单图)。

答案:边数一定是 4 条。以"五边形加两条对角线"这种图为例,生成树数量可用枚举法求出;对完全图 K5,生成树数量为 55−2 = 125(Cayley 公式)。

易错:把"生成树"与"生成子图"混淆 —— 生成子图可以有多余边,生成树必须恰好 n−1 条且无环。

A2编程题

用 Kruskal 求下面图的最小生成树权值:4 个点,边为 1-2:1、2-3:5、3-4:2、1-3:4、1-4:3。

查看参考答案

手算过程:排序后依次是 1-2:1、3-4:2、1-4:3、1-3:4、2-3:5。

选 1-2(1) → 块 {1,2};选 3-4(2) → 块 {1,2} 与 {3,4};选 1-4(3) → 合并成 {1,2,3,4},已有 3 = n−1 条边,停止。总权值 = 1+2+3 = 6。

#include <bits/stdc++.h>
using namespace std;
struct E { int u, v, w; } e[105];
int fa[105];
int find(int x){ while(fa[x]!=x){ fa[x]=fa[fa[x]]; x=fa[x]; } return x; }
int main(){
    int n = 4, m = 5;
    int uu[5]={1,2,3,1,1}, vv[5]={2,3,4,3,4}, ww[5]={1,5,2,4,3};
    for(int i=0;i<m;i++) e[i]={uu[i],vv[i],ww[i]};
    for(int i=1;i<=n;i++) fa[i]=i;
    sort(e,e+m,[](const E&a,const E&b){ return a.w<b.w; });
    long long tot=0; int cnt=0;
    for(int i=0;i<m&&cnt<n-1;i++){
        int a=find(e[i].u), b=find(e[i].v);
        if(a==b) continue;
        fa[a]=b; tot+=e[i].w; cnt++;
    }
    cout << tot << endl;   // 6
}

易错:忘记初始化 fa;或把 2-3(5) 也选进来(4 条边就不是树了)。

A3编程题

用 Prim 朴素版求同一张图(A2 中的图)的最小生成树,并说明与 Kruskal 结果是否一致。

查看参考答案

过程:从 1 出发,lowcost 初始 = [−, 1, 4, 3](1 到 2 是 1、到 3 是 4、到 4 是 3)。

① 选 2(权 1),用 2 松弛:3 变成 min(4, 5) = 4,4 不变(无边)。lowcost = [−,−,4,3]。

② 选 4(权 3),用 4 松弛:3 变成 min(4, 2) = 2。lowcost = [−,−,2,−]。

③ 选 3(权 2)。总权值 = 1 + 3 + 2 = 6,与 Kruskal 完全一致。

const int INF = 0x3f3f3f3f;
int g[5][5], lowcost[5]; bool vis[5];
int main(){
    for(int i=1;i<=4;i++) for(int j=1;j<=4;j++) g[i][j]=INF;
    g[1][2]=g[2][1]=1;
    g[2][3]=g[3][2]=5;
    g[3][4]=g[4][3]=2;
    g[1][3]=g[3][1]=4;
    g[1][4]=g[4][1]=3;

    for(int i=1;i<=4;i++) lowcost[i]=INF;
    lowcost[1]=0;
    int tot=0;
    for(int r=1;r<=4;r++){
        int u=0;
        for(int i=1;i<=4;i++) if(!vis[i]&&(u==0||lowcost[i]<lowcost[u])) u=i;
        vis[u]=true; tot+=lowcost[u];
        for(int v=1;v<=4;v++) if(!vis[v]&&g[u][v]<lowcost[v]) lowcost[v]=g[u][v];
    }
    cout << tot << endl;   // 6
}

易错:松弛写成累加式(变成最短路树);或忘记把 lowcost[1] 置 0,导致第一轮选点错误。

A4判断题

判断:① 最小生成树一定唯一;② 最小生成树一定是瓶颈生成树;③ 生成树的边数随图的边数增加而增加。

查看参考答案

① 错。只有当所有边权互不相同时 MST 才唯一。有等权边时可能有多棵 MST,但权值和相同。

② 对。MST 使"最大边权"最小,这就是瓶颈生成树的定义。(反过来不成立:瓶颈生成树未必是 MST。)

③ 错。生成树的边数只与点数有关,恒为 n − 1。增加图里的边只会让"可选的生成树更多",不会改变树的边数。

易错:把"瓶颈生成树"与"最小生成树"当成同一个概念的双向等价。

🟡 B组 · 提高(4题)

B1变形应用

最大生成树:给定带权无向图(权值为正),求使总权值最大的生成树。

查看参考答案

分析:把 Kruskal 的排序改成降序即可。正确性同样来自割性质(取该割的最大割边)。

sort(e + 1, e + m + 1, [](const Edge& a, const Edge& b) {
    return a.w > b.w;               // 唯一的改动:降序
});

long long total = 0; int picked = 0;
for (int i = 1; i <= m && picked < n - 1; i++) {
    int a = find(e[i].u), b = find(e[i].v);
    if (a == b) continue;
    fa[a] = b; total += e[i].w; picked++;
}
cout << (picked == n - 1 ? total : -1) << "\n";

应用场景:"货车运输"(P1967)中要求"路径上的最小边尽量大",先求最大生成树,问题就变成了树上路径最小值查询。

易错:以为必须重新写一套算法,其实只是一个排序方向的区别;另外不要忘记仍然要判断连通性。

B2并查集应用

优化:如何把 Kruskal 的排序换成"桶排序/基数排序",使复杂度降到近乎线性?

查看参考答案

分析:当边权 w 的范围很小(例如 0 ≤ w ≤ 103)时,可以用计数排序把 m 条边按权值分桶,再按桶顺序处理。

vector<vector<int>> bucket(MAXW + 1);   // 每个权值一个桶,存边编号
for (int i = 1; i <= m; i++)
    bucket[e[i].w].push_back(i);

long long total = 0; int picked = 0;
for (int w = 0; w <= MAXW && picked < n - 1; w++)
    for (int id : bucket[w]) {
        int a = find(e[id].u), b = find(e[id].v);
        if (a == b) continue;
        fa[a] = b; total += w; picked++;
    }

复杂度:O(m + W),比 O(m log m) 更快,但只在权值范围很小时有意义。

易错:权值范围大(如 w ≤ 109)时开桶会 MLE,必须先离散化或改用 sort。

B3复杂度分析

一张图上 n = 2000、m ≈ 2×106(接近完全图),应该用 Kruskal 还是 Prim?请给出两者的运算量估算。

查看参考答案

Kruskal:排序 2×106 条边,约 m log m = 2×106 × 21 ≈ 4.2×107 次比较;此外还要遍历 2×106 次并查集操作。

Prim 朴素:n² = 4×106 次基本操作,比 Kruskal 快约 10 倍以上!但邻接矩阵需要 2000×2000 × 4 字节 = 16MB,可以接受。

结论:这种"点少边多"的稠密图,Prim 朴素 O(n²) 明显更优。这也是教材里反复强调"稠密图用 Prim"的量化原因。

易错:看到 m 大就以为 Kruskal 一定慢 —— 其实要看 n 与 m 的比值。判断标准是 n² 与 m log m 谁更小。

B4构造题

构造一个图,使得它的最小生成树不唯一,但每条边的权值都不同(或说明不可能)。

查看参考答案

结论:不可能。如果所有边的权值互不相同,那么每一步 Kruskal 面对的都是唯一的最小候选边,MST 被完全确定;Prim 同理(每轮 lowcost 最小的点唯一)。所以边权全部互异 ⟹ MST 唯一。

不唯一的例子:三角形,三条边权分别为 1、1、2。两棵不同的 MST:{1, 1} 只有一种(两条权 1 的边),其实这里是唯一的。真正不唯一的例子:正四边形,四条边权分别为 1、1、1、2。MST 需要 3 条边:任选 3 条不含 2 的都不行(只有 3 条权 1 的边,取它们刚好成生成树,唯一)。

更典型的例子:4 个点,边为 1-2:1、1-3:1、1-4:1、2-3:1。
MST 需 3 条边、总权 3:可以是 {1-2,1-3,1-4},也可以是 {1-2,1-3,2-3}(不是树,含环)—— 需要仔细:{1-2,1-3,1-4} 与 {1-2,1-3,2-3} 后者含环 1-2-3,不行;正确的不唯一例子是 {1-2,1-3,1-4} 与 {1-2,2-3,1-4},两者都是总权 3 的生成树,且都只用了权 1 的边。所以 MST 不唯一。

易错:认为"有等权边就一定有多个 MST"。等权边是必要条件而非充分条件(关键看等权边是否真的提供了不同的选择)。

🔴 C组 · 挑战(4题)

C1综合编程

给定 n 个点与 m 条边,求最小生成树,并输出所有被选中的边(按权值升序)。

查看参考答案

思路:Kruskal 天然就是按权值升序选边,所以只要在选中时把边打印出来,顺序自动满足要求。

struct Edge { int u, v, w, id; } e[MAXM];
sort(e + 1, e + m + 1, [](const Edge& a, const Edge& b) {
    if (a.w != b.w) return a.w < b.w;
    return a.id < b.id;        // 权值相同时按读入顺序,保证输出稳定
});

vector<Edge> chosen;
for (int i = 1; i <= m && (int)chosen.size() < n - 1; i++) {
    int a = find(e[i].u), b = find(e[i].v);
    if (a == b) continue;
    fa[a] = b; chosen.push_back(e[i]);
}

if ((int)chosen.size() < n - 1) { cout << "orz" << "\n"; return 0; }
long long tot = 0;
for (auto& ed : chosen) { tot += ed.w; cout << ed.u << " " << ed.v << " " << ed.w << "\n"; }
cout << tot << "\n";

要点:为了输出稳定(便于对拍),排序比较函数里加上"权值相同时按输入编号"的次级关键字。

易错:忘记保存边的原始编号,输出时顺序混淆;或只在最后输出总权值而漏掉了具体边。

C2初赛真题风格

下列关于最小生成树的说法,正确的有( )

A. Kruskal 算法的时间复杂度只与边数有关(除排序外并查集视为常数),因此稀疏图上通常优于 Prim。
B. Prim 算法每次加入的点是"到已生成子树距离最近的未加入点"。
C. 若图中所有边权两两不同,则最小生成树唯一。
D. 最小生成树的权值和可能不唯一。

查看参考答案

答案:A、B、C。

A 对:Kruskal 的复杂度 O(m log m) 由排序与 m 次并查集操作构成,与 n 关系不大;m 小时自然快。

B 对:这正是 lowcost 数组的含义 —— 到"已生成点集"的最短单边。

C 对:边权互异时每一步的选择唯一,所以 MST 唯一。

D 错:所有 MST 的权值和必然相同(这正是"最小"的含义)。不唯一的是"具体选了哪些边",不是"总权值"。

易错:把"方案不唯一"与"权值不唯一"混为一谈,这是选择题最常见的陷阱。

C3综合设计

给定 n 个点和 m 条边,每条边有一个"修复工期"。要求用最少的工期让所有点连通,输出这个最小工期。

查看参考答案

分析:这是典型的瓶颈生成树问题:要让所有点连通,且"最慢的那条边"尽量快。由性质,MST 的最大边权就是答案。

方法一(MST):Kruskal 一直选边到"选满 n−1 条",最后选中的那条边的权值即为答案。复杂度 O(m log m)。

方法二(二分 + 并查集):二分答案 x,只保留工期 ≤ x 的边,判断图是否连通。复杂度 O(m log maxW)。

/* 方法一:Kruskal,最后一个被选中的边权就是答案 */
sort(e + 1, e + m + 1, [](const Edge& a, const Edge& b) { return a.w < b.w; });

int picked = 0, ans = -1;
for (int i = 1; i <= m && picked < n - 1; i++) {
    int a = find(e[i].u), b = find(e[i].v);
    if (a == b) continue;
    fa[a] = b; picked++; ans = e[i].w;      // 最后赋值的即为瓶颈值
}
if (picked < n - 1) cout << -1 << "\n";   // 无法连通
else cout << ans << "\n";

要点:Kruskal 的第 n−1 次成功合并所用的边,恰好就是 MST 的最大边,直接就是答案 —— 不需要真的把树建出来。

易错:误以为要用"最小生成树 + 再求树上最大边",多写了一堆 LCA;其实 Kruskal 的过程已经顺带给出了这个值。

C4反例构造

举出一个反例,说明"图的次小生成树"不等于"删掉 MST 中最大边后重求 MST"。

查看参考答案

反例:4 个点,边为 1-2:1、2-3:2、3-4:3、4-1:3、1-3:4。

MST:选 1-2(1)、2-3(2)、3-4(3),总权 = 6(或 4-1(3) 也得到 6)。设取前者,最大边是 3-4(3)。

错误做法:删掉 3-4 后重求 MST,得到 1-2(1)、2-3(2)、4-1(3) 或 1-3(4),权值仍是 6 —— 这压根不是"次小",而是另一棵 MST。

正确做法:枚举非树边。非树边有 4-1(3) 与 1-3(4)。加 4-1(3) 成环 1-2-3-4-1,环上除它以外最大边是 3-4(3),权值 = 6 + 3 − 3 = 6(等权,不"严格");加 1-3(4) 成环 1-2-3-1,环上最大边 2-3(2),权值 = 6 + 4 − 2 = 8。所以次小(非严格)是 6,严格次小是 8。

要点:次小生成树一定是"原 MST 加上一条非树边再删掉一条环上的边",而不是简单地把最大边删掉重求。

易错:把"换一条非树边"与"删一条树边"混为一谈;后者会破坏"恰好 n−1 条边仍连通"的结构。

🏆 洛谷原题精选(12道)

以下 12 道洛谷题目都以最小/最大生成树为核心,按难度从普及-到提高排列。

1
P2820
局域网(Kruskal 入门)普及-
2
P1195
口袋的天空(连成 k 块)普及-
3
P1546
最短网络 Agri-Net(邻接矩阵 Prim)普及/提高-
4
P3366
【模板】最小生成树普及+
5
P2330
繁忙的都市(瓶颈生成树)普及+
6
P2121
拆地毯(最大生成树)普及+
7
P2872
Building Roads 修路普及+
8
P1991
无线通讯网(MST + 删最大边)普及+
9
P1265
公路修建(完全图 Prim)提高
10
P1550
Watering Hole G(虚拟源点)提高
11
P1967
货车运输(最大生成树 + LCA)提高
12
P4180
严格次小生成树提高