扫一眼 USACO 的题单,看到 Learning Languages 这个标题,很容易觉得它是模拟题——学语言嘛,可能就是个字符串匹配或者模拟录入的事。实际做一遍就知道,这题压根不考语言,考的是图论里的连通分量,而且解法极其典型,属于那种学会一道就能秒杀一片的题。P3026 这题在 USACO 2011 年 11 月赛季里算入门难度,但对于刚接触并查集、刚学会把现实问题抽象成图的人来说,它的价值比很多"难题"都大。
这题解决的问题很直白:农场里 N 头牛,M 种语言,每头牛会其中若干种。两头牛如果有共同语言,可以直接交流;如果没有共同语言,还可以靠别的牛当翻译间接交流。问最少让几头牛多学一门语言,才能让所有牛都能互相交流。适合谁做?正在刷并查集、学图论建模、准备 USACO Bronze/Silver 或者 NOIP 入门组的选手。我当年刷这题时最大的收获不是会了并查集模板,而是弄明白了一个道理:很多"最少操作几次"的问题,最后都落在"有几个连通块"上。
1. 先看清楚题:这题目到底想让你干什么
1.1 表面是学语言,本质是连通性
题面设定很"农场风":有 N 头牛,M 种语言,每头牛会 K 门语言。两头牛能直接交流,当且仅当它们至少有一种共同语言。如果没有共同语言,还可以通过别的牛中转——比如 A 会汉语,B 会汉语和英语,C 会英语,那 A 和 C 虽然语言完全没交集,但通过 B 就能搭上线。
这个"间接交流"是解题的关键信号。它意味着交流关系不是看两两之间有没有共同语言,而是看整个关系网能不能通过中间人传递。翻译成图论语言,这就是连通性:把每个元素看成点,把"能建立联系"看成边,只要两个点在同一个连通块里,它们就能互相到达。
题目允许的操作是:每次选一头牛,让它额外学一门它不会的语言。问最少操作几次,能让所有牛都处在同一个连通块里。注意这里不是让每头牛都去学其他所有语言,而是用最少的"新增联系"把所有分散的群体串起来。
我第一次看到这题时,第一反应是建一张"牛与牛"的邻接矩阵,然后跑 BFS 判断连通性。后来发现没必要,因为答案只取决于连通块的数量,和每个块内部长什么样完全无关。这就是典型的"连通分量计数"题。
1.2 输入输出格式与样例精讲
先看输入格式,数据很规整:
- 第一行两个整数 N 和 M,表示牛的数量和语言总数。
- 接下来 N 行,第 i 行第一个数是 K,表示第 i 头牛会 K 门语言,后面跟着 K 个语言编号。
输出一个整数,即最少需要让几头牛学习新语言。
拿样例来说:
3 3 2 1 2 1 3 1 2三头牛,三种语言。牛 1 会说语言 1 和 2;牛 2 只会语言 3;牛 3 会说语言 2。
画一下关系:牛 1 和牛 3 都懂语言 2,所以它们直接能聊天。牛 2 只会语言 3,既不能直接跟牛 1 聊,也不能直接跟牛 3 聊。再看间接交流:牛 1 和牛 3 都没学过语言 3,所以没人能帮牛 2 翻译,牛 2 是完全孤立的。
场上其实有两拨牛:{牛 1, 牛 3} 是一拨,{牛 2} 是一拨。要让所有牛互通,最简单的办法是让牛 2 学语言 2,学会之后它就能直接和牛 1、牛 3 交流,答案就是 1。让牛 2 学语言 1 也可以,只要让它进入另一个群体会的语言集合就行。
多品一下这个例子会发现:答案根本不关心具体让哪头牛学哪门语言,只关心场上互相隔绝的牛群一共有几个。如果有 k 个牛群,把第 1 个和第 2 个连起来,再把第 2 个和第 3 个连起来……连成一条链,只需要 k-1 次。这个推导是整个题目的核心,后面我会详细证明。
2. 怎么想到用并查集:问题的传递性
2.1 交流的传递性等价于图的连通性
提到"传递性"和"集合合并",大多数搞过竞赛的人第一反应都是并查集。并查集这个数据结构说穿了就干两件事:快速判断两个元素是否在同一个集合里,快速合并两个集合。它不擅长处理"路径具体怎么走",但它恰恰擅长处理"到底在不在一个圈子里"这种问题。
把问题抽象成无向图:节点分成两类,一类是牛,一类是语言。每头牛和它会说的每一种语言之间连一条无向边。这样一来,牛 A 和牛 B 能不能交流,就等价于在这个图里 A 和 B 是否在同一个连通分量里。
为什么这个转化是合法的?因为"交流"的本质就是路径。牛 A -> 共同语言 L1 -> 牛 B -> 共同语言 L2 -> 牛 C,这样一条路径上,相邻节点之间要么是"牛和它会的语言",要么是"语言和会它的牛",这就是我们建的边。有路径就代表信息能传递,没路径就是彻底隔绝。即使中间绕了七八头牛,只要有一条路径,就能完成间接交流。
这个建模思想值得记下来:当问题里出现两类实体、且关系是"某类实体属于另一类实体"的时候,优先考虑把它们放在同一个图里做连通块分析。语言和牛、人和技能、城市和航线,都是这样的关系。
2.2 两种建图方式,优缺点对比
我刷题的时候见过两种主流写法,都能过,但思路略有差异。这里都拿出来对比,方便你选一种更合自己口味的。
方法一:只对语言建并查集。每读入一头牛,如果它说的语言不止一门,就把它会的所有语言合并进同一个集合。处理完之后,统计"有牛会说"的语言一共有几个连通块。设这个数量为 ans,答案就是 ans - 1。
方法二:牛和语言一起建并查集。给每头牛分配一个编号(比如 1 到 N),给每种语言分配另一段编号(比如 N+1 到 N+M)。读入牛 i 会说语言 x,就把 i 和 x 对应的节点合并。最后统计"至少包含一头牛"的连通分量个数 cnt,答案 cnt - 1。
两种方法本质完全一样,只是视角不同。方法一代码更短,但统计时容易漏掉"哪些语言是有效的"这个判断;方法二更直观,每一个节点都有明确的含义,不容易乱。我个人的建议是:新手优先用方法二,因为思路不容易出错,调试时也方便打印连通分量里的成员。等彻底理解之后再换成方法一,写起来会更快。
| 对比项 | 方法一(只对语言建图) | 方法二(牛和语言混合建图) |
|---|---|---|
| 节点含义 | 只有语言节点 | 牛节点 + 语言节点 |
| 代码长度 | 较短 | 略长 |
| 统计注意点 | 要过滤掉没有牛说的语言 | 要过滤掉没有牛的连通分量 |
| 适合人群 | 熟悉建模后再用 | 新手首选,思路直观 |
| 空间占用 | 较小 | 稍大,但本题范围无所谓 |
3. 核心实现:代码一步一步来
3.1 基础并查集模板
先把最基本的并查集模板写出来,这个模板包含了 find 和 unite 两个核心操作。
#include <bits/stdc++.h> using namespace std; const int MAXN = 1005; int fa[MAXN * 2]; // 牛和语言一起编号,最多 N + M 个节点 int find(int x) { while (fa[x] != x) { fa[x] = fa[fa[x]]; // 路径压缩 x = fa[x]; } return x; } void unite(int a, int b) { int ra = find(a), rb = find(b); if (ra != rb) fa[ra] = rb; }数组开 MAXN * 2,是因为牛和语言共用一套编号。如果只对语言建并查集,开到 M + 5 就够了。
有些朋友喜欢用递归版 find,遇到链特别深的时候可能会有递归栈压力,但本题数据范围 N、M 最大只有 1000 左右,递归完全没问题。真正需要注意的是路径压缩一定要写,否则最坏情况下并查集的查找复杂度会退化到 O(n),整体效率就被拖垮了。路径压缩的原理并不复杂:每次查找时顺手把路径上所有节点直接挂到根节点下面,下次再查就是一步到位。
3.2 读入与合并:把牛和语言的关系连起来
关键在读入部分。我约定牛的编号从 1 到 N,语言的编号做偏移,变成 N+1 到 N+M。比如语言 1 对应的节点是 N+1,语言 M 对应 N+M。
int main() { int n, m; cin >> n >> m; for (int i = 1; i <= n + m; i++) fa[i] = i; for (int i = 1; i <= n; i++) { int k; cin >> k; while (k--) { int x; cin >> x; unite(i, n + x); // 牛 i 和语言 x 连边 } } // 统计部分后面补上 }为什么是"牛 i 和每一门语言合并"而不是"语言之间互相合并"?因为我们在用并查集模拟无向图:牛 i 和语言 a 之间有边,牛 i 和语言 b 之间也有边,那么 a 和 b 自然通过牛 i 被连到同一个集合里。这正好对应一头牛会说多门语言时,这些语言之间是可以相互"翻译"的关系。
如果 K 是 0,说明这头牛一门语言都不会,它自己会成为一个孤独的连通分量,统计时要算进去。所以 K 为 0 的情况不需要特殊处理,只要保证后面的统计逻辑包含了所有牛节点。
3.3 统计连通块个数:别把没有牛的孤立语言算进去
这是全题最容易错的一步,我在这里翻过车。
按照方法二,我们建了 N + M 个节点,但并非所有节点都是"活跃"的。语言节点如果没有任何牛会说,它就是一个孤立点,绝不能算进答案。统计时核心思路是:先找出所有集合的根节点,再检查每个根节点所在的集合里有没有牛。
代码可以这样写:
vector<int> roots; vector<bool> has_cow(n + m + 1, false); // 先给每头牛打标记:它所在的集合里有牛 for (int i = 1; i <= n; i++) { has_cow[find(i)] = true; } // 收集所有根节点 for (int i = 1; i <= n + m; i++) { if (find(i) == i) roots.push_back(i); } int cnt = 0; for (int r : roots) { if (has_cow[r]) cnt++; } cout << cnt - 1 << '\n';这里的顺序其实可以互换。先标记 has_cow 再收集 roots,或者先收集 roots 再标记 has_cow,结果一样。关键点是 has_cow 必须用 find(i) 的返回值做下标,因为一头牛的根节点可能经过多次合并后不再是它自己。
有朋友可能会问:能不能直接在遍历节点时统计?比如写 if (fa[i] == i && has_cow[i]) cnt++。当然可以,但前提是你已经先完整跑了一遍 has_cow 标记。否则你边遍历边标记边统计,很容易漏掉那些"根节点还没被创建"的集合。分两步走,逻辑清楚,不容易出错。
3.4 完整 AC 代码
把前面的部分拼起来,得到一版可以直接提交的代码:
#include <bits/stdc++.h> using namespace std; const int MAXN = 1005; int fa[MAXN * 2]; int find(int x) { while (fa[x] != x) { fa[x] = fa[fa[x]]; x = fa[x]; } return x; } void unite(int a, int b) { int ra = find(a), rb = find(b); if (ra != rb) fa[ra] = rb; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; cin >> n >> m; for (int i = 1; i <= n + m; i++) fa[i] = i; for (int i = 1; i <= n; i++) { int k; cin >> k; for (int j = 0; j < k; j++) { int x; cin >> x; unite(i, n + x); } } vector<int> roots; vector<bool> has_cow(n + m + 1, false); for (int i = 1; i <= n; i++) { has_cow[find(i)] = true; } for (int i = 1; i <= n + m; i++) { if (find(i) == i) roots.push_back(i); } int cnt = 0; for (int r : roots) { if (has_cow[r]) cnt++; } cout << cnt - 1 << '\n'; return 0; }这段代码的时间复杂度是 O((N + M + 总语言数) * α(N + M)),其中 α 是反阿克曼函数,在题目数据范围下可以当作常数。N 和 M 最多 1000,总语言数也不大,所以跑起来毫无压力。
3.5 另一种实现:只对语言建并查集
方法一代码更短,这里也贴出来给想对比的朋友。它的思路是:把所有"被某头牛说过"的语言合并到同一个集合里,最后统计这些语言一共占据多少个连通块。
#include <bits/stdc++.h> using namespace std; const int MAXM = 1005; int fa[MAXM]; bool used[MAXM]; int find(int x) { while (fa[x] != x) { fa[x] = fa[fa[x]]; x = fa[x]; } return x; } void unite(int a, int b) { int ra = find(a), rb = find(b); if (ra != rb) fa[ra] = rb; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; cin >> n >> m; for (int i = 1; i <= m; i++) fa[i] = i; int first_lang = -1; for (int i = 1; i <= n; i++) { int k; cin >> k; int pre = -1; for (int j = 0; j < k; j++) { int x; cin >> x; used[x] = true; if (pre != -1) unite(pre, x); pre = x; if (first_lang == -1) first_lang = x; } } if (first_lang == -1) { // 所有牛都不说话,每头牛都要学一门语言,答案 n cout << n << '\n'; return 0; } int cnt = 0; for (int i = 1; i <= m; i++) { if (used[i] && find(i) == i) cnt++; } cout << cnt - 1 << '\n'; return 0; }这段代码有一个额外处理的边界:如果所有牛的 K 都是 0,那所有牛各自都是孤立的,每头牛都需要学一门新语言,答案就是 n。这个分支在方法二里自然会被统计逻辑覆盖,在方法一里需要单独判断,所以两个版本各有利弊。
4. 踩过的坑:这些细节不注意就是 WA
4.1 统计连通块时的"有牛"判断
这题最阴间的点就是统计答案的方式。我第一次写这题时,直接统计了所有节点的连通分量个数,然后减一,结果一跑样例第二组就炸了。为什么?因为只要某个语言编号没被任何牛提到,它就会在并查集里占一个孤立集合,白白多算一个连通分量。
举个例子:有 3 头牛、5 种语言,但所有牛只会说语言 1。如果直接统计并查集里所有根节点,会发现语言 2、3、4、5 各自都是孤立点,算出来连通分量有 5 个,答案 4,但实际答案是 0,因为三头牛早就通过语言 1 连在一起了。
所以统计时一定要问自己一句:"这个集合里真的有牛吗?"没有牛的集合对答案毫无贡献。这个教训延伸到所有"节点包含两类实体"的题目里都适用:过滤时要想清楚什么才算"有效节点"。
4.2 编号混用:把牛当语言,把语言当牛
我自己犯过的一个低级错误是:读入牛 i 会说语言 x 时,不小心写成 unite(i, x),牛的编号和语言编号直接混在一个体系里。当 N 和 M 范围接近时,语言 1 和牛 1 被当成同一个节点,整个并查集就乱了。
解决办法很简单:要么给语言节点加偏移量,像方法二那样统一用 n + x 作为语言节点编号;要么在建图之前就想清楚两套编号的映射关系。我建议在代码注释里写清楚"牛:1 到 N,语言:N+1 到 N+M",避免后来看代码时自己也分不清。
4.3 输入里 K 很大但语言不存在的误解
题目说了语言总数是 M,但输入里某头牛的 K 可能等于 0,或者某些语言编号从头到尾没出现过。很多新手会默认"编了 1 到 M 的语言就一定有人用",这是错的。
比如 N=2、M=100,两头牛都会语言 1,那么连通分量只有一个,答案 0。但如果你统计所有语言节点的连通块,会数出 99 个孤立语言节点,直接爆炸。再次强调:统计前必须用"是否被使用"或"是否有牛关联"来过滤。
4.4 并查集路径压缩对统计的真实影响
路径压缩本身不会影响统计结果的正确性,但它会影响你用什么方式去统计。如果你在合并之后、统计之前没有对每个节点重新调用 find(i),那么 fa[i] 可能不是最终的根节点。比如某节点 i 的父节点是 j,而 j 后来又挂到了 k 下面,此时 fa[i] == j 不是根,但 find(i) == k 才是根。
所以统计根节点列表时,一定要用 find(i) == i 而不是 fa[i] == i?其实两者在这道题里都可以,因为初始化时 fa[i] = i,只要合并操作后根节点的 fa[root] 仍然指向自己。但为了保险起见,我习惯统一写成 find(i) == i,因为 find 过程会顺便做路径压缩,让后续操作更快。
4.5 边界情况的处理
把这题的边界全列出来,对照检查:
- N=1,无论几门语言,所有牛已经能交流,答案 0。
- 所有牛的 K=0,每头牛都得学一门语言,答案 N。
- M 很大但只有一种语言出现,答案 0。
- 每头牛只会一种语言且都不同,答案 N-1。
这些边界不一定会出现在测试数据里,但自己造数据验证代码时,它们是很好的检查点。
5. 解题之外:这类"最少连线"题的通法
5.1 从连通分量到答案的推导
为什么答案是连通分量数减一,而不是其他数?这值得认真推一遍。
假设场上有 k 个互相不可达的牛群,每个牛群是一个包含至少一头牛的连通分量。一次操作,选择任意一头牛,让它学一门它不会的语言。这门语言如果属于另一个牛群,那么这头牛所在的牛群和那个牛群就会因为共享同一门语言而合并成一个更大的牛群。如果学的是一门全新语言,暂时不会减少牛群数量,但这种情况显然不是最优选择。
于是问题变成了:有 k 个集合,每次操作可以合并两个集合,问最少几次能让所有集合合并成一个。每次操作让集合总数减一,从 k 到 1 必然经过 k-1 次。k-1 次一定可行,少于 k-1 次一定不够,所以答案就是 k-1。
这个推导还揭示了一个重要事实:答案不依赖于图的具体形态,只依赖于连通分量数量。所以不管图长成什么样,只要连通块数一样,答案就一样。这也是为什么用并查集而不是 BFS 逐点判断的原因——我们只需要块数,不需要路径细节。
5.2 变式与扩展:换个皮你还认识吗
这类题在竞赛里换壳特别常见,举几个例子:
- n 个城市,m 条航线,问最少新建几条航线能让所有城市连通。这就是无向图连通分量数减一。
- n 个人,m 条好友关系,问最少加几条好友关系能让所有人都在一个圈子里。同上。
- n 台机器,m 根网线,问最少加几根网线能让所有机器互通。同上。
- 更复杂的版本会在节点种类上做文章,比如两类节点、一类节点只能连接特定另一类节点,就像这题的牛和语言。
核心套路都是:建模 -> 求连通分量数 -> 输出分量数减一。建模的关键是看出哪些关系构成"边"。语言题里的边是"牛-语言",好友题里的边是"人-人",城市题里的边是"城市-航线",本质没有区别。
我建议刷题时多做一步:拿到题目背景,先不急着写代码,把关系提取成图,然后问自己三个问题——节点是什么、边是什么、目标是什么。三个问题一答,大部分题的解法就浮出水面了。
5.3 复杂度与编程技巧
最后聊一下编程层面的小技巧。这题 N 和 M 都很小,代码怎么写都能过,但如果数据范围放到 1e5,就要注意几件事:
- 不要开二维数组存"牛-语言"关系,会爆内存。用并查集合并是 O(总边数) 的,非常省。
- 统计连通块时不要用 unordered_set 存根节点再转 vector,直接遍历所有节点收集根就行。
- 读入用 cin 配 ios::sync_with_stdio(false) 足够,不用手写快读。
- 如果语言编号范围很大(比如 1e9),可以用 map 做离散化,把出现过的语言映射到连续的 1 到 tot,再做并查集。
这道题我前前后后写过三遍,每一遍都有新体会。第一遍用 BFS 染色,第二遍用牛和语言混合并查集,第三遍才写出干净的语言并查集版本。回头看,最值钱的不是代码本身,而是"看破背景、提取关系、统计块数"这个思考路径。最后再分享一个小习惯:每次 AC 之后,我都会故意把数据改成边界情况再跑一遍,比如所有牛都不会语言、所有牛都会同一门语言、语言总数远大于实际出现数。这个小习惯帮我避开了至少三成以上的低级 WA,建议你也试试。