并查集(Union Find)
何为集,集合,用树表示;何为并,集合的合并(union);何为查,查询元素所属集合(find)。
一、树的双亲表示法
使用树的双亲表示法来表示集合。如果两个结点具有相同的根结点,就认为这两个结点属于同一集合,一棵树表示一个集合。
这棵树对应的双亲表示法数组如下:
如果我们想要知道元素 2 的父结点,则直接取 parent[2] 的值即可,parent[2] 的值为 5,所以元素 2 的父结点为元素 5。
如果树的根结点为 root,parent[root] 的值可以是 root,也可以是一个负数,只要能够表示 root 不以其它结点为父结点即可。当 parent[root] 存储一个负数时,可以包含树的结点数或深度等启发信息。
二、并查集的基础实现
public class UnionFind {
private int[] parent;
public UnionFind(int len) {
parent = new int[len];
for(int i = 0; i < len; ++i) {
parent[i] = i;
}
}
public int find(int a) {
while(a != parent[a]) {
a = parent[a];
}
return a;
}
public void union(int a, int b) {
parent[find(a)] = find(b);
}
}
固定将 a 的根合并到 b 的根上,极端情况下,合并后的树就是一个线性表,find 函数的时间复杂度较高。
三、并查集的优化实现
1. 优化 union 函数
借助树的结点数或深度等启发信息,决定究竟是 a 的根合并到 b 的根上,还是 b 的根合并到 a 的根上。通俗来讲,就是把小树合并到大树或低树合并到高树。
1.1. size 优化
size 指树的结点数。
public class UnionFind {
private int[] parent;
public UnionFind(int len) {
parent = new int[len];
for(int i = 0; i < len; ++i) {
parent[i] = -1; // 相反数表示树的结点数
}
}
public int find(int a) {
while(parent[a] >= 0) {
a = parent[a];
}
return a;
}
public void union(int a, int b) {
if((a = find(a)) == (b = find(b))) return;
if(parent[a] < parent[b]) {// b 挂在 a 身上
parent[a] += parent[b];
parent[b] = a;
}else {
parent[b] += parent[a];
parent[a] = b;
}
}
}
1.2. depth 优化
depth 指树的深度。
public class UnionFind {
private int[] parent;
public UnionFind(int len) {
parent = new int[len];
for(int i = 0; i < len; ++i) {
parent[i] = -1; // 相反数表示树的深度
}
}
public int find(int a) {
while(parent[a] >= 0) {
a = parent[a];
}
return a;
}
public void union(int a, int b) {
if((a = find(a)) == (b = find(b))) return;
if(parent[a] < parent[b]) parent[b] = a;
else parent[a] = b;
if(parent[a] == parent[b]) --parent[b]; // 两棵树深度相同时,才更新深度信息
}
}
2. 优化 find 函数
路径压缩优化,在查询时顺便压缩路径,让结点离根更近,提高下次查询的效率。
2.1. 折半压缩
public class UnionFind {
private int[] parent;
public UnionFind(int len) {
parent = new int[len];
for(int i = 0; i < len; ++i) {
parent[i] = i;
}
}
public int find(int a) {
while(a != parent[a]){ // 查询时直接同步压缩
parent[a] = parent[parent[a]];
a = parent[a];
}
return a;
}
public void union(int a, int b) {
parent[find(a)] = find(b);
}
压缩过程像这个样子:
2.2. 完全压缩
2.2.1. 递归算法
public class UnionFind {
private int[] parent;
public UnionFind(int len) {
parent = new int[len];
for(int i = 0; i < len; ++i) {
parent[i] = i;
}
}
public int find(int a) { // 递归获取根结点,回溯更改路径上结点的父结点为根结点
if(a != parent[a]) return parent[a] = find(parent[a]);
else return a;
}
public void union(int a, int b) {
parent[find(a)] = find(b);
}
}
2.2.2. 递推算法
public class UnionFind {
private int[] parent;
public UnionFind(int len) {
parent = new int[len];
for(int i = 0; i < len; ++i) {
parent[i] = i;
}
}
public int find(int a) {
int root = a;
while(parent[root] != root) { // 先获取根结点
root = parent[root];
}
int t;
while(parent[a] != root) { // 依次让路径上的结点直接指向根结点
t = parent[a];
parent[a] = root;
a = t;
}
return root;
}
public void union(int a, int b) {
parent[find(a)] = find(b);
}
}
压缩过程像这个样子:
虽然压缩的比较彻底,但步骤要更多。
3. 同时优化 find 函数和 union 函数
find 函数和 union 函数的优化并不冲突,可以自由组合前面提到的优化方法。
仅举两个例子:
一、 同时使用折半路径压缩和 size 优化
public class UnionFind {
private int[] parent;
public UnionFind(int len) {
parent = new int[len];
for(int i = 0; i < len; ++i) {
parent[i] = -1;
}
}
public int find(int a) {
int t;
while(parent[a] >= 0) {
if((t = parent[parent[a]]) >= 0) parent[a] = t;
a = parent[a];
}
return a;
}
public void union(int a, int b) {
if((a = find(a)) == (b = find(b))) return;
if(parent[a] < parent[b]) {
parent[a] += parent[b];
parent[b] = a;
}else {
parent[b] += parent[a];
parent[a] = b;
}
}
}
二、同时使用完全路径压缩和 depth 优化
public class UnionFind {
private int[] parent;
public UnionFind(int len) {
parent = new int[len];
for(int i = 0; i < len; ++i) {
parent[i] = -1;
}
}
public int find(int a) {
if(parent[a] >= 0) return parent[a] = find(parent[a]);
else return a;
}
public void union(int a, int b) {
if((a = find(a)) == (b = find(b))) return;
if(parent[a] < parent[b]) parent[b] = a;
else parent[a] = b;
if(parent[a] == parent[b]) --parent[b];
}
}
其实在路径压缩的时候,我们并没有维护 depth 的语义,它可能已经不再是树的深度,所以你可以在其它地方看到用 rank(秩)来回避这里说的 depth。虽然它已经不能正确的表示树的深度,但仍然具有优化 union 函数的启发意义。
四、不同优化策略的性能测试
测试代码统一为:
static final int N = 10000000;
public static void main(String[] args) {
int len = N;
int unionCount = N;
int findCount = N;
UnionFind uf = new UnionFind(len);
Random r = new Random(1);
int sum = 0;
for(int iter = 0; iter < 10; ++iter) {
long start = System.currentTimeMillis();
for(int i = 0; i < unionCount; ++i) {
int a = r.nextInt(len);
int b = r.nextInt(len);
uf.union(a, b);
}
for(int i = 0; i < findCount; ++i) {
int a = r.nextInt(len);
uf.find(a);
}
long end = System.currentTimeMillis();
System.out.println(end-start);
sum += (end-start);
}
System.out.println("平均用时:"+sum/10);
}
测试分十轮迭代,每次迭代都输出用时,最后计算平均用时。
集合规模为一千万,每次迭代随机做一千万次合并和一千万次查询。
size 优化和 depth 优化的性能对比
对比来看 size 优化的效果要更好。
组合策略优化的性能对比
从路径压缩和 depth 优化的组合来看,折半路径压缩优于完全路径压缩,完全路径压缩的递推算法优于其递归算法。
所以并查集最优的优化组合为折半路径压缩和 size 优化。
本文所有代码见 github