欢迎来到起遇信息学
起遇信息学正处于上线筹建阶段,以下功能已全部开放免费体验: ✅ 完整题库浏览与代码提交评测(C / C++ / Python / Java 等) ✅ 入门到进阶的系列课程试读、作业与考试 ✅ AI 提示、AI 作业分析等智能助教功能 ✅ 赛事模拟与个人能力报告 ✅ 邮箱注册开放 ⏳ 付费课程订阅与微信/支付宝支付通道 ⏳ 手机号登录,微信扫码登录、微信公众号绑定 使用中如遇任何问题,欢迎通过页面底部 **"联系我们"** 与我们沟通。
Day 11 学生讲义:并查集、连通块、MST 入门
学习方式:先理解题型的核心想法,再手算例子,随后阅读模板并独立实现。完成后复盘关键变量、复杂度和边界。
适用班级:已学过 DFS/BFS(Day8)、树上递归(Day9)与图论建模(Day10),开始处理"动态连通性"和"把点分组"的学生。
学习重点:并查集是图论和数据结构之间的桥。今天把三个基本动作写熟:找祖先、合并集合、统计连通块,并用 Kruskal 打开最小生成树的大门。Day11 与 Day12 全部题目共用同一份 find/merge 模板,今天必须一字不差地写熟。
配套题单:
| 题号 | 题目 | rating | 本课对应知识点 | 掌握目标 |
|---|---|---|---|---|
| 1263D | Secret Passwords | 1500 | 换对象合并:合并字母 | 学会"把关系挂到更小的对象上" |
| 103B | Cthulhu | 连通性 + 边数判环 | 学会 m==n 加连通判基环图 |
|
| 977E | Cyclic Components | 连通块内度数统计 | 学会把信息打到根上 | |
| 1139C | Edgy Trees | 补集计数 + 快速幂 | 学会"总方案 - 坏方案" | |
| 1468J | Road Reform | 1800 | Kruskal、阈值代价 | 学会"模板之后还要分析代价" |
| 2060E | Graph Composition | 1500 | 两张图连通块对齐 | 学会双并查集比较结构 |
1. 为什么需要并查集
1.1 从一个会超时的做法说起
任务:n 个点,边一条一条加入,随时询问"x 和 y 现在连通吗"。
朴素做法:每次询问都 BFS/DFS 一遍,单次 O(n+m);询问 q 次总共 O(q(n+m))。n、q 都到 2e5 时约 8e10,必然超时。
问题出在:连通关系只会"越连越多",朴素做法却每次从头搜索,大量重复劳动。
并查集就是为"只增不减的连通关系"设计的,它维护若干个不相交集合:
find(x):返回 x 所在集合的代表(祖先)
merge(x,y):把 x 和 y 所在的两个集合合并成一个
| 做法 | 加一条边 | 判一次连通 | 总复杂度 |
|---|---|---|---|
| 每次重新 BFS | O(1) | O(n+m) | O(q(n+m)),超时 |
| 并查集 | 近似 O(1) | 近似 O(n+m+q),稳过 | |
1.2 并查集适合的题型
- 两点是否连通、一条边加入后是否成环;
- 连通块个数、每个连通块的大小;
- Kruskal 最小生成树;
- 把字母、编号、等价关系分组。
注意:并查集只能加边、不能删边。需要删边的题一般离线倒着处理,今天不涉及。
2. 并查集模板:全篇统一写法
2.1 两个数组的含义
fa[x]:x 的父亲。fa[x] == x 表示 x 是根(集合代表)。
sz[x]:只在根上有意义,表示该集合的点数。
fa 连出来的树叫"并查集树",它只表示归属关系,和原图的边没有任何关系。这句话今天会反复用到(见 977E)。
2.2 统一模板
合并策略统一用按大小合并(按秩合并的常用实现):把小块的根挂到大块的根下面,防止树退化成链。配合路径压缩,单次操作复杂度 O(α(n)),α 是反阿克曼函数,课堂上当常数即可。
本讲义与 Day12 的所有题目,find/merge 都是下面这份,一个字不改:
#include<bits/stdc++.h>
using namespace std;
const int N = 2e5 + 10;
int fa[N], sz[N];
int find(int x)
{
if (fa[x] == x) return x;
return 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, m;
cin >> n >> m;
for (int i = 1; i <= n; i++)
fa[i] = i, sz[i] = 1;
for (int i = 1; i <= m; i++)
{
int u, v;
cin >> u >> v;
merge(u, v);
}
int blocks = 0; // 连通块个数
for (int i = 1; i <= n; i++)
if (find(i) == i)
blocks++;
cout << blocks << endl;
return 0;
}
讲解重点:
- 初始化
fa[i] = i, sz[i] = 1一个都不能少,多组数据每组都要重置; merge开头先find,后面的 x、y 已经是两个根;- 需要给块维护额外信息(大小、度数标记、最大编号)时,就在
merge里"顺手多合并一行",Day12 会大量使用这个技巧。
2.3 手动模拟
n=5,依次执行 merge(1,2)、merge(3,4)、merge(2,4):
| 操作 | find 出的两根 | 谁挂谁 | fa[1..5] | 根上的 sz |
|---|---|---|---|---|
| 初始 | - | 1 2 3 4 5 | 全为 1 | |
| merge(1,2) | 1, 2 | 2 挂到 1 | 1 1 3 4 5 | sz[1]=2 |
| merge(3,4) | 3, 4 | 4 挂到 3 | 1 1 3 3 5 | sz[3]=2 |
| merge(2,4) | 1, 3 | 3 挂到 1 | 1 1 1 3 5 | sz[1]=4 |
| 之后调用 find(4) | 1 | 路径压缩 | 1 1 1 1 5 | 不变 |
结论:根是 1 和 5,共 2 个连通块。sz[1]=4 是真实块大小,sz[3] 已经失去意义——sz 只看根。
2.4 三个常用统计动作(关键代码)
// 动作一:数连通块
int blocks = 0;
for (int i = 1; i <= n; i++)
if (find(i) == i) blocks++;
// 动作二:查 i 所在块的大小
int size_i = sz[find(i)];
// 动作三:往块上打标记(写到根上)
flag[find(i)] = true;
3. 1263D Secret Passwords:把字母并成一块
题型定位: CSP 图论建模题(T2 位置常客):关系看不见摸不着,先想清楚"合并什么对象"。
3.1 题目大意
有 n 个由小写字母组成的密码串(n ≤ 2e5,总长 ≤ 1e6)。两个密码"直接等价"当且仅当它们含有至少一个相同字母;等价关系可以传递。问这些密码最终分成多少个等价组。
3.2 从暴力到正解
暴力/部分分: 两两比较密码是否有公共字母,把有关系的密码 merge,最后数块。比较一对要 26 次判断,总共 O(n^2 · 26),n=2e5 时约 1e12,只能过小数据。
正解升级——换合并对象: 密码有 2e5 个,但字母只有 26 个。一个密码里的所有字母互相等价,所以:
每读一个密码,把其中所有字母 merge 到它的第一个字母上。
答案 = 出现过的字母中,不同根的个数。
为什么等价:两个密码有公共字母 ⇔ 它们的字母集合在并查集中连通。密码本身根本不用建点。
复杂度 O(总串长 · α)。
3.3 参考代码(已编译,样例 3/3 通过)
#include<bits/stdc++.h>
using namespace std;
const int LETTER = 26;
int fa[LETTER + 5], sz[LETTER + 5];
bool used[LETTER + 5];
int find(int x)
{
if (fa[x] == x) return x;
return 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()
{
ios::sync_with_stdio(false);
cin.tie(0);
int n;
cin >> n;
for (int i = 0; i < LETTER; i++)
fa[i] = i, sz[i] = 1;
for (int i = 1; i <= n; i++)
{
string pwd;
cin >> pwd;
int firstLetter = pwd[0] - 'a';
for (char ch : pwd)
{
int letter = ch - 'a';
used[letter] = true;
merge(firstLetter, letter); // 同一个密码里的字母连成一块
}
}
set<int> roots;
for (int letter = 0; letter < LETTER; letter++)
if (used[letter])
roots.insert(find(letter));
cout << roots.size() << endl;
return 0;
}
3.4 赛场策略与易错点
- 赛场策略:想不出"换对象"时,先写
O(n^2)暴力保底;再盯数据范围——"字母只有 26 个"就是提示。 - 易错点:没出现过的字母不能参与统计(样例 1 中
a b ab d答案是 2,若把 26 个字母全数进去就错了);统计的是"出现过字母的不同根数",不是密码的根数。
4. 103B Cthulhu:连通且恰有一个环
题型定位: 图结构判定题(CSP T1/T2 风格):先数边数,再查连通。
4.1 题目大意
给 n 个点 m 条边的无向图(无重边、无自环,n、m ≤ 100),判断它是否是"一个简单环上挂着若干棵树"的结构。是则输出 FHTAGN!,否则输出 NO。
4.2 从暴力到正解
暴力/部分分: 找出图中所有环再数个数——实现繁琐且容易写错。
正解升级——数边数: 这个结构等价于"连通且恰好含一个环"。结论:
图合法 ⇔ m == n 且整张图是一个连通块
为什么:连通图至少 n-1 条边,n-1 条时是树(无环);每多一条边恰好多一个独立环。m == n 意味着比树恰好多 1 条边,也就是恰好一个环。反过来,m == n 但不连通时,某个块内部一定有多个环,不合法。
连通性用并查集三行判掉。复杂度 O((n+m)α)。
4.3 参考代码(已编译,样例 2/2 通过)
#include<bits/stdc++.h>
using namespace std;
const int N = 110;
int fa[N], sz[N];
int find(int x)
{
if (fa[x] == x) return x;
return 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, m;
cin >> n >> m;
for (int i = 1; i <= n; i++)
fa[i] = i, sz[i] = 1;
for (int i = 1; i <= m; i++)
{
int u, v;
cin >> u >> v;
merge(u, v);
}
int comp = 0; // 连通块个数
for (int i = 1; i <= n; i++)
if (find(i) == i)
comp++;
// 连通且 m == n:恰好一个环挂着若干棵树
if (m == n && comp == 1) cout << "FHTAGN!" << endl;
else cout << "NO" << endl;
return 0;
}
4.4 赛场策略与易错点
- 赛场策略:结构判定题先列必要条件(边数、连通性、度数),往往列完就是充要条件。
- 易错点:只判
m == n不判连通是最常见的错(两个分离的环也满足m == n);输出字符串FHTAGN!别抄错。
5. 977E Cyclic Components:块内度数全为 2
题型定位: 连通块性质统计(CSP T2 常见):把"结构判定"翻译成"数量等式"。
5.1 题目大意
给 n 个点 m 条边的无向图(n、m ≤ 2e5,无重边自环),统计有多少个连通块本身是一个简单环——即块里的点恰好围成一个圈,没有任何多余的边或分叉。
5.2 从暴力到正解
暴力起手: 对每个连通块 DFS 收集所有点再逐一检查——思路可行,代码量偏大,还要小心递归深度(2e5 的链会爆栈,Day9 提醒过)。
正解升级——性质转化: 一个连通块是简单环,当且仅当:
块内每个点的度数都恰好是 2
为什么:每点度数 2 ⇒ 块内边数 = 点数(每条边贡献 2 度)⇒ 连通且比树多一条边 ⇒ 恰有一个环;又因为没有度数 1 的点,环上不能挂任何分叉,所以整块就是一个圈。
实现:读边时 merge 两端点并统计度数;然后把"度数不为 2"的点所在块的根打上"出局"标记;最后数没出局的根。复杂度 O((n+m)α)。
5.3 参考代码(已编译,样例 2/2 通过)
#include<bits/stdc++.h>
using namespace std;
const int N = 2e5 + 10;
int fa[N], sz[N], deg[N];
bool isCycle[N]; // 只在根下标上有意义:该连通块是否每个点度数都是 2
int find(int x)
{
if (fa[x] == x) return x;
return 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()
{
ios::sync_with_stdio(false);
cin.tie(0);
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++)
fa[i] = i, sz[i] = 1, isCycle[i] = true;
for (int i = 1; i <= m; i++)
{
int u, v;
cin >> u >> v;
merge(u, v);
deg[u]++, deg[v]++; // 原图度数,与 DSU 树无关
}
for (int i = 1; i <= n; i++)
if (deg[i] != 2)
isCycle[find(i)] = false; // 任意一点度数不为 2,整块出局
int ans = 0;
for (int i = 1; i <= n; i++)
if (find(i) == i && isCycle[i])
ans++;
cout << ans << endl;
return 0;
}
5.4 赛场策略与易错点
- 赛场策略:判定"块是不是某种形状",优先找点数、边数、度数之间的等式,比真去遍历形状省事得多。
- 易错点:
deg统计的是原图度数,和并查集树的父子关系没有任何关系(本节最重要的一句话);isCycle标记必须打在find(i)上而不是 i 上。
6. 1139C Edgy Trees:总方案减坏方案
题型定位: 组合计数 + 补集转化(CSP-S 计数题标准套路:"至少一个"转"总数减全不")。
6.1 题目大意
给一棵 n 个点的树(n ≤ 1e5),每条边是红色(输入色值 0)或黑色(色值 1)。统计长度为 k(k ≤ 100)的点序列 [v1, v2, ..., vk](点可重复)的个数,要求按顺序走 v1→v2→…→vk 的树上路径时,至少经过一条黑边。答案对 1e9+7 取模。
6.2 从暴力到正解
暴力/部分分: 枚举全部 n^k 个序列逐一检查——指数级,只能过极小数据。
正解升级——补集: "至少一条黑边"很难直接数,反过来数"一条黑边都不碰":
总方案 = n^k
坏方案 = 序列全程只走红边 ⇔ k 个点全部落在同一个"红边连通块"里
答案 = n^k - Σ (每个红块大小 s 的 s^k)
做法:并查集只合并红边(色值 0),黑边直接忽略;每个红块贡献坏方案 s^k。幂次用快速幂计算。
样例验证:n=4、k=4、三条边全黑 ⇒ 红块是 4 个孤点,坏方案 4×1^4=4,答案 4^4-4=252,与官方样例一致。
复杂度 O(n(α + log k))。
6.3 快速幂 10 行回顾框
快速幂的系统课在假期包 A 模块(7/26 数学基础),今天先把这 10 行会用即可:把指数按二进制拆开,base 不断平方,指数当前位是 1 就乘进答案,O(log expo)。
ll qmi(ll base, ll expo) // 快速幂:base^expo % MOD
{
ll res = 1;
base %= MOD;
while (expo)
{
if (expo & 1) res = res * base % MOD;
base = base * base % MOD;
expo >>= 1;
}
return res;
}
6.4 参考代码(已编译,样例 3/3 通过)
#include<bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 1e5 + 10;
const ll MOD = 1e9 + 7;
int fa[N], sz[N];
int find(int x)
{
if (fa[x] == x) return x;
return 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];
}
ll qmi(ll base, ll expo) // 快速幂:base^expo % MOD
{
ll res = 1;
base %= MOD;
while (expo)
{
if (expo & 1) res = res * base % MOD;
base = base * base % MOD;
expo >>= 1;
}
return res;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int n;
ll k;
cin >> n >> k;
for (int i = 1; i <= n; i++)
fa[i] = i, sz[i] = 1;
for (int i = 1; i < n; i++)
{
int u, v, color;
cin >> u >> v >> color;
if (color == 0) merge(u, v); // 只合并红边(非黑边)
}
ll bad = 0; // 坏方案:k 个点全落在同一个红边连通块
for (int i = 1; i <= n; i++)
if (find(i) == i)
bad = (bad + qmi(sz[i], k)) % MOD;
ll ans = ((qmi(n, k) - bad) % MOD + MOD) % MOD; // 减法后补 MOD 防负数
cout << ans << endl;
return 0;
}
6.5 赛场策略与易错点
- 赛场策略:看到"至少经过一条…",第一反应就是补集;树上"只用红边可达"天然就是红边连通块,不需要真的模拟走路径。
- 易错点:只合并色值为 0 的边,黑边一条都不能合并;
n^k与s^k都要取模;取模减法后必须(x % MOD + MOD) % MOD防负数;树只有 n-1 条边,读边循环是i < n。
7. 1468J Road Reform:Kruskal 入门与阈值代价
题型定位: MST 模板 + 代价分类讨论(CSP-S T3 风格:模板只是起点,代价函数要单独分析)。
7.1 先讲 Kruskal 模板
Kruskal 求最小生成树只有三步:
1. 所有边按权值从小到大排序;
2. 依次尝试每条边:两端点若不连通(find 不同根)就把边选入树并 merge;
3. 选满 n-1 条边即为最小生成树。
正确性直觉(用 Day3 的交换论证):假设最优树不含当前最小的可加边 e,把 e 加进去会形成一个环,环上必有一条不小于 e 的边,换掉它不会变差。所以"从小到大、不成环就加"是安全的。
复杂度 O(m log m),瓶颈在排序。
Kruskal 核心框架(嵌进具体题目时再补输入输出):
sort(edges + 1, edges + 1 + m, cmpEdge); // 按 w 从小到大
for (int i = 1; i <= m; i++)
{
int ru = find(edges[i].u), rv = find(edges[i].v);
if (ru == rv) continue; // 成环,跳过
merge(ru, rv); // 选入生成树
// 这里按题意统计:累加边权 / 更新最大边 / 维护块信息
}
7.2 题目大意
多组数据。给 n 个点 m 条边的带权连通无向图(Σn、Σm ≤ 2e5,边权 ≤ 1e9),要求保留恰好 n-1 条边构成生成树,然后可以调整所保留边的权值,每加一或减一花费 1。最终要求生成树中最大边权恰好等于 k。求最小总花费。
7.3 从暴力到正解
暴力/部分分: 枚举生成树不可能。先写标准 Kruskal 求出 MST,观察 MST 的最大边权 mxTree 与 k 的关系,能拿到第一类情形的分。
正解升级——按 mxTree 与 k 的关系分两类:
情形一:mxTree >= k。任何生成树的最大边权都 ≥ MST 的最大边权(MST 使最大边最小),所以最大边一定要被降到 k,答案就是把树上所有超过 k 的边降到 k:Σ max(0, w - k)。按边权从小到大的 Kruskal 恰好使"超过 k 的部分总和"最小(大边尽量不进树)。降完后最大边恰好是 k,条件满足。
情形二:mxTree < k。树上全部边都小于 k,必须"制造"一条恰好为 k 的边。两个候选取最小:
候选 1:把树上最大边升到 k,花费 k - mxTree(升最大边最便宜)
候选 2:把任意一条边 e 调整成 k 再换进树,花费 |w(e) - k|
(e 加入树成环,删掉环上另一条边仍是生成树)
7.4 参考代码(已编译,样例 1/1 通过,样例含 4 组子测试)
#include<bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 2e5 + 10;
struct Edge
{
int u, v;
ll w;
} edges[N];
int n, m;
ll k;
int fa[N], sz[N];
int find(int x)
{
if (fa[x] == x) return x;
return 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];
}
bool cmpEdge(const Edge &e1, const Edge &e2)
{
return e1.w < e2.w;
}
void solve()
{
cin >> n >> m >> k;
for (int i = 1; i <= m; i++)
cin >> edges[i].u >> edges[i].v >> edges[i].w;
for (int i = 1; i <= n; i++)
fa[i] = i, sz[i] = 1;
sort(edges + 1, edges + 1 + m, cmpEdge);
ll over = 0; // MST 中超过 k 的部分总和
ll mxTree = 0; // MST 最大边权
for (int i = 1; i <= m; i++)
{
int ru = find(edges[i].u), rv = find(edges[i].v);
if (ru == rv) continue;
merge(ru, rv);
mxTree = max(mxTree, edges[i].w);
if (edges[i].w > k) over += edges[i].w - k;
}
if (mxTree >= k)
{
// 任何生成树最大边都 >= mxTree >= k:把超出的边都降到 k
cout << over << '\n';
return;
}
// MST 全部边 < k:需要把某条边改成 k,选改动最小的边换进树
ll best = k - mxTree;
for (int i = 1; i <= m; i++)
best = min(best, abs(edges[i].w - k));
cout << best << '\n';
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int T;
cin >> T;
while (T--)
solve();
return 0;
}
7.5 赛场策略与易错点
- 赛场策略:拿到 MST 变形题,先把标准 Kruskal 写对跑通,再对着"额外要求"分类讨论;分类漏了至少还有一类的分。
- 易错点:边权到 1e9、求和会爆 int,
over、mxTree、k 都用long long;多组数据每组重置fa/sz;情形二别漏"非树边也能换进树"这个候选。
8. 2060E Graph Composition:两张图的连通块对齐(压轴)
题型定位: 连通性构造题(Div2 E / CSP-S 并查集综合):下界分析 + 构造达到下界。
8.1 题目大意
多组数据。同一批 n 个点上给两张无向图 F 和 G(Σn、Σ(m1+m2) ≤ 2e5)。每次操作可以在 F 中删除一条现有边,或加入一条新边。要求操作后 F 的连通性与 G 完全一致:任意两点在 F 中连通 ⇔ 在 G 中连通。求最少操作次数。
8.2 从暴力到正解
暴力起手: 逐条边试删试加再判连通——指数级,放弃。这题直接做"下界 + 构造"。
正解升级——把 F 的边分成两类:
第一类:跨越 G 连通块的 F 边。
最终图里这条边的两端不允许连通,而这条边直接把它们连上了——必须删。
第二类:G 块内部的 F 边。
保留它不会连出任何"不该连"的点对——白拿的连通性,全部保留。
删完第一类后,看每个 G 块内部:F 的剩余边把这个块分成了若干片,把 p 片连起来需要恰好 p-1 条新边。所以:
答案 = 跨块的 F 边数 + Σ (每个 G 块内 F 的片数 - 1)
= 跨块的 F 边数 + (F 保留边形成的块数 - G 的块数)
下界:第一类边每条必须删(1 次操作跑不掉);每个 G 块内部想从 p 片连成 1 片至少 p-1 条边。构造恰好达到下界,所以最优。
实现技巧——双并查集开一个数组: 下标 1..n 给 F 用,n+1..2n 给 G 用。先把 G 的边全部合并好,再扫 F 的边分类。
复杂度 O((n+m1+m2)α)。
8.3 手动模拟表
用官方样例第 1 组:n=3,F 边 {1-2, 2-3},G 边 {1-3}。
| 步骤 | 动作 | 状态/结论 |
|---|---|---|
| 1 | 合并 G 的边 1-3 | G 块:{1,3}、{2},块数 2 |
| 2 | 查 F 边 1-2:1 与 2 在 G 中不同块 | 必须删,操作数 1 |
| 3 | 查 F 边 2-3:2 与 3 在 G 中不同块 | 必须删,操作数 2 |
| 4 | F 保留边为空 | F 块:{1}、{2}、{3},块数 3 |
| 5 | 补边 3 - 2 = 1 条(把 1 和 3 连上) | 操作数 3 |
答案 3,与官方样例一致。
8.4 参考代码(已编译,样例 1/1 通过,样例含 5 组子测试)
#include<bits/stdc++.h>
using namespace std;
const int N = 4e5 + 10; // 下标 1..n 给 F 的并查集,n+1..2n 给 G 的并查集
int fa[N], sz[N];
int n, mF, mG;
int find(int x)
{
if (fa[x] == x) return x;
return 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];
}
void solve()
{
cin >> n >> mF >> mG;
for (int i = 1; i <= 2 * n; i++)
fa[i] = i, sz[i] = 1;
vector<pair<int, int>> edgeF(mF);
for (auto &[u, v] : edgeF)
cin >> u >> v;
for (int i = 1; i <= mG; i++)
{
int u, v;
cin >> u >> v;
merge(u + n, v + n); // 先建好 G 的连通块(目标结构)
}
long long ops = 0;
for (auto [u, v] : edgeF)
{
if (find(u + n) != find(v + n)) ops++; // 跨 G 块的 F 边必须删除
else merge(u, v); // 块内的 F 边保留,参与 F 的连通块
}
int rootsF = 0, rootsG = 0;
for (int i = 1; i <= n; i++)
if (find(i) == i) rootsF++;
for (int i = n + 1; i <= 2 * n; i++)
if (find(i) == i) rootsG++;
ops += rootsF - rootsG; // 每个 G 块内 F 碎成 p 片就补 p-1 条边
cout << ops << '\n';
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int T;
cin >> T;
while (T--)
solve();
return 0;
}
8.5 赛场策略与易错点
- 赛场策略:两张图/两种关系的题,先固定"目标结构"(这里是 G),把当前结构的每条边分成"必须处理 / 可以保留"两类,操作数往往就是"必删数 + 补连通数"。
- 易错点:必须先把 G 全部合并完再扫 F 的边(边分类依赖 G 的最终块);多组数据要初始化
2n个位置而不是 n 个;比较的是"连通块结构",不是边数相等。
9. 本节易错清单
find(x)之后才能比较两点是否同集合,直接比fa[x]是经典错误。sz[x]、块上标记只在根上有效,读取前先find。- 多组数据必须重置
fa、sz、deg等所有数组,范围题(2060E)要重置到2n。 - 并查集不能删边,只适合"只加边"或离线问题。
- 度数、边数是原图的量,与并查集树的父子关系无关。
- 计数题(1139C):取模、快速幂、减法补 MOD 三件套缺一不可。
- 边权求和、点对计数用
long long。
10. 训练顺序(下午 3.5 小时)
| 顺序 | 题目 | rating | 预计用时 | 训练目标 |
|---|---|---|---|---|
| 1 | 1263D Secret Passwords | 1500 | 25 分钟 | 换对象合并热身 |
| 2 | 103B Cthulhu | 20 分钟 | 连通性 + 边数结论 | |
| 3 | 977E Cyclic Components | 25 分钟 | 块上打标记 | |
| 4 | 1139C Edgy Trees | 30 分钟 | 补集计数 + 快速幂 | |
| 5 | 1468J Road Reform | 1800 | 45 分钟 | Kruskal + 代价分类 |
| 6 | 2060E Graph Composition | 1500 | 双 DSU 压轴构造 |
卡题纪律:单题超过预计用时 50% 仍无思路,回读讲义对应小节;再卡 10 分钟看题解并标记"重写"。
11. 小结口诀
并查集两件事:find 找根,merge 并块。
块的信息挂根上,读取之前先 find。
至少一条黑边?总方案减全红。
最小生成树:排序加边不成环。
两图对齐:跨块必删,剩片补边。
0 条评论
目前还没有评论...
Be the first to comment!