刷PAT的L2题,最怕的不是算法难,而是题目看着像模拟题,样例一跑就对,提交却卡住半个钟头。L2-024“部落”就是典型代表。很多人第一次看到这道题,第一反应是用图或者并查集,但真正动手时容易在“编号范围”“统计部落数”“合并方式”这些细节上翻车,更别说有人连题都没读完就开始DFS了。
先说结论:这道题考的是并查集(Disjoint Set Union),属于L2阶段几乎必考的一类数据结构题。它的核心价值不在于算法本身有多难,而在于你能不能把“圈子重叠导致人物间接关联”的自然语言,翻译成“集合合并与集合归属查询”的抽象模型。这篇内容适合两类人看:一是正在备考PAT、刷到L2题单的选手,二是刚学并查集、想知道这玩意儿到底怎么用在真实题目里的同学。我会把从读题到AC的全过程掰开揉碎讲一遍,包括考场上的坑和提交环境里的雷。
1. 别被“部落”这个名字骗了:题目到底在让你算什么
先回归原题。题目会给你若干个圈子,每个圈子会列出里面所有人的编号。比如第一个圈子是“1 2 3”,意思是编号1、2、3三个人属于同一个圈子;第二个圈子是“4 5 6”,第三个圈子是“3 4 7”。注意这里的关键信息:编号3同时出现在第一个圈子和第三个圈子,编号4同时出现在第二个和第三个圈子。这个时候,表面上看起来是三个圈子,实际上所有人的关系已经通过“共有成员”串成了一张连通网,这张网里所有人最终属于同一个大的社交社区。
所以题目问两件事:
- 第一行输出两个数:总共有多少个人、这些人一共分成多少个互不相交的部落(社区)。
- 接下来Q次查询,每次给两个编号a、b,判断他们是否属于同一个部落,属于就输出Y,否则输出N。
题目描述里“部落”这个词挺容易误导人。它不是说每个圈子就是一个部落,而是说“只要两个人能通过一层层圈子关系连在一起,他们就算同一个部落的人”。这就是典型的传递闭包思想。如果你把每个圈子当成一个集合,那么两个圈子只要存在一个人同时在两边,这两个集合就必须合并成一个。合并完之后,剩下的独立集合数量才是部落数,而不是输入的圈子数。
第一次做题的人最容易在“部落数是圈子数”这里踩坑。输入的N个圈子是可能重叠的,重叠会让圈子合并,所以部落数一定小于等于N。举个例子:圈子1是“1 2”,圈子2是“2 3”,圈子3是“4 5”。因为2同时出现在圈子1和圈子2,所以1、2、3全部属于一个部落;圈子3是独立的一拨。这时总人数5人,部落数2。你要是输出3个部落,直接WA到怀疑人生。
再补一个容易忽略的点:人物的编号并不是从1连续排到N的,而是题目里出现过的任意编号。比如第一行给的N是3,但实际出现的编号可能是10、20、30,也可能中间缺号。所以你不能用“读入N然后遍历1到N”的方式去统计总人数,必须自己维护一个“当前出现过的最大编号”,或者用标记数组记录所有见过的编号。后面写代码的时候这个点非常关键,很多人的数组越界都是从这里冒出来的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 并查集是怎么变成“部落合并”的:先搞懂原理再写代码
并查集这个名字听着挺劝退,但它的核心思想用一句话就能说清楚:每个集合选一个“代表”,判断两个元素是否在同一个集合,就看它们的代表是不是同一个人。
我来打个比方。假设你是村里管户口的人,每个家族选一个族长。刚开始每个人都自己当自己的族长。后来A家族和B家族联姻了,两家合成一家,那就指定其中一位族长当合并后家族的大族长,另一家的族长跟大族长报到。以后你想知道两个人是不是一个家族的,就顺着他们的“汇报链”往上找到大族长,比较大族长是不是同一个人就行。
对应到题目里,一个圈子就是一次“联姻事件”。圈子里的人必须全部合并到同一个集合,也就是共同推举一个人当这根链条上的代表。
并查集需要维护一个数组fa[i],表示编号i的“上级”是谁。注意这里不是父节点那种树形结构的父节点,而是“往上一层汇报”的对象。初始状态是fa[i]=i,每个人都代表自己。
然后是两个核心操作:
- find(x):找到x所在集合的代表。实现方式是一直往上找,直到fa[i]==i为止。
- unite(a,b):把a和b所在的两个集合合并。做法是分别找到ra=find(a)、rb=find(b),如果ra不等于rb,就让fa[ra]=rb,或者fa[rb]=ra。
路径压缩是find操作里最常见的优化。它的意思是:当你在查找x的代表时,沿途经过的所有节点的上级直接改成根。这样下次再查它们的时候,一步就能到位。实现上就是一行递归:
cpp复制int find(int x) {
if (fa[x] != x) fa[x] = find(fa[x]);
return fa[x];
}
这个递归写法在绝大多数题目里都够用。但有个前提,你的系统栈要扛得住递归深度。虽然这题数据量不大,但如果你在Linux环境下用C++提交,递归深度一般不是问题;如果用Java,且自己写了很深层的递归方法,偶尔会遇到StackOverflowError。更稳妥的做法是用迭代版find:
cpp复制int find(int x) {
int root = x;
while (fa[root] != root) root = fa[root];
while (x != root) {
int next = fa[x];
fa[x] = root;
x = next;
}
return root;
}
两种写法都能AC,看个人习惯。我建议对递归有顾虑的人直接背迭代版,省心。
再来说合并。unite的时候,如果不做任何优化,极端情况下可能会形成一条长链。虽然路径压缩能缓解,但你可以顺手做一个按秩合并:维护一个rank数组,记录每棵树的“高度”或“大小”,把高度小的树挂到高度大的树上。实测下来,这题不写按秩合并也能过,因为数据规模不大,但养成这个习惯能让你遇到L2系列的加强版时更从容。
前面我提过“缩小版”做法:处理圈子时,直接用圈子里第一个人的编号作为这棵树的根,然后让后面的所有人都和它合并。这个思路是对的。但有个细节:如果圈子里第一个人自己就是独立一个人(k=1),那么他不需要和任何人合并,但他必须被标记为“出现过”,否则总人数会少算。
那为什么不能用简单的“把同一个圈子的所有人打上同一个编号”来完成判断呢?因为圈子之间会重叠,如果你只是在读入时给每个人分配小组编号,当出现“3号同时在圈子1和圈子2”这种交叉时,你没法快速把两个小组的所有人改成一个编号。就算强行用数组维护“每个人属于哪个组”,遇到大量重叠会导致反复遍历,复杂度直接爆炸。并查集的好处就是,合并操作只改动一个fa指针,剩下的查询全部延后到你需要的时候再处理,效率高出好几个量级。
3. 完整AC代码逐段拆解:从初始化到查询,每一行都有用处
写代码之前先定两个数组:
- fa数组:并查集的上级关系。
- vis数组:标记某个编号是否出现过,用来统计总人数。
数组大小需要根据题目常识定。L2-024原题的数据范围不大,编号一般在1到10000以内,所以开10005完全够用。但如果你不放心,可以开10005或者10010,再多也无所谓,只要别开得太夸张导致栈内存爆炸就行。(PAT判题机一般对内存限制比较宽松,但全局数组别动不动10的7次方,没必要。)
cpp复制#include <bits/stdc++.h>
using namespace std;
const int MAXN = 10005;
int fa[MAXN];
bool vis[MAXN];
int find(int x) {
if (fa[x] != x) fa[x] = find(fa[x]);
return fa[x];
}
void unite(int a, int b) {
int ra = find(a);
int rb = find(b);
if (ra != rb) fa[ra] = rb;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
for (int i = 0; i < MAXN; i++) fa[i] = i;
int n;
cin >> n;
int maxId = 0;
while (n--) {
int k;
cin >> k;
int first;
if (k > 0) {
cin >> first;
vis[first] = true;
maxId = max(maxId, first);
}
for (int i = 1; i < k; i++) {
int id;
cin >> id;
vis[id] = true;
maxId = max(maxId, id);
unite(first, id);
}
}
int people = 0;
for (int i = 1; i <= maxId; i++) {
if (vis[i]) people++;
}
bool rootCnt[MAXN] = {false};
for (int i = 1; i <= maxId; i++) {
if (vis[i]) rootCnt[find(i)] = true;
}
int tribes = 0;
for (int i = 1; i <= maxId; i++) {
if (rootCnt[i]) tribes++;
}
cout << people << " " << tribes << "\n";
int q;
cin >> q;
while (q--) {
int a, b;
cin >> a >> b;
if (find(a) == find(b)) cout << "Y\n";
else cout << "N\n";
}
return 0;
}
这段代码有几个设计点值得单独说明。
第一,maxId的动态维护。我读入每个圈子时,边读边更新当前出现过的最大编号。这样统计总人数时只需要从1遍历到maxId,不需要每次查数组全范围。如果你偷懒直接遍历10000,也能过,但不够优雅。
第二,vis数组的标记时机。每读到一个编号就立刻标记为出现过,不管它是圈子里第一个人还是后面的人。这一步漏掉的话,总人数统计就会少算。
第三,统计部落数的方式。有人会写“遍历所有vis过的点,用set存find结果,然后输出set.size()”。这也行,但用bool数组rootCnt可以省掉set的额外开销。原理很简单:如果vis[i]为true,说明编号i这个人真实存在,我们就找到他的集合代表,并把rootCnt[代表]标记为true。最后遍历一遍所有可能作为代表的编号(其实只需要1到maxId),数一数有多少个代表被标记过,那就是部落数。
注意,判断部落数时遍历的终点必须是maxId,不是n。因为编号最大值可能比输入组数大得多,也可能小得多。如果你写for (int i = 1; i <= n; i++),而在某个数据的编号是5000、n只有3,那vis[5000]里存的true根本不会被扫到,部落数直接少算。
第四,查询部分的find调用。有个小细节:查询时如果a和b根本没在题目里出现过,find(a)和find(b)依然能正常返回,因为fa数组初始就自环了,find函数不会越界。也就是说,即使查询涉及一个“不存在的人”,它也会把自己当成一个独立部落。题目一般不会这么出,但你能正确处理总归是好事。
跑一遍样例。假设输入:
code复制3
3 1 2 3
3 4 5 6
4 3 4 7 8
2
1 3
3 8
处理过程:圈子1把1、2、3合并;圈子2把4、5、6合并;圈子3把3、4、7、8合并。因为3和4分别在圈子1/2和圈子3中重叠,所以最后1到8全部属于同一个部落。总人数8,部落数1。查询1和3,Y;3和8,Y。如果我的代码逻辑没问题,输出就是:
code复制8 1
Y
Y
这里你可以直观看到为什么“部落数不等于圈子数”:输入了3个圈子,但最终只有1个部落。
4. 从WA到AC的完整踩坑过程:常见报错和它们的根因
很多人写完第一版提交,信心满满,结果屏幕上飘来一个红色的WA。这时候不要去怀疑并查集本身,先检查几个高频踩坑点。
第一个坑:数组开小。 我见过有人看到N的数据范围是100,就直接开fa[105],结果实际人物编号到了1000。这类题有个潜规则:N代表圈子数量,不是人物编号上限。人物编号可以比N大得多。解决办法就是像我上面一样,数组开到题目约束的安全上限,如果题目没给出明确编号范围,就根据经验开,比如10005。开数组前仔细看输入格式里面对编号的说明,实在没写就往大了开,但不建议超过10的7次方,避免MLE。
第二个坑:统计总人数时用n当遍历边界。 这个问题前面已经强调过。用一个maxId变量动态记录最大值,遍历时只在1到maxId范围内做标记扫描,是最稳的写法。
第三个坑:统计部落数时对find结果去重失败。 如果你用bool rootCnt[MAXN],一定要先确保vis[i]为true再取find(i)。否则你会把“从没出现过的编号”也当成一个部落的代表,导致部落数虚高。特别是有一些编号虽然fa[i]==i,但这个人从未出场,不应该被计数。
第四个坑:忘了vis标记“第一个人”。 处理每个圈子时,第一个人要和后面的所有人合并,但他自己也要被标记为出现过。很多人在循环里给后面的成员打了标记,却忘了给first打标记。结果就是总人数少1,部落数可能恰好不小,但输出第一行直接错。
第五个坑:输入输出超时。 这道题数据量不算大,但如果你用C++的cin/cout默认流同步,遇到大量查询时偶尔会卡在边上。最稳妥的方式是加上一行ios::sync_with_stdio(false); cin.tie(0);。如果你用Java,尽量用BufferedReader和BufferedWriter,别用Scanner;Scanner在数据量稍大的时候确实会拖时间。
第六个坑:递归爆栈。 虽然这题卡递归概率不大,但如果你在别的高难度并查集题里写过深层递归版find,遇到大数据时可能会爆栈。解决方案有两个:一是用迭代版find,二是把递归find改成尾递归(不少编译器能优化,但别依赖)。
第七个坑:输出大小写和换行。 L2题对输出格式要求很严格,Y和N必须是大写,每行输出后要有换行。第一行两个数字之间只能有一个空格,行尾有没有多余空格都不行。这种格式题,样例过了也可能因为最后一个换行问题被卡,但其实PAT判题一般忽略行末空格和末尾换行,主要问题是别把Y/N写成y/n。
排查顺序建议是:先检查读入逻辑和maxId更新,再检查合并逻辑,再打印中间结果(比如每个点的根是谁),看部落数统计和总人数统计是否一致。这种题目调试起来比算法设计还要耗时间,很多人思路完全正确,却在细节上磨了半小时。
5. 考场外的实操经验:编译环境、刷题平台与这类题的扩展方向
题目本身讲完了,再分享点我在实际刷题和备考过程中总结的经验。
先说编译环境里一个常见报错。 热词里有一条“error: java_home is not set and no 'java' command could be found in your pat”。这个提示通常出现在你本机没配置JAVA_HOME,或者PATH里找不到java命令时,跟题目本身没关系。它一般发生在你用命令行编译运行Java程序时,系统找不到JDK。解决办法是先安装JDK,然后设置JAVA_HOME环境变量:
bash复制export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64
export PATH=$JAVA_HOME/bin:$PATH
Windows下就是在系统环境变量里添加JAVA_HOME,并把%JAVA_HOME%\bin加到Path里。这个报错并不特殊,很多人误以为是判题机的问题,其实是你自己电脑环境的问题。
再说“PAT模式是指什么”。 理解PAT的评分模式,对你答题策略有直接影响。PAT不是ACM那种只认AC或WA的零和判定,而是按测试点给分:每个测试点占了若干分值,你过了几个测试点就给几分,哪怕没全对也能拿部分分。这意味着实战时,不要死磕一个用例导致后面题目没时间写。L2级别的题目通常有二三十个测试点,其中一定有基础样例、边界数据、大数据量数据。你的目标不是“一次AC”,而是在有限时间内拿最多分。
所以考场上比较稳的打法是:先读题,判断算法类型,快速写能过常规样例的版本,提交后根据错误反馈再修边界。对于L2-024这道题,如果你能把并查集写出来,再把编号范围、vis标记这些细节处理好,拿到的分数会非常可观。
刷题资源方面。 热词里提到的“浙大翁恺PAT练习题”和“翁恺C语言PAT习题网站”,其实是指翁恺老师团队维护的PTA平台。PAT的历史真题、练习题库都在上面,而且支持C、C++、Java、Python多种语言。对刚入门的人来说,跟着翁恺老师的C语言课程把基础语法过一遍,再去PTA上按题单顺序刷,是比较顺的路径。如果是在校生,PTA还能关联课程作业;如果不是在校生,也可以注册账号自己练,不影响使用。
我把这类并查集题目归成三个层次,方便你举一反三。
- 第一层:纯并查集模板题,比如L2-024“部落”。核心是合并、查询、计数。你只要把模板写熟,注意编号边界,基本就拿下了。
- 第二层:并查集加上额外信息统计,比如L2-007“家庭房产”。除了合并集合,还要统计每个集合的总人数、房产套数、总面积,输出时还要按某个关键字的规则排序。这要求你在合并时同步维护集合属性,思路比L2-024又高了一截。
- 第三层:并查集结合图论或离线处理,比如L2-010“排座位”。它可能要求你区分“朋友关系”和“敌对关系”,要用不同数据结构分别维护。
刷题时不要只背模板,要理解“合并哪些元素、统计什么信息、查询什么关系”。L2-024就是个很好的起点,表面上是部落,实际上考的是“如何把一组关系抽象成集合合并”,这种抽象能力在PAT后续题目里会反复用到。
最后再分享一个我自己的小习惯:每次AC后,我会把代码里的printf临时换成输出根节点、vis标记数组的调试版本,手动构造几组数据跑一遍,看看自己能不能卡出Bug。比如构造一组“两个圈子完全独立”的数据,再构造一组“两个圈子通过多个人交叉重叠”的数据,再构造一组“只有一个圈子且只有一个人”的数据。这些边界测试能帮你把代码的隐含问题暴露出来。
L2-024这道题的价值,不止是让你背下来一个并查集模板。它更像一个筛子,筛掉那些“看着题目像模拟就硬写”的选手,留下那些能从题面里嗅出数据结构味道的人。希望这篇拆解能让你下次刷到类似题目时,读完题就能在脑子里自动把问题转化成集合操作,并且直接在纸上写出AC代码。
