不相交集合(disjoint-set)又称为并查集(union-find),用于处理一系列没有重复元素的集合(不交集)的合并与查询问题,并查集支持如下操作:
- 查询:查询某个元素属于哪个集合。返回集合内的一个“代表元素”。
- 合并:两个集合并成一个。
- 添加:添加一个新集合,其中有一个新元素。
9.2 快速查找
引子:实现高级算法的基础数据结构和工具很重要。不相交集合如果用集合列表实现的话会很慢,代码也很复杂。更适合的数据结构是只使用一个int列表,如下文所解释。
直观地说,我们可以首先考虑将 "互不相交集 "表示为一个集合列表,例如 List<Set<Integer>>。
例如,如果我们有 N=6 个元素,并且没有任何元素被连接,那么我们的集合列表看起来就像这样:[{0}, {1}, {2}, {3}, {4}, {5}, {6}].看起来不错。但是,考虑一下如何完成 connect(5, 6) 这样的操作。我们必须遍历多达 N 个集合才能找到 5,再遍历 N 个集合才能找到 6。我们的运行时间就变成了 O(N)。而且,如果要尝试实现这一点,代码会相当复杂。
QuickFind(Disjoint Set)如下图:
数组的索引代表集合的元素。
索引上的值就是它所属的集合编号。
例如,我们将{0, 1, 2, 4}、{3, 5}、{6}表示为
![](https://i-blog.csdnimg.cn/blog_migrate/c77ad92a0a31564c62c0159cf81b6542.png)
数组索引(0...6)是元素。id[i]处的值是它所属的集合。只要同一集合中的所有元素都有相同的 id,那么具体的集合编号并不重要。
让我们看看连接操作是如何进行的。现在,id[2] = 4,id[3] = 5。调用 connect(2, 3) 后,id 为 4 和 5 的所有元素的 id 应该相同。让我们暂时把它们都赋值为 5:
isConnected(x, y)
要检查 isConnected(x,y),我们只需检查 id[x] 是否 == id[y]。请注意,这是一个恒定时间操作!
我们称这种实现为 "快速查找",因为查找元素是否相连需要恒定的时间。
QuickFind示例程序如下:
// Java
public class QuickFindDS implements DisjointSets {
private int[] id;
/* Θ(N) */
public QuickFindDS(int N){
id = new int[N];
for (int i = 0; i < N; i++){
id[i] = i;
}
}
/* need to iterate through the array => Θ(N) */
public void connect(int p, int q){
int pid = id[p];
int qid = id[q];
for (int i = 0; i < id.length; i++){
if (id[i] == pid){
id[i] = qid;
}
}
}
/* Θ(1) */
public boolean isConnected(int p, int q){
return (id[p] == id[q]);
}
}
// c++
class QuickFind {
private:
int* id;
int size;
public:
QuickFind(int n) {
size = n;
id = new int[n];
for (int i = 0; i < n; i++) {
id[i] = i;
}
}
bool connected(int p, int q) {
return id[p] == id[q];
}
void unionSet(int p, int q) {
int pid = id[p];
int qid = id[q];
for (int i = 0; i < size; i++) {
if (id[i] == pid) {
id[i] = qid;
}
}
}
};
9.3 QuickUnion
QuickFind & QuickUnion
Quickfind的优点是IsConnect很快,缺点是Connect很慢。要修改connect情况。我们必须修改相当于一个集合里的项数那么多的数字。例如在这个图中,如果我们要把第一个集合和第二个集合连接起来,就要吧int[]里的所有3全改为2。为了加快Connect的速度,我们引入QuickUnion。
为了使操作快速,我们仍然会用数组来表示我们的集合。我们将每个集合想象成一棵树,我们为每个项目分配其父节点的值,而不是其ID。如果一个项目没有父节点,那么他是一个根,根节点不妨赋值为-1。这样的结构实际上就是树。例如:
如此,我们进行Connect的时候只需要修改一个值即可,很快啊。 最好是把短树的根节点连接成为大树的根节点的子节点。但是有一个问题是因为要连接到根部,就要找到根部,这时不得不从最下层的叶子节点一个一个遍历到根部,搜索到树的根部浪费时间,尤其是当树高度足够大时。
public class QuickUnion {
private int[] id;
public QuickUnion(int n) {
id = new int[n];
for (int i = 0; i < n; i++) {
id[i] = i;
}
}
private int root(int p) {
while (p != id[p]) {
p = id[p];
}
return p;
}
public boolean connected(int p, int q) {
return root(p) == root(q);
}
public void unionSet(int p, int q) {
int rootP = root(p);
int rootQ = root(q);
id[rootP] = rootQ;
}
}
如果我们不把根部连接到根部,而是总是一个接一个连接节点,我们会得到一个高度为N的树。树的长度会快速增加,这很不好。
9.4 Weighted Quick Union(WQU)
在QuickUnion中,每次调用find时都必须遍历到树的根节点。因此,树的高度越小,计算越快。
为了降低树的高度,我们对QuickUnion做如下修改:
1. 跟踪树的大小(结点的数量,即"weight"),我们可以把这个信息(size of trees)存入树的根节点来代替-1。
2. 永远把较小的树的根连接到较大的树的根上,较小数的根作为较大树的根的子节点。如图:
这么做之后形成树的最大高度:log(N)
Connect和IsConnected的最大运行时间为O(logN)。
既然目的是让高度(height)尽量小,为什么根据权重(weight)而不根据高度(height)来链接树。事实证明,Height Quick Union实现起来更复杂,而且得到的高度大小结果是同样的 Θ(log N) 高度限制。
具体实现(在QuickUnion基础上)
- isConnect(int p, int q)函数无需更改
- Connect(int p, int q)函数需要跟踪树的大小:
- lab6中揭晓
- 两个常用方法
- 在树的根节点数组中用数字" - 数的大小"代替-1。
- 或者直接创建一个单独的存放大小数据的数组。
- 在树的根节点数组中用数字" - 数的大小"代替-1。
在这里我们可以回到刚才提过的为什么不用Height Quick Union的问题,如果用高度标记根节点,高度大小的获取会相当之复杂,得不偿失。
public class WeightedQuickUnion {
private int[] id;
private int[] size;
public WeightedQuickUnion(int n) {
id = new int[n];
size = new int[n];
for (int i = 0; i < n; i++) {
id[i] = i;
size[i] = 1;
}
}
private int root(int i) {
while (i != id[i]) {
id[i] = id[id[i]]; // 路径压缩
i = id[i];
}
return i;
}
public boolean connected(int p, int q) {
return root(p) == root(q);
}
public void union(int p, int q) {
int rootP = root(p);
int rootQ = root(q);
if (rootP == rootQ) {
return;
}
if (size[rootP] < size[rootQ]) {
id[rootP] = rootQ;
size[rootQ] += size[rootP];
} else {
id[rootQ] = rootP;
size[rootP] += size[rootQ];
}
}
public static void main(String[] args) {
WeightedQuickUnion wqu = new WeightedQuickUnion(10);
wqu.union(4, 3);
wqu.union(3, 8);
wqu.union(6, 5);
wqu.union(9, 4);
wqu.union(2, 1);
System.out.println(wqu.connected(8, 9)); // true
System.out.println(wqu.connected(5, 4)); // false
wqu.union(5, 0);
wqu.union(7, 2);
wqu.union(6, 1);
wqu.union(7, 3);
for (int i = 0; i < 10; i++) {
System.out.print(wqu.root(i) + " ");
}
// 输出为:1 8 1 8 3 0 1 8 8 8
}
}
WQU的性能分析
最坏的结果中,树的高度为Θ(log N),如下图
所以可以认为WeightQuickUnion在尽量降低长度上并不比HeightQuickUnion差。
9.5 路径压缩(Path Compression)
我们不关心树的连通结构,只关心元素之间的连通性,以及树的高度——高度尽量小。
由此,我们可以将沿途的所有项目连接到根,将有助于使我们的树在每次调用find时都更短(不用爬树了)。相当于用空间换时间。
这样一层一层地往根节点上连,最终高度为2。
更具体地说,对于 N 个元素的 M 操作,具有路径压缩的WeightQuickUnion以 O(N + M (lg* N)) 为单位。lg* 是迭代对数,对于任何实际输入,它都小于 5。