ARTICLE DETAIL

资讯详情

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

并查集算法精讲:从合根植物到动态连通性实战

并查集算法精讲:从合根植物到动态连通性实战 1. 问题引入从“合根植物”到并查集最近在整理算法笔记时又翻到了洛谷上这道经典的“合根植物”题目。题目本身描述的是一个有趣的生物场景在一个温室里植物通过根茎相连如果两株植物的根在地下相连它们就被视为同一株“合根植物”。现在给你一些已知的连接关系问你温室里总共有多少株独立的合根植物。初看这描述是不是觉得有点像在玩一个连连看的游戏给你一堆点植物和一些连接线根茎相连让你数一数最后形成了几个独立的连通块。对你的直觉没错这本质上就是一个图的连通分量计数问题。而在算法竞赛和实际工程中解决这类问题有一个“神器”级别的数据结构——并查集。这道题之所以经典是因为它完美地诠释了并查集的核心应用场景动态维护元素的分组集合关系并高效地查询两个元素是否属于同一组以及合并不同的组。很多朋友在初次学习并查集时会觉得概念有点抽象但通过“合根植物”这样具象化的生活例子就能瞬间理解它的用武之地。今天我们就来彻底拆解这道题不仅给出AC代码更重要的是把并查集从原理到优化再到实际编码中的各种“坑”一次讲透。无论你是正在备战蓝桥杯的新手还是想巩固基础的老手相信这篇详尽的拆解都能让你有所收获。2. 并查集核心原理如何管理“家族关系”在深入代码之前我们必须先搞清楚并查集到底在干什么。你可以把它想象成一个管理“家族”或“帮派”的系统。最初江湖上每个人都是自成一派自己是自己的老大。后来一些人决定合并形成一个更大的帮派。系统需要支持两种核心操作1. 快速查找某个人属于哪个帮派Find2. 将两个帮派合并成一个Union。并查集就是为了高效完成这两件事而生的。2.1 数据结构设计父节点数组并查集最常用的实现是使用一个数组parent[]。parent[i]存储的是元素i的“父节点”或“直接上级”。如果一个元素i的parent[i] i那恭喜它它就是自己这个集合的“根节点”或“帮派老大”。所有属于同一个集合的元素最终通过不断向上查找父节点都会找到同一个根节点。初始状态有n个元素我们初始化parent[0] 0, parent[1] 1, ..., parent[n-1] n-1。每个人都是自己的老大形成了n个独立的单元素集合。对应到“合根植物”题目就是最初有m*n株独立的植物。查找操作要查找元素x属于哪个集合就是沿着parent数组一路向上找直到找到那个parent[root] root的根节点root。这个root就是x所在集合的唯一标识。合并操作给定两个元素x和y我们想合并它们所在的集合。步骤是1. 分别找到x和y的根节点rootX和rootY。2. 如果rootX rootY说明它们本来就在一个集合里无需操作。3. 否则将其中一个根节点的父指针指向另一个根节点比如parent[rootX] rootY。这样两个集合就合并了所有元素现在都指向同一个新根。这个设计看似简单但有一个致命问题如果合并时总是随意连接可能会形成一条很长的“链”。在链上执行查找操作最坏情况下需要遍历整个链时间复杂度退化为O(n)对于大量操作来说是不可接受的。2.2 关键优化路径压缩与按秩合并为了解决退化问题并查集有两个经典的优化策略它们能保证单次操作的平均时间复杂度接近常数级。路径压缩这是在查找过程中进行的优化。当我们为了找到元素x的根节点而遍历整条路径时路径上的所有节点最终的目的地都是那个根节点。那么何不“顺便”把这条路径“压扁”呢具体做法是在查找函数中不仅返回根节点而且在递归返回的过程中将沿途每个节点的父节点直接设置为根节点。这样下次再查找这些节点时就能一步到位。代码实现通常有递归和迭代两种方式递归写法简洁但迭代写法在极端深度下可能更安全。按秩合并这是在合并过程中进行的优化。“秩”可以粗略理解为树的高度或集合的大小。合并时我们总是将“秩”较小的树的根接到“秩”较大的树的根下面。这样可以有效避免树的不平衡增长防止树高过高。通常我们会用一个额外的数组rank[]来记录每个根节点的秩。合并时比较rank[rootX]和rank[rootY]将低秩树合并到高秩树下。如果两者秩相等则任意合并并将新根的秩加1。注意路径压缩和按秩合并是正交的优化可以同时使用。路径压缩会改变树的高度因此有时按秩合并中的“秩”就不再是准确的高度而是一个估计值或上界但这并不影响算法的正确性和高效性。在竞赛中为了编码简便有时只使用路径压缩就足够了。理解了这些原理我们再回头看“合根植物”问题。输入中每一行给出的(a, b)就代表植物a和植物b的根相连这对应了一次Union(a, b)操作。当所有连接关系都处理完毕后我们只需要遍历所有植物统计有多少个不同的根节点即parent[i] i的i的个数这个数量就是独立合根植物的数量。3. “合根植物”问题完整题解与代码实现现在我们结合题目要求给出完整的C解决方案。题目关键点温室是一个m行n列的矩阵植物按格子编号1, 2, ..., m*n。给出k对连接关系。我们需要输出最终独立集合的个数。3.1 代码框架与初始化首先我们需要确定并查集的大小。总植物数量是m * n但为了方便我们通常将数组大小声明为m*n 1让下标从1开始使用符合题目编号习惯。#include iostream #include vector using namespace std; class UnionFind { private: vectorint parent; vectorint rank; // 用于按秩合并 int count; // 连通分量计数 public: // 构造函数初始化n个元素 UnionFind(int n) : count(n) { parent.resize(n 1); rank.resize(n 1, 0); // 初始秩为0 for (int i 1; i n; i) { parent[i] i; // 每个元素自成一派 } } // 查找操作带路径压缩 int find(int x) { // 迭代式路径压缩 while (parent[x] ! x) { parent[x] parent[parent[x]]; // 路径压缩将x提到其祖父节点 x parent[x]; } return x; // 递归式路径压缩写法更简洁 // if (parent[x] ! x) { // parent[x] find(parent[x]); // 递归查找并压缩 // } // return parent[x]; } // 合并操作带按秩合并 void unite(int x, int y) { int rootX find(x); int rootY find(y); if (rootX rootY) return; // 已在同一集合无需合并 // 按秩合并将低秩树合并到高秩树下 if (rank[rootX] rank[rootY]) { parent[rootX] rootY; } else if (rank[rootX] rank[rootY]) { parent[rootY] rootX; } else { // 秩相等任意合并并将新根的秩加1 parent[rootY] rootX; rank[rootX]; } count--; // 每成功合并一次连通分量减少一个 } // 查询是否连通 bool isConnected(int x, int y) { return find(x) find(y); } // 获取当前连通分量数量 int getCount() const { return count; } }; int main() { int m, n, k; cin m n k; int totalPlants m * n; UnionFind uf(totalPlants); for (int i 0; i k; i) { int a, b; cin a b; uf.unite(a, b); // 处理每一对连接关系 } // 最终剩余的连通分量数就是答案 cout uf.getCount() endl; return 0; }3.2 代码逐行解析与避坑指南类封装将并查集封装成UnionFind类是一个好习惯提高了代码的复用性和可读性。私有成员parent,rank,count分别管理父节点、秩和连通分量计数。初始化在构造函数中我们分配了n1的空间因为植物编号从1开始。count初始化为n代表最初有n个独立的集合。find函数这里提供了迭代和递归两种路径压缩的注释。迭代写法通过parent[x] parent[parent[x]]这个操作在查找过程中将节点向上提升一层虽然不是一次压缩到根但多次操作后也能达到近乎常数的效果且避免了递归深度问题。递归写法parent[x] find(parent[x])则是一次性压缩到根非常简洁是竞赛中的常见写法。unite函数这是合并的核心。先找到两个元素的根如果相同则直接返回。否则根据rank数组决定合并方向。关键点合并后一定要记得将连通分量计数count减1。这样我们就不需要在最后再去遍历统计根节点数量了getCount()直接返回结果效率更高。主函数逻辑主函数逻辑非常清晰读入m, n, k初始化并查集循环读入k对关系并执行合并最后输出计数。这里有一个易错点题目中植物的编号是连续的1到m*n所以UnionFind初始化的大小必须是m*n而不是m或n。输入输出本题的输入量不大使用标准的cin/cout即可。如果遇到数据量更大的类似题目可能需要考虑使用更快的输入输出方式例如scanf/printf或关闭cin/cout的同步流。4. 并查集在算法竞赛中的典型变种与应用场景掌握了基础并查集我们来看看它的几种常见变体这些变体极大地扩展了并查集解决问题的能力。4.1 带权并查集维护节点间的关系在基础并查集中我们只关心“是否连通”。但有些问题需要知道连通分量内元素间的某种“关系”比如距离、差值、相对类别等。这时就需要带权并查集。每个节点不仅记录父节点还记录一个到父节点的“权值”。在查找和合并时需要同时维护权值的正确性。经典例题“食物链”、“奇偶游戏”。以“食物链”为例动物之间有A吃BB吃CC吃A的循环关系。我们可以用权值0、1、2分别表示“同类”、“被父节点吃”、“吃父节点”三种关系。find时路径压缩需要递归计算节点到新根节点的关系权值。unite时根据题目给出的关系推导出两个根节点之间应有的权值关系并进行连接。这需要一些数学推导是并查集问题的难点之一。4.2 可撤销并查集支持“时光倒流”普通并查集的合并操作是不可逆的。但有些问题如某些离线查询、线段树分治题目需要支持“回退”到之前的某个状态。这就需要可撤销并查集。其核心思想是不进行路径压缩因为压缩会破坏结构难以回退只使用按秩合并。同时用一个栈记录每一次合并操作的具体信息合并了哪两个根秩是否增加等。当需要撤销时就从栈顶弹出操作信息恢复原来的父指针和秩。因为没有了路径压缩单次操作时间复杂度是O(log n)。4.3 并查集在图论中的其他应用最小生成树Kruskal算法的核心就是并查集。它将所有边按权值排序然后从小到大遍历利用并查集判断边的两个端点是否已经连通如果不连通则加入这条边并合并两个集合。这个过程高效地避免了环的形成。离线LCA在一些离线处理最近公共祖先的问题中可以利用并查集配合深度优先搜索来高效求解。动态连通性这是并查集最本质的应用。判断一个无向图是否连通、计算连通分量个数、判断两个点是否在同一个连通分量中都可以用并查集在近乎常数时间内完成比深度优先搜索DFS或广度优先搜索BFS更适合动态加边的场景。5. 从“合根植物”出发的举一反三与实战训练“合根植物”是一个标准的并查集模板题。为了真正掌握我建议进行以下拓展练习它们都在洛谷、力扣等平台有对应题目。第一步维度扩展“合根植物”是二维网格上的连通性。可以尝试三维空间中的连通块问题例如“三维地牢”中计算空气连通区域。原理完全一样只是将二维坐标(x, y)映射到一维IDid x * (列数) y的公式扩展到三维id x * (列数*层数) y * (层数) z。关键在于正确且唯一地建立坐标到ID的映射。第二步条件扩展原题连接是直接给出的。可以尝试连接带有条件的情况例如“朋友圈”问题如果A和B是朋友B和C是朋友则A和C也是朋友传递性。这依然是标准的并查集。再比如有些连接可能无效如连接后超过某个阈值需要在合并前进行判断。第三步反向思维“合根植物”是正向添加连接。可以尝试“破坏连接”的问题例如“打砖块”游戏有一个由砖块组成的网格敲掉某些砖块后问有多少砖块会因失去连接而掉落这类问题往往需要逆向思考从最终状态所有该敲的砖块都已敲掉出发逆序“添加”砖块相当于恢复连接并用并查集维护连通性同时统计每次添加后新连通的砖块数。这是并查集处理动态删边问题的常用技巧。第四步结合其他算法并查集很少单独作为压轴题它常常作为工具嵌入到更复杂的算法中。例如在“除法求值”问题中需要处理变量间的除法关系这可以转化为带权并查集权值表示与根节点的比值。在“模拟网络延迟”或“水位上升的泳池”问题中需要找到满足条件的最小/最大边界值这可以结合二分答案和并查集二分一个阈值将满足条件的所有边连接起来用并查集判断起点和终点是否连通。个人心得学习并查集切忌只背模板。一定要亲手推导一次路径压缩和按秩合并时权值如果是带权的更新公式。遇到新题先抽象出“元素”和“关系”判断是否属于动态维护连通性的问题。如果是再思考关系是简单的“同组”还是复杂的“相对关系”决定用普通还是带权。最后考虑操作是否可逆决定是否需要可撤销。按照这个思路大部分并查集问题都能迎刃而解。
返回列表