← 目录 第 43 / 60 节 全部 60 节

43 · 最小生成树与并查集

一张图里挑 n−1 条边把所有点连起来,还要最省。Kruskal 的两块拼图:并查集负责判断“会不会成环”,排序贪心负责“最省”。顺带把 MST 的唯一性与瓶颈路两个理论问题一次讲清。
提高-预计 70 分钟Kruskal并查集瓶颈路
本节出现的新函数 / 新语法(先看懂再用)

给定一张 n 个点、m 条带权边的连通图,从中选 n−1 条边把所有点连起来,使边权和最小——这就是最小生成树(MST)。先记住一个事实:n 个点、n−1 条边、连通,这三条同时成立时,图一定是树——任意两点之间路径唯一,没有环。于是“选一棵树”只剩一个自由度:到底选哪 n−1 条边。

Kruskal 的正文只有三句话:把边按权值升序排;从小到大逐条看;两端还没连通就要它,已经连通就扔掉。“连通”这个词在图上很难判,但对一张不断合并的图来说,它恰好是并查集的全部工作。

Kruskal 的正确性只靠一条性质:当前还未处理过的最轻的那条边,只要它跨了两个连通块,要它一定不吃亏。
KruskalPrim(堆优化)
套路边按权值升序,能加就加,成环就跳从一点出发,每次把离生成树最近的点拉进来
依赖边排序 + 并查集判环邻接表 + 优先队列取最近点
复杂度O(m log m)O((n + m) log n)
稀疏图 m ≈ n快,只跟边数有关差不多,但堆操作多
稠密图 m ≈ n²要排 n² 条边,偏慢更快;n 小时用 O(n²) 的邻接矩阵版
附带产物边权从小到大的合并过程,能直接回答瓶颈路每个点被拉进来时的距离,是它到已选点集的最小边
本节为什么用 Kruskal★ 判环用并查集,只有 5 行,且不用建邻接表第 42 节已学,思路是 Dijkstra 的孪生兄弟

先拆三块骨头:find、merge、Kruskal 主循环。前两个是并查集(判“在不在一块”),第三个是贪心框架。这三块加起来不到 20 行,但每一行都有一个专属的错法。

新函数拆解 · 并查集 find(路径压缩)
int fa[N]; int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
组成含义 / 格式要求
fa[x]x 的父指针。fa[x] == x 时 x 就是所在集合的代表(根)。初始化 fa[i] = i,每个点自成一个集合。
fa[x] == x ? x :递归出口:自己就是根,返回自己。整个函数只有这一个终止条件。
fa[x] = find(fa[x])先递归找到根,再把 x 直接挂到根下面——这就是路径压缩。赋值号不能省,省掉就只剩递归、没有压缩。
路径压缩的效果链 a→b→c→root:第一次 find(a) 沿链走到底,返回时顺手把 a、b、c 全改成指向 root;之后再问就是 O(1)。
返回值根节点的编号。它只用来判“是不是同一集合”,不代表任何顺序或大小。
为什么递归不会爆栈没压缩前树高最多 log₂n(按大小合并保证),n = 5000 时不过 13 层;压缩后更浅。
初始化必写for (int i = 1; i <= n; ++i) fa[i] = i; 漏写时 fa 全是 0,所有点被认成同一集合 → 一条边都加不进去 → WA。
三目写法比 if/else 短,能直接写成 return 表达式,编译器容易内联。竞赛里几乎只写这一种。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int fa[10];

int find(int x) {                              // 找根 + 顺手压缩
    return fa[x] == x ? x : fa[x] = find(fa[x]);
}

int main() {
    for (int i = 1; i <= 5; ++i) fa[i] = i;    // 初始化:各自是根
    fa[3] = 2; fa[2] = 1;                      // 手工造一条链 3 → 2 → 1

    cout << find(3) << "\n";                   // 1:根是 1
    cout << fa[3] << "\n";                     // 1:压缩后 3 直接指向 1
    cout << fa[2] << "\n";                     // 1:2 也被压平了
    return 0;
}
注意

压缩发生在返回的路上:先递归到根,再一层层把 fa[x] 改成根。所以第一次调用最慢,之后全是 O(1)。

