☰
畅通工程与并查集:从连通分量到最少补边数
2026/10/21 9:06:48 网站建设 项目流程

1. 先拆标题:ZJUKY110 和“畅通工程”分别透露了哪些信息

1.1 题目编号背后的语境

第一次看到“ZJUKY110 畅通工程”这个标题,如果不熟悉题库命名规则,很容易一头雾水。拆开看,“ZJUKY110”更像是某个教学平台或学校内部题库里的唯一编码,其中“ZJU”通常代表题目来源的标识,“KY110”可能是课程编号或者题目序号。真正给做题者提供方向的是后半部分“畅通工程”。

很多数据结构课程会把这一题放在图论章节的第一课,目的是让学生在正式学习邻接表、割点、最短路径之前,先用一个轻量级问题掌握“连通性”的概念。“畅通工程”这个题目名在编程题库里出现频率极高,从最早的基本连通题,到后续演变出的各类带成本、带坐标的版本,整个系列一直在考并查集和最小生成树。对刚接触数据结构的人来说,这几乎是绕不开的第一道图论作业题。

1.2 输入输出约定的完整描述

把标准输入输出格式完整展开是这样的。输入包含多组测试数据,每组第一行包含两个整数 N 和 M,N 代表城镇数量,M 代表已经规划的道路数量。城镇编号从 1 到 N。当输入的 N 等于 0 时,表示所有测试数据结束,程序不需要处理这一组。之后的 M 行,每行两个整数 a 和 b,含义是城镇 a 和 b 之间已经存在一条可以通行的道路。

对每条道路来说,a 和 b 之间不一定满足 a < b,也没有保证同一对城镇只会出现一次,所以代码要能容忍重边和乱序边。输出要求是一行一个整数,表示要让整个区域畅通,最少还需要新建的道路数量。比如输入三组测试数据,输出就对应三行数字。

1.3 这个题真正在考什么

表面上看,这题考的是有没有读懂题干,实际上考的是三件事。第一,能否把文字描述转化为无向图模型:城镇是顶点,道路是边。第二,能否准确统计连通分量数量。第三,能否证明“补边数 = 连通分量数 - 1”这个结论。

很多复习代码的人会忽略第三点,把公式死记硬背下来,这样做基础题还行,一旦遇到需要推导的变种题,立刻卡住。所以第 2 节我会专门把推导过程拆细,这部分直接决定你能不能把同一套路迁移到其他题上。

2. 设计思路:为什么答案是“连通分量数 - 1”

2.1 从文字到图模型的转化过程

在无向图 G=(V, E) 里,V 是城镇集合,E 是道路集合。两个城镇如果直接相连,它们之间就有一条边;如果一个城镇能通过若干条边到达另一个城镇,我们就说它们属于同一个连通分量。连通分量是极大连通子图,分量内部的任意两个点都可以互相到达,而不同分量之间没有任何边。

题目要求是让整个图只有一个连通分量。在这个前提下,新增一条连接两个不同分量的边,会立即把两个分量合并成一个;新增一条连接同一分量内部的边,不会改变分量数量。问题于是等价于:通过最少的边数,把多个连通分量合并成一个。

2.2 为什么最少补边数恰好是 k - 1

记当前连通分量数量为 k。先看下界:一次合并操作最多只能让分量数减一,不可能一次减少两个或更多分量。要把 k 最终变成 1,至少需要 k - 1 次操作,所以答案不可能小于 k - 1。

再看上界:构造一种方案,随意挑第一个连通分量的任意点 x,再挑第二个连通分量的任意点 y,连接 x 和 y,这两块立刻合并成一块。接着用合并后的新块里的任意点去连第三个分量的任意点,如此继续。每次操作都能让分量数量恰好减一,因此 k - 1 次操作足够。下界和上界相等,答案恰为 k - 1。

这个证明过程很朴素,却非常重要,很多变种题都依赖类似的构造思路。比如最小生成树里,加一条边减少一个连通分量的思想一直在被复用。

2.3 备选方案对比:DFS/BFS 和并查集

如果考试时间紧,用 DFS/BFS 直接统计连通分量也能做对。思路是:建立邻接表,用一个 visited 数组标记访问情况,外层循环遍历所有点,没访问过就从该点开始做一次 DFS 或 BFS,并把访问到的点全部标记,每启动一次搜索就是一个新的连通分量。实现难度不高,时间复杂度 O(N+M),同样可以接受。

但并查集的优势在于,它不需要预先构建邻接表。输入边的时候,我们一边读一边合并,后面的查询和统计都变得极其自然。更重要的是,这种方式能处理在线场景:如果后续还有新增边,不需要重新遍历图,只需再调用一次 union 就更新了。

2.4 动态连通性在工程里的意义

实际系统里,一张静态图的情况反而不多,更多时候是不断有节点加入、有边新增。比如服务注册中心新增实例、容器集群的节点互联、缓存系统新增副本之间的通信链路。每次改动都全图扫描一遍,代价很高;而并查集天然支持增量维护,所以它不只是课程作业里的技巧,也经常出现在系统设计思路中。

3. 并查集:一道题的灵魂数据结构

3.1 从数组出发理解“集合”

并查集的原始数据就是一个数组 parent,下标是点的编号,值是该点的父节点。开始时每个点孤立,所以 parent[x] = x。三个基本操作都围绕这个数组展开:初始化给每个点设成自己的根;查找 find(x) 顺着父指针一路向上走到根;合并 unionSets(a, b) 把 a 所在集合和 b 所在集合合并。

一句话概括:parent 数组表示的是一棵多叉树,根节点是集合代表元。找根的过程就是沿着 parent 上跳的过程。

也可以用“直系领导”来类比:每个员工记录自己的直系领导,领导再向上有领导,直到最大的领导指向自己。查询“你和另一个人是否属于同一部门”就是分别追溯两个人的最大领导,看是不是同一个人。部门合并时,不需要通知每一个员工,只要让两个部门的最高领导之间建立上下级关系,整个部门在逻辑上就合成一个集体了。

3.2 路径压缩:让树变矮的关键优化

在没优化的并查集里,如果合并操作总是把一棵树的根挂到另一棵树的某个子节点上,树会越来越像单链表,find 的复杂度会退化到 O(N)。路径压缩的思想是:在 find(x) 的过程中,顺路经过的所有节点的父指针都直接指向根。下一次再查这些节点时,一步就能到达根,查找代价几乎变成 O(1)。

递归实现路径压缩的代码很经典:

int findRoot(vector<int>& parent, int x) { if (parent[x] != x) { parent[x] = findRoot(parent, parent[x]); } return parent[x]; }

这个写法在递归回溯时把当前节点的父指针改成了根。如果担心递归太深,可以改成迭代版,第一遍循环找根,第二遍循环把路径上的节点全部直连根:

int findRoot(vector<int>& parent, int x) { int root = x; while (parent[root] != root) { root = parent[root]; } while (x != root) { int next = parent[x]; parent[x] = root; x = next; } return root; }

3.3 按秩合并:让树长得更均衡

路径压缩只在查询时发生,合并时如果完全不管树的形状,仍然可能构造出很不平衡的树。按秩合并的思想是:记录每棵树的秩,秩近似等于树的深度;合并时让秩较小的树挂到秩较大的树下面。这样整棵树的深度增长得更慢,后续查找更省事。

实现时用一个 rank 数组。刚开始所有点的秩都是 0;合并时先比较两个根节点的秩,把秩小的根的父亲设为秩大的根;如果两者秩相等,选一个做根,并把它的秩加一。加了按秩合并后,单独操作的复杂度都保持在 O(log N),和路径压缩配合后还能进一步降到 O(α(N))。

3.4 复杂度分析:为什么能当作常数

对于数据范围比较大的题目,人们常说并查集单次操作均摊时间复杂度是 O(α(N))。这里的 α 是逆阿克曼函数。不用去背函数定义,只需要知道它在现实中增长极慢:即使 N 大到难以想象,α(N) 也不超过 5。所以实际编码中,完全可以把它当作常数处理。也正是因为这一点,并查集能在很多高数据量场景里变成最优首选。

4. 完整的实战编码过程

4.1 环境准备与输入加速

写这道题前先把编译器打开。C++ 版本建议 C++11 或以上,因为代码里会用到 vector,容器初始化更方便。另一个建议是关闭 C 和 C++ 输入输出的同步:

ios::sync_with_stdio(false); cin.tie(nullptr);

这两行不计入算法复杂度,但对多组输入有明显的提速效果。与默认状态的 cin 相比,关闭同步后能快不少。被大数据卡住超时的时候,有时不是算法问题,而是输入输出层面的开销。

4.2 逐模块拆解初始化、读边和统计

一开始初始化:

int n, m; while (cin >> n >> m && n != 0) { vector<int> parent(n + 1); vector<int> rank(n + 1, 0); for (int i = 1; i <= n; i++) { parent[i] = i; } ... }

这里每组测试数据都重新构造 parent 和 rank,保证上一组数据不会污染下一组。数组大小开成 n + 1,是为了让下标从 1 开始,下标 0 的槽位空出来不用。

然后逐条读道路并合并:

for (int i = 0; i < m; i++) { int a, b; cin >> a >> b; unionSets(parent, rank, a, b); }

读入的顺序不影响并查集结果,因为合并操作满足交换律和结合律。重边也没关系,union 时会先判断两个根是否相同,相同就直接返回。

统计连通分量:

int components = 0; for (int i = 1; i <= n; i++) { if (findRoot(parent, i) == i) { components++; } } cout << components - 1 << '\n';

每个根节点都是集合代表元,根的特征就是 parent[i] == i。统计根的个数就是连通分量的个数。

4.3 手把手过一遍小型用例

假设输入是:

4 2 1 2 3 4 0 0

初始化后 parent 为 [0, 1, 2, 3, 4],四个点各自独立。读入第一条边 1 2,find(1)=1,find(2)=2,根不同,执行合并。rank 都为 0,所以把 2 的父节点设为 1,rank[1] 变成 1。此时 parent 变成 [0, 1, 1, 3, 4]。

读入第二条边 3 4,find(3)=3,find(4)=4,两个根不同,执行合并。把 4 挂到 3 下面,rank[3] 变成 1。此时 parent 变成 [0, 1, 1, 3, 3]。

统计时从 1 到 4 依次判断:1 是根,2 的根是 1 但本身不是根,3 是根,4 的根是 3 但本身不是根。所以连通分量数是 2,答案 2 - 1 = 1。结果与预期完全一致。

4.4 完整代码(C++)

#include <iostream> #include <vector> using namespace std; int findRoot(vector<int>& parent, int x) { if (parent[x] != x) { parent[x] = findRoot(parent, parent[x]); // 路径压缩 } return parent[x]; } void unionSets(vector<int>& parent, vector<int>& rank, int a, int b) { int ra = findRoot(parent, a); int rb = findRoot(parent, b); if (ra == rb) return; if (rank[ra] < rank[rb]) { swap(ra, rb); } parent[rb] = ra; if (rank[ra] == rank[rb]) { rank[ra]++; } } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; while (cin >> n >> m && n != 0) { vector<int> parent(n + 1); vector<int> rank(n + 1, 0); for (int i = 1; i <= n; i++) { parent[i] = i; } for (int i = 0; i < m; i++) { int a, b; cin >> a >> b; unionSets(parent, rank, a, b); } int components = 0; for (int i = 1; i <= n; i++) { if (findRoot(parent, i) == i) { components++; } } cout << components - 1 << '\n'; } return 0; }

如果你更喜欢用静态数组,可以直接声明int parent[100005];,但要注意不同题目 N 的上限可能不同,数组大小必须开够。用 vector 一是省空间,二是不容易越界。

4.5 有关变量命名的经验

变量命名里我习惯把 rank 写成 rank,findRoot 而不是 find,unionSets 而不是 union。因为 union 在很多 C++ 环境里和内置关键字或同名函数冲突,直接用会带来不必要的编译错误。find 也和标准库函数有重叠,建议加后缀。这些细节能帮初学者少踩莫名其妙的坑。

5. 测试、对拍与边界用例

5.1 为什么样例不能代表一切

题库给出的样例通常很简单,目的是演示功能,不是验证正确性。比如题目示例可能只有两个城镇一条路,答案 0;或者三个城镇两条路,答案 1。通过了样例,不代表代码能处理所有边界情况。我每写一次图论题,都会顺手多构造几组数据。

单个测试用例最好覆盖这些情况:没有任何道路的纯孤立点集合;只有一个城镇和零条道路;一条道路的端点都是同一个城镇;多条重复边;所有点在一条直线上串成链;所有点全部连通。每组输出都要能手工推演一遍。

5.2 边界用例推演

下面这组用例建议直接放进本地测试文件:

1 0 3 3 1 2 2 3 1 3 5 0 4 2 1 2 3 4 0 0

第一组 N=1,M=0,只有一个城镇,不需要任何道路,答案是 0。第二组三个城镇三条边,已经全连通,答案是 0。第三组五个孤立城镇,答案是 4。第四组两两成对,两个连通分量,答案是 1。最后一组 0 0 代表输入结束。

5.3 自环和重边的处理

如果道路给了“1 2”和“2 1”两条,并查集合并结果和只有一条“1 2”完全相同,因为合并操作会先判断两个端点是否同根,同根就跳过。自环“1 1”更是什么都不触发。这些都是无害输入,但如果代码在合并前没有判断,或者用邻接矩阵存图,就可能出现重复边引起的问题。并查集天然免疫重边,这是它在这类题目里特别省心的原因。

5.4 用暴力算法互相验证

想进一步确认代码正确,可以写一段用 DFS 统计连通分量的暴力版本,然后在 N 比较小比如 N 不超过 10 的范围内生成随机图,把两份代码的输出逐组对比。随机图生成规则很简单:从 1 到 N 随机生成若干条边,允许重复,允许自环。跑上几千组数据,如果两边结果一致,基本可以放心提交。这个流程叫对拍,是搞算法题的人都会用到的测试手段,拿来验证作业题同样有效。

6. 常见问题排查与避坑清单

6.1 编译和初始化类问题

第一类问题多出在 C++ 的细节上。数组开成 n 而不是 n+1,使用编号 n 时越界;多个测试用例之间,parent 数组或 rank 数组没有重新初始化;不同测试用例里残留上一次的全局数据。这些都是很常见的错误,最好用 vector 在每组数据内重新构造,从根本上避免跨组污染。

6.2 逻辑层面的隐蔽错误

第二类问题更隐蔽。合并时直接判断 parent[a] != parent[b] 就执行合并,没有先 find,这样实际上只更新了某个节点的父指针,没有把整个集合合并,导致统计结果多出一两个分量。本质上是因为对“集合代表元”这个概念没吃透。

还有一种隐蔽错误:统计连通分量时用了 findRoot(i) == i,但有些写法把 findRoot 写成了不压缩路径的版本,在数据多、结构复杂时,可能出现查到的根不是真正根的情况。解决方法是先执行压缩版 find,再判断根节点身份。

6.3 性能与内存的隐忧

虽然并查集快,但代码写不好也会退化。比如用 vector 存储 parent 但不做路径压缩,在一条长链上反复 find,每次都要走到链尾,最坏复杂度会退化到 O(NM)。一些 N 到 10^5、M 到 10^5 的用例会直接超时。应对方法就是两个优化都做上:合并时按秩,查找时压缩。

内存方面,如果 N 最大是 10^6,parent 数组用 int 大约 4MB,rank 数组又是 4MB,静态开完全没问题。如果 N 更大,可以考虑用 short 存 rank,但没必要为了这个小题优化到那种程度。

6.4 提交时的输出格式和细节

这道题的输出要求每行一个整数。有人写成cout << components - 1 << endl;也能过,但每次 flush 缓冲区会慢一些。刷大数据时建议用'\n'而不是 endl。另外千万别输出任何提示文字,比如“答案是:”,否则评测系统会判错。多组输入如果格式要求有空行,题目会明确说明;这题没有,所以每行输出完直接换行即可。

现象可能原因解决方式
输出比正确答案大 1下标从 1 开始但初始化循环从 0 开始,或统计时把 0 号点也算进去初始化只做 1 到 n,统计也只做 1 到 n
输出比正确答案小 1合并时没有找到根,把非根节点直接挂到另一个节点下面使用 findRoot 得到根后再合并
M=0 时答案不对没有处理 M=0 的输入,或者循环条件写错即使 M=0,也要正常初始化并统计分量
超时缺少路径压缩或按秩合并两个优化都加上,输入用快速 IO
递归深度爆栈find 写成无压缩递归且数据成链使用迭代 find 或在递归中做路径压缩
评测结果 PE / WA输出多空格、多换行严格按格式输出,只输出一个整数加换行

7. 大胆联想:同一题型的 N 种变形

7.1 判断是否已全连通

应用场景里常见的是判断图是否已经全连通。只需要把最后输出改成 components 是否等于 1 的判断结果,其余代码几乎不变。这类变体常出现在面试里,因为面试官想快速确认你理解连通分量的意义。

7.2 最小生成树(Kruskal)

如果把道路分成“已建”和“可建”两类,且可建道路有成本,题目立刻升级为最小生成树问题,典型的是“还是畅通工程”这类变体。Kruskal 做法是:把所有可建道路按成本升序排序;用并查集逐个连接,只要不会成环就加入;选择 N-1 条边后停止,累加成本输出。

并查集在 Kruskal 里的作用是高效的环路检测。因为如果两个端点已经在同一分量里,再加这条边一定会形成环,干脆跳过。复杂度 O(M log M),瓶颈在排序,不在并查集。

7.3 带权并查集

如果边上有数值信息并且需要动态维护,比如两个城镇之间的延迟值、两个用户之间的关系分值,那就需要在父节点关系上额外保存“到父节点”的权值。find 时累加权值,union 时根据已知两个节点的相对值推算出新的权值关系。常见经典题有食物链、奇偶游戏等,适合在掌握基础版本之后继续钻研。

7.4 冗余连接问题

有些题目反过来问:在一棵已经多加了边的树上,找到那条导致环出现的冗余边。并查集扫描边列表,当发现一条边的两个端点已经在同一集合中时,这条边就是冗余边。这个思路在动态维护环境的异常检测里很有用,可以快速定位造成循环引用的连接。

8. 对这道题的一些个人体会

做这类基础算法题,我最大的感受是:不要把结论当成理所当然。刚学并查集时,我也曾直接把“components - 1”背下来,直到有一天被问为什么不是 components,才认真去想。一旦认真想了,后面学最小生成树、带权并查集、甚至处理环检测问题,都会顺水推舟。

建议在读这篇文章后,抽出十分钟,把代码重写一遍,不要复制粘贴。写的过程中体会三件事:find 的路径压缩到底改了什么;union 按秩合并为什么能保性能;主循环统计根节点时为什么不会漏点。如果这三件事都能自己解释清楚,这道“ZJUKY110 畅通工程”就算真正内化了。之后再去挑战那个系列的后续题目,你会发现自己已经比别人省了一整个踩坑过程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询