1. 专题训练:从“Aproblem”到掌握并查集
最近在整理算法笔记,翻到了以前做专题训练时标记为“Aproblem”的一系列题目。这个标记通常意味着,这类题目是某个知识点的典型应用,或者是我在初次接触时觉得“有点东西”的难题。而“并查集”,绝对是这个列表里的常客。它不像动态规划那样变化多端,也不像图论算法那样直观复杂,但它在解决“连通性”和“分组”问题上,有着近乎“作弊”般的简洁与高效。很多看似需要复杂模拟或者深度搜索的问题,用上并查集,代码量能直接砍半,运行效率更是飙升。今天,我就想从一个老鸟的角度,拆解一下这个专题,聊聊并查集到底怎么学、怎么用,以及如何避开那些新手时期最容易踩的坑。
并查集,英文叫 Union-Find 或 Disjoint Set Union (DSU)。它的核心功能就两个:合并(Union)和查找(Find)。给你一堆元素,你可以把其中任意两个元素所在的集合合并成一个;你也可以快速查询任意两个元素是否属于同一个集合。听起来很简单,对吧?但它的威力恰恰就藏在这份简单里。从社交网络的好友关系(判断两个人是否间接认识),到迷宫生成与求解,再到编译器中的变量等价类分析,甚至是一些在线游戏中的队伍系统,底层都可能用到它。对于算法竞赛和面试刷题而言,它更是解决“连通块计数”、“最小生成树(Kruskal算法)”、“最近公共祖先(某些变种)”等问题的基石。掌握它,是迈向高阶算法学习的必经之路。
2. 并查集的核心思想与数据结构设计
2.1 如何用数组表示“森林”
并查集最经典、最常用的实现方式是使用一个一维数组。这个数组的下标代表每一个元素(通常编号从0或1开始),而数组里存储的值,代表这个元素的“父节点”。如果某个元素的值等于它自己的下标,那它就是它所在集合的“根”(Root)或“代表元”。
举个例子,假设我们有6个元素:0, 1, 2, 3, 4, 5。初始化时,每个元素自成一家,所以父节点就是自己。
parent[] = [0, 1, 2, 3, 4, 5]这表示有6棵独立的“树”,每棵树只有一个节点,自己就是根。
现在,如果我们想把元素1和元素2合并。一种常见的做法是,把其中一个集合的根节点,挂到另一个集合的根节点下面。比如,我们把元素2的根(现在是2)的父节点,设置为元素1的根(现在是1)。那么数组就变成了:
parent[] = [0, 1, 1, 3, 4, 5] // parent[2] = 1此时,元素1和元素2就在同一个集合里了,这个集合的根是1。
如果再合并元素2和元素3呢?注意,我们不是直接设置parent[3] = 2,而是要先找到元素2和元素3各自的根。元素2的根是1,元素3的根是3。然后我们把根3挂到根1下面:
parent[] = [0, 1, 1, 1, 4, 5] // parent[3] = 1这样,元素1、2、3就在同一个集合里了,根是1。整个结构就像一片森林,每棵树代表一个集合,树根就是集合的代表。
注意:这里的选择(把谁挂到谁下面)看似随意,但会影响树的形状,进而影响效率。我们后面会讲“按秩合并”来优化。
2.2 “查找”与“合并”的朴素实现
基于上面的数组,我们可以写出最基础的find和union操作。
查找(Find):给定一个元素x,找到它所在集合的根。方法就是沿着父节点指针一直向上走,直到找到那个父节点是自己的节点。
def find(x, parent): while parent[x] != x: # 如果不是根,就继续向上找 x = parent[x] return x对于上面的例子,find(3, parent)的路径是:3 -> 1(因为parent[3]=1),然后发现parent[1]=1,所以根是1。
合并(Union):给定两个元素x和y,将它们所在的集合合并。
- 分别找到
x和y的根:rootX = find(x),rootY = find(y)。 - 如果
rootX == rootY,说明它们本来就在一个集合,无需操作。 - 否则,将其中一个根的父亲设置为另一个根:
parent[rootY] = rootX(或parent[rootX] = rootY)。
def union(x, y, parent): rootX = find(x, parent) rootY = find(y, parent) if rootX != rootY: parent[rootY] = rootX # 将rootY挂到rootX下这就是并查集最核心的逻辑。但是,这个朴素版本有很大的效率问题。考虑一种最坏情况:我们依次合并(0,1),(0,2),(0,3), ...,最终会形成一条长长的链。此时执行find(n)操作,需要遍历整条链,时间复杂度退化为 O(n)。对于大量操作,这是不可接受的。
2.3 路径压缩:让查找接近O(1)
路径压缩是并查集第一个,也是最重要的优化。它的思想非常巧妙:既然find操作的目的是找到根,那么在查找的过程中,顺带把沿途所有节点的父节点都直接指向根。这样,下次再查找这些节点时,就能一步到位。
通常我们用递归的方式实现,代码简洁得惊人:
def find(x, parent): if parent[x] != x: parent[x] = find(parent[x], parent) # 递归查找根,并赋值 return parent[x]这个过程可以这样理解:find(3)发现parent[3]=1,它不是根,于是去问find(1)。find(1)发现parent[1]=1,它是根,于是返回1。在返回的过程中,find(3)拿到了根1,然后它做了一件事:parent[3] = 1。这样,节点3的父指针就从原来的1(它的直接父亲)直接指向了根1。如果之前是一条长链3->2->1,那么一次find(3)之后,就变成了3->1和2->1(假设也递归压缩了)。经过多次操作后,整棵树会变得非常扁平,几乎所有节点都直接挂在根节点下,find操作的平均时间复杂度接近常数级 O(α(n)),其中 α(n) 是增长极慢的反阿克曼函数,在实际应用中可视为常数。
实操心得:路径压缩的递归写法虽然优雅,但在极端深度(或某些编程语言的递归栈限制)下可能有栈溢出风险。非递归写法更安全,思路是:先循环找到根,再循环一次将路径上所有节点的父节点设为根。我通常优先使用递归,因为代码清晰,在算法题的数据规模下完全够用。如果担心栈深度,可以换用非递归。
2.4 按秩合并:维持树的平衡
路径压缩主要优化了“查”,而“并”的操作也有优化空间。在合并两棵树时,如果总是随意地将一棵树挂到另一棵树下,可能会意外地产生一棵很深的树。虽然后续的find会压缩它,但那个“意外”的find操作本身可能会比较耗时。
“按秩合并”就是为了避免这种情况。我们引入一个额外的数组rank(或size),用来记录以每个节点为根的树的深度(或大小)的一个上界。合并时,总是将秩较小的树挂到秩较大的树下。这样,可以保证合并后的树深度增长较慢。如果两棵树秩相等,则任意合并,但需要将新根的秩加1(因为深度增加了)。
def union(x, y, parent, rank): rootX = find(x, parent) rootY = find(y, parent) if rootX == rootY: return # 按秩合并 if rank[rootX] < rank[rootY]: parent[rootX] = rootY elif rank[rootX] > rank[rootY]: parent[rootY] = rootX else: # 秩相等,任意合并,这里将rootY挂到rootX下 parent[rootY] = rootX rank[rootX] += 1 # 合并后深度增加了这里的“秩”并不严格等于树的深度(因为路径压缩会改变深度),但它是一个有效的启发式值,能很好地指导合并顺序。路径压缩和按秩合并一起使用,可以将并查集单次操作的平均时间复杂度优化到近乎常数,这是它高效的关键。
注意事项:在同时使用路径压缩和按秩合并时,
rank数组的含义更接近于“秩的一个上界”,而不是精确的深度。因此,在路径压缩后,我们通常不会去更新其他节点的rank值。这个“不更新”是正确的,不会影响算法的正确性和渐进复杂度。这是很多初学者容易困惑的地方,记住一点:rank主要用在union时做决策,find操作不用管它。
3. 并查集的经典应用场景与解题模板
3.1 连通性问题与岛屿数量
这是并查集最直白的应用。LeetCode 上的“岛屿数量”(Number of Islands)问题,虽然通常用DFS/BFS解决,但用并查集也别有一番风味。题目给定一个二维网格,'1'代表陆地,'0'代表水,计算岛屿的数量(相连的陆地视为一个岛屿)。
思路:
- 初始化并查集,大小为网格中单元格的总数。每个
'1'的单元格初始时都是一个独立的“岛屿”。 - 遍历整个网格。对于每个
'1'的单元格,查看其右侧和下方的邻居(避免重复连接)。 - 如果邻居也是
'1',则将当前单元格与邻居单元格在并查集中进行union操作。 - 遍历结束后,统计有多少个
'1'的单元格,其父节点仍然是它自己(即根节点),这个数量就是岛屿的数量。
这里的关键技巧是二维坐标到一维索引的映射:index = i * n + j,其中n是列数。这样就能用一维的并查集来处理二维的连通关系。
class UnionFind: def __init__(self, n): self.parent = list(range(n)) self.count = n # 初始集合数,这里指‘1’的个数,后续会动态减 def find(self, x): if self.parent[x] != x: self.parent[x] = self.find(self.parent[x]) return self.parent[x] def union(self, x, y): rootX = self.find(x) rootY = self.find(y) if rootX != rootY: self.parent[rootY] = rootX self.count -= 1 # 每成功合并一次,集合(岛屿)数减1 def numIslands(grid): if not grid: return 0 rows, cols = len(grid), len(grid[0]) uf = UnionFind(rows * cols) # 首先,统计所有‘1’的位置,并初始化“虚拟”的集合 # 更常见的做法是:只对‘1’进行合并,最后统计根节点数。 # 这里采用另一种思路:初始化时count为0,遇到‘1’先加1,合并时减1。 # 但为了清晰,我们采用最后统计根节点的方法。 # 初始化:将所有‘1’视为独立集合,但并查集大小仍是rows*cols,‘0’也占位置。 # 优化:只处理‘1’,‘0’不参与。我们修改一下UnionFind,让它支持动态处理。 # 实际上,更简单的模板是: dummy_node = rows * cols # 一个虚拟节点,代表“水” uf = UnionFind(rows * cols + 1) # 多一个位置给虚拟节点 for i in range(rows): for j in range(cols): if grid[i][j] == '1': # 与右方、下方合并 if i + 1 < rows and grid[i+1][j] == '1': uf.union(i*cols + j, (i+1)*cols + j) if j + 1 < cols and grid[i][j+1] == '1': uf.union(i*cols + j, i*cols + (j+1)) else: # 如果是水,就把它和虚拟节点合并 uf.union(i*cols + j, dummy_node) # 统计岛屿数量:所有‘1’的格子中,根节点不是虚拟节点的唯一根的数量 root_set = set() for i in range(rows): for j in range(cols): if grid[i][j] == '1': root = uf.find(i*cols + j) if root != uf.find(dummy_node): # 根不是水 root_set.add(root) return len(root_set)这个例子展示了并查集在网格连通性问题上的应用。相比DFS/BFS,并查集的代码逻辑更集中(主要就是union),并且可以动态处理网格变化(比如后续有单元格从'0'变成'1')而无需重新全局搜索。
3.2 关系传递与等式方程的可满足性
LeetCode 990 “等式方程的可满足性” 是并查集处理关系传递性的经典题。给定一个字符串数组equations,包含"a==b"或"a!=b"的等式/不等式,判断所有方程是否可能同时成立。
思路:
- 遍历所有等式(
==),将等式两边的变量进行union操作。这相当于声明,这些变量是相等的,属于同一个集合。 - 遍历所有不等式(
!=),检查不等式两边的变量。如果它们属于同一个集合(即find(a) == find(b)),那就矛盾了,因为等式告诉我们它们相等,而不等式要求它们不等。如果发现矛盾,返回false。 - 如果所有不等式检查都通过,返回
true。
这里,并查集完美地维护了“等价关系”的传递性。a==b和b==c能推导出a==c,这个推导过程由并查集的union和find自动完成。
class UnionFind: # ... 同上,实现find和union ... def equationsPossible(equations): uf = UnionFind(26) # 26个小写字母 # 第一遍,处理所有等式 for eq in equations: if eq[1] == '=': x = ord(eq[0]) - ord('a') y = ord(eq[3]) - ord('a') uf.union(x, y) # 第二遍,检查所有不等式 for eq in equations: if eq[1] == '!': x = ord(eq[0]) - ord('a') y = ord(eq[3]) - ord('a') if uf.find(x) == uf.find(y): return False return True这个模板非常清晰:先union所有确定的关系,再用find检查冲突。它适用于所有需要维护元素分组,并后续进行冲突检测的场景。
3.3 带权并查集:维护相对关系
前面两个例子中,并查集只维护了“是否属于同一组”的信息。但有些问题需要知道组内元素的相对关系。比如经典的“食物链”问题,或者判断一句话里的人物关系是否矛盾。
这就需要带权并查集。我们在每个节点到其父节点的边上,增加一个“权值”,这个权值代表该节点与其父节点的某种关系(如差值、比例、类别等)。在find进行路径压缩时,需要同时更新权值;在union合并时,需要根据两个元素与各自根的关系,推导出两个根之间的关系,并设置正确的权值。
以“判断数组是否可以通过交换特定位置元素变得有序”的变种题为例:假设我们有一些数对(i, j),表示位置i和j的数可以任意交换。问是否可以通过这些交换,让数组有序。我们可以把可以交换的位置看成是连通的,它们形成一个集合,集合内的数字可以自由排列。那么,要使数组最终有序,每个集合里必须包含且仅包含那些“最终应该在这个集合位置上的数字”。一个更简单的模型是:给定一些(a, b)对,表示a和b必须属于同一集合。问能否将所有人分成两个集合,满足某些条件。
带权并查集通常用“关系”的模运算来表示。例如,在“食物链”问题中,关系有三种:A吃B,B吃C,C吃A,形成一个循环。我们可以定义权值0表示同类,1表示被父节点吃,2表示吃父节点(模3运算)。find时,权值要累加取模;union时,要根据已知的两个节点与各自根的关系,以及两个节点之间的关系,解出两个根之间应有的关系。
由于带权并查集代码较长且问题特定,这里不展开完整代码,但它的核心模板如下:
class WeightedUnionFind: def __init__(self, n): self.parent = list(range(n)) self.weight = [0] * n # weight[i] 表示 i 与 parent[i] 的关系 def find(self, x): if self.parent[x] != x: origin_parent = self.parent[x] self.parent[x] = self.find(self.parent[x]) # 路径压缩时,更新权值:x与新根的关系 = (x与旧根的关系 + 旧根与新根的关系) % MOD self.weight[x] = (self.weight[x] + self.weight[origin_parent]) % MOD return self.parent[x] def union(self, x, y, relation): # relation 表示 x 与 y 的关系 rootX, rootY = self.find(x), self.find(y) if rootX == rootY: # 检查已有关系是否矛盾 # (weight[x] - weight[y]) % MOD 应该等于 relation return (self.weight[x] - self.weight[y]) % MOD == relation # 合并,需要计算 rootX 与 rootY 的关系 # 有公式:relation(x, y) = weight[x] - weight[y] + relation(rootX, rootY) # 所以 relation(rootX, rootY) = relation(x, y) + weight[y] - weight[x] rel_root = (relation + self.weight[y] - self.weight[x]) % MOD self.parent[rootY] = rootX self.weight[rootY] = rel_root return True理解带权并查集的关键在于画出关系链,推导出权值更新的公式。这是并查集专题里难度较高的部分,但一旦掌握,解决复杂关系问题就得心应手。
4. 实战刷题策略与高效调试技巧
4.1 如何识别并查集问题
并不是所有连通性问题都用并查集最好。我总结了几条特征,当题目出现这些特征时,可以优先考虑并查集:
- 动态连通性:问题涉及大量“将两个元素连接起来”和“查询两个元素是否连通”的操作。如果只是单次查询,BFS/DFS可能更直接;但如果是多次、交替的合并与查询,并查集的优势就大了。
- 分组与等价关系:需要将元素分成若干组,同组内的元素满足某种等价、互通、或者必须在一起的性质。典型的如“朋友的朋友是朋友”、“等式传递”、“可以交换的位置”。
- 离线查询:有时问题会给出一系列操作和查询,我们可以先读完所有输入,然后按照特定顺序处理(比如先处理所有合并操作,再处理查询)。并查集很适合这种模式。
- 作为其他算法的子过程:最典型的就是Kruskal最小生成树算法。需要对边按权重排序后,依次尝试加入,用并查集来判断加入这条边是否会形成环(即边的两个端点是否已经连通)。
一个简单的判断方法是:如果题目描述里频繁出现“连接”、“合并”、“是否属于同一组”、“关系是否矛盾”这些关键词,就该亮出并查集这把“瑞士军刀”了。
4.2 通用模板与初始化陷阱
经过大量练习,我固定了一套自己的并查集模板,它包含了路径压缩和按秩合并,并且把parent和rank数组作为实例变量,用起来很顺手:
class DSU: def __init__(self, n): self.parent = list(range(n)) self.rank = [1] * n # 初始秩为1,代表树的大小或深度 # 有时需要 count 记录集合数,初始为 n # self.count = n def find(self, x): if self.parent[x] != x: self.parent[x] = self.find(self.parent[x]) return self.parent[x] def union(self, x, y): rootX = self.find(x) rootY = self.find(y) if rootX == rootY: return False # 未合并 # 按秩合并 if self.rank[rootX] < self.rank[rootY]: rootX, rootY = rootY, rootX # 交换,确保rootX是秩大的根 self.parent[rootY] = rootX if self.rank[rootX] == self.rank[rootY]: self.rank[rootX] += 1 # self.count -= 1 return True # 成功合并初始化陷阱:最常犯的错误就是初始化不对。parent数组一定要初始化为parent[i] = i,这是并查集一切逻辑的起点。我有一次调试了半小时,就是因为写成了parent = [0] * n。另外,rank数组的初始值可以是0也可以是1,这取决于你把rank定义为高度还是大小。按高度合并通常初始为0,按大小合并初始为1。只要合并逻辑一致就行。我的模板里用rank代表一个近似高度,初始为1,这样在秩相等时合并,高度会加1,逻辑比较自然。
4.3 调试与常见错误排查
即使有了模板,调试并查集的问题有时也挺让人头疼。问题往往不直接出在union和find上,而是出在如何将原问题映射到并查集操作。
常见错误1:索引映射错误尤其是在处理二维网格时,把(i, j)映射到一维索引idx = i * cols + j,一定要确保cols是列数,而不是行数。我曾经因为把rows和cols搞反,导致合并了完全不相干的格子,结果怎么算都不对。
排查方法:打印出小规模测试用例的网格和对应的并查集parent数组。手动模拟一下合并过程,看看union操作对应的索引是否正确。
常见错误2:合并条件遗漏或重复比如在岛屿问题中,我们只向右和向下合并,以避免重复。如果向四个方向合并,必须确保每个连接只被处理一次,否则虽然结果可能正确,但会做大量重复的find操作。更隐蔽的错误是,在某些问题中,合并的条件不是简单的相邻,而是满足某个公式或规则,漏掉一个条件就会导致连通块计算错误。
排查方法:画图!在纸上画出元素和它们之间的关系,明确哪些应该被合并。然后单步调试你的代码,或者打印出每次union操作的两个元素,检查是否符合预期。
常见错误3:带权并查集关系推导错误这是最难调试的。权值更新公式写错一个符号,或者模数用错,都会导致结果全盘皆输。
排查方法:
- 小数据暴力对拍:写一个暴力算法(比如BFS判断关系)用于小数据量(n<=10)的随机测试。生成大量随机数据和操作,对比并查集的结果和暴力结果是否一致。这是最有效的查错方法。
- 打印关系链:在
find和union函数中加入详细的打印语句,输出当前节点的权值、父节点权值、计算出的新权值等。对照你推导的公式,一步步检查。 - 理解模运算:确保你对模运算的性质(如
(a-b) mod M可能为负,在编程中要转为正数)非常熟悉。在Python中(a-b) % M会自动得到非负结果,但在其他语言如C++/Java中,%可能是取余运算,对于负数结果需要手动调整。
一个实用的调试技巧:可视化并查集状态对于不超过20个节点的问题,可以写一个简单的函数来打印并查集的状态:
def debug_dsu(dsu, n): print("Index:", list(range(n))) print("Parent:", dsu.parent) print("Roots:", [dsu.find(i) for i in range(n)]) # 打印每个集合的成员 from collections import defaultdict groups = defaultdict(list) for i in range(n): groups[dsu.find(i)].append(i) print("Groups:", dict(groups))在关键操作后调用这个函数,可以一目了然地看到合并的效果,非常有助于定位问题。
5. 从“Aproblem”到举一反三:并查集的变种与拓展
刷完基础题,你会遇到很多“Aproblem”级别的变种题。它们都在基础并查集上套了一层“外壳”,核心依然是union和find。
变种1:维护集合大小或集合数量我们经常需要知道某个集合有多少个元素,或者总共有多少个集合。这很简单,在初始化时用一个size数组,size[i]=1。在union时,将小集合的根挂到大集合的根下,并更新大集合的size:size[rootX] += size[rootY]。集合数量count可以在每次成功union后减1。
变种2:支持“断开连接”标准的并查集只支持合并,不支持拆分。但有些问题需要。一种思路是使用“离线处理+逆向操作”:如果所有操作已知,我们可以从最终状态倒着往回推,把“断开”操作变成逆向的“合并”操作。另一种思路是使用“时光倒流”或“持久化并查集”,但这已经属于高级技巧了。
变种3:二维并查集与动态添加比如在游戏地图中,动态地添加障碍物或打通通道,实时查询两个区域是否连通。这需要并查集支持“删除”操作(将某个元素从集合中移除),通常比较棘手。更常见的做法是,将问题转化为对静态结构的多重查询,或者使用其他数据结构如线段树维护连通性。
举一反三的练习路径:
- 基础:LeetCode 547 (省份数量)、LeetCode 200 (岛屿数量)、LeetCode 684 (冗余连接)、LeetCode 721 (账户合并)。
- 进阶:LeetCode 399 (除法求值 - 带权并查集)、LeetCode 765 (情侣牵手 - 巧妙建模)、LeetCode 128 (最长连续序列 - 并查集并非最优,但可做)。
- 挑战:LeetCode 803 (打砖块 - 逆向并查集)、LeetCode 952 (按公因数计算最大组件大小 - 并查集+数论)。
并查集的魅力在于,你理解它的核心后,面对各种复杂问题,都能尝试着去建模:哪些元素是点?怎样的关系需要建边(union)?要查询的信息是否可以通过find操作间接得到?多思考这几个问题,并查集就从一道题目的解法,变成你解决问题工具箱里一件趁手的兵器了。
最后,分享一个我自己的体会:学习并查集,初期死记模板没问题,但一定要亲手实现几次,并尝试用不同的方式(比如不用递归实现路径压缩)。然后去找3-5道不同应用场景的题目反复练习,直到你能在几分钟内写出无bug的代码,并且能清晰地向别人解释为什么这么做。这时,你才算真正“拿下”了这个专题,以后再看到“Aproblem”里出现它,心里就有底了。