news 2026/9/24 21:35:57

并查集实战:从“村村通”到连通分量统计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
并查集实战:从“村村通”到连通分量统计

1. 题目到底在说什么:从生活场景到图论模型

1.1 一读题面,先别急着写代码

题目给出了两个整数n和m,n表示村庄数量,m表示现有道路数量。接下来的m行,每行给出两个整数a和b,表示村庄a和村庄b之间已经有一条路了。注意,这里的“路”是双向的,a能到b,b也就能到a,所以这是一张无向图。

很多新手拿到题先想的是“我要怎么建图?邻接矩阵还是邻接表?”对于这道题来说,其实根本不需要显式地把图存下来,你只需要维护村庄之间的连通关系就够了。这就是并查集的用武之地:不需要知道具体路径长什么样,只需要知道两个点是否在同一个连通集合里。

如果把村庄抽象成点,道路抽象成边,那整个问题就是:有一个无向图,图里可能有好几个连通分量(也就是几片互不相连的“孤岛”),现在要修新路把所有连通分量连成一个整体。需要修的路的数量,就是连通分量个数减一。理解到这层,题目基本就转化成了“数连通分量个数”,这个思路是整道题的题眼。

1.2 为什么答案是连通分量减一,而不是其他

这里面的数学直觉很朴素。假设当前图里有 k 个连通分量,你每修一条新路,最多只能把两个连通分量合并成一个。也就是说,每修一条路,连通分量数量最多减少 1。要让 k 变成 1,最少需要 k-1 条新路。

那能不能用更少的路、一条路一次串起三个分量?不可能,一条无向边只有两个端点,一条路只能落在两个村庄之间,所以它最多把两个分量连通。这是最朴素的“边数下限”论证。另一方面,k-1 条路一定够用:你随便挑一个连通分量当作“核心”,然后在它和其余每个连通分量之间各修一条路,就全通了。所以最少就是 k-1,不多不少。

这个推导过程值得多说一句,因为我在新手阶段就是卡在这:我老想着“万一某条新路能同时连接多个不连通的部分呢?”后来才反应过来,边只有两个端点,这是图论里最基础也最容易忽略的约束。想清楚这一点,代码怎么写反而不难了。

2. 并查集:这道题背后的不二主角

2.1 并查集到底在干什么

并查集是一种管理元素分组的数据结构,核心就两个操作:查找(Find)和合并(Union)。查找是找某个元素所在集合的根节点,顺便还能做路径压缩;合并是把两个不同集合合并成一个。通常我们还会维护一个“集合数量”或者“每个集合的大小”,方便统计连通分量个数。

拿“村村通”来说,一开始每个村庄都单独算一个集合。读入一条已经修好的路 (a, b),就把 a 和 b 所在的两个集合合并。所有边读完后,统计现在还有几个集合,答案就是集合数量减一。整个过程非常直观,几乎可以照着并查集模板直接抄。

很多文章讲并查集会直接甩模板,然后说“背下来就行”,但我个人不太建议这么学。你得先理解一个关键点:并查集并不是真的把“图”结构存下来了,它只是记录了“谁和谁是同一组”。对于只关心连通性、不关心路径细节的问题,这种抽象恰好够用。这也是为什么它在最小生成树Kruskal算法、网格连通性判断、朋友圈划分等场景里都是标配。

2.2 路径压缩和按秩合并,到底要不要写

网上关于并查集的模板有两种常见写法:一种是只做路径压缩,另一种是路径压缩加按秩合并。对 P1536 这种数据规模来说,其实只做路径压缩就完全够用,运行时间漂亮得很。但作为学习,我还是建议把按秩合并也理解透。

所谓路径压缩,就是在 find 的过程中,顺手让路径上的所有节点直接指向根节点。这样下次再查这些节点时,就能一步到位。复杂度的神奇之处在于,加入了路径压缩之后,并查集的单次操作复杂度可以近似看作常数级别,准确说是反阿克曼函数,这个函数增长慢到离谱,基本可以认为是 O(1)。

按秩合并则是说,合并时把“浅的树”接到“深的树”下面,避免树越来越深。如果只做路径压缩不按秩合并,理论上会有一些特殊构造让复杂度退化,但实际题目基本遇不到。这道题的数据范围我记得非常宽松,n 好像是不超过 1000,m 也不大,所以怎么写都能过。不过别因为“能过”就只写一套,面试或者实际工程里,理解这两种优化能帮你应对更复杂的需求。