fa 数组是全局的,多组数据时必须重新 for (i) fa[i] = i,不然上一组的关系会被继承下来。

新函数拆解 · 并查集 merge(按大小合并)
int sz[N]; void merge(int x, int y) { x = find(x); y = find(y); if (x == y) return; if (sz[x] < sz[y]) swap(x, y); fa[y] = x; sz[x] += sz[y]; }
组成含义 / 格式要求
x = find(x); y = find(y);先把两个点换成各自的根。传进来的是普通点,直接 fa[y] = x 会把一条路径接乱,之后全错。
if (x == y) return;已在同一集合,合并是空操作。漏写不崩,但 size 会重复累加、判环失效(见易错清单)。
if (sz[x] < sz[y]) swap(x, y);保证 x 是大的那块。把小树挂到大树底下,树高最多涨 1。这就是按大小合并。
fa[y] = x;把 y 的根接到 x 的根下面。整个集合的结构只靠这一行改动。
sz[x] += sz[y];只维护新根 x 的 size。y 已经不是根了,它的 size 再没人读,不用清。
返回值void。想知道“这次合并是否真的发生”,判 find(u) == find(v) 更好——Kruskal 正是靠它跳过成环的边。
按秩还是按大小维护 size 比维护 rank(树高)简单,效果一样:都给 O(log n) 上界,配上路径压缩就都是 O(α(n))。本节用 size。
复杂度单次 merge = 两次 find + 常数。α(n) ≤ 4,见本节数学补给。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int fa[10], sz[10];

int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }

void merge(int x, int y) {
    x = find(x); y = find(y);
    if (x == y) return;                     // 已经在一起
    if (sz[x] < sz[y]) swap(x, y);          // 小的挂到大的下面
    fa[y] = x;
    sz[x] += sz[y];
}

int main() {
    for (int i = 1; i <= 5; ++i) { fa[i] = i; sz[i] = 1; }

    merge(1, 2);                            // {1,2}
    merge(3, 4);                            // {3,4}
    merge(2, 3);                            // 合成 {1,2,3,4}

    cout << find(4) << " " << sz[find(1)] << "\n";   // 1 4
    return 0;
}
注意

merge 内部一定会重新 find,所以调用时传原始点即可,别在外面再 find 一次(不报错,但容易忘)。

按大小合并防的是最坏情况:不加这一步,父指针的深度没有任何上界——数据可以把它串成一条长度为 n 的链,单次 find 从 O(1) 退化成 O(n)。

新函数拆解 · Kruskal 主循环(排序 + 贪心加边)
struct Edge { int u, v, w; } e[M]; sort(e + 1, e + m + 1, [](const Edge &a, const Edge &b) { return a.w < b.w; }); int cnt = 0; long long ans = 0; for (int i = 1; i <= m && cnt < n - 1; ++i) { if (find(e[i].u) == find(e[i].v)) continue; merge(e[i].u, e[i].v); ans += e[i].w; ++cnt; }
组成含义 / 格式要求
struct Edge一条边存三个 int:u、v 是无向的两个端点,w 是权值。用结构体而不是三个平行数组,排序时整条边一起搬。
sort 的第三参数lambda,按 w 升序。必须写 < 不能写 <=——cmp 违反严格弱序会让 GCC 的 sort 越界访问,直接 RE。
e + 1, e + m + 11-indexed 的数组要用这两根指针。写成 e, e+m 会漏掉第 m 条边(代价是偶尔少一条最重边,答案偏小)。
cnt < n − 1树只有 n−1 条边,凑够就停。它既是提前退出,也是最后判连通的依据。
find(u) == find(v)两端已连通 → 加这条边会成环 → 跳过。整个算法唯一做判断的地方。
merge(u, v)两端不在同一块,这条边把两个连通块缝起来了。并查集记下这次合并,后续判环就靠它。
ans += e[i].w只有真加进树的边才累加权值。必须写在 continue 之后。
结束条件cnt == n−1 说明成功;cnt < n−1 说明图不连通,此时 ans 只是“最小生成森林”的权值,不是答案。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int N = 10, M = 20;
struct Edge { int u, v, w; } e[M];
int fa[N], sz[N];

