起因
最近做的需求中遇到了对各种公告依照关联关系去重复的问题,偶然间学习到了并查集。
一、什么是并查集
并查集(Disjoint Set Union,简称 DSU 或 Union-Find)是一种 树形结构的数据结构 ,用于解决 动态连通性问题 。
它的核心作用是高效地回答两个问题:
- Find(查找) :判断某个元素属于哪个集合(找到它的"代表"或"根")。
- Union(合并) :把两个集合合并为一个。
比如:有一群人,他们之间有"朋友关系",每次你可以告诉系统"两个人是朋友"。
系统需要能快速回答:"这两个人是不是朋友?" 或 "他们属于同一个朋友圈吗?"
并查集就是为这种"分组 + 判断是否同组"的问题而生的。
二、原理与优化策略
1. 基本原理
每个元素都属于一个集合,使用一个数组 parent[] 来存储:
parent[x] 表示 x 的父节点。
- 如果
parent[x] = x,说明它是一个集合的代表(根节点)。 - 查找根节点时,不断沿着父节点向上找,直到找到自己是自己的父节点。
2. 路径压缩(Path Compression)
路径压缩是优化 find 的手段,使集合结构更扁平。
原理:
在查找根节点时,将路径上所有节点的父节点都直接指向根节点。
这样后续查询同一个集合的元素时,查找速度更快。
例子:
初始时:
1 → 2 → 3 → 4
查找 find(1) 时路径压缩后:
1 → 4
2 → 4
3 → 4
4 → 4
结构变得更"平",后续查找会更快。
3. 按秩合并(Union by Rank / Size)
当两个集合合并时,我们希望保持树的深度尽可能小。
做法:
- 为每个集合维护一个"rank"或"size"。
- 在合并时,让"rank"小的树挂到"rank"大的树下面。
例如:
css
集合A 深度=2,集合B 深度=1
→ 把B挂在A下,而不是反过来。
这样可以避免树变得太"高"。
三、Ruby 实现
下面给出两种实现方式:
1. 递归实现(路径压缩版)
ruby
class UnionFind
attr_reader :parent, :rank
def initialize(n)
@parent = Array.new(n) { |i| i }
@rank = Array.new(n, 0)
end
# 查找根节点(递归 + 路径压缩)
def find(x)
return x if @parent[x] == x
@parent[x] = find(@parent[x])
end
# 按秩合并
def union(x, y)
root_x = find(x)
root_y = find(y)
return if root_x == root_y
if @rank[root_x] < @rank[root_y]
@parent[root_x] = root_y
elsif @rank[root_x] > @rank[root_y]
@parent[root_y] = root_x
else
@parent[root_y] = root_x
@rank[root_x] += 1
end
end
end
优点:
- 实现简洁,逻辑清晰。
- Ruby 的递归函数写法优雅。
缺点:
- 当数据规模较大(如 10⁵ 级别)时,递归层数可能导致栈溢出。
- 性能略低于迭代实现(函数调用有开销)。
2. 非递归实现(路径压缩版)
ruby
class UnionFindIter
attr_reader :parent, :rank
def initialize(n)
@parent = Array.new(n) { |i| i }
@rank = Array.new(n, 0)
end
# 查找根节点(非递归 + 路径压缩)
def find(x)
root = x
# 找到根节点
root = @parent[root] while @parent[root] != root
# 路径压缩
while x != root
parent = @parent[x]
@parent[x] = root
x = parent
end
root
end
# 按秩合并
def union(x, y)
root_x = find(x)
root_y = find(y)
return if root_x == root_y
if @rank[root_x] < @rank[root_y]
@parent[root_x] = root_y
elsif @rank[root_x] > @rank[root_y]
@parent[root_y] = root_x
else
@parent[root_y] = root_x
@rank[root_x] += 1
end
end
end
优点:
- 没有递归,适合大规模数据。
- 执行速度更稳定。
缺点:
- 实现稍复杂,代码略长。
性能比较总结:
| 实现方式 | 路径压缩 | 按秩合并 | 是否递归 | 时间复杂度 | 空间开销 | 适用场景 |
|---|---|---|---|---|---|---|
| 递归 | ✅ | ✅ | ✅ | 近似 O(α(n)) | 低 | 小规模或教学 |
| 非递归 | ✅ | ✅ | ❌ | 近似 O(α(n)) | 稍高 | 大规模、高性能 |
注:α(n) 是阿克曼函数的反函数,增长极慢,可视为常数时间。
四、使用场景与实战案例
1. 常见使用场景
- 网络连通性判断:判断两台计算机是否在同一个局域网。
- 社交网络分组:判断两人是否在同一个朋友圈。
- 图的连通块统计:计算图中有多少个独立的连通分量。
- 最小生成树(Kruskal算法) :判断边的两端是否属于同一集合。
- 图像分割 / 聚类分析:把像素或点按相似度聚为一类。
2. 实战案例:朋友圈分组统计
假设我们有 5 个人(编号 0~4),朋友关系如下:
scss
(0, 1), (1, 2), (3, 4)
我们希望知道一共有几个独立的朋友圈。
ini
uf = UnionFindIter.new(5)
relations = [[0,1],[1,2],[3,4]]
relations.each { |a,b| uf.union(a,b) }
# 统计根节点数量
roots = uf.parent.map { |x| uf.find(x) }.uniq
puts "朋友圈数量:#{roots.size}"
输出:
朋友圈数量:2
说明存在两个朋友圈:
- {0,1,2}
- {3,4}
五、总结
-
并查集是一种 高效维护动态连通性 的结构。
-
优化手段包括:
- 路径压缩:让树更平。
- 按秩合并:让树不长。
-
在 Ruby 中既可以用递归,也可以用非递归实现。
-
常用于 图算法、社交关系、网络连通、聚类分析 等场景。