3. 手把手拆解并查集代码实现

3.1 C++ 实现和关键细节

我平常用 C++ 写这种题比较多,模板基本长这样:

#include <iostream> using namespace std; const int MAXN = 1005; int fa[MAXN]; void init(int n) { for (int i = 1; i <= n; i++) { fa[i] = i; } } int find(int x) { if (fa[x] == x) { return x; } return fa[x] = find(fa[x]); // 路径压缩 } void unite(int a, int b) { int ra = find(a); int rb = find(b); if (ra != rb) { fa[ra] = rb; } } int main() { int n, m; while (cin >> n >> m) { if (n == 0) break; init(n); for (int i = 0; i < m; i++) { int a, b; cin >> a >> b; unite(a, b); } int cnt = 0; for (int i = 1; i <= n; i++) { if (fa[i] == i) { cnt++; } } cout << cnt - 1 << endl; } return 0; }

有几个点值得单独拿出来说。第一,输入有几个测试用例,直到读到 n = 0 才结束,这是题目固定的输入格式,别漏了。第二,统计连通分量个数的时候,我直接数 fa[i] == i 的个数,也就是“有多少个根节点”。这里有个前提,就是我 init 的时候让每个 fa[i] = i,并且路径压缩不会改变根节点自身指向自己的性质,所以这个统计方式是安全且稳定的。

第三,注意这里合并的时候我是直接 fa[ra] = rb,没有判断秩。对于这个题目规模完全没问题。如果你用递归 find,在数据很大时小心爆栈,可以考虑改成迭代写法。虽然本题不会,但养成这个意识没坏处。

3.2 Python 实现:用竞赛题练工程手感

如果你不搞 C++,Python 写这道题也很快。用列表存父节点,代码是下面这样:

import sys def find(x): while fa[x] != x: fa[x] = fa[fa[x]] # 路径压缩 x = fa[x] return x def unite(a, b): ra = find(a) rb = find(b) if ra != rb: fa[ra] = rb def solve(): data = sys.stdin.read().strip().split() idx = 0 out = [] while idx < len(data): n = int(data[idx]); idx += 1 m = int(data[idx]); idx += 1 if n == 0: break global fa fa = list(range(n + 1)) for _ in range(m): a = int(data[idx]); idx += 1 b = int(data[idx]); idx += 1 unite(a, b) cnt = sum(1 for i in range(1, n + 1) if fa[i] == i) out.append(str(cnt - 1)) sys.stdout.write("\n".join(out)) if __name__ == "__main__": solve()

这里我用了一个小技巧,把整个输入一次性读进来再切分,避免一行行读导致的速度损失。虽然这题数据量小,但竞赛里养成“快速读入”的习惯没坏处。find 我用的是迭代写法,同时做了路径压缩,在这个写法里 fa[x] = fa[fa[x]] 的意思是让 x 跳到它父节点的父节点,相当于压缩了一半的路径。如果追求极致,也可以写成完整的递归压缩,但对这个题来说意义不大。

3.3 一个坑:节点的编号范围可能不是连续的

这是我最想提醒的一点。题目说的是 n 个村庄,编号一般从 1 到 n。但有些同学会把数组开成 n 个,也就是下标从 0 到 n-1,结果读入 a = n 的时候直接越界。还有变体题里可能出现某些编号根本没有边连接,但依然算一个单独连通块,所以统计时要遍历 1 到 n 的全部编号,而不是只统计出现过的点。

我见过不少人在这里翻车:只把“在边中出现过的点”拿去判连通块数量,结果没在边里出现的孤立点全被漏掉了,导致答案偏小。说白了,只要题目声明了有 n 个村庄,那就每个编号都是一个“点”,不管它有没有出现在输入里。这种细节,才是决定一道题能不能一次 AC 的关键。

4. 从“村村通”到工程实践:并查集还能干什么

4.1 最小生成树 Kruskal 算法里的并查集

很多刷题的人认识并查集,其实是从 Kruskal 算法开始的。Kruskal 的做法是把所有边按权值排序,然后从小到大一条条尝试加入生成树。加入之前先判断两个端点是不是已经在同一个集合里,如果是,说明加上这条边会成环,得跳过;如果不是,就合并,同时把边权累加进答案。这个过程里,“判断成环”就是并查集最典型的应用场景。

