并查集路径压缩(千字长文)
💡一则或许对你有用的小广告
欢迎加入小哈的星球 ,你将获得:专属的项目实战 / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
- 新项目:《从零手撸:仿小红书(微服务架构)》 正在持续爆肝中,基于
Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...
,点击查看项目介绍 ;- 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/ ;
截止目前, 星球 内专栏累计输出 82w+ 字,讲解图 3441+ 张,还在持续爆肝中.. 后续还会上新更多项目,目标是将 Java 领域典型的项目都整一波,如秒杀系统, 在线商城, IM 即时通讯,权限管理,Spring Cloud Alibaba 微服务等等,已有 2900+ 小伙伴加入学习 ,欢迎点击围观
并查集基础:从集合到树形结构
并查集(Disjoint Set Union, DSU)是一种用于管理动态集合的数据结构,核心功能是支持两种操作:
- 查找(Find):判断两个元素是否属于同一集合。
- 合并(Union):将两个不同集合合并为一个集合。
想象一个家族树:每个节点代表一个人,而并查集的作用就是快速判断两个人是否属于同一家族,或者将两个家族合并。这种结构在社交网络分析、图的连通性问题中广泛应用。
并查集的基础实现通常使用树形结构,每个节点存储其父节点的索引。例如,用数组 parent
表示,其中 parent[i]
表示节点 i
的父节点。初始时,每个元素的父节点指向自身(parent[i] = i
)。
基础实现代码示例
class UnionFind:
def __init__(self, size):
self.parent = list(range(size)) # 初始时每个元素是独立的集合
def find(self, x):
while self.parent[x] != x:
x = self.parent[x]
return x
def union(self, x, y):
root_x = self.find(x)
root_y = self.find(y)
if root_x != root_y:
self.parent[root_y] = root_x
路径压缩:让树更扁平化
在基础并查集中,频繁的 find
操作可能导致树的高度变得很高,例如线性链表结构。假设查找一个位于链表末端的节点,时间复杂度会达到 O(n),这显然不够高效。
路径压缩(Path Compression) 是一种优化策略,它通过将查找路径上的所有节点直接指向根节点,从而缩短树的高度。这一过程类似于“修剪树枝”,让树的结构更扁平,接近森林中“矮灌木丛”的形态。
路径压缩的实现原理
在 find
方法中,路径压缩的核心逻辑是:
- 查找节点
x
的根节点。 - 在查找过程中,将路径上的所有节点直接指向根节点。
例如,假设路径为 x → A → B → root
,路径压缩后,x
、A
、B
的父节点都会被更新为 root
。
递归实现路径压缩
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x]) # 路径压缩
return self.parent[x]
这段代码通过递归,将路径上的每个节点的父指针直接指向根节点,从而实现扁平化。
迭代实现路径压缩
def find(self, x):
original_x = x
# 查找根节点
while self.parent[x] != x:
x = self.parent[x]
# 压缩路径
while self.parent[original_x] != x:
next_parent = self.parent[original_x]
self.parent[original_x] = x
original_x = next_parent
return x
迭代版本通过两次循环,先找到根节点,再回溯路径修改父指针。
路径压缩的优势与性能分析
时间复杂度优化
引入路径压缩后,并查集的 find
和 union
操作的时间复杂度从 O(n) 降低到 近似 O(1)。具体而言,路径压缩结合按秩合并(Rank)的策略,可以将时间复杂度优化到 O(α(n)),其中 α 是阿克曼函数的反函数,增长极慢(在 n 实际应用范围内,α(n) ≤ 5)。
实际效果对比
假设有一个包含 1000 个节点的线性链表结构:
- 无路径压缩:查找最后一个节点需要遍历 1000 次。
- 有路径压缩:第一次查找后,所有节点的父指针直接指向根,后续查找只需 1 次操作。
这种优化对大规模数据的处理尤为重要。
典型应用场景与案例分析
案例 1:迷宫路径连通性检测
假设需要判断迷宫中两个点是否连通,可以将迷宫的每个格子视为节点,用并查集管理连通区域。路径压缩能显著加速频繁的连通性查询。
class MazeSolver:
def __init__(self, rows, cols):
self.uf = UnionFind(rows * cols)
# 假设已知相邻格子的连通性,合并对应的集合
...
def is_connected(self, x, y):
return self.uf.find(x) == self.uf.find(y)
案例 2:社交网络的朋友分组
在社交网络中,用户分组问题可转化为并查集的合并操作。路径压缩能快速响应用户频繁的分组查询和合并操作。
性能对比实验
假设测试数据规模为 1,000,000 个节点,进行 100,000 次随机合并和查找操作:
| 策略 | 平均查找时间 | 平均树高度 |
|--------------------|-------------|-----------|
| 无路径压缩 | 0.5秒 | ~500 |
| 仅路径压缩 | 0.02秒 | ~2 |
| 路径压缩 + 按秩合并 | 0.01秒 | ~1 |
实现细节与注意事项
1. 路径压缩与按秩合并的结合
路径压缩通常与 按秩合并(Union by Rank) 结合使用,进一步优化树的平衡性。按秩合并的规则是:
- 合并时,将秩(树的高度)较小的树挂到秩较大的树下。
- 若两棵树的秩相同,则任选一棵作为根,并将另一棵的秩加 1。
class UnionFindWithRank:
def __init__(self, size):
self.parent = list(range(size))
self.rank = [1] * size # 初始秩为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):
root_x = self.find(x)
root_y = self.find(y)
if root_x == root_y:
return
if self.rank[root_x] < self.rank[root_y]:
self.parent[root_x] = root_y
else:
self.parent[root_y] = root_x
if self.rank[root_x] == self.rank[root_y]:
self.rank[root_x] += 1
2. 递归与迭代的权衡
- 递归实现路径压缩:代码简洁,但可能因栈溢出问题不适用于极深的树。
- 迭代实现路径压缩:避免栈溢出,但代码稍复杂。
3. 空间复杂度
并查集的空间复杂度始终为 O(n),因为仅需存储父节点和秩数组。
常见问题与解答
Q1:路径压缩是否会影响其他操作?
路径压缩仅在 find
操作中执行,不会干扰 union
的合并逻辑,因此是安全的。
Q2:如何验证路径压缩的效果?
可通过统计 find
操作中遍历的节点数量。例如,无路径压缩时,路径长度可能随操作次数增长,而路径压缩后路径长度迅速收敛到 1。
Q3:为什么路径压缩不会破坏按秩合并的策略?
路径压缩仅调整父指针,不影响秩数组的记录。因此,按秩合并的规则仍能保证树的平衡性。
结论:路径压缩的价值与应用场景
路径压缩是并查集优化的核心技巧,它通过扁平化树的结构,将查找操作的时间复杂度降低到几乎常数级别。对于需要频繁处理集合合并与查询的场景(如社交网络分析、图的连通性问题、动态分组管理等),路径压缩能显著提升算法的效率。
掌握这一技巧后,开发者可以更自信地应对复杂的数据结构问题。例如,在 LeetCode 的“岛屿数量”或“朋友圈”等经典题中,路径压缩能帮助写出更高效的解决方案。
总之,并查集路径压缩不仅是算法优化的典范,更是理解数据结构动态调整逻辑的重要案例。通过结合按秩合并等策略,它为处理大规模数据提供了坚实的理论基础和实践指导。