int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
void merge(int x, int y) {
    x = find(x); y = find(y);
    if (x == y) return;
    if (sz[x] < sz[y]) swap(x, y);
    fa[y] = x; sz[x] += sz[y];
}

int main() {
    int n = 4, m = 5;
    int raw[6][3] = {{0,0,0}, {1,2,2}, {1,3,2}, {1,4,3}, {2,3,4}, {3,4,3}};
    for (int i = 1; i <= m; ++i) { e[i].u = raw[i][0]; e[i].v = raw[i][1]; e[i].w = raw[i][2]; }

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

    long long ans = 0; int cnt = 0;
    for (int i = 1; i <= m && cnt < n - 1; ++i) {
        if (find(e[i].u) == find(e[i].v)) continue;   // 成环,扔掉
        merge(e[i].u, e[i].v);
        ans += e[i].w; ++cnt;
    }
    cout << cnt << " " << ans << "\n";   // 3 7:加了 3 条边,权和 7
    return 0;
}
注意

排序是最贵的一步:m = 2×10⁵ 时约 3.5×10⁶ 次比较,比并查集那几十万次操作重得多。想优化,先优化排序。

cnt 一定要在真加边时才 ++。写在 for 里或写在 continue 之前,会让“边数”算成 m,判连通永远成立 → 输出一个偏小的错答案。

例题 · 【模板】最小生成树洛谷 P3366普及/提高-去洛谷做这题 ↗

给出一个包含 N 个结点的无向图,求它的最小生成树。

输入格式:第一行两个整数 N、M,表示 N 个结点、M 条无向边。
接下来 M 行,每行三个整数 Xᵢ、Yᵢ、Zᵢ,表示有一条长度为 Zᵢ 的边连接结点 Xᵢ 与 Yᵢ。

输出格式:如果图连通,输出一个整数,表示最小生成树各边长度之和;如果图不连通,输出 orz。

数据范围:1 ≤ N ≤ 5000,1 ≤ M ≤ 2×10⁵,1 ≤ Zᵢ ≤ 10⁴。

输入: 4 5 1 2 2 1 3 2 1 4 3 2 3 4 3 4 3 输出: 7
把“选边”拆成三个问题

问题一:什么样的边集是树?n 个点、n−1 条边、连通。前两条用计数就能保证,第三条只能靠“加边时不成环”来保证——从 n 个孤立点开始,每加一条两端不在同一块的边,连通块数量恰好减 1。加满 n−1 条,块数变成 1。

问题二:怎么保证最小?用贪心:把边按权值升序,能加就加。正确性靠下面的割性质。

割性质(cut property)。把点集任意分成两块,取所有“一端在这块、一端在另一块”的边里最轻的一条 e,则存在一棵最小生成树包含 e。
证明:设 T 是不含 e 的最小生成树。T 中从 e 的一个端点到另一个端点有唯一路径,这条路径必然在某处跨出该块,设为边 f。则 T − f + e 仍是生成树,且 w(e) ≤ w(f),权值不增,故它也是最小生成树并含 e。∎

问题三:Kruskal 选的那条边,凭什么是“跨割最轻”的?设当前考察 eₖ,它的一端 u 在连通块 C、另一端不在。若有一条更轻的边 f 跨 (C, 外部),它一定在 eₖ 之前被处理过:f 若被加入,C 早已跨出去;f 若被跳过,说明 f 两端当时已连通,那另一端的点今天也该在 C 里。两种都矛盾。所以 eₖ 就是跨这个割最轻的边,由割性质它可以进 MST。

复杂度。排序 O(m log m) = 2×10⁵ × 17.6 ≈ 3.5×10⁶ 次比较;加边部分每条边至多两次 find,共 4×10⁵ 次、每次不超过 4 步。总计 10⁶ 量级,实测 20~40 ms。

不连通怎么办。加不满 n−1 条边。此时 ans 是“最小生成森林”的权值,题目要的是 orz,所以最后必须用 cnt 判一次,而不是直接输出 ans。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

const int N = 5005, M = 200005;      // 点数与边数差 40 倍,千万别共用一个上界

struct Edge { int u, v, w; } e[M];
int fa[N], sz[N];