回到“村村通”,如果你把“已有道路”想象成权值为 0 的边,把“可能要修的新路”想象成权值为 1 的边,那这个问题其实可以归约成一种特殊的最小生成树问题:已经存在大量免费边,剩下的边权值全部为 1,求最小生成树的总权值。这样理解的话,答案“连通分量数减一”就解释得更顺了。

4.2 动态连通性:网络、朋友圈、集群节点

工程里还有一种更常见的需求:动态连通性判断。比如服务器集群里有几台机器要互相通信,新加了一条链路,就要更新连通关系;比如社交网络里两个人是不是同一个圈子;再比如地图上两块区域是否已经打通。这类问题如果每次实时搜索所有路径,代价很高,而并查集几乎是以 O(1) 的均摊代价维护“是否连通”这个信息。

我以前参与过一个内部系统,里面需要判断两个配置文件是否属于同一棵依赖树。最初方案是每次查都做 DFS,后来数据量大了才发现不对劲,改成并查集之后,合并和查询都快了几个数量级。虽然场景里不会有“路径压缩”这么学术的名字,但本质就是一个东西。这也解释了为什么竞赛题刷多了,写业务代码时思路会开阔很多。

4.3 “合并集合”不等于“合并所有边”:一个容易糊涂的点

有同学可能会问:既然并查集讲究合并,那我把所有边都 unite 一遍之后,是不是就已经把所有能连的村庄连成一个大集合了?答案是肯定的,这也是核心。但要注意,“能连的”指的是已有道路形成的连通关系,并不会因为“未来可能修的路”而提前连通。后面要修多少条路,是统计完现有集合数量之后才计算的。

这个边界如果没想清楚,很容易写出“每读入一条边先判断、再决定是否计数”的错误逻辑。正确顺序是:先把所有已有边全部合并完,之后再去数集合数量。不要边读边统计,除非你能保证统计逻辑不依赖后续边,但那样既要维护额外变量,又容易出错,不如“先合并再统计”来得干脆。

5. 完整梳理一遍题目思路:从读题到 AC 的思维路径

5.1 我建议按这个顺序去思考任何连通性题

第一步,把所有对象看成点。这里就是 n 个村庄。第二步,看清边是什么。这里就是 m 条已有道路。第三步,判断问题问的是“连通块数量”还是“两点是否可达”还是“最小连接代价”。P1536 问的是“还要多少条边让整张图连通”,本质上就是数连通块。第四步,选数据结构。只关心连通关系,不关心具体路径,选并查集。第五步,代码实现。初始化、合并、统计、输出。

这套思考顺序,几乎可以套用到所有并查集相关的题上,从“朋友圈”到“省份数量”,再到“冗余连接”,都是同一个套路。学会这种“先把问题抽象成图,再选择合适的数据结构”的思维,比记住某道题的具体代码重要得多。

5.2 用一组数据手动推演一遍

假设输入是:

4 2 1 2 3 4 0 0

n=4,m=2。一开始 1、2、3、4 各自独立。读入 (1,2) 后,1 和 2 合并成一个集合;读入 (3,4) 后,3 和 4 合并成一个集合。此时集合数量是 2,它们是 {1,2} 和 {3,4}。要让所有村庄连通,需要 2-1=1 条路,比如在 2 和 3 之间修一条,四个村庄就全通了。

再看一个稍微复杂点的例子:

5 3 1 2 2 3 4 5 0 0

1、2、3 通过两条边连成一体,4、5 连成一体,剩下没有出现在边里的村庄……哦这里没有,但如果 n 改成 6,再多一个 6,那 6 就是一个单独集合。总共 3 个集合,答案是 2。这个例子可以帮你理解“没出现在边里的点也要算一个集合”。

说到手动推演,我习惯在草稿纸上把每个集合的“根节点”写出来,然后每次合并就画一个箭头。你会发现并查集明明叫“树”,但操作起来更像在维护一个“森林”:每棵树是一个集合,根是集合的代言人。最终要修的路线数量,就是森林里的树数量减一。

5.3 关于时间复杂度,补一刀

