1. 从“找老大”到“路径压缩”:并查集到底在解决什么问题?
如果你写过一些算法题,或者处理过一些需要动态分组、合并、查询关系的场景,大概率会碰到一种让人又爱又恨的数据结构——并查集。我第一次接触它是在处理一个社交网络的好友关系问题,需要判断两个人是否在同一个朋友圈里。当时我第一反应是,这不就是个图遍历吗?DFS或者BFS跑一遍不就知道了?直到数据量上到百万级别,频繁的查询请求直接把我的服务打挂,我才意识到问题的严重性。后来,一位前辈扔给我一句“用并查集试试”,我才真正理解了什么叫“降维打击”。
并查集,英文叫Union-Find,或者Disjoint-Set Union (DSU)。这个名字听起来有点学术,但它的核心思想非常生活化,我习惯把它叫做“找老大”算法。想象一下,你加入了一个公司,公司里有不同的部门。你想知道你和另一个同事是不是同一个部门的,最直接的办法是什么?不是去翻公司的组织架构图,而是去问你的“老大”(部门领导)。如果你们俩的“老大”是同一个人,那你们就在同一个部门。如果公司重组,两个部门合并了,那也很简单,让其中一个部门的“老大”认另一个部门的“老大”当老大就行了。并查集干的就是这个事:高效地管理一堆元素的动态分组关系,支持两种核心操作——合并两个组(Union),以及查询某个元素属于哪个组(Find)。
它的高效,就高效在“找老大”这个过程被优化到了近乎常数级别的时间复杂度。这背后有两个至关重要的“黑科技”:路径压缩和按秩合并。很多人学并查集,只记住了代码模板,却不知道为什么模板要那么写,更不理解那些动图里“树”的形态变化到底意味着什么。这篇文章,我就想用最直白的语言和多张示意图,把并查集从“是什么”、“为什么”到“怎么用”彻底讲透。你会发现,它不仅仅是解决“朋友圈”问题,在最小生成树(Kruskal算法)、游戏中的连通块计算、编译器中的变量等价类分析等场景下,都是不可或缺的利器。
2. 并查集的核心三要素:数组、Find与Union
要理解并查集,我们必须先抛开那些花哨的优化,从最朴素、最直观的实现开始。这样你才能明白,后来的那些优化到底在解决什么问题。
2.1 用数组表示“父子关系”
并查集通常用一个一维数组来实现,我们称之为parent数组。数组的下标代表每一个元素(比如员工编号0, 1, 2...),而数组里存储的值,代表这个元素的“父节点”或者说“直接上级”。
初始状态下,每个元素都是自成一派的,自己就是自己的老大。所以,我们会把parent[i]初始化为i。
# 初始化一个大小为 n 的并查集 def __init__(self, n): self.parent = [i for i in range(n)] # 初始时,每个人的老大都是自己这个数组构成了一片“森林”。每个元素都是一棵树的根节点,这棵树目前只有它自己一个节点。
2.2 Find操作:如何找到真正的“根老大”?
Find(x)操作的目标是:给定元素x,找到它所在集合的“根代表”,也就是那棵树最顶上的那个“终极老大”。
在朴素实现里,这非常简单,就是沿着parent数组一路向上找,直到找到那个parent[i] == i的节点。
# 朴素Find实现 def find_naive(self, x): while self.parent[x] != x: # 如果x不是自己的老大 x = self.parent[x] # 那就去找x的老大 return x # 返回找到的终极老大这个过程就像你问一个同事:“你的部门领导是谁?”他告诉了你他的直接领导A。你再问A:“你的领导是谁?”A告诉你他的领导是B... 一直问到某个人说:“我就是最大的领导。”这个人就是你们这个部门的根代表。
2.3 Union操作:如何合并两个“帮派”?
Union(x, y)操作的目标是:把元素x和元素y所在的两个集合合并成一个。
在朴素实现里,思路也很直接:
- 分别找到
x和y的根节点rootX和rootY。 - 如果
rootX == rootY,说明他俩本来就在一个集合里,不用合并。 - 否则,就把其中一个根节点挂到另一个根节点下面,让其中一个“老大”认另一个“老大”当老大。
# 朴素Union实现 def union_naive(self, x, y): rootX = self.find_naive(x) rootY = self.find_naive(y) if rootX != rootY: self.parent[rootX] = rootY # 让rootX的老大变成rootY这里有一个关键选择:是把rootX挂在rootY下,还是把rootY挂在rootX下?在朴素版本中,这个选择是随意的。但就是这个看似随意的选择,为后面的性能问题埋下了伏笔。
注意:合并操作永远是对两个集合的根节点进行的。直接修改非根节点的
parent值是错误的,这会破坏集合的结构。
3. 朴素实现的致命伤:退化成链表的树
现在,让我们来看一个最坏情况的例子,它揭示了朴素并查集的性能瓶颈。
假设我们有5个元素:0, 1, 2, 3, 4。初始状态各自为政。 我们按顺序执行以下合并操作:union(0, 1),union(1, 2),union(2, 3),union(3, 4)。
按照我们朴素的union_naive方法(假设总是将前一个的根挂到后一个的根上),过程如下:
union(0,1):find(0)=0,find(1)=1。将parent[0]设为1。森林状态: 1 (根) | 0 2 (根) 3 (根) 4 (根)union(1,2):find(1)=1,find(2)=2。将parent[1]设为2。森林状态: 2 (根) | 1 | 0 3 (根) 4 (根)union(2,3):find(2)=2,find(3)=3。将parent[2]设为3。森林状态: 3 (根) | 2 | 1 | 0 4 (根)union(3,4):find(3)=3,find(4)=4。将parent[3]设为4。森林状态: 4 (根) | 3 | 2 | 1 | 0
最终,我们得到了一棵非常“瘦高”的树,它已经退化成了一个链表!这时,如果我们执行find(0),就需要从0开始,依次访问1, 2, 3, 4,总共4步才能找到根节点4。如果元素数量是n,最坏情况下,Find操作的时间复杂度就退化成了O(n)。这完全违背了我们使用并查集追求近乎常数时间复杂度的初衷。
问题的根源在于,我们总是随意地将一棵树挂到另一棵树上,而没有考虑两棵树的“规模”或“高度”。这会导致合并后的树可能变得非常不平衡。为了解决这个问题,我们必须引入优化策略。
4. 优化利器一:路径压缩(Path Compression)
路径压缩是并查集第一个,也是最重要的优化。它的思想非常巧妙:既然Find操作的目的是找到根节点,那么在找的过程中,为什么不“顺手”把沿途经过的所有节点的父节点都直接指向根节点呢?
这样,下次再查找这些节点,或者查找它们的子节点时,路径就会大大缩短。这就像公司里传八卦,A从B那里听到一个消息,最终溯源到老板C。路径压缩相当于A在知道消息来自老板C后,下次再有人问他消息来源,他直接就说“是老板C说的”,而不再经过B这个中间人了。
4.1 两种实现方式:迭代与递归
迭代式路径压缩:在找到根节点后,再重新遍历一遍路径,将路径上所有节点的父节点都设为根节点。
def find_iter_pc(self, x): root = x # 第一遍:找到根节点root while self.parent[root] != root: root = self.parent[root] # 第二遍:将路径上所有节点的父节点都指向根节点root while self.parent[x] != root: parent_temp = self.parent[x] self.parent[x] = root x = parent_temp return root递归式路径压缩:代码更简洁,在递归返回的过程中,逐层将父节点指向最终找到的根节点。
def find_recursive_pc(self, x): if self.parent[x] != x: self.parent[x] = self.find_recursive_pc(self.parent[x]) # 递归查找并压缩 return self.parent[x]递归版本是更常见的写法,它完美体现了“在查找过程中完成压缩”的思想。虽然递归有额外的函数调用开销,但在实际应用中,由于树经过压缩后会变得非常扁平,递归深度很小,这点开销可以接受。
让我们用之前的退化链表例子看看路径压缩的效果。对退化链表4->3->2->1->0执行find_recursive_pc(0):
- 查找0,发现
parent[0]=1,递归查找find(1)。 - 查找1,发现
parent[1]=2,递归查找find(2)。 - 查找2,发现
parent[2]=3,递归查找find(3)。 - 查找3,发现
parent[3]=4,递归查找find(4)。 - 查找4,发现
parent[4]=4,返回4。 - 递归返回,设置
parent[3] = 4。 - 递归返回,设置
parent[2] = 4。 - 递归返回,设置
parent[1] = 4。 - 递归返回,设置
parent[0] = 4,返回4。
操作完成后,树的结构变成了:
4 (根) / | \ 0 1 2 3所有节点都直接指向了根节点4。下次再执行find(0)、find(1)等操作,都只需要一步。
实操心得:在绝大多数情况下,只使用路径压缩这一种优化,就足以得到非常高效的并查集。它的均摊时间复杂度接近常数O(α(n)),其中α(n)是增长极其缓慢的反阿克曼函数,对于任何在宇宙可观测范围内的n,α(n)都不会超过5。所以,你可以简单理解为常数时间。
5. 优化利器二:按秩合并(Union by Rank)
路径压缩主要优化了Find操作。而Union操作也有优化空间,目标就是在合并两棵树时,有策略地选择谁挂载谁,从而避免树变得过高。这就是“按秩合并”。
“秩”(Rank)可以粗略地理解为树的高度的一个上界。我们使用一个额外的数组rank来记录每个根节点对应的秩。初始时,每个节点都是根,秩为0或1(通常初始化为0或1,含义相同,代表只有自己一个节点)。
合并时,我们比较两棵树的秩:
- 秩不同:将秩较小的树的根节点,挂到秩较大的树的根节点下。这样合并后,新树的高度等于原来较高的那棵树的高度。秩较大的树的根,成为新根。
- 秩相同:任意选择一棵树挂到另一棵下。但是,新根的秩需要加1。因为两棵高度相同的树合并,新树的高度会增加1。
class UnionFind: def __init__(self, n): self.parent = [i for i in range(n)] self.rank = [0] * n # 初始化秩为0 def find(self, x): # 带路径压缩的find 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 # 按秩合并 if self.rank[rootX] < self.rank[rootY]: # rootX的树更矮,把它挂到rootY下 self.parent[rootX] = rootY elif self.rank[rootX] > self.rank[rootY]: # rootY的树更矮,把它挂到rootX下 self.parent[rootY] = rootX else: # 两棵树一样高,任意挂载,但新根秩要+1 self.parent[rootY] = rootX self.rank[rootX] += 1为什么按秩合并有效?它保证了树的生长是“谨慎”的。只有当两棵同样高的树合并时,树的高度才会增加。这极大地延缓了树退化成链表的进程。单独使用按秩合并,可以将Find操作的最坏时间复杂度优化到 O(log n)。
6. 强强联合:路径压缩 + 按秩合并
在实际应用中,尤其是对性能要求极高的场景(如算法竞赛、大型系统核心组件),我们通常会同时使用路径压缩和按秩合并。这两者结合,才能达到理论上最优的均摊时间复杂度 O(α(n))。
这里有一个非常重要的细节:当同时使用路径压缩时,“秩”不再精确等于树的高度,而更像是一个“高度的估计值”或“等级”。因为路径压缩会改变树的结构,使树变扁,但我们在压缩时并不会去更新其他节点的rank值(那样做代价太高)。所以,rank记录的是“未进行路径压缩时,树高度的上界”。这并不影响合并策略的正确性,因为rank值大的树,在合并前确实曾经更高、更庞大。
class UnionFind: def __init__(self, n): self.parent = list(range(n)) self.rank = [1] * 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: return False # 已连通,合并失败 # 按秩(高度)合并 if self.rank[rootX] < self.rank[rootY]: self.parent[rootX] = rootY elif self.rank[rootX] > self.rank[rootY]: self.parent[rootY] = rootX else: # 高度相等,任意合并,新根高度+1 self.parent[rootY] = rootX self.rank[rootX] += 1 return True # 合并成功这个模板就是并查集的“完全体”,足以应对99%的挑战。初始化、查找(带压缩)、合并(按秩),三个操作都达到了近乎常数时间的效率。
7. 并查集实战:从“朋友圈”到“最小生成树”
理解了原理和模板,我们来看看并查集如何解决实际问题。我挑选了两个最经典的应用场景。
7.1 应用一:LeetCode 547. 省份数量(朋友圈问题)
问题描述:有n个城市,其中一些城市彼此相连。如果城市a与城市b直接相连,且城市b与城市c直接相连,那么城市a与城市c间接相连。省份是一组直接或间接相连的城市。给你一个n x n的矩阵isConnected,其中isConnected[i][j] = 1表示第i个城市和第j个城市直接相连,否则为0。返回矩阵中省份的数量。
思路解析:这就是典型的并查集应用。每个城市是一个元素。遍历矩阵,对于每个isConnected[i][j] == 1的关系,我们就执行一次union(i, j),将城市i和城市j合并到同一个集合(省份)中。最后,统计有多少个元素的parent[i] == i(即有多少个根节点),就有多少个独立的省份。
代码实现:
class Solution: def findCircleNum(self, isConnected: List[List[int]]) -> int: n = len(isConnected) uf = UnionFind(n) for i in range(n): for j in range(i+1, n): # 矩阵是对称的,遍历一半即可 if isConnected[i][j] == 1: uf.union(i, j) # 统计根节点(省份)的数量 provinces = 0 for i in range(n): if uf.find(i) == i: # 或者 if uf.parent[i] == i provinces += 1 return provinces避坑提示:统计省份数量时,务必使用
find(i)来获取根节点,而不是直接看parent[i]。因为经过路径压缩后,大部分parent[i]直接指向根,但为了代码的健壮性(兼容未压缩或部分压缩的状态),使用find(i)是更安全的做法。
7.2 应用二:Kruskal算法求最小生成树(MST)
Kruskal算法是求加权无向图最小生成树的经典贪心算法,而并查集是其高效实现的关键。
算法步骤:
- 将图中所有边按权重从小到大排序。
- 初始化一个并查集,每个顶点自成一个集合。
- 按权重从小到大遍历每条边
(u, v, w): a. 使用并查集检查u和v是否已经连通(即find(u) == find(v))。 b. 如果不连通,则将这条边加入最小生成树,并执行union(u, v),将两个顶点所在的集合合并。 - 当最小生成树中的边数达到
n-1(n为顶点数)时,算法结束。
并查集的作用:高效地(近乎O(1)时间)判断两个顶点是否已在同一连通分量中,从而避免成环。如果没有并查集,判断连通性需要DFS/BFS,时间复杂度为O(V+E),会使Kruskal算法的总复杂度从O(E log E)退化到O(E * V),对于稠密图是无法接受的。
def kruskal(n, edges): # edges: list of (u, v, weight) uf = UnionFind(n) edges.sort(key=lambda x: x[2]) # 按权重排序 mst_edges = [] total_weight = 0 for u, v, w in edges: if uf.find(u) != uf.find(v): # 如果u和v不连通 uf.union(u, v) # 合并集合 mst_edges.append((u, v, w)) total_weight += w if len(mst_edges) == n - 1: break # 已找到最小生成树 return total_weight, mst_edges8. 进阶技巧与常见问题排查
掌握了标准模板和经典应用,你已经能解决大部分问题了。但在实际编码,尤其是面试或竞赛中,还有一些细节和变种需要留意。
8.1 如何维护每个集合的大小或其它属性?
有时我们不仅需要知道元素是否连通,还需要知道每个连通分量里有多少个元素,或者维护一些聚合信息(如总和、最大值)。我们可以在并查集里增加一个size数组。
class UnionFindWithSize: def __init__(self, n): self.parent = list(range(n)) self.size = [1] * n # 每个集合的初始大小是1 def find(self, x): # ... 路径压缩 ... def union(self, x, y): rootX = self.find(x) rootY = self.find(y) if rootX == rootY: return # 按大小合并:将小集合挂到大集合下 if self.size[rootX] < self.size[rootY]: self.parent[rootX] = rootY self.size[rootY] += self.size[rootX] else: self.parent[rootY] = rootX self.size[rootX] += self.size[rootY]按大小合并(Union by Size)是按秩合并的一种常见变体,效果类似,都能保证树的高度增长缓慢。
8.2 “带权”并查集如何处理?
有些问题中,元素之间的关系不仅仅是“是否连通”,还有“权值”或“偏移量”。例如,判断一堆等式/不等式是否矛盾(LeetCode 399, 990),或者计算食物链中动物的关系。这就需要“带权并查集”。
在带权并查集中,parent数组依然记录父节点,同时新增一个weight数组,记录当前节点到其父节点的“权值”(或关系)。在Find进行路径压缩时,需要同时更新权值;在Union时,需要根据题目给出的关系方程来推导和设置权值。这是一个更高级的话题,但其核心——路径压缩与合并时维护额外信息的思想——是相通的。
8.3 调试与问题排查:我的并查集为什么出错了?
如果你写的并查集代码结果不对,可以按以下步骤排查:
- 初始化检查:
parent数组是否正确地初始化为parent[i]=i?rank或size数组是否初始化? - Find函数:递归版本的路径压缩,返回值是否正确?是否是
return self.parent[x]而不是return x?确保压缩逻辑正确。 - Union函数:
- 最经典的错误:没有使用
Find得到的根节点进行合并,而是直接parent[x] = y。必须合并根节点! - 按秩/按大小合并的逻辑判断是否有误?特别是两棵树秩相等时,秩的更新 (
rank[rootX] += 1) 是否遗漏? Union前是否检查了rootX和rootY是否相等?避免无意义的操作。
- 最经典的错误:没有使用
- 状态查询:判断两个元素是否属于同一集合,一定要用
find(a) == find(b),不能直接用parent[a] == parent[b],因为它们的直接上级可能不同,但终极老大相同。 - 输入边界:元素下标是否从0开始?题目给的编号是1-based的话,需要在初始化时转换为0-based。
并查集的代码模板其实非常短小精悍。我建议你彻底理解后,形成自己的肌肉记忆。在需要使用时,花一分钟默写出来,能为你节省大量的调试时间,尤其是在紧张的时间限制下。它就像一把瑞士军刀,简单,但当你真正理解其精妙之处后,会发现它能优雅地解决一大类复杂的动态连通性问题。