int find(int x) {                    // 路径压缩:找到根,顺便把路上的点挂到根上
    return fa[x] == x ? x : fa[x] = find(fa[x]);
}

void merge(int x, int y) {
    x = find(x); y = find(y);
    if (x == y) return;              // 已同集合:不需要改任何东西
    if (sz[x] < sz[y]) swap(x, y);   // 小树挂大树,保证树高只涨 1
    fa[y] = x;
    sz[x] += sz[y];
}

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

    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= m; ++i) cin >> e[i].u >> e[i].v >> e[i].w;

    sort(e + 1, e + m + 1, [](const Edge &a, const Edge &b) { return a.w < b.w; });

    for (int i = 1; i <= n; ++i) { fa[i] = i; sz[i] = 1; }   // 并查集初始化

    long long ans = 0;
    int cnt = 0;                                   // 已加入树的边数
    for (int i = 1; i <= m && cnt < n - 1; ++i) {  // 凑够 n-1 条就停
        if (find(e[i].u) == find(e[i].v)) continue;  // 两端已连通,加了会成环
        merge(e[i].u, e[i].v);
        ans += e[i].w;
        ++cnt;
    }

    if (cnt < n - 1) cout << "orz\n";              // 图不连通
    else cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
const int N = 5005, M = 200005;两个上界分开写。n ≤ 5000、m ≤ 2×10⁵,共用一个 5005 会读入越界 → RE。
struct Edge { int u, v, w; } e[M];一条边三个 int。用结构体排序时整条边一起搬;用三个平行数组,sort 就没法直接排。
return fa[x] == x ? x : fa[x] = find(fa[x]);递归出口是 fa[x] == x;赋值号把压缩写进 return 里。少写等号左边那半,就退化成朴素查找。
if (sz[x] < sz[y]) swap(x, y);只比 size、不比值。这一行让最坏树高变成 log₂n,是“不压缩也不至于太慢”的保险。
sort(e + 1, e + m + 1, ...)按权值升序,这是全部贪心内容。cmp 用 < 而不是 <=,否则严格弱序被破坏 → 可能 RE。
for (int i = 1; i <= n; ++i) { fa[i] = i; sz[i] = 1; }初始化必须放在加边之前。写在读入之前也错——n 还没读进来。
long long ans = 0;本题 5000 个点、边权 10⁴,上界 5×10⁷,int 够;但求和一律 long long 是习惯,改题面时不用重想。
for (int i = 1; i <= m && cnt < n - 1; ++i)循环条件带 cnt:n−1 条边已经构成生成树,再看的边只可能更重。
if (find(e[i].u) == find(e[i].v)) continue;判环。这是并查集在本算法里唯一的用途,也是 Kruskal 比 Prim 好写的全部原因。
ans += e[i].w; ++cnt;两条都要在 continue 之后。顺序反了(cnt 先加)会让计数多 1,判连通永远成立。
if (cnt < n - 1) cout << "orz\n";不连通时 ans 是森林的权值,直接输出它 → WA。输出 orz 才是题目要求的。
两个必须知道的理论问题

一、最小生成树唯一吗?不一定。所有边权互不相同时,MST 唯一;有相等边权时可能有多棵。反例:三个点两两相连、三条边权都是 1,删掉任意一条都是一棵 MST,共 3 棵,但它们的权值和都是 2。

所以做题时要分清题目问的是哪一个:问“最小代价”永远有唯一答案(Kruskal 直接输出);问“方案数 / 是否唯一”才需要额外处理,那属于生成树计数,超出本节范围。

为什么边权互不相同时一定唯一?假设有两棵不同的 MST,取它们的对称差,其中必有一条权值最小的边 e 属于 T₁ 不属于 T₂。把 e 加进 T₂ 形成环,环上必有一条不属于 T₁ 的边 f;由最小性 w(f) ≥ w(e),又因边权全不同只能 w(f) > w(e),于是 T₂ − f + e 更轻,与 T₂ 最小矛盾。∎

二、瓶颈路。定义 d(u,v) = 所有 u→v 路径中“最大边权”的最小值——走这条路,最宽的那道坎最窄。定理:对任意一棵 MST,树上 u→v 路径的最大边权就等于 d(u,v)。