并查集单次 find 和 unite 近似 O(1),所以整个程序的复杂度基本是 O((n+m)·α(n)),α(n) 是反阿克曼函数。对本题数据来说,完全不用担心超时。真正值得注意的是输入输出方式:C++ 用 cin/cout 也不慢,但如果遇到更大数据量的变体,建议加 ios::sync_with_stdio(false) 和 cin.tie(nullptr),或者干脆用 scanf/printf。Python 则推荐一次性读取所有数据,然后再处理。

6. 踩坑记录:我在 P1536 上犯过的错和排查思路

6.1 忘记处理多组输入,直接 Wrong Answer

这道题是多组测试数据,最后一组是“0 0”。我第一次写的时候只处理了一组输入就 return 了,结果样例过了,一提交就 WA。后来仔细读题才发现问题。很多“水题”其实是输在上手习惯上,而不是算法本身。所以拿到题目,第一件事应该是把输入输出格式完全搞清楚。

6.2 数组越界:开小了

我刚开始开数组时习惯用 n+5 大小,这题是没问题的。但假如题目里村庄编号不是 1 到 n,而是离散的大编号,比如 1 和 100000,那我开 1005 的数组就会越界。解决方案有两种:一种是根据编号范围开足够大的数组,另一种是用哈希表 / map 做离散化。P1536 虽然用不到,但在其他变体里很常见。

6.3 统计根节点的方式:fa[i] == i 不总是对的

如果初始化时所有 fa[i] = i,并且所有合并都通过 find 走到根,那统计 fa[i] == i 就是对的。但有人会写出“把子节点指向父节点,但不保证根节点指向自己”的写法,比如合并时只改了非根节点的 fa,那就乱了。规范做法是:init 时 fa[i]=i,unite 时把其中一个根接到另一个根,find 做路径压缩。只要这三个点都规范,统计就很安全。

我在排查这类问题时,习惯写一个 debug 函数,把 fa 数组完整打印出来,看看哪些点指向自己。比如 n=5 时打印出 fa = [0,1,1,3,3,5],就能一眼看出根节点是 1、3、5,集合数量是 3。这个小技巧在正式比赛里也很有用,尤其当你确认逻辑没错却老 AC 不了的时候,先别怀疑人生,先打印出来看数据。

6.4 合并方向的坑:fa[ra] = rb 还是 fa[rb] = ra

这两种写法一个意思,只要保证把一边的根指向另一边的根即可。但如果写成 fa[a] = b 而不是 fa[find(a)] = find(b),那就漏掉了路径压缩,可能导致某个点没被真正合并到根上。后面统计时就会多算集合。所以合并前一定要 find 两个点,拿到根再操作。这是新手最常见的 bug,没有之一。

7. 这题还能怎么变着玩:从“村村通”到更复杂的模型

7.1 变体一:带权并查集

如果题目里加一个要求:不仅要知道两个村庄是否连通,还想知道两个村庄之间的“距离”或“关系”,那就需要用带权并查集。每个节点除了指向父节点,还要记录一个到父节点的权值。这个权值可以是路径长度、逻辑关系的偏移量,甚至是模运算下的差值。带权并查集在“食物链”“银河英雄传说”这类经典题里是常客。

7.2 变体二:反向并查集 / 删边问题

有些题目会问“如果某条路被切断了,连通性会产生什么影响?”这种动态删边问题如果用并查集正着做很难,因为并查集只支持合并、不支持删除。但可以换个思路:离线处理,倒着来。先把所有要删的边都删掉之后的状态建好,然后从最后一步向前逐步“加回”边,每次加边都对应一次合并。这就是所谓离线反向并查集。P1536 的“修路”方向正好和它相反,但底层结构是同一套。

7.3 变体三:最小生成树变体

如果每条新修的路有各自的成本,而不是统一的“1 条路”,那问题就升级成了“最小成本让所有村庄连通”。Kruskal 算法直接做就行。换句话说,P1536 其实是 Kruskal 的一个特例,把所有候选边的权值都视为 1。理解了这一点,以后看到“最少需要修几条/多少钱”这类题,就能快速归类。

8. 日常做题的几点体会

说回到最开头,我为什么觉得 P1536 值得写一篇博客?因为它的核心解题步骤极简,但背后承载的知识点却是一条完整的链:图的连通分量概念、并查集数据结构、反阿克曼函数带来的复杂度优势、以及把生活问题抽象成图论模型的能力。从学习的角度看,性价比非常高。

