并查集(Disjoint Set Union,简称DSU)是一种处理不相交集合合并与查询问题的数据结构。我第一次接触这个数据结构是在解决图论问题时,当时需要快速判断两个节点是否连通。传统方法在频繁查询时效率很低,而并查集可以在近乎常数时间内完成这些操作。
并查集的核心思想是用树结构来表示集合,每棵树的根节点作为该集合的代表元。想象一下公司里的组织架构:每个部门有一个部门经理(根节点),要判断两个员工是否属于同一个部门,只需要看他们最终的汇报线是否指向同一位经理。
三个基本操作构成了并查集的骨架:
init(n):初始化时,每个元素自成一个集合,就像刚入职的员工都自己成立一个单人部门find(x):查找元素x所在集合的根节点,相当于查找员工的最终汇报对象union(x, y):合并两个元素所在的集合,类似于公司部门重组关键理解:当
fa[x] == x时,说明x就是根节点,就像部门经理的汇报对象是自己
让我们从最基础的实现开始。初始化时,我们创建一个数组fa,其中fa[i]表示元素i的父节点:
python复制class DSU:
def __init__(self, n):
self.fa = list(range(n + 1)) # 通常我们从1开始编号
这种初始化方式使得每个元素最初都是自己的父节点,形成n个独立的单节点树。
朴素查找操作可能会遇到长链问题,导致效率下降。路径压缩通过在查找过程中"拍平"树结构来优化:
python复制def find(self, x):
if self.fa[x] == x:
return x
self.fa[x] = self.find(self.fa[x]) # 递归路径压缩
return self.fa[x]
递归写法虽然直观,但在Python中可能引发栈溢出。更安全的非递归实现采用路径减半策略:
python复制def find(self, x):
while self.fa[x] != x:
self.fa[x] = self.fa[self.fa[x]] # 跳两步压缩一步
x = self.fa[x]
return x
路径压缩后,树的高度会被显著压低,使得后续操作接近O(1)时间复杂度。这就像公司内部简化汇报层级,让普通员工可以直接联系到高层。
简单合并可能导致树的不平衡。按秩合并总是将小树合并到大树下:
python复制def __init__(self, n):
self.fa = list(range(n + 1))
self.size = [1] * (n + 1) # 新增size数组记录集合大小
def union(self, x, y):
rx, ry = self.find(x), self.find(y)
if rx == ry: return
if self.size[rx] > self.size[ry]:
rx, ry = ry, rx # 确保ry是较大的集合
self.fa[rx] = ry
self.size[ry] += self.size[rx]
这种优化与路径压缩配合使用,能将时间复杂度降到接近O(α(n)),其中α是反阿克曼函数,在实际应用中可视作常数。
在图论中判断两个节点是否连通是并查集的典型应用。例如社交网络中的好友关系:
python复制dsu = DSU(user_count)
for a, b in friendships:
dsu.union(a, b)
# 判断两人是否是好友(直接或间接)
def are_connected(user1, user2):
return dsu.find(user1) == dsu.find(user2)
Kruskal算法通过并查集高效判断边的两个顶点是否已连通:
python复制edges.sort() # 按权重排序
mst_edges = []
dsu = DSU(vertex_count)
for w, u, v in edges:
if dsu.find(u) != dsu.find(v):
dsu.union(u, v)
mst_edges.append((u, v))
处理动态加入的连通关系时,并查集表现出色。比如游戏中的实时区域合并:
python复制class GameMap:
def __init__(self, size):
self.dsu = DSU(size)
self.regions = size # 初始独立区域数
def connect(self, x, y):
if self.dsu.find(x) != self.dsu.find(y):
self.dsu.union(x, y)
self.regions -= 1
常见错误是数组大小设置不当。如果元素从1开始编号:
python复制# 错误示范:缺少+1会导致索引越界
self.fa = list(range(n)) # 当访问fa[n]时出错
# 正确做法
self.fa = list(range(n + 1))
路径压缩会改变树结构,这在某些需要维护历史信息的场景会产生问题。例如:
python复制# 如果需要保留原始结构(如可持久化并查集)
# 应该使用不进行路径压缩的朴素查找
def naive_find(self, x):
while self.fa[x] != x:
x = self.fa[x]
return x
除了按大小合并,还可以按深度合并:
python复制def __init__(self, n):
self.fa = list(range(n + 1))
self.rank = [0] * (n + 1) # 初始深度为0
def union(self, x, y):
rx, ry = self.find(x), self.find(y)
if rx == ry: return
if self.rank[rx] > self.rank[ry]:
rx, ry = ry, rx
self.fa[rx] = ry
if self.rank[rx] == self.rank[ry]:
self.rank[ry] += 1
添加可视化方法帮助调试:
python复制def __str__(self):
from collections import defaultdict
groups = defaultdict(list)
for i in range(1, len(self.fa)):
groups[self.find(i)].append(i)
return "\n".join(f"Root {k}: {v}" for k, v in groups.items())
并查集的时间复杂度分析很有趣:
其中α(n)是反阿克曼函数,对于任何实际应用的n值,α(n) ≤ 4。
对于超大集合,可以考虑:
python复制self.fa = {i:i for i in elements}
python复制import array
self.fa = array.array('L', range(n+1)) # 无符号长整型
虽然并查集本身难以并行化,但可以:
维护节点到根节点的相对关系:
python复制class WeightedDSU:
def __init__(self, n):
self.fa = list(range(n + 1))
self.weight = [0] * (n + 1) # 到父节点的权重
def find(self, x):
if self.fa[x] != x:
orig_fa = self.fa[x]
self.fa[x] = self.find(self.fa[x])
self.weight[x] += self.weight[orig_fa]
return self.fa[x]
def union(self, x, y, w):
rx, ry = self.find(x), self.find(y)
if rx == ry: return
# w = weight[x→y] = weight[x→rx] + weight[rx→ry] - weight[y→ry]
self.fa[rx] = ry
self.weight[rx] = self.weight[y] - self.weight[x] + w
应用场景:食物链问题、等式方程的可满足性等。
支持操作回退:
python复制class UndoableDSU:
def __init__(self, n):
self.fa = list(range(n + 1))
self.history = []
def find(self, x):
while self.fa[x] != x:
x = self.fa[x]
return x
def union(self, x, y):
rx, ry = self.find(x), self.find(y)
if rx == ry: return False
self.history.append((rx, self.fa[rx])) # 记录修改前状态
self.fa[rx] = ry
return True
def undo(self):
if not self.history: return False
x, orig_fa = self.history.pop()
self.fa[x] = orig_fa
return True
LeetCode 547. 朋友圈数量:
python复制def findCircleNum(M):
n = len(M)
dsu = DSU(n)
for i in range(n):
for j in range(i + 1, n):
if M[i][j]:
dsu.union(i, j)
return len({dsu.find(i) for i in range(n)})
优化技巧:只需要遍历矩阵的上三角部分,避免重复合并。