并查集是一种简洁优雅的数据结构,本文是笔者对并查集的学习和整理,包含原理、优化方法和代码示例等。发出来作为个人的并查集模板归档。
1. 概念与特性
并查集主要用于解决一些 元素分组 问题,它管理 一系列不相交的集合,并支持两种操作:
- 查询(Find):查询两个元素是否在同一个集合中。
- 合并(Union):把两个不相交的集合合并为一个集合。
并查集的核心思想在于,用集合中的一个元素代表集合。并查集是用数组描述的一种树结构,数组的索引表示子节点,数组的值表示父节点。要寻找集合的代表元素,只需要一层一层往上访问父节点,直达树的根节点即可。根节点的父节点是它自己。
class UnionFind {
private int fa[] = new int[N];
public void init() {
for (int i = 1; i < N; i++) {
fa[i] = i;
}
}
public int find(int x) {
if (fa[x] == x) {
return x;
}
return find(fa[x]);
}
public void union(in x, int y) {
fa[find(x)] = find(y);
}
}
2. 路径压缩
最简单并查集的效率比较低,可能会形成一条长长的链。既然我们只关心一个元素对应的 根节点,那我们希望每个元素到根节点的路径尽可能短,最好只需要一步,所以我们在查询的过程中,把沿途的每个节点的父节点都设为根节点即可。
class UnionFind {
// ...
public int find(int x) {
if (fa[x] == x) {
return x;
}
fa[x] = find(fa[x]); // 路径压缩的核心步骤
return fa[x];
}
// ...
}
3. 按秩合并
由于路径压缩只对访问过的路径进行压缩,不是每个节点的父节点都是根节点。所以并查集的结构仍然可能是负载的。看下面的情形:
1 和 8 合并,这时我们有两种方案,8 作为 1 的父节点,或 1 作为 8 的父节点。
显然第一种方式效率更加高。
因为如果把 8 作为 1 的父节点,会使得树的深度(树中最长链的长度)加深,原来树的每个节点到根的距离都变长了,后面我们每次查询根节点复杂度也会变高。而把 1 作为 8 的父节点就不会有这个问题。因此我们每次合并的时候都应该把简单的树合并到复杂的树上面,保证每次合并到根节点距离变长的节点数量最少。
我们用一个数组 rank 记录每个根节点对应的树的深度(如果不是根节点,其 rank 相当于以它作为根节点的子树的深度)。一开始,把所有元素的 rank 设为一。合并时比较两个根节点,把深度较小者往较大者上合并,并更新 rank。在数学上,rank 的含义是并查集的 秩。
如果一起使用路径压缩和按秩合并,时间复杂度接近 ,但是很可能会破坏 rank 的准确性(相关证明可以在网上搜索关键字「并查集」、「反阿克曼函数」)。
class unionFind {
private int fa[] = new int[N];
private int rank[] = new int[N];
public void init() {
for (int i = 1; i < N; i++) {
fa[i] = i;
rank[i] = 1;
}
}
public int find(int x) {
if (fa[x] == x) {
return x;
}
fa[x] = find(fa[x]); // 路径压缩
return fa[x];
}
public void union(in x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rank[rootX] <= rank[rootY]) {
fa[rootX] = rootY;
} else {
fa[rootY] = rootX;
}
// 特殊情况下新的根节点深度加一
if (rank[rootX] == rank[rootY] && rootX != rootY) {
rank[rootY]++;
}
}
}
上面代码中提到了一类特殊情况,需要将合并后的新的根节点深度加一,我们通过一个案例来分析下原因。
有两棵深度为 2 的树需要合并,无论是将 4 的父节点设为 1,还是把 1 的父节点设为 4,结果都是一样的,新的根节点深度为 3。
所以,在使用按秩合并时,如果两个不同集合的秩相同,合并后的集合的秩需要加一。