如果你现在正在刷题,我的建议是:不要急着看题解,自己先动手写。哪怕写得又臭又长,哪怕一开始只会用 BFS/DFS 去数连通块,也要先写完。等你意识到“每次查询连通性都要重走一遍搜索也太麻烦了”,你才能真正体会到并查集那种“用树根代表集合身份”的精妙。之后再去比较 BFS/DFS 和并查集两种写法的时间和空间开销,收获会比直接背模板大很多。

顺带说一句,这道题用 BFS/DFS 也能做:建邻接表,从每个未访问的节点出发做遍历,每次从未访问节点开启新遍历就说明发现了一个新的连通块,最后连通块数量减一就是答案。但对于大规模数据,并查集的空间开销更小,写起来也更简洁。两者各有适用场景,别觉得会一种就天下无敌。

最后分享我在实际做题时的一个习惯:AC 之后多回头想想,如果 n 变成 10^5,m 变成 10^6,我的代码还能不能秒出?如果答案是否定的,说明还有优化空间。P1536 的并查集写法完全扛得住这种压力,这也是我推荐新手认真掌握它的原因。希望这篇拆解能帮你在“村村通”这道题和大名鼎鼎的并查集之间,建立起真正属于自己的连接。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 21:35:53

iPhone 17与iPhone 18区别详解:芯片、内存、AI与选购建议

很多朋友最近都在问我同一个问题&#xff1a;苹果17和18到底差在什么地方&#xff1f;尤其是那些手机已经用了两三年、正卡在换机节点上的人&#xff0c;特别纠结。一边是iPhone 17已经摆在店里可以随时入手&#xff0c;另一边是iPhone 18的各种传闻满天飞&#xff0c;看起来好…

作者头像 李华
网站建设 2026/9/24 21:35:52

功函数详解:从物理图像到器件应用与测量调控实战

功函数这个概念&#xff0c;我最早认真琢磨它&#xff0c;是在做金属-半导体接触实验的时候。当时费了好大劲制备了一批Ni电极&#xff0c;测出来的接触特性跟理论预期怎么都对不上&#xff0c;后来仔细排查才发现&#xff0c;问题出在我对功函数数值的“想当然”——我直接拿了…

作者头像 李华
网站建设 2026/9/24 21:34:46

将安全审计封装成Skill:面向AI编码代理的可复用工作流

1. 为什么安全审计要“做成一个 skill”先说结论&#xff1a;这个security-audit-skill&#xff0c;本质上不是传统意义上的安全扫描脚本&#xff0c;也不是一个单纯挂在聊天窗口里的“帮我审一下这段代码”的提示词&#xff0c;而是给AI编码代理&#xff08;类似Codex、Claude…

作者头像 李华
网站建设 2026/9/24 21:34:39

遥感道路分割实战:DeepGlobe数据集加载、损失函数与泛化评估

简介&#xff1a;本资源面向深度学习图像分割方向的学习者与研究者&#xff0c;提供大分辨率遥感影像道路提取任务的完整数据集&#xff0c;适合用于分割网络的训练、测试与效果验证。数据集已预先划分训练集与测试集&#xff1a;训练集包含4981张图像及4981张对应mask&#xf…

作者头像 李华
网站建设 2026/9/24 21:34:28

Python UNet细胞分割实战:从数据预处理到模型训练与预测的完整Demo

简介&#xff1a;这份资源是一套面向深度学习初学者的图像细胞分割Python实战Demo&#xff0c;围绕医疗图像分析场景&#xff0c;帮助零基础读者理解并跑通从数据准备到模型预测的完整流程。压缩包共625个文件、约34.32MB&#xff0c;其中516张jpg与90张png构成细胞图像数据集&…

作者头像 李华
网站建设 2026/9/24 21:34:08

Python环境配置完全指南:从解释器、pip到虚拟环境

1. 先别急着敲代码&#xff1a;把Python环境一次装对&#xff0c;后面少折腾一个月我看到太多人学Python&#xff0c;第一周就放弃了&#xff0c;不是语法难&#xff0c;而是卡在了环境上。明明照着教程敲了三行print("hello")&#xff0c;结果要么提示python不是内部…

作者头像 李华