这条定理的用途很直接:把“最小化最大值”的连通问题全部交给 MST。比如“用最小的最大边长把所有点连通”,答案就是 Kruskal 最后加进去的那条边的权值,连和都不用求——这正是 P2330 一类的题。

单次询问的做法:先求 MST,再从 u 出发 DFS 到 v,沿途记最大边权,O(n)。多组询问就要用倍增在树上跳(第 53 节),或把 Kruskal 的合并过程建成一棵重构树。本节只需记住结论。

这一节的 6 个坑,按症状记

cmp 写成 return a.w <= b.w;:违反严格弱序,GCC 的 sort 在数据稍大时越界访问 → RE,而且报错位置在 sort 里面,看起来跟你的代码无关,最难查。

漏写 if (find(u) == find(v)) continue;:每条边都被 merge、都计入 cnt,边数超过 n−1、ans 多加权值 → WA,并且程序不崩、不超时,只是答案偏大。

并查集初始化漏写或写错位置:fa 全是 0,所有点被判成同一集合 → 一条边都加不进去,cnt 恒为 0 → WA(输出 orz,或对 n = 1 才偶然正确)。

find 里省掉 fa[x] = ,同时 merge 又不按大小合并:父指针退化成一条长链,每次 find 走 O(n),2×10⁵ 条边 × 5000 步 = 10⁹ 次操作 → TLE。两个优化至少要留一个。

边数组按 n 开:n ≤ 5000 但 m ≤ 2×10⁵,只开 e[5005] 读入就写穿 → RE;开小了还能跑完时更可怕,表现为随机 WA。

不连通时直接输出 ans:题目要 orz,你输出了森林权值 → WA。养成“最后判 cnt == n−1”的习惯。

自己动手复现

关掉页面,把 P3366 的 45 行从零敲一遍,用样例验证答案是 7。

改一改①:把 cmp 的 < 改成 <=,交一遍。小数据可能过,用 n = 5000、m = 2×10⁵ 的随机数据看 RE。

改一改②:删掉 if (find(...) == find(...)) continue;,只用样例跑一遍。正确答案 7,你会得到 11——多出来的是那条成环的边 4。

改一改③:把 merge 的两行换成 fa[find(x)] = find(y);(不按大小),同时把 find 里的 fa[x] = 删掉。用链式数据 (1,2)、(2,3)、…、(4999,5000) 再补 19 万条 (1,i) 边,观察 TLE;把两处改回来,同样的数据 0.02 s 跑完。

数学补给 · 并查集为什么可以当成 O(1) 用

并查集的均摊复杂度既不是 log 也不是常数,而是一个增长极慢的函数:反阿克曼函数 α(n)。

阿克曼函数的前几项:A(1) = 2,A(2) = 4,A(3) = 16,A(4) = 65536,A(5) = 2⁶⁵⁵³⁶ ≈ 10¹⁹⁷²⁹。递推规则是 A(k+1) = 2A(k)——每一层把上一层的值整个塞进指数。

α(n) 就是“取多少个 A 才盖得住 n”:α(n) = min{ k : A(k) ≥ n }。

n 的上界α(n)直观
42课上演示用的规模
163再大一点也只多一步
655364n = 5000 的 Kruskal 落在这一档
2⁶⁵⁵³⁶ ≈ 10¹⁹⁷²⁹5宇宙原子数约 10⁸⁰,离这档上界还差得远

结论:只要 n 小于 10¹⁹⁷²⁹,单次 find / merge 的均摊代价不超过 5 步,可以直接写成 O(1)。注意前提是路径压缩与按大小合并同时开:只开一个,上界是 O(log n);两个都不开,是 O(n)。

那 Kruskal 的时间花在哪?排序。m = 2×10⁵ 时 log₂m ≈ 17.6,比较次数约 m·log₂m = 3.5×10⁶;并查集部分只有约 2m = 4×10⁵ 次操作。排序占了九成以上。

这解释了两条实践结论:① 优化 Kruskal 先看排序,别抠 find 的常数;② 边权范围很小时(0/1,或 ≤ 10⁴ 的整数),把 sort 换成计数排序 / 桶排序,总复杂度压到 O(m + W),2×10⁵ 条边几乎是线性的。

