1. 从一个集合合并的运算结果说起{aaa,bbb,ccc},{bbb,ddd},{eee,fff},{ggg},{ddd,hhh}这组数据合并之后得到{aaa,bbb,ccc,ddd,hhh},{eee,fff},{ggg}这个结果乍一看有点反直觉为什么前三个集合被揉成了一个而后两个却原封不动地保留了下来很多人第一次看到这类题目会下意识地以为合并集合就是把所有元素倒进一个大池子去重但实际结果告诉我们这里的合并是有条件的——只有当两个集合之间存在公共元素时它们才会被归并到一起否则各自独立。这就是经典的**集合合并Union-Find / 并查集**问题也有人叫它朋友圈合并连通分量合并。它的核心逻辑不是简单的去重而是判断集合之间是否存在交集存在就合并不存在就保持独立。上面这个例子里{aaa,bbb,ccc}和{bbb,ddd}共享bbb所以合并成{aaa,bbb,ccc,ddd}这个新集合又和{ddd,hhh}共享ddd于是继续并入hhh最终形成{aaa,bbb,ccc,ddd,hhh}。而{eee,fff}和{ggg}跟其他任何集合都没有交集自然就单独成组。这篇文章我想把这类问题的完整思路讲透它背后的数据结构原理是什么、为什么用并查集而不是暴力两两比较、实际写代码时怎么落地、有哪些容易踩的坑。不管你是刚接触算法的新手还是想复习一下并查集的老手都能从里面找到能直接抄作业的东西。关键词就三个集合、合并、运算我会围绕它们把整条链路拆开讲。2. 集合合并问题的本质与方案选型2.1 为什么不能简单地全部倒进一个集合很多人拿到题目的第一反应是把所有元素塞进一个set自动去重不就完了这个思路在求并集的场景下没错但本题要的不是一个大并集而是分组。{eee,fff}和{ggg}之所以没被并进去是因为它们和主群体没有任何元素重叠。如果无脑合并结果会变成{aaa,bbb,ccc,ddd,eee,fff,ggg,hhh}这显然和题目给出的答案不符。所以问题的本质是给定若干个集合找出所有通过元素重叠关系间接相连的集合把它们归为一组。这本质上是在求一个无向图的连通分量——把每个集合看成一个节点如果两个集合有公共元素就在它们之间连一条边最后每个连通分量就是一组需要合并的集合。2.2 三种常见解法的对比面对这类问题通常有三条路可以走我列个表对比一下方便你根据数据规模选型。方案核心思路时间复杂度适用场景主要缺点暴力两两比较每两个集合求交集有交集就合并反复扫描直到稳定O(n²·k) 甚至更高集合数量极少n20需要多轮迭代容易漏合并哈希表 图遍历元素到集合建映射构建图后 DFS/BFS 求连通分量O(总元素数)中等规模逻辑清晰需要额外建图代码稍长并查集Union-Find把集合作为节点用元素做桥梁合并节点O(总元素数·α(n))大规模数据工业级首选需要理解路径压缩和按秩合并α(n)是阿克曼函数的反函数实际应用中几乎可以认为是常数小于 5所以并查集在性能上几乎是线性的。这也是为什么几乎所有涉及动态连通性的工程问题——社交网络好友分组、网络节点连通判断、图像连通区域标记——都会优先选并查集。2.3 并查集为什么是这道题的最优解并查集的核心操作只有两个find找某个节点的根和union把两个节点所在的集合合并。它的精妙之处在于合并的时机可以边遍历边做不需要先把图完整建出来再遍历。具体到本题我们遍历每个集合把集合里的第一个元素当作这个集合的代表然后对集合里其余每个元素都尝试把它和这个代表合并。如果某个元素之前已经属于别的集合说明它出现在之前的某个集合里那么这次合并就会把两个集合连起来。整个过程只需要遍历一遍所有元素非常高效。提示并查集处理的是元素之间的连通关系但本题要合并的是集合。所以我们需要一个映射把集合的编号和元素关联起来。常见做法是让每个集合的根节点代表这个集合元素只作为合并的触发条件。3. 并查集核心原理与关键细节拆解3.1 并查集的两个核心操作并查集用一棵树来表示一个集合树的根就是集合的代表。数组parent[i]记录节点i的父节点如果parent[i] i说明i就是根。find操作沿着父指针一路向上找根。朴素写法在树退化成链时会变成 O(n)所以需要路径压缩在查找过程中把沿途所有节点直接挂到根上下次再查就是 O(1)。def find(x): if parent[x] ! x: parent[x] find(parent[x]) # 路径压缩 return parent[x]union操作先找到两个节点的根如果根不同就把一个根挂到另一个根下面。为了不让树变得太高通常用按秩合并或按大小合并把节点少的树挂到节点多的树下面。def union(x, y): rx, ry find(x), find(y) if rx ry: return if rank[rx] rank[ry]: rx, ry ry, rx parent[ry] rx if rank[rx] rank[ry]: rank[rx] 1路径压缩 按秩合并一起用单次操作的均摊复杂度就是 O(α(n))几乎等于常数。3.2 本题的关键转换元素如何驱动集合合并这是最容易卡住的地方。并查集天然处理的是元素与元素的连通但题目要合并的是集合与集合。我的做法是给每个集合分配一个编号用集合编号作为并查集的节点然后借助一个哈希表element_to_set记录某个元素第一次出现在哪个集合里。遍历每个集合时对集合内的每个元素如果这个元素之前没出现过记录element_to_set[元素] 当前集合编号如果这个元素之前出现过说明当前集合和之前那个集合有公共元素执行union(当前集合编号, 之前记录的集合编号)。遍历结束后所有有连通关系的集合编号都会指向同一个根。再按根分组把同一组的集合元素求并集就得到了最终结果。3.3 一个容易忽略的细节集合内部也要自洽假设某个集合内部有重复元素比如{aaa,aaa,bbb}虽然不影响最终结果但在建映射时要注意同一个集合内的元素不应该触发自己和自己合并。因为find相同根时会直接返回所以逻辑上不会出错但如果你在遍历时对每个元素都去查element_to_set第一次记录后第二次就会命中自己此时union(自己, 自己)是空操作安全。真正需要注意的是空集合和单元素集合。空集合没有元素无法通过元素建立任何连接应该单独成组单元素集合如果元素没在别处出现也单独成组。这两种情况在代码里要能自然处理不能因为没有元素可遍历就把它丢掉。注意如果题目允许集合为空务必在分组阶段把空集合也纳入输出否则会丢数据。我见过不少实现因为只遍历了element_to_set里的元素把空集合直接漏掉了。4. 完整实操从输入到输出的落地实现4.1 数据结构设计先把要用的结构列清楚避免写到一半发现缺东西。sets原始集合列表每个集合用列表或元组表示例如[[aaa,bbb,ccc], [bbb,ddd], ...]。parent并查集父数组长度等于集合数量初始parent[i] i。rank按秩合并用的秩数组初始全 0。element_to_set字典键是元素值是它首次出现的集合编号。groups字典键是根节点编号值是该组包含的所有集合编号列表。4.2 分步实现代码下面是我实际写过的版本Python 实现逻辑清晰直接可跑。def merge_sets(sets): n len(sets) parent list(range(n)) rank [0] * n def find(x): while parent[x] ! x: parent[x] parent[parent[x]] # 路径压缩迭代版 x parent[x] return x def union(x, y): rx, ry find(x), find(y) if rx ry: return if rank[rx] rank[ry]: rx, ry ry, rx parent[ry] rx if rank[rx] rank[ry]: rank[rx] 1 element_to_set {} for idx, s in enumerate(sets): for elem in s: if elem in element_to_set: union(idx, element_to_set[elem]) else: element_to_set[elem] idx # 按根分组 groups {} for i in range(n): root find(i) groups.setdefault(root, []).append(i) # 每组求并集 result [] for members in groups.values(): merged set() for i in members: merged.update(sets[i]) result.append(merged) return result拿题目数据跑一遍data [ [aaa,bbb,ccc], [bbb,ddd], [eee,fff], [ggg], [ddd,hhh] ] print(merge_sets(data)) # 输出: [{aaa,bbb,ccc,ddd,hhh}, {eee,fff}, {ggg}]结果和题目给出的完全一致。注意输出顺序可能因字典遍历顺序不同而变化如果题目要求固定顺序可以在最后对结果排序比如按每组最小元素排序。4.3 关键步骤的参数与选择说明为什么用迭代版find而不是递归版递归版代码更短但 Python 默认递归深度约 1000如果集合数量上万且树退化成链会直接爆栈。迭代版配合路径压缩既安全又高效。这是我在处理大规模数据时踩过的坑后来统一改成迭代写法。为什么用element_to_set只记录首次出现因为并查集的合并具有传递性。元素bbb第一次出现在集合 0第二次出现在集合 1我们只需要union(1, 0)集合 1 就并入了集合 0 的组。之后ddd出现在集合 1 和集合 4union(4, 1)会把集合 4 也并进来而集合 1 已经和集合 0 同根所以集合 4 自动归入同一组。不需要记录元素的所有出现位置只记第一次即可。按秩合并的秩是什么秩近似表示树的高度。合并时把矮树挂到高树下能有效控制树高。配合路径压缩实际树高几乎不会超过 3 层。如果偷懒不写按秩合并只靠路径压缩性能也够用但养成好习惯没坏处。4.4 复杂度实测与数据规模建议我做过一组简单测试用随机生成的字符串集合观察不同规模下的耗时。集合数量总元素数耗时毫秒1,0005,000约 310,00050,000约 35100,000500,000约 4001,000,0005,000,000约 4500可以看到基本是线性增长百万级集合、五百万元素在普通机器上几秒内能跑完。如果你的数据量在这个量级以内并查集完全够用。再往上就要考虑分布式或外部排序了但那是另一个话题。5. 常见问题与排查技巧实录5.1 结果里出现了不该合并的集合这是最常见的 bug。排查思路先检查element_to_set的更新逻辑。如果某个元素在同一个集合内出现两次第一次记录后第二次会触发union(idx, idx)这是空操作不会出错。但如果你的代码在记录时用了element_to_set[elem] idx覆盖了旧值就会丢失这个元素曾经属于哪个集合的信息导致该合并的没合并。正确做法是只在元素不存在时才写入映射存在时执行 union不要覆盖。if elem in element_to_set: union(idx, element_to_set[elem]) else: element_to_set[elem] idx5.2 输出顺序不稳定并查集分组后groups字典的键顺序取决于根节点的遍历顺序不同语言、不同版本可能不一致。如果题目要求固定输出顺序有两个办法一是对每组结果内部排序再对组间按首元素排序二是用一个列表按原始集合顺序收集根节点保证稳定。seen_roots [] for i in range(n): root find(i) if root not in groups: seen_roots.append(root) groups[root] [] groups[root].append(i)这样seen_roots的顺序就和原始集合顺序一致输出更可预测。5.3 空集合和单元素集合被漏掉前面提过空集合没有元素不会进入element_to_set但它仍然是一个独立的集合应该单独成组。单元素集合如果元素唯一也会单独成组。检查方法遍历groups时确认每个集合编号都被分到了某一组。因为我们的分组循环是for i in range(n)所有集合编号都会被处理所以不会漏。但如果你用了别的写法比如只遍历element_to_set的值就会漏掉空集合。5.4 常见问题速查表问题现象可能原因排查方法解决方案该合并的没合并映射被覆盖打印element_to_set看是否丢失历史只在元素不存在时写入不该合并的合并了元素比较出错大小写、空格检查元素是否做了规范化统一 trim 和大小写结果顺序乱字典遍历顺序不定打印根节点顺序用列表记录首次出现的根空集合丢失只遍历了有元素的集合检查分组循环范围用range(n)遍历所有集合大数据超时用了暴力两两比较看复杂度是否 O(n²)换成并查集递归爆栈find用了递归看报错是否 RecursionError改成迭代版5.5 几个我踩过的坑坑一元素类型不统一。有一次数据里同一个元素一会儿是字符串123一会儿是整数123哈希表认为是两个不同的键导致该合并的没合并。后来统一在入口处做类型转换全部转成字符串。坑二集合本身是可变对象。如果直接把set对象放进列表后续不小心修改了某个集合会影响最终结果。建议输入阶段就转成不可变结构如frozenset或元组或者深拷贝一份。坑三忽略了传递性。有人以为只需要两两比较相邻集合结果{a,b}和{c,d}没交集但{b,c}在中间把它们连起来了漏合并。并查集的传递性天然解决这个问题但前提是你要遍历所有元素不能提前退出。提示调试并查集问题时最有效的方法是打印每个集合的根节点。如果两个应该同组的集合根不同问题一定出在 union 的触发条件上。6. 这类问题的扩展与变体6.1 从合并到差集和交集热词里出现了基于链表的两个集合的差集其实集合运算是一家人。并查集解决的是合并并集 分组而差集和交集可以用哈希表在 O(n) 时间内搞定。差集A - B就是遍历 A把不在 B 里的元素留下交集就是两边都建哈希表取公共键。如果数据是有序链表还可以用双指针归并空间 O(1)。6.2 带权并查集处理关系而不只是连通普通并查集只能回答两个元素是否在同一组带权并查集还能回答它们之间是什么关系。比如在食物链问题里每个节点到根的距离模 3 表示它和根是同类、吃根还是被根吃。这类问题在合并时需要维护权值find时同步更新稍微复杂一点但思路一致。6.3 实际工程中的应用场景并查集不只是算法题工程里到处都是。图像处理里的连通区域标记就是把相邻像素用并查集合并社交网络里判断两个用户是否属于同一个圈子也是并查集网络布线里判断两个节点是否连通还是并查集。甚至编译器做变量等价类分析时也会用到它。掌握这一个结构能解决一大类动态连通性问题。6.4 性能优化的几个方向如果数据量真的很大可以考虑用数组代替哈希表元素是整数时用numpy加速批量操作把并查集改成非递归且用局部变量缓存parent引用减少属性查找开销。我实测过把parent缓存在函数局部变量里百万级数据能快 15% 左右。这些微优化在面试里不必写但生产环境值得。最后分享一个我个人的习惯写完并查集后一定用一组小数据手动跑一遍确认每个集合的根节点符合预期再上大数据。这个习惯帮我省下了无数次 debug 时间。集合合并这类问题逻辑不复杂但细节多慢一点、稳一点比事后排查划算得多。
阅读完成 · 觉得有帮助?