并查集(UnionFind)及优化
内容学习自小码哥的《恋上数据结构与算法》,图片来自视频截图。
什么是并查集?先看下面问题。
解决上面这个问题我们需要设计一种新的数据结构,能够快速合并2组数据为一组。同时又能快速识别两个数是否为一组。
符合这种条件的就是 并查集
并查集两个核心方法就是:Union和Find。
下面是以整数为例。
Quick Find
这是第一种实现方式,注意不是推荐实现。
思路:
用数组的index代表整数,value代表他的父元素。
父元素或者父节点相同的我们视为一组元素。
所以每个元素的初始父元素就是自己,每个元素单独为一组。
public class UnionFind {
private int[] parents;
public UnionFind(int capacity) {
if (capacity < 0) {
throw new IllegalArgumentException("capacity must be >0");
}
parents = new int[capacity];
for (int i = 0; i < parents.length; i++) {
parents[i] = i;//每个元素的父元素是自己
}
}
...
}
Union
如何合并两组元素?
其实只要改变组内所有元素的父节点都为为一个。
比如下图,将左边的值合并到右边:union(v1,v2)就是将与v1父字节点一样的结点都将自己的父节点改为v2的父节点。
可以看到树的高度最高就是2。
public class UnionFind {
...
/**
* 将元素v1所在的组合并到元素v2所在的组中
*
* @param v1 元素
* @param v2 元素
* 其实就是修改他们的父元素
*/
public void union(int v1, int v2) {
int parent1 = find(v1);
int parent2 = find(v2);
if (parent1 == parent2) return;
for (int i = 0; i < parents.length; i++) {
if (parents[i] == parent1) {
parents[i] = parent2;
}
}
}
...
}
注意需要改变v1所在组中所有元素(即父节点跟v1的所有元素)的父节点。
Find
直接获取数组位置的值就是获取父元素,父元素相同 就是一组的。
所以find的时间复杂度是O(1)
public class UnionFind {
...
/**
* 查找当前元素的父元素
*
* @param element 当前元素
* @return 父元素
*/
public int find(int element) {
rangCheck(element);
return parents[element];
}
/**
* 判断这两个元素是否在一组,父元素相同认为是一组
*
* @param v1 元素1
* @param v2 元素2
* @return 是否为一组
*/
public boolean isSame(int v1, int v2) {
return find(v1) == find(v2);
}
...
}
写完了,其实很简单。时间复杂度是O(N)
根据下图编写测试代码:
public class UninFindMain {
public static void main(String[] args) {
UnionFind uf=new UnionFind(12);
uf.union(0, 1);
uf.union(0, 3);
uf.union(0, 4);
uf.union(2, 3);
uf.union(2, 5);
uf.union(6, 7);
uf.union(8, 10);
uf.union(9, 10);
uf.union(9, 11);
System.out.println(uf.isSame(0,6));
System.out.println(uf.isSame(6,7));
uf.union(1,6);
System.out.println(uf.isSame(0,7));
}
}
输出结果如下:
false
true
true
Process finished with exit code 0
可以看到上面Find步骤复杂度是O(1)的,find很快所以称作QuickFind。但是Union的发咋度确实O(N)。
Quick Union
区别于QuickFind,QuickUnion的写法是find和union的时间复杂度都是O(logN)。
Union
与QuickFind的区别是,找到当前组的 根节点,改变根节点指向即可,不需要改变所有结点的指向。
Find
与QuickFind的区别在于,这里需要不停向上找,直到找到根节点返回。根节点相同的认为是一组的。
Find的时间复杂度其实就是树的高度,是O(logN)。
Union的时间复杂度也是O(logN),因为要做通过find找到两个根节点,然后跟换指向。O(logN)+O(logN)+O(1)=O(logN)
代码实现很简单:
@Override
public int find(int element) {
rangCheck(element);
while (element != parents[element]) {
element = parents[element];
}
return element;
}
@Override
public void union(int v1, int v2) {
//找到根节点
int p1 = find(v1);
int p2 = find(v2);
if (p1 == p2) return;
//将根节点的元素的值 改成v2根节点
parents[p1] = p2;
}
Quick Union优化
QuickUnion在合并的时候可能会出现树不平衡状况,甚至树退化成了链表。这样union的复杂度就是O(N)了。
基于size的优化
之前在union时都是将左边的元素合并到右边。如果是上图的情况完全可以将右边合并到左边。这样就又回到了树结构。
思路:元素少的合并到元素多的组中。
开辟一个数组size[]用来存在每个树的size。存储时以这个树的根节点作为index,value存储size值。
那么一开始,value都是1。
/**
* QuickUnion size的优化
*/
public class UnionFind_QU_S extends UnionFind_QU {
private int sizes[];
protected UnionFind_QU_S(int capacity) {
super(capacity);
sizes = new int[capacity];
for (int i = 0; i < capacity; i++) {
sizes[i] = 1;
}
}
...
}
public int find(int element)方法不需要动,只需要优化public void union(int v1, int v2)。
/**
* 将v1的根节点指向v2的根节点位置,从而实现合并
*
* @param v1 元素
* @param v2 元素
*/
@Override
public void union(int v1, int v2) {
//找到根节点
int p1 = find(v1);
int p2 = find(v2);
if (p1 == p2) return;
if (sizes[p1] < sizes[p2]) {
//p1作为根节点的树size小于 p2作为根节点的树的size 将根节点的元素的值 改成v2根节点
parents[p1] = p2;
//p2的size增加
sizes[p2] += sizes[p1];
} else {
//与上面的想反
parents[p2] = p1;
sizes[p1] += sizes[p2];
}
}
基于rank的优化
即矮的树合并到高的树中去。
很明显这样树更平衡,比基于size要好。
同上使用一个数组来存储树的高度rank。起始树的高度都是1:
/**
* QuickUnion rank的优化
*/
public class UnionFind_QU_R extends UnionFind_QU {
private int ranks[];
protected UnionFind_QU_R(int capacity) {
super(capacity);
ranks = new int[capacity];
for (int i = 0; i < capacity; i++) {
ranks[i] = 1;
}
}
...
}
同样 public int find(int element)方法不需要动,只需要优化public void union(int v1, int v2)。
需要注意:rank的高度在矮的合并到高的树时,高的树的高度是不变的。只要当两个树的高度相同时,新合成的树的高度需要增加。
/**
* 将v1的根节点指向v2的根节点位置,从而实现合并
*
* @param v1 元素
* @param v2 元素
*/
@Override
public void union(int v1, int v2) {
//找到根节点
int p1 = find(v1);
int p2 = find(v2);
if (p1 == p2) return;
if (ranks[p1] < ranks[p2]) {
//p1作为根节点的树高度小于 p2作为根节点的树的高度
parents[p1] = p2;
//矮的嫁接到高的 高的高度不用改动
} else if (ranks[p1] > ranks[p2]) {
//与上面的想反
parents[p2] = p1;
} else {
//两个数的高度一样,树的高度+1,谁合并到谁无所谓,主要是高度别加错
parents[p1] = p2;
ranks[p2]++;
}
}
PathCompression(路径压缩)
虽然基于rank的优化,树会相对平衡点。
但是树这合并的次数增加,树的高度仍然会越来越高。从而导致每次find的时间复杂度增大。
由于在执行find操作时,我们会遍历树上的一条路径。我们可以在此时进行优化--> 路径压缩
在执行find时,将路径上的所有结点都指向根节点。从而降低树的高度。
由此可见,我们只需要重写基于rank优化的find(element)方法即可:
/**
* QuickUnion rank的优化 的路径压缩(Path Compression)
* <p>
* 重写find即可
*/
public class UnionFind_QU_R_PC extends UnionFind_QU_R {
protected UnionFind_QU_R_PC(int capacity) {
super(capacity);
}
@Override
public int find(int v) {
rangCheck(v);
if (parents[v] != v) {
parents[v] = find(parents[v]);
}
//路径压缩后 v和parents[v]不一样了
return parents[v];
}
}
随机找3000000个数。对比下速度。
【UnionFind_QU_S】
开始:18:41:28.591
结束:18:41:30.448
耗时:1.856秒
-------------------------------------
【UnionFind_QU_R】
开始:18:41:30.478
结束:18:41:32.371
耗时:1.893秒
-------------------------------------
【UnionFind_QU_R_PC】
开始:18:41:32.383
结束:18:41:34.075
耗时:1.692秒
-------------------------------------
Process finished with exit code 0
可以看出来路径压缩提升并不多。主要是find里面的操作比较耗时。
PathSpliting(路径分裂)
上一个方法路径压缩由于要求树过于平衡对其性能有一定影响。
所以我们可以修改下压缩路径的方式。
路径分列:使路径上每个节点都指向其祖父节点。
如下图:
将原有路径一分为二。
与路径压缩就是find方法有些区别。
/**
* QuickUnion rank的优化 的路径分裂(Path Splitting)
* <p>
* 重写find即可
*/
public class UnionFind_QU_R_PS extends UnionFind_QU_R {
protected UnionFind_QU_R_PS(int capacity) {
super(capacity);
}
@Override
public int find(int v) {
rangCheck(v);
while (parents[v] != v) {//找到根节点
//先保留父节点
int p = parents[v];
//v指向它的祖父节点
parents[v] = parents[parents[v]];
v = p;//不修改就漏了2->4->6...
}
return parents[v];
}
}
Path Halving(路径减半)
这个其实跟路径分裂差不多,只是路径压缩的另一种优化。
路径减半:使路径上每隔一个节点就指向其祖父节点。
如名字就是将原有路径长度缩成原有的一半。
/**
* QuickUnion rank的优化 的路径减半(Path Halving)
* <p>
* 重写find即可
*/
public class UnionFind_QU_R_PH extends UnionFind_QU_R {
protected UnionFind_QU_R_PH(int capacity) {
super(capacity);
}
@Override
public int find(int v) {
rangCheck(v);
while (parents[v] != v) {//找到根节点
//v指向它的祖父节点
parents[v] = parents[parents[v]];
v = parents[v];// 1->3->5...
}
return parents[v];
}
}
总结
随机生成了3000000个数值,对比下优化后的性能
【UnionFind_QU_S】 基于Size的优化
开始:22:32:10.819
结束:22:32:12.943
耗时:2.123秒
-------------------------------------
【UnionFind_QU_R】 基于Rank的优化
开始:22:32:12.971
结束:22:32:14.849
耗时:1.878秒
-------------------------------------
【UnionFind_QU_R_PC】 基于Rank的优化+路径压缩
开始:22:32:14.861
结束:22:32:16.425
耗时:1.564秒
-------------------------------------
【UnionFind_QU_R_PS】基于Rank的优化+路径分裂
开始:22:32:16.431
结束:22:32:18.008
耗时:1.577秒
-------------------------------------
【UnionFind_QU_R_PH】基于Rank的优化+路径减半
开始:22:32:18.024
结束:22:32:19.568
耗时:1.544秒
-------------------------------------
Process finished with exit code 0
测试代码如下:
public class UninFindMain {
static final int count = 3000000;
public static void main(String[] args) {
// testTime(new UnionFind_QF(count));
// testTime(new UnionFind_QU(count));
testTime(new UnionFind_QU_S(count));
testTime(new UnionFind_QU_R(count));
testTime(new UnionFind_QU_R_PC(count));
testTime(new UnionFind_QU_R_PS(count));
testTime(new UnionFind_QU_R_PH(count));
}
static void testTime(UnionFind uf) {
uf.union(0, 1);
uf.union(0, 3);
uf.union(0, 4);
uf.union(2, 3);
uf.union(2, 5);
uf.union(6, 7);
uf.union(8, 10);
uf.union(9, 10);
uf.union(9, 11);
Asserts.test(!uf.isSame(2, 7));
uf.union(4, 6);
Asserts.test(uf.isSame(2, 7));
Times.test(uf.getClass().getSimpleName(), () -> {
for (int i = 0; i < count; i++) {
uf.union((int) (Math.random() * count),
(int) (Math.random() * count));
}
for (int i = 0; i < count; i++) {
uf.isSame((int) (Math.random() * count),
(int) (Math.random() * count));
}
});
}
}
建议使用路径分裂/路径压缩/路径减半+基于Rank优化。时间复杂度能到 O( α(n)), α(n)<5。