举一反三 · 想清楚这些再往下走
  1. 把所有权值取负再求“最大生成树”,Kruskal 要改几处?最大生成树能回答“删掉哪些边最省”这类问题吗?
  2. 如果图里有自环和重边,Kruskal 会怎样?自环有可能被加进树里吗?重边会让 cnt 出错吗?
  3. 如何判断某一条给定的边 e 是否“属于某一棵最小生成树”?又怎么判断它是否“属于所有最小生成树”?提示:想想 e 与 u、v 在 MST 上路径最大边的关系。
  4. 题目问“最早加入第几条边时整张图连通”,用二分答案 + 并查集怎么做?为什么直接跑一遍 Kruskal、看第 n−1 条边是第几轻的,就更快?
  5. 除了判环,并查集还能维护集合大小、集合内最大边权。把 Kruskal 的每次合并建成一棵新树(合并点作为父亲),为什么就能 O(log n) 回答任意两点的瓶颈路?
  6. n = 10⁶ 但 m 只有 10⁵ 时,Kruskal 和 Prim 哪个更合适?这时内存瓶颈在哪?
自测:能全部回答再进下一节
挑战题 · 无线通讯网
洛谷 P1991提高-去洛谷做这题 ↗

国防部计划用无线网络连接若干个边防哨所。每台哨所都配一台无线电收发器,收发器的通信距离为 d:两个哨所之间的距离不超过 d 时才能用无线电直接通信。

现在有 S 台哨所可以额外加装卫星电话,卫星电话之间的距离不受任何限制(装了卫星电话的两台哨所永远可以通信)。

给出 S、P 和 P 个哨所的坐标,求能连通所有哨所的最小的 d。

输入格式:第一行两个整数 S、P(1 ≤ S ≤ 100,S ≤ P ≤ 500)。
接下来 P 行,每行两个整数 xᵢ、yᵢ(0 ≤ xᵢ, yᵢ ≤ 10000),表示第 i 个哨所的坐标。

输出格式:一行一个实数,表示 d 的最小值,保留两位小数。

引导性提示(先只看这里)

卫星电话的作用不是“传得更远”,而是“允许整张网络断成 S 块”——块内必须靠无线电连起来。

先假想 d 已经确定:只保留长度 ≤ d 的边,你能算出图被分成几块吗?这个判定和 Kruskal 有什么关系?

把边按长度从小到大加,每加一条能连通两块的边,块数就减 1。数一数块数减到 S 时你加到第几条边。

想看思路了?点开完整思路

把卫星电话翻译成连通块数。S 台卫星电话各自装在一个连通块里,块与块之间靠卫星通信,块内部必须靠无线电连通。所以最终恰好允许剩下 S 个连通块。分成更少块就浪费了电话,分成更多块则有一块没有电话、无法与外界通信。

问题变成:把 P 个点分成 S 块,使每块内部连通所需的无线电距离尽可能小,并对所有块取最大值。这正是“最小化最大边”的瓶颈问题,交给 MST。

Kruskal 恰好是一个不断减少连通块的过程。初始 P 块,每加入一条不成环的边,块数减 1。块数从 P 减到 S 需要 P − S 次合并,第 P − S 条被加入的边的长度就是答案——因为它正是“让块数刚好降到 S”的那道坎。

为什么不是第 P − S + 1 条?第 P − S 条加入后块数已经是 S,此时可以收手了;再加一条就把块数压到 S − 1,等于浪费了一台卫星电话,而那条边只会更长。

边界:S ≥ P 时每台哨所都能装卫星电话,不需要任何无线电,d = 0。不特判会让“凑够 P − S 条”永远不成立,ans 被更新到最大边。

复杂度。边数 P(P−1)/2 ≤ 124750,排序约 1.2×10⁵ × 17 ≈ 2×10⁶ 次比较,并查集十余万次操作,几十毫秒。

等价写法。也可以二分 d,判定“只用长度 ≤ d 的边能否把图连成 ≤ S 块”,复杂度 O(P² log),能过但多一个 log,而 Kruskal 一次就做完了。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXP = 505, MAXE = MAXP * MAXP;   // 完全图最多 P(P-1)/2 条边

struct Edge { int u, v; double w; } e[MAXE];
int fa[MAXP], sz[MAXP];
double x[MAXP], y[MAXP];

int find(int a) { return fa[a] == a ? a : fa[a] = find(fa[a]); }

bool merge(int a, int b) {                  // 返回 true 表示这次真的合并了两块
    a = find(a); b = find(b);
    if (a == b) return false;               // 已连通,这条边会成环
    if (sz[a] < sz[b]) swap(a, b);
    fa[b] = a; sz[a] += sz[b];
    return true;
}

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

    int S, P;
    cin >> S >> P;
    for (int i = 1; i <= P; ++i) cin >> x[i] >> y[i];

    if (S >= P) {                           // 每台都装卫星电话,不需要无线电
        cout << fixed << setprecision(2) << 0.0 << "\n";
        return 0;
    }

    int m = 0;
    for (int i = 1; i <= P; ++i)            // 建完全图:任意两点都可能用无线电通信
        for (int j = i + 1; j <= P; ++j) {  // 从 i+1 开始,避免自环与重复边
            double dx = x[i] - x[j], dy = y[i] - y[j];
            e[++m] = {i, j, sqrt(dx * dx + dy * dy)};
        }

    sort(e + 1, e + m + 1, [](const Edge &a, const Edge &b) { return a.w < b.w; });

    for (int i = 1; i <= P; ++i) { fa[i] = i; sz[i] = 1; }

    int need = P - S;                       // 从 P 块压到 S 块,需要 P-S 次合并
    int got = 0;
    double ans = 0;
    for (int i = 1; i <= m; ++i) {
        if (!merge(e[i].u, e[i].v)) continue;   // 成环,跳过
        ans = e[i].w;                       // 记下这次合并用的边长
        if (++got == need) break;           // 凑够 P-S 条就停
    }

    cout << fixed << setprecision(2) << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
const int MAXE = MAXP * MAXP;完全图有 P(P−1)/2 条边,P ≤ 500 时是 124750。开 P² 更宽松,也不用算半天。
bool merge(...)把 merge 改成返回 bool:返回 true 表示“这次真的合并了两块”,Kruskal 的计数就靠它。返回值比在外面再判一次 find 更省一次查找。
S >= P每台哨所一台卫星电话,d 可以取 0。不特判的话 need ≤ 0,++got == need 永远不成立,循环会一路加到最后一条边,ans 变成最大边长 → WA。
for (int j = i + 1; ...)只连 i < j 的点对。写成 j = 1 会多出一倍的重复边和 P 条自环(距离 0,会排在最前面,被 merge 判成同集合跳过,不致命但白排一遍)。
sqrt(dx * dx + dy * dy)欧氏距离。坐标差最大 10⁴,平方和最大 2×10⁸,double 有 53 位有效位,精度绰绰有余。
sort 按 w 升序Kruskal 的核心。用 double 排序时 cmp 仍写 <,别写 <=。
int need = P - S;P 个点最终要剩 S 个连通块,每次成功合并让块数减 1,所以需要 P − S 条边。
ans = e[i].w;每次真合并都覆盖一次,最后一次覆盖的正是第 need 条边的长度。
if (++got == need) break;凑够就停。后面的边更长,不可能让答案更小。
fixed << setprecision(2)固定两位小数。用 cout 而不是 printf,避免和关闭同步的 cin 混用出问题。
这题最容易错的地方
  • 忘了 S ≥ P 的特判:need ≤ 0 时 break 永远不触发,ans 变成最大边长 → WA。
  • 距离用 int 存或让 sqrt 结果被截断成整数:排序顺序错,答案偏差可达 0.5 以上 → WA。
  • 只统计到 P − S − 1 条(下标差一):答案取到了第二大的那条,比正确答案小 → WA。
  • 把 ++got == need 写成 got++ == need:先比较后自增,会多加一条边 → 答案偏大。
  • 输出不带两位小数,或写成 cout << ans:评测要求 212.13 这样的格式 → WA。
  • 二分答案的写法里把“连通块数 ≤ S”错写成“< S”:答案偏大 → WA。
延伸阅读 / 参考