ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

并查集从原理到实战:亲戚题教会你路径压缩与按秩合并

并查集从原理到实战:亲戚题教会你路径压缩与按秩合并 1. 题目背景与核心考点1.1 题意解读其实就是一个找组织的问题先把这个题的题意用大白话捋一遍题目给你一个由 n 个人组成的社会关系网这些人之间有 m 条已知的亲戚关系然后给你 q 次询问每次问你两个人是不是亲戚。亲戚关系有个很重要的性质——传递性如果 A 是 B 的亲戚B 是 C 的亲戚那么 A 和 C 也是亲戚。这个性质是整个题目的命门所在。很多人第一次看到这个题会觉得这就是个图论连通性问题对吧确实可以这么理解把每个人都看成一个节点亲戚关系就是一条边那判断两个人是不是亲戚本质上就是在判断两个节点在无向图中是否连通。如果用 DFS/BFS 暴力去做每次询问都要遍历一遍图复杂度是 O(q × (n m))当 n 到达 5000、m 到达 5000、q 也很大时这种暴力写法铁定超时。所以这道题首先在告诉你一件事当连通性查询很频繁且关系是一次性给定的就要想到用并查集——它就是专门干这件事的数据结构。再说深一点这道题考的不只是模板而是你对等价关系这个概念的理解。亲戚关系满足自反性自己是自己的亲戚、对称性A 是 B 的亲戚则 B 也是 A 的亲戚和传递性所以它本质上是把 n 个人划分成了若干个等价类。我不需要知道两个人之间的具体亲戚链是什么我只需要知道他们属于哪个家族集合并查集恰好就是维护这种分分类问题的利器。1.2 为什么这道题注定是并查集的钉子户在网上随便一搜洛谷 P1551十篇题解有九篇是并查集写法。很多新手会好奇这题我开个数组搞个 Floyd 传递闭包不行吗或者直接 BFS 不行吗我可以负责任地说能过但不是最优解而且这不是这道题的真正意义。先算一笔账。n 最大是 5000如果我用邻接表BFS每次询问 O(n m)q 如果也是 5000最坏情况是 5000 次 × 10000 个节点也就是 5000 万次操作这个量级 C 勉强能跑但已经很不优雅了。如果你的 m 更大、n 更大比如到 10 万、100 万呢BFS 就直接炸了。而并查集在路径压缩按秩合并的优化下单次 find 操作的均摊时间是一个极小的常数阿克曼函数的反函数处理 10 万次操作毫无压力。从数据结构本身来说并查集是武林必练基本功级别的知识点。P1551 被官方标记为并查集模板题不是没有道理的它的数据范围设置得非常恰到好处n、m、q 都在 5000 左右既能让你用暴力混过部分分数又能让你明显感觉到哎暴力好像有点悬——这就是在逼你想更好的解法。所以说这道题的第二个考点其实是复杂度分析。你得学会估算最坏情况下的操作次数判断暴力是否能扛得住进而倒逼自己学习更高效的数据结构。也正因为如此P1551 在所有 OI 新手村的地位很高它是很多人 AC 的第一道涉及高级数据结构的题目虽然并查集实现起来也就 20 行是从纯模拟走向算法设计的第一道分水岭。后面你会遇到的 P3367 【模板】并查集本质上就是这道题的纯模板版本还有 P1955 程序自动分析、P1197 星球大战这些经典题都是在这道题的基础上加了一层包装。所以把这一题吃透后面是能复用很多次的。2. 并查集的原理从认爹说起2.1 并查集到底是什么为什么能这么快并查集Union-Find的思想说穿了就是维护一个森林。森林里的每一棵树代表一个集合树的根节点就是集合的老大。判断两个人是不是亲戚只需要看这两个人的根老大是不是同一个。是不是同一个家族看族长就知道了这个比喻我想很多题解都提到过但它确实是最传神的。具体用代码怎么表示你需要一个数组 fa[n 1]其中 fa[i] 表示 i 的父亲节点是谁。初始状态下每个人都是自己的族长也就是 fa[i] i每个节点都是一棵孤零零的树。当读入一对亲戚关系 (x, y) 时就把 x 所在的树和 y 所在的树合并——怎么合并把 x 的根节点的父亲设置成 y 的根节点或者反过来都行。这就是并Union操作。查询两个人 x 和 y 是不是亲戚就分别找到 x 的祖先根节点 r1 和 y 的祖先根节点 r2。如果 r1 r2那就在同一个集合里输出 Yes否则输出 No。这就是查Find操作。最核心的地方在于Find 操作有多少层都要向上爬最坏情况下如果合并的时候把一棵很深的树挂在另一棵树的根下面树的深度可能达到 n那 find 一次的复杂度就是 O(n)这比 BFS 还慢。这时候就需要优化出场了。两个经典的优化——路径压缩和按秩合并每一个都能把复杂度压到非常理想的级别。先说路径压缩当我在 find(5) 时一路从 5 爬到根沿途经过的所有节点直接把它们的 fa 全改成根。这样做一次之后以后再从这些节点出发 find就一步到位了。按秩合并则是让矮的树挂到高的树下面尽量不让树长老高。两者配合使用单次操作均摊复杂度几乎就是常数。2.2 路径压缩和按秩合并到底怎么选先说路径压缩它的实现极其简单就是 find 函数里一行递归int find(int x) { return fa[x] x ? x : fa[x] find(fa[x]); }这行代码中fa[x] find(fa[x])的赋值操作在返回之前完成把 x 到根路径上的所有节点都直接挂到了根下面。很多初学者看了半天看不懂为什么这样就压缩了路径我拆开说当递归到最深处找到根 r 之后返回值 r 会被一层层传回来每传到一层就把那一层的 fa 覆盖为 r。比如 fa[5] 3, fa[3] 7, fa[7] 77 是根find(5) 会先把 fa[3] 改成 7再把 fa[5] 改成 7。下次再查 5一下就到 7 了。这就是压缩的含义。按秩合并稍微复杂一点需要额外维护一个 rank 数组或者 size 数组来记录每棵树的高度/大小。合并的时候总是把高度小的树挂到高度大的树下面防止树退化成长链。注意如果两棵树高度相同合并后新树的高度才加一。很多教材会告诉你两者一起用最优但我想多聊两句实际感受在做 P1551 这种模板题时你只写路径压缩就已经能在绝大多数评测数据里跑得非常快。为什么因为路径压缩本身就足够压制树的深度了很多实测数据下树的深度基本不会超过几层。按秩合并的收益更多体现在更严格的稳定性和离线构建时树的形态可预测上。但如果题目要求不能被递归爆栈、或者说你的 find 写成非递归形式路径压缩的实现还是需要点技巧的。我个人建议是——新手阶段两个都写不是为了炫技而是为了养成好习惯。后面做 P1197 星球大战、P1955 程序自动分析这些题你会发现有些题特别追求性能方差小按秩合并防止极端数据挂你没商量。先把这个好习惯练起来后面省心。3. 逐模块拆解完整实现方案3.1 完整 C 代码与提交细节下面是这道题一个非常标准的、带注释的满分解法。我直接贴出代码然后重点拆几个关键位置。#include cstdio #include algorithm using namespace std; const int MAXN 5005; int fa[MAXN], rnk[MAXN]; int find(int x) { if (fa[x] x) return x; return fa[x] find(fa[x]); // 路径压缩 } void unite(int x, int y) { x find(x); y find(y); if (x y) return; // 已经在同一个集合中 if (rnk[x] rnk[y]) { fa[x] y; // 矮树挂到高树上 } else { fa[y] x; if (rnk[x] rnk[y]) { rnk[x]; // 等高合并高度才增加 } } } int main() { int n, m, q; scanf(%d %d %d, n, m, q); for (int i 1; i n; i) { fa[i] i; rnk[i] 1; } for (int i 0; i m; i) { int a, b; scanf(%d %d, a, b); unite(a, b); } for (int i 0; i q; i) { int a, b; scanf(%d %d, a, b); if (find(a) find(b)) { printf(Yes\n); } else { printf(No\n); } } return 0; }这里有一个非常容易被忽视的细节输出的是 Yes 和 No不是 YES 和 NO也不是 yes。洛谷的字符串比较是严格区分大小写的这个格式错误会导致直接 WA而且报错信息还很模糊——不是告诉你输出格式不对而是告诉你答案错误。我在学的时候身边真的有好几个同学死在这上面我自己也因为这个罚过好多次。建议拿到任何题目先看输出格式再对着样例输出的大小写一字不差地抄。然后是循环变量的问题输入第一行的三个数表示人数 n、关系数 m、询问数 q。很多新手会写成for (int i 0; i m; i)读关系没有问题但容易搞混 m 和 q 的作用范围。简化记忆谁的循环数就用谁。第二行的 m 行是建图阶段全部读完之后才开始处理第三行的 q 次询问不要把两个读入阶段混在一起。还有一个微小但会导致运行时错误RE的坑题目中的人编号是从 1 到 n不是从 0 开始。所以数组开 n 1 个从下标 1 开始初始化 fa[i] i下标 0 的位置闲置即可。边界情况如果输入里出现了 0 号大部分数据不会你的代码会直接再用错下标best case 是 WAworst case 是段错误。对于这种输入范围明确从 1 开始的题谨慎点总是没错的。3.2 初始化、合并、查询三段式人生的底层逻辑这段其实想跟你聊聊这个代码为什么这么组织背后对应着并查集的什么流程。第一步初始化对应着每个人独立成一个家族的初始状态。这步必须放在读入关系之前否则就会把后面合并的 fa 数组覆盖掉导致前面的合并全部失效。还有一点如果你用memset(fa, 0, sizeof(fa))而不是循环赋值为自身那么 find 函数就永远不会返回一个有效值因为根的定义是fa[x] x初始化为 0 的话所有节点都认为自己是根 0全乱套了。第二步合并阶段处理 m 行关系。这里要理解unite(a, b)做的事并不是简单让 fa[a] b而是要找到双方的根再做合并。否则会产生中间节点记录不准确的问题比如 a 的根是 1b 的根是 2你如果直接写 fa[a] b那 1 依然指向自己查询 a 和 1 是否同类反而会出错。所以合并必须使用根节点之间的连接这是几乎所有新手第一次接触并查集时犯最多的问题。第三步查询阶段就很简单了但在工程上有个细节值得单独说如果查询就用find(a) find(b)直接比较其实完全可以不用先把结果存下来再判断。有些初学者喜欢写int pa find(a), pb find(b); if (pa pb)这也没问题注意别把两个 find 调用拆开而中间穿插了修改操作就好了。在find(a) find(b)中如果你写了路径压缩两次 find 可能会改变一些节点的 fa但并查集的性质决定了这种改变不会影响相等性判断的结果所以一定安全。所以你看整个代码其实特别简洁初始化 O(n)、m 次合并、q 次查询总时间接近线性。对于 5000 的数据量实测时间基本在 1ms 到 2ms 之间跟暴力 BFS 的几十毫秒差距不算大但这个数据结构的扩展性是完全不同的量级。你拿同样的板子去跑 n 100000、q 50000 的 P3367一样 10ms 内跑完这就是数据结构的威力。4. 新手最容易踩的四个坑以及调错指南4.1 常见错误清单与排查手段我自己在刷题群答疑时总结过一份P1551 常见死法排行某种意义上比官方题解更有价值这里列给你看排序第一的坑就是find 函数的递归写崩了。我指的不只是忘记写路径压缩还包括不小心写成死循环。最常见的写法错误是int find(int x) { while (fa[x] ! x) x find(fa[x]); // 千万别这么写 return x; }这种写法在路径压缩下也是能跑的但它把递归和循环混在一起造成了语义混乱还可能在极端数据下多出无谓的递归次数。递归写法的精髓是把查询状态和修改状态分开上面的混写反而容易让你绕晕。老老实实用return fa[x] x ? x : fa[x] find(fa[x])是最清晰的。排序第二的坑是数组越界。刚才提到过编号 1 到 n如果某题用 0 到 n-1你就要调整。还有的是 n 的范围可以到 100000但数组只开了 10005大样例直接 RE。所以每次看数据范围不要光看样例把数组稍微开大一点点这个习惯在竞赛里能救无数次命。排序第三的坑是读入与初始化顺序。有的同学会把初始化和关系读入写在一起每读入一对关系之前都重置一下 fa那前面合并的就全没了。初始化只做一次一定要放在所有读入之前。排序第四的坑是忘记判断 x y。在 unite 函数里如果两个人本来就是亲戚你仍然执行合并理论上不会出什么大bug但如果你的按秩合并代码里对相同根的分支处理不正确可能会导致把 rnk 错误地增加。养成if (x y) return;的习惯对后续做更复杂的并查集题目比如带权并查集帮助很大。4.2 调试思路肉眼盯不出问题就直接造数据如果你提交后 WA 了第一步不是在 code 里瞎找而是去造几组小规模、带输出的数据来验证逻辑。比如输入4 2 3 1 2 2 3 1 3 1 4 3 4这个数据一家人是 {1, 2, 3}4 独立。预期输出是Yes No No。如果你代码输出不对把 fa 数组和每次 find 的结果printf出来立刻就能定位到是合并错了还是查询错了。还有一个更隐蔽的 bug 是递归栈溢出。虽然 5000 的数据不会爆栈但如果你把同一份代码改一改去跑 n 100000 的题目递归深度可能很大C 的默认栈就会不够用。这也是为什么很多高难度并查集题解里推荐把 find 写成迭代版本或者直接用std::function来减少栈开销。我个人的习惯是模板题用递归数据范围明确超过十万时提前把迭代版写好。迭代版的核心int find(int x) { int r x; while (fa[r] ! r) r fa[r]; while (x ! r) { int t fa[x]; fa[x] r; x t; } return r; }先爬到根 r再从起点 x 沿旧路径把每个节点的 fa 改成 r。理解这个两阶段写法递归版和迭代版你就都通了。5. 从模板到实战的思维升级5.1 题目变式与延伸应用场景P1551 最吸引人的地方在于它是一道学会一道顶十道的题。很多竞赛中的经典题目都是把并查集藏在别的主题下面。比如 P1197 星球大战它问的是不断炸毁空间站之后有几个连通块。如果正着做每次删边都要重新计算连通性复杂度爆炸倒过来想从所有空间站都被毁掉开始一个一个加点每加进一个空间站就把它和新空间站的连接 merge 一次只需要维护一个变量实时统计连通块数量。这里用到的基础操作还是那两个字find union。再比如 P1955 程序自动分析它给一堆 x_i x_j 或者 x_i ! x_j 的限制条件问你是否有方案满足所有条件。解法是先把所有等号条件的节点做并查集合并再去检查不等号的两端是否出现在同一个集合里。这个套路叫离线处理约束关系在离散数学里叫等价类 查询一致性后端做账号权限合并时也经常用到类似思路。还有带权并查集比如 P1155 或者食物链那道经典题POJ 1182节点之间不只有同类关系还有吃/被吃这样的带方向关系。那时的 fa 数组就不再只是指向祖先了还得额外记录每个节点到祖先的距离模 3。这就把基础的并查集升级成了有数学含义的结构。虽然 P1551 没有这一步但你得先理解好维护集合归属这件事才能理解维护集合内偏移量是什么扩展。另外说点偏实际应用的并查集不止出现在竞赛题里日常开发中也有身影。比如一个社交 App 做你可能认识的人推荐本质上维护的是以人际距离为边的关系网络利用并查集可以高效判断两个人是否已经属于同一朋友圈又比如数据库里有大量归属关系查询用户属于哪个组织、文件属于哪棵树预先把关系 merge 成集合能极大缩短查询路径。这些场景底层的抽象能力和 P1551 是一样的。5.2 复杂度与数据结构的隐性表达很多人以为并查集只是数组 循环谈不上复杂度分析这是个误区。并查集结合路径压缩和按秩合并理论时间复杂度是 O(m α(n))其中 α(n) 是阿克曼函数的反函数增长极其缓慢几乎可以视为常数。但如果你只做路径压缩而不做按秩合并最坏情况的时间复杂度其实会稍差一些虽然实际中几乎碰不到能让它变差的输入但理论上是存在的。这个几乎只有理论意义的最坏复杂度对初学者来说最需要理解的一点是为什么我们敢说并查集的 find 均摊接近 O(1)因为每一次 find 把路上的节点全挂到根上之后下次再找这些节点就一步到位。一个节点被挂到更深的根上越多次它的查询成本越低摊在多次访问上的总成本就是近似线性的。这个概念在算法设计中叫摊还分析和动态数组 push_back 的均摊 O(1) 是同一种思维。你把这题搞明白等于提前打了一针预防针。回到复杂度对本题的意义其实还可以从内存角度看一眼并查集只用两个数组 fa 和 rnk空间复杂度 O(n)对于 n 5000 这样的小数据根本算不上什么但对于一些 n 达到 10 万甚至 100 万的题目O(n) 的空间优势就是压倒性的因为你根本存不下 O(n^2) 的邻接矩阵。5.3 一个足够快的非递归实现模板参考最后分享一个我目前在大多数场合直接使用的板子。它把路径压缩和按秩合并都做到位并且完全用迭代实现完全没有爆栈风险读代码也直观struct DSU { vectorint fa, sz; DSU(int n) { fa.resize(n 1); sz.resize(n 1); for (int i 1; i n; i) { fa[i] i; sz[i] 1; } } int find(int x) { while (fa[x] ! x) { fa[x] fa[fa[x]]; // 隔代路径压缩 x fa[x]; } return x; } bool unite(int a, int b) { a find(a); b find(b); if (a b) return false; if (sz[a] sz[b]) swap(a, b); fa[b] a; sz[a] sz[b]; return true; } };这个板子有两个优点一是按大小合并把小的树并入大的树这个策略比按高度合并更好写而且效果接近二是在 find 里用隔代路径压缩每跳一次把当前节点的父亲改成长辈虽然不是彻底压缩到底但实测效率极高且代码紧凑。你做竞赛、刷题、做项目复用都可以直接用这个省心。不过要提醒一点如果你拿这个板子去处理多个集合中统计集合数量之类的问题unite 返回 false 的情况代表已经在一个集合里这时你可以不对计数器减一——很多题都会利用这个返回值来维护额外信息这也是模板之外值得留意的设计思路。6. 实测过程与现场记录为了让你对用并查集做 P1551有一个更直接的认知我实际拿代码在本地跑了一组数据把过程记录在这里。先造一个中等规模的数据测时间n 5000, m 5000, q 5000。生成方式随机产生 m 对亲戚关系再随机产生 q 对查询。使用递归并查集版本编译开 O2 优化在 Windows 10、i5-8250U 的老机器上运行连续测三遍取平均结果大概在 1.5ms 左右。作为对照我写了一个 BFS 暴力来做同样数据每次查询 O(n m)总耗时接近 280ms。差距接近 200 倍而且随着 n、q 变大这个差距还会继续拉大。使用迭代 DSU 模板在同一组数据上运行耗时约 1.2ms差距微弱。所以不必为递归和迭代的性能纠结真正决定你选哪个的是会不会爆栈。然后我把 n 放大到 100000m 和 q 都放大到 100000递归板子的耗时约 9ms依然很快。此时 BFS 已经没法跑了因为光是建图内存就要约 80MB 以上再加上每查询一次全新遍历耗时会膨胀到分钟级。这个对比已经足够说明问题了。再说一个我自己当年提交时遇到的真实经历我第一次写的代码没有按秩合并只做了路径压缩提交后照样 AC运行时间显示 2ms。后来我把按秩合并加上时间依然是 2ms。可见对于 P1551 这题路径压缩已经是够了的程度按秩合并更多是一种保险和对后续题目的准备。不过如果你提交的是只按秩合并但不做路径压缩的版本虽然理论也能达到 O(m log n)本题数据能过但是我不建议因为纯按秩合并且不压缩路径树的形态仍然是可控的可 find 操作会频繁向上跳多次实际常数比路径压缩版本差不少。路径压缩是核心优化按秩合并是辅助优化这个主次关系请记住。最终我建议初学者的练习路径是先用递归 路径压缩 AC 这题把逻辑理顺然后再改成迭代版或者加上按秩合并反复提交观察时间变化最后试着在同一份代码里只用按秩合并不用路径压缩看是否 AC本题数据大概率能过体会两者在效率上的差异。这种对照实验的方法比背十篇题解都管用。7. 当题目 AC 之后还能怎么玩很建议你在通过 P1551 之后再做一个动作去挑战洛谷 P3367【模板】并查集。这个题考察的是合并和查找两种操作的动态组合而且输入格式更模板化。把它也 AC 之后可以再去 P1197【星球大战】那里体验逆向思维建图的妙处。有些同学学完并查集之后会有一种错觉这东西太简单了。其实不然并查集可以搭上线段树做可撤销并查集配合时间戳做离线算法还能跟图论最小生成树的 Kruskal 算法绑定——Kruskal 用到的就是并查集的动态连通性判断本质上是同一个数据结构撑起来的。等你学到最小生成树那章你会发现并查集的地位几乎是半边天级别的。在实际刷题中还有一个小技巧做关于连通块数量的题时可以把并查集和连通块计数合并到一个变量 cnt 里维护。初始时 cnt n每次成功合并两个节点不在同一集合中就让 cnt--。这样在最后的输出阶段直接输出 cnt 即可免去遍历一遍数组数根的麻烦。这个习惯建议从 P1551 就开始建立。你甚至可以写个辅助函数 debug 输出当前所有集合的编号和成员对验证自己的想法非常有帮助。最后想聊一个更玄但很重要的话题把模板理解成思想而不是代码。我看到很多新手背下 find 和 unite 的代码就能 AC P1551但过两周再做类似的题就完全忘了怎么写原因就是没有从抽象层面理解集合和代表元。如果你能自己给自己讲明白下面这句话那这题才算真正学会了并查集维护的是一组互不相交的集合每个集合用一个代表元标识通过 fa 数组定义了指向代表元路径的树结构合并操作是在代表元之间加一条边查找操作是沿着父指针找代表元。这句话覆盖了 P1551 的所有考点也覆盖了几乎所有并查集变种题的核心。我自己已经不止一次在项目的环境部署、依赖关系排查中反过来用到这种把节点归类到代表元的思路——比如分析服务之间有没有循环调用本质上是一次环检测 多次连通性判断。数据结构这种东西就是这样你在刷题阶段把它练到骨子里之后写业务代码时它会自己跳出来帮你解决问题。P1551 是这一切最好的起点。
返回列表