并查集
引出并查集
假设有 n 个村庄,有些村庄之间有连接的路,有些村庄之间并没有连接的路
设计一个数据结构,能够快速执行 2 个操作:
- 查询 2 个村庄之间是否有连接的路
- 连接 2 个村庄
如果使用数组、链表、平衡二叉树、集合(Set) 都可以完成需求,但是查询、连接的时间复杂度都是 O(n)。
并查集能做到查询、连接的均摊时间复杂度都是 O(α(n)),α(n) < 5,非常适合解决这类“连接”相关的问题。
并查集(Union Find)
并查集也叫作不相交集合(Disjoint Set)
并查集有2个核心操作:
- 查找(Find):查找元素所在的集合
(这里的集合并不是特指Set这种数据结构,是指广义的数据集合) - 合并(Union):将两个元素所在的集合合并为一个集合
有 2 种常见的实现思路:
-
Quick Find
查找(Find)的时间复杂度:O(1)
合并(Union)的时间复杂度:O(n) -
Quick Union
查找(Find)的时间复杂度:O(logn), 可以优化至 O(𝛼(𝑛)), α(𝑛) < 5
合并(Union)的时间复杂度:O(logn), 可以优化至 O(𝛼(𝑛)), α(𝑛) < 5
如何存储数据?
假设并查集处理的数据都是整型,那么可以用整型数组来存储数据。
- 数组索引代表元素值
- 索引对应的值代表这个元素的根节点
将{0,1,2,3,4,5,6,7}存储到数组中,如下图:
因此,并查集是可以用数组实现的树形结构(二叉堆、优先级队列也是可以用数组实现的树形结构)
接口定义
/**
* 查找v所属的集合(根结点)
*/
public abstract int find(int v);
/**
* 合并v1、v2所在的集合
*/
public abstract void union(int v1, int v2);
/**
* 检查v1、v2是否属于同一集合
*/
public boolean isSame(int v1, int v2);
isSame()
的实现十分简单:
public boolean isSame(int v1, int v2){
return find(v1) == find(v2);
}
元素的初始化
初始化时,每个元素各自属于一个单元素集合
private int[] parents;
public UnionFind(int capacity){
if(capacity < 0){
throw new IllegalArgumentException("capacity must >= 1");
}
parents = new int[capacity];
for (int i = 0; i < parents.length; i++) {
parents[i] = i;
}
}
UnionFind.java
这是个并查集的抽象类,后面的所有并查集都将继承它。
package com.mj.union;
public abstract class UnionFind {
protected int[] parents;
public UnionFind(int capacity) {
if(capacity < 0){
throw new IllegalArgumentException("capacity must be >= 1");
}
parents = new int[capacity];
for (int i = 0; i < parents.length; i++) {
parents[i] = i;
}
}
/**
* 查找v所属的集合(根结点)
*/
public abstract int find(int v);
/**
* 合并v1、v2所在的集合
*/
public abstract void union(int v1, int v2);
/**
* 检查v1、v2是否属于同一集合
*/
public boolean isSame(int v1, int v2){
return find(v1) == find(v2);
}
/**
* 检查传入值的合法性
*/
protected void rangeCheck(int v) {
if(v < 0 || v >= parents.length){
throw new IllegalArgumentException("v is out of bounds");
}
}
}
Quick Find
Quick Find 的 union(v1, v2)
:让 v1 所在集合的所有元素都指向 v2 的根节点。
并且 Quick Find 的高度永远保持 <= 2。
union 示例及实现
例如:
将{0,1,2,3,4,5}初始化为并查集,每个元素各自属于一个单元素集合:{0}, {1}, {2}, {3}, {4} 。
合并 1 和 0,union(1, 0)
,即 {1} 指向了 {2} 。
然后,合并 1 和 2,union(1, 2)
,1 所在集合有 {0, 1},即 {0, 1} 指向了 2 。
再合并 3 和 4,union(3, 4)
,即 {3} 指向了 {4} 。
合并 0 和 3,union(0, 3)
,0 所在集合为 {0, 1, 2},3 所在集合为 {3,4},如下:
/**
* 将v1所在集合的所有元素都嫁接到v2的父节点上
* v1 v2 union(v1,v2)
* 0 4 4
* 1 2 3 0 1 2 3
*/
public void union(int v1, int v2){
int p1 = parents[v1];
int p2 = parents[v2];
for (int i = 0; i < parents.length; i++) {
if(parents[i] == p1){
parents[i] = p2;
}
}
}
union 时间复杂度:O(n)
find 实现
Quick Find 查找的时候,由于数组中存储的就是根结点,因此直接取出即可。
对上图执行 find():
find(0) == 2
find(1) == 2
find(2) == 2
find(3) == 4
/**
* 父节点就是根节点
*/
public int find(int v){
rangeCheck(v);
return parents[v];
}
find 时间复杂度:O(1)
Quick Find 完整代码
package com.mj.union;
/**
* Quick Find
*/
public class UnionFind_QF extends UnionFind {
public UnionFind_QF(int capacity) {
super(capacity);
}
/**
* 父节点就是根节点
*/
public int find(int v){
rangeCheck(v);
return parents[v];
}
/**
* 将v1所在集合的所有元素都嫁接到v2的父节点上
* v1 v2 union(v1,v2)
* 0 4 4
* 1 2 3 0 1 2 3
*/
public void union(int v1, int v2){
int p1 = parents[v1];
int p2 = parents[v2];
for (int i = 0; i < parents.length; i++) {
if(parents[i] == p1){
parents[i] = p2;
}
}
}
}
Quick Union
Quick Find 的
union(v1, v2)
:让 v1 所在集合的所有元素都指向 v2 的根节点。
Quick Union 的 union(v1, v2)
:让 v1 的根节点指向 v2 的根节点 。(与Quick Find进行对比)
union 示例及实现
将{0,1,2,3,4,5}初始化为并查集,每个元素各自属于一个单元素集合。
union(1, 0)
,1的根结点(还是1) 指向 0的根结点(还是0)。
union(1, 2)
,1的根结点(0) 指向 2的根结点(2)。
union(3, 4)
,3的根结点(3) 指向 4的根结点(4)。
union(3, 1)
,3的根结点(4) 指向 1的根结点(2)。
/**
* 将v1的根节点嫁接到v2的根节点上
*/
public void union(int v1, int v2) {
int p1 = find(v1);
int p2 = find(v2);
if(p1 == p2) return;
parents[p1] = p2;
}
时间复杂度:O(logn)
find 实现
Quick Union 的查找,需要通过父节点不断往上找,直到找到根结点(某节点的根结点为自己)。
对上图执行 find():
find(0) == 2
find(1) == 2
find(3) == 2
find(4) == 2
/**
* 通过parent链条不断往上找,直到找到根节点
*/
@Override
public int find(int v) {
rangeCheck(v);
while(v != parents[v]){
v = parents[v];
}
return v;
}
Quick Union 完整实现
package com.mj.union;
/**
* Quick Union
*/
public class UnionFind_QU extends UnionFind {
public UnionFind_QU(int capacity) {
super(capacity);
}
/**
* 通过parent链条不断往上找,直到找到根节点
*/
@Override
public int find(int v) {
rangeCheck(v);
while(v != parents[v]){
v = parents[v];
}
return v;
}
/**
* 将v1的根节点嫁接到v2的根节点上
*/
public void union(int v1, int v2) {
int p1 = find(v1);
int p2 = find(v2);
if(p1 == p2) return;
parents[p1] = p2;
}
}
Quick Union – 优化
一般用的更多的是 Quick Union,我们研究一下它的优化方案。
在 union 的过程中,可能会出现树不平衡的情况,甚至退化成链表。
有 2 种常见的优化方案:
- 基于 size 的优化:元素少的树 嫁接到 元素多的树
- 基于 rank 的优化:矮的树 嫁接到 高的树
Quick Union – 基于size的优化
不是固定的让某一棵树嫁接到另一棵树,让元素少的树 嫁接到 元素多的树。
该类继承了 UnionFind_QU,表明它是在 Quick Union 的基础上优化,并且只需要重写uniont(int v1, int v2)
即可,find(int v)
无需做任何变化。
package com.mj.union;
/**
* Quick Union - 基于size的优化
*/
public class UnionFind_QU_S extends UnionFind_QU {
private int[] sizes;
public UnionFind_QU_S(int capacity) {
super(capacity);
sizes = new int[capacity];
for (int i = 0; i < sizes.length; i++) {
sizes[i] = 1;
}
}
/**
* 将v1的根节点嫁接到v2的根节点上
*/
public void union(int v1, int v2) {
int p1 = find(v1);
int p2 = find(v2);
if(p1 == p2) return;
if(sizes[p1] < sizes[p2]){
parents[p1] = p2;
sizes[p2] += sizes[p1];
}else{
parents[p2] = p1;
sizes[p1] += sizes[p2];
}
}
}
Quick Union – 基于rank的优化
上面所说的,基于size的优化,也可能会存在树不平衡的问题。
基于rank的优化,让矮的树 嫁接到 高的树。
该类继承了 UnionFind_QU,表明它是在 Quick Union 的基础上优化,并且只需要重写uniont(int v1, int v2)
即可,find(int v)
无需做任何变化。
package com.mj.union;
/**
* Quick Union - 基于rank的优化
* @author yusael
*/
public class UnionFind_QU_R extends UnionFind_QU {
private int[] ranks;
public UnionFind_QU_R(int capacity) {
super(capacity);
ranks = new int[capacity];
for (int i = 0; i < parents.length; i++) {
ranks[i] = 1;
}
}
public void union(int v1, int v2){
int p1 = find(v1);
int p2 = find(v2);
if(ranks[p1] < ranks[p2]){
parents[p1] = p2;
}else if(ranks[p1] > ranks[p2]){
parents[p2] = p1;
}else{ // ranks[p1] == ranks[p2]
parents[p1] = p2;
ranks[p2] += 1;
}
}
}
路径压缩(Path Compression)
虽然有了基于 rank 的优化,树会相对平衡一点,但是随着 union 次数的增多:树的高度依然会越来越高,导致 find 操作变慢,尤其是底层节点 (因为 find 是不断向上找到根节点) 。
什么是路径压缩?
- 在 find 时使路径上的所有节点都指向根节点,从而降低树的高度。
如下图,
find(1)
使路径上的 1,2 都指向了根结点 4。find(0)
使路径上的 0 指向了根结点 4。find(7)
使路径上的 7 指向了根结点 4。
该类继承了 UnionFind_QU_R,表明它是在 Quick Union 的 rank 优化的基础上,再优化,并且只需要重写find(int v)
即可。
package com.mj.union;
/**
* Quick Union - 基于rank的优化 - 路径压缩(Path Compression)
* @author yusael
*/
public class UnionFind_QU_R_PC extends UnionFind_QU_R {
public UnionFind_QU_R_PC(int capacity) {
super(capacity);
}
/**
* 在find时使路径上的所有节点都指向根节点,从而降低树的高度
*/
public int find(int v){
rangeCheck(v);
if(parents[v] != v){
parents[v] = find(parents[v]);
}
return parents[v];
}
}
路径压缩使路径上的所有节点都指向根节点,所以实现成本稍高。
还有2种更优的做法,不但能降低树高,实现成本也比路径压缩低:
- 路径分裂(Path Spliting)
- 路径减半(Path Halving)
路径分裂、路径减半的效率差不多,但都比路径压缩要好。
路径分裂(Path Spliting)
路径分裂:使路径上的每个节点都指向其祖父节点(parent的parent)。
该类继承了 UnionFind_QU_R,表明它是在 Quick Union 的 rank 优化的基础上,再优化,并且只需要重写find(int v)
即可。
package com.mj.union;
/**
* Quick Union - 基于rank的优化 - 路径分裂(Path Spliting)
*/
public class UnionFind_QU_R_PS extends UnionFind_QU_R {
public UnionFind_QU_R_PS(int capacity) {
super(capacity);
}
public int find(int v){
rangeCheck(v);
while(v != parents[v]){
int p = parents[v];
parents[v] = parents[parents[v]];
v = p;
}
return parents[v];
}
}
路径减半(Path Halving)
路径减半:使路径上每隔一个节点就指向其祖父节点(parent的parent)。
该类继承了 UnionFind_QU_R,表明它是在 Quick Union 的 rank 优化的基础上,再优化,并且只需要重写find(int v)
即可。
package com.mj.union;
/**
* Quick Union - 基于rank的优化 - 路径减半(Path Halving)
*/
public class UnionFind_QU_R_PH extends UnionFind_QU_R {
public UnionFind_QU_R_PH(int capacity) {
super(capacity);
}
public int find(int v){
rangeCheck(v);
while(v != parents[v]){
parents[v] = parents[parents[v]];
v = parents[v];
}
return v;
}
}
时间测试
package com.mj;
public class Main {
static final int count = 1000000;
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) {
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或者size的优化可以确保每个操作的均摊时间复杂度为 O(𝛼(𝑛)) ,α(𝑛) < 5。
建议搭配:
- Quick Union
- 基于 rank 的优化
- Path Halving 或 Path Spiliting
自定义类型使用并查集
- 方案一:通过一些方法将自定义类型转为整型后使用并查集(比如生成哈希值)
- 方案二:使用链表+映射(Map)
这里采用方案二实现:
package com.mj.union;
public class GenericUnionFind<V> {
private Map<V, Node<V>> nodes = new HashMap<>();
public void makeSet(V v){
if(nodes.containsKey(v)) return;
nodes.put(v, new Node<>(v));
}
/**
* 找出v的根结点
*/
private Node<V> findNode(V v){
Node<V> node = nodes.get(v);
if(node == null) return null;
while(!Objects.equals(node.value, node.parent.value)){
node.parent = node.parent.parent;
node = node.parent;
}
return node;
}
public V find(V v) {
Node<V> node = findNode(v);
return node == null ? null : node.value;
}
public void union(V v1, V v2){
Node<V> p1 = findNode(v1);
Node<V> p2 = findNode(v2);
if(p1 == null || p2 == null) return;
if (Objects.equals(p1.value, p2.value)) return;
if(p1.rank < p2.rank){
p1.parent = p2;
}else if(p1.rank > p2.rank){
p2.parent = p1;
}else{
p1.parent = p2;
p1.rank += 1;
}
}
public boolean isSame(V v1, V v2){
return Objects.equals(find(v1), find(v2));
}
private static class Node<V> {
V value;
Node<V> parent = this;
int rank = 1;
Node(V value) {
this.value = value;
}
}
}
时间测试
package com.mj;
import com.mj.tools.Asserts;
import com.mj.tools.Times;
import com.mj.union.GenericUnionFind;
import com.mj.union.UnionFind;
import com.mj.union.UnionFind_QU_R;
import com.mj.union.UnionFind_QU_R_PC;
import com.mj.union.UnionFind_QU_R_PH;
import com.mj.union.UnionFind_QU_R_PS;
import com.mj.union.UnionFind_QU_S;
public class Main {
static final int count = 10000000;
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));
testTime(new GenericUnionFind<Integer>());
}
static void testTime(GenericUnionFind<Integer> uf) {
for (int i = 0; i < count; i++) {
uf.makeSet(i);
}
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));
}
});
}
static void testTime(UnionFind uf) {
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));
}
});
}
}
GenericUnionFind 通用性是最强的,自然比之前的要慢一些。