并查集(Union Find)
一、概述
由孩子节点指向父亲节点的树结构,解决连接问题,如图来判断两个点之间是否是连接的
并查集:可以快速判断网络中节点间的连接状态【网络:抽象概念,用户之间形成的网络】可以高效回答连接问题的数据结构
对于一组数据,主要支持两个动作:
1.uoion(p,q) --并,传入数据 p 和 q,在并查集内部将这两个数据,以及他们所在的集合合并起来
2.isConnected(p,q) ---查询 ,对于给定的两个数据 p 和 q 是否属于同一个集合
二、并查集的基本数据表示
第一版Union-Find本质就是一个数组
对 10 个元素 (0-9)分成 2 个集合,其中 (0-4) 这 5 个元素为 集合0 ;(5-9) 这 5 个元素为 集合1
或者
在 isConnected(p,q) ---查询 中,只需要查看 p 和 q 所对应的 Id 值是否相等即可,将查看 p 和 q 背后的 Id 是谁的过程抽象为函数:find(p) == find(q) ,这种方式为 Quick Find,其时间复杂度为 O(1)
但 Quick Find 中的 uoion(p,q) --并 时间复杂度为 O(n);合并过程需要遍历一遍所有元素, 将两个元素的所属集合编号合并
代码实现:
UF.java
public interface UF { //定义接口
int getSize(); //元素数量
boolean isConnected(int p, int q);
void unionElements(int p, int q);
}
UnionFind1.java
// 我们的第一版Union-Find
public class UnionFind1 implements UF {
private int[] id; // 我们的第一版Union-Find本质就是一个数组
public UnionFind1(int size) {
id = new int[size];
// 初始化, 每一个id[i]指向自己, 没有合并的元素
for (int i = 0; i < size; i++)
id[i] = i;
}
@Override
public int getSize(){
return id.length;
}
// 查找元素p所对应的集合编号
// O(1)复杂度
private int find(int p) {
if(p < 0 || p >= id.length)
throw new IllegalArgumentException("p is out of bound.");
return id[p];
}
// 查看元素p和元素q是否所属一个集合
// O(1)复杂度
@Override
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
// 合并元素p和元素q所属的集合
// O(n) 复杂度
@Override
public void unionElements(int p, int q) {
int pID = find(p);
int qID = find(q);
if (pID == qID)
return;
// 合并过程需要遍历一遍所有元素, 将两个元素的所属集合编号合并
for (int i = 0; i < id.length; i++)
if (id[i] == pID)
id[i] = qID;
}
}
第二版Union-Find, 使用一个数组构建一棵指向父节点的树
将每一个元素,看做是一个节点;其中,节点 3 指向其父节点 2,2 为根节点,其指针指向自己即可;
节点1 和 节点3 合并,则让 节点1 所对应的指针指向 节点3 所在树的根节点,即 节点2;
如果让 节点7 和 节点2 合并 ,即让 节点7 所在的 根节点5 ,指向 节点2 即可;
如果让 节点7 和 节点3 合并 ,即让 节点7 所在的 根节点5 ,指向 节点3 所在树的 根节点2 即可;
Quick Union:并查集不是一个树结构,而是 森林结构 ,里面存在很多树 ,在初始亲情况下有 10 棵树,每棵树只有一个节点;
如果执行 union 4,3 操作
再执行 union 3,8
再执行 union 6,5
再执行 union 9,4[让节点 9 指向 节点4 所在的根节点]
在 Quick Union 中,查操作 与 并操作 的时间复杂度均为 O(h) ,h为树的高度
代码实现:
UF.java
public interface UF { //定义接口
int getSize(); //元素数量
boolean isConnected(int p, int q);
void unionElements(int p, int q);
}
UnionFind2.java
// 我们的第二版Union-Find
public class UnionFind2 implements UF {
// 我们的第二版Union-Find, 使用一个数组构建一棵指向父节点的树
// parent[i]表示第一个元素所指向的父节点
private int[] parent;
// 构造函数
public UnionFind2(int size){
parent = new int[size];
// 初始化, 每一个parent[i]指向自己, 表示每一个元素自己自成一个集合
for( int i = 0 ; i < size ; i ++ )
parent[i] = i;
}
@Override
public int getSize(){
return parent.length;
}
// 查找过程, 查找元素p所对应的集合编号
// O(h)复杂度, h为树的高度
private int find(int p){
if(p < 0 || p >= parent.length)
throw new IllegalArgumentException("p is out of bound.");
// 不断去查询自己的父亲节点, 直到到达根节点
// 根节点的特点: parent[p] == p
while(p != parent[p])
p = parent[p];
return p;
}
// 查看元素p和元素q是否所属一个集合
// O(h)复杂度, h为树的高度
@Override
public boolean isConnected( int p , int q ){
return find(p) == find(q);
}
// 合并元素p和元素q所属的集合
// O(h)复杂度, h为树的高度
@Override
public void unionElements(int p, int q){
int pRoot = find(p);
int qRoot = find(q);
if( pRoot == qRoot )
return;
parent[pRoot] = qRoot;
}
}
三、优化
基于size的优化
并查集的实现由于对真正合并的元素不做形状上的判断,这个合并的过程会不断增加树的高度,甚至演化为 链表;
解决办法:考虑(size)当前这棵树整体有多少个节点,例如 union 4,9,正常操作如下图
这时的高度为 4,但完全可以让 9 指向 4 所在的根节点 8 ,即 9 指向 8;高度变为 3
代码实现:UnionFind3.java
// 我们的第三版Union-Find
public class UnionFind3 implements UF{
private int[] parent; // parent[i]表示第一个元素所指向的父节点
private int[] sz; // sz[i]表示以i为根的集合中元素个数【新增代码】
// 构造函数
public UnionFind3(int size){
parent = new int[size];
sz = new int[size]; //【新增代码】
// 初始化, 每一个parent[i]指向自己, 表示每一个元素自己自成一个集合
for(int i = 0 ; i < size ; i ++){
parent[i] = i;
sz[i] = 1; //【新增代码】
}
}
@Override
public int getSize(){
return parent.length;
}
// 查找过程, 查找元素p所对应的集合编号
// O(h)复杂度, h为树的高度
private int find(int p){
if(p < 0 || p >= parent.length)
throw new IllegalArgumentException("p is out of bound.");
// 不断去查询自己的父亲节点, 直到到达根节点
// 根节点的特点: parent[p] == p
while( p != parent[p] )
p = parent[p];
return p;
}
// 查看元素p和元素q是否所属一个集合
// O(h)复杂度, h为树的高度
@Override
public boolean isConnected( int p , int q ){
return find(p) == find(q);
}
// 合并元素p和元素q所属的集合
// O(h)复杂度, h为树的高度
@Override
public void unionElements(int p, int q){
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot)
return;
// 根据两个元素所在树的元素个数不同判断合并方向
// 将元素个数少的集合合并到元素个数多的集合上
if(sz[pRoot] < sz[qRoot]){ //【新增代码】
parent[pRoot] = qRoot;
sz[qRoot] += sz[pRoot];
}
else{ // sz[qRoot] <= sz[pRoot]
parent[qRoot] = pRoot;
sz[pRoot] += sz[qRoot];
}
}
}
检测时间复杂度:Main.java
import java.util.Random;
public class Main {
private static double testUF(UF uf, int m){
int size = uf.getSize();
Random random = new Random();
long startTime = System.nanoTime();
for(int i = 0 ; i < m ; i ++){
int a = random.nextInt(size);
int b = random.nextInt(size);
uf.unionElements(a, b);
}
for(int i = 0 ; i < m ; i ++){
int a = random.nextInt(size);
int b = random.nextInt(size);
uf.isConnected(a, b);
}
long endTime = System.nanoTime();
return (endTime - startTime) / 1000000000.0;
}
public static void main(String[] args) {
// UnionFind1 慢于 UnionFind2
// int size = 100000;
// int m = 10000;
// UnionFind2 慢于 UnionFind1, 但UnionFind3最快
int size = 100000;
int m = 100000;
UnionFind1 uf1 = new UnionFind1(size);
System.out.println("UnionFind1 : " + testUF(uf1, m) + " s");
UnionFind2 uf2 = new UnionFind2(size);
System.out.println("UnionFind2 : " + testUF(uf2, m) + " s");
UnionFind3 uf3 = new UnionFind3(size);
System.out.println("UnionFind3 : " + testUF(uf3, m) + " s");
}
}
输出:如此优化,性能得到极大提升
基于rank的优化【树的高度】
执行 union 4,2 ,以 size 优化方式执行,高度变高了
更合理的方式: 在每一个节点上记录以这个节点为根的对应的树,其最大深度为多少,在真正合并的时候,应该使用深度比较低的那棵树向深度比较高的树进行合并;整体更加合理;称为 基于rank 的优化,k[i] 表示根节点为 i 的树的高度
代码实现:
UnionFind4.java
// 我们的第四版Union-Find
public class UnionFind4 implements UF {
private int[] rank; // rank[i]表示以i为根的集合所表示的树的层数
private int[] parent; // parent[i]表示第i个元素所指向的父节点
// 构造函数
public UnionFind4(int size){
rank = new int[size];
parent = new int[size];
// 初始化, 每一个parent[i]指向自己, 表示每一个元素自己自成一个集合
for( int i = 0 ; i < size ; i ++ ){
parent[i] = i;
rank[i] = 1;
}
}
@Override
public int getSize(){
return parent.length;
}
// 查找过程, 查找元素p所对应的集合编号
// O(h)复杂度, h为树的高度
private int find(int p){
if(p < 0 || p >= parent.length)
throw new IllegalArgumentException("p is out of bound.");
// 不断去查询自己的父亲节点, 直到到达根节点
// 根节点的特点: parent[p] == p
while(p != parent[p])
p = parent[p];
return p;
}
// 查看元素p和元素q是否所属一个集合
// O(h)复杂度, h为树的高度
@Override
public boolean isConnected( int p , int q ){
return find(p) == find(q);
}
// 合并元素p和元素q所属的集合
// O(h)复杂度, h为树的高度
@Override
public void unionElements(int p, int q){
int pRoot = find(p);
int qRoot = find(q);
if( pRoot == qRoot )
return;
// 根据两个元素所在树的rank不同判断合并方向
// 将rank低的集合合并到rank高的集合上
if(rank[pRoot] < rank[qRoot])
parent[pRoot] = qRoot;
else if(rank[qRoot] < rank[pRoot])
parent[qRoot] = pRoot;
else{ // rank[pRoot] == rank[qRoot]
parent[pRoot] = qRoot;
rank[qRoot] += 1; // 此时, 我维护rank的值
}
}
}
测试时间复杂度:Main.java
import java.util.Random;
public class Main {
private static double testUF(UF uf, int m){
int size = uf.getSize();
Random random = new Random();
long startTime = System.nanoTime();
for(int i = 0 ; i < m ; i ++){
int a = random.nextInt(size);
int b = random.nextInt(size);
uf.unionElements(a, b);
}
for(int i = 0 ; i < m ; i ++){
int a = random.nextInt(size);
int b = random.nextInt(size);
uf.isConnected(a, b);
}
long endTime = System.nanoTime();
return (endTime - startTime) / 1000000000.0;
}
public static void main(String[] args) {
int size = 10000000;
int m = 10000000;
// UnionFind1 uf1 = new UnionFind1(size);
// System.out.println("UnionFind1 : " + testUF(uf1, m) + " s");
//
// UnionFind2 uf2 = new UnionFind2(size);
// System.out.println("UnionFind2 : " + testUF(uf2, m) + " s");
UnionFind3 uf3 = new UnionFind3(size);
System.out.println("UnionFind3 : " + testUF(uf3, m) + " s");
UnionFind4 uf4 = new UnionFind4(size);
System.out.println("UnionFind4 : " + testUF(uf4, m) + " s");
}
}
输出:
路径压缩(Path Compression)
由上述几种优化方式,知
查找节点:左侧的高度最大,其执行速度最慢;下侧的高度最小,其执行速度最快;故将 高度大的树 压缩成为 高度小的树 称为路径压缩;
对并查集来说,每一个节点其子树是没有限制的,故理想情况下,希望树的形态如下侧那样(根节点在第一层,其余节点均在第二层),但很难实现吗,通常只要追求高度减小即可提高运行速率;
压缩过程:find 4 【在查询过程中,压缩路径,使高度变小】
1.
、
2.
3.
代码实现:
UnionFind5.java
// 我们的第五版Union-Find
public class UnionFind5 implements UF {
// rank[i]表示以i为根的集合所表示的树的层数
// 在后续的代码中, 我们并不会维护rank的语意, 也就是rank的值在路径压缩的过程中, 有可能不在是树的层数值
// 这也是我们的rank不叫height或者depth的原因, 他只是作为比较的一个标准
private int[] rank;
private int[] parent; // parent[i]表示第i个元素所指向的父节点
// 构造函数
public UnionFind5(int size){
rank = new int[size];
parent = new int[size];
// 初始化, 每一个parent[i]指向自己, 表示每一个元素自己自成一个集合
for( int i = 0 ; i < size ; i ++ ){
parent[i] = i;
rank[i] = 1;
}
}
@Override
public int getSize(){
return parent.length;
}
// 查找过程, 查找元素p所对应的集合编号
// O(h)复杂度, h为树的高度
private int find(int p){
if(p < 0 || p >= parent.length)
throw new IllegalArgumentException("p is out of bound.");
while( p != parent[p] ){
parent[p] = parent[parent[p]];
p = parent[p];
}
return p;
}
// 查看元素p和元素q是否所属一个集合
// O(h)复杂度, h为树的高度
@Override
public boolean isConnected( int p , int q ){
return find(p) == find(q);
}
// 合并元素p和元素q所属的集合
// O(h)复杂度, h为树的高度
@Override
public void unionElements(int p, int q){
int pRoot = find(p);
int qRoot = find(q);
if( pRoot == qRoot )
return;
// 根据两个元素所在树的rank不同判断合并方向
// 将rank低的集合合并到rank高的集合上
if( rank[pRoot] < rank[qRoot] )
parent[pRoot] = qRoot;
else if( rank[qRoot] < rank[pRoot])
parent[qRoot] = pRoot;
else{ // rank[pRoot] == rank[qRoot]
parent[pRoot] = qRoot;
rank[qRoot] += 1; // 此时, 我维护rank的值
}
}
}
测试:Main.java
import java.util.Random;
public class Main {
private static double testUF(UF uf, int m){
int size = uf.getSize();
Random random = new Random();
long startTime = System.nanoTime();
for(int i = 0 ; i < m ; i ++){
int a = random.nextInt(size);
int b = random.nextInt(size);
uf.unionElements(a, b);
}
for(int i = 0 ; i < m ; i ++){
int a = random.nextInt(size);
int b = random.nextInt(size);
uf.isConnected(a, b);
}
long endTime = System.nanoTime();
double time = (endTime - startTime) / 1000000000.0;
return time;
}
public static void main(String[] args) {
int size = 10000000;
int m = 10000000;
// UnionFind1 uf1 = new UnionFind1(size);
// System.out.println("UnionFind1 : " + testUF(uf1, m) + " s");
//
// UnionFind2 uf2 = new UnionFind2(size);
// System.out.println("UnionFind2 : " + testUF(uf2, m) + " s");
UnionFind3 uf3 = new UnionFind3(size);
System.out.println("UnionFind3 : " + testUF(uf3, m) + " s");
UnionFind4 uf4 = new UnionFind4(size);
System.out.println("UnionFind4 : " + testUF(uf4, m) + " s");
UnionFind5 uf5 = new UnionFind5(size);
System.out.println("UnionFind5 : " + testUF(uf5, m) + " s");
}
}
输出:、
理想状况:通过 递归 实现
代码实现:UnionFind6.java
// 我们的第六版Union-Find
public class UnionFind6 implements UF {
// rank[i]表示以i为根的集合所表示的树的层数
// 在后续的代码中, 我们并不会维护rank的语意, 也就是rank的值在路径压缩的过程中, 有可能不在是树的层数值
// 这也是我们的rank不叫height或者depth的原因, 他只是作为比较的一个标准
private int[] rank;
private int[] parent; // parent[i]表示第i个元素所指向的父节点
// 构造函数
public UnionFind6(int size){
rank = new int[size];
parent = new int[size];
// 初始化, 每一个parent[i]指向自己, 表示每一个元素自己自成一个集合
for( int i = 0 ; i < size ; i ++ ){
parent[i] = i;
rank[i] = 1;
}
}
@Override
public int getSize(){
return parent.length;
}
// 查找过程, 查找元素p所对应的集合编号
// O(h)复杂度, h为树的高度
private int find(int p){
if(p < 0 || p >= parent.length)
throw new IllegalArgumentException("p is out of bound.");
// path compression 2, 递归算法
if(p != parent[p])
parent[p] = find(parent[p]);
return parent[p];
}
// 查看元素p和元素q是否所属一个集合
// O(h)复杂度, h为树的高度
@Override
public boolean isConnected( int p , int q ){
return find(p) == find(q);
}
// 合并元素p和元素q所属的集合
// O(h)复杂度, h为树的高度
@Override
public void unionElements(int p, int q){
int pRoot = find(p);
int qRoot = find(q);
if( pRoot == qRoot )
return;
// 根据两个元素所在树的rank不同判断合并方向
// 将rank低的集合合并到rank高的集合上
if( rank[pRoot] < rank[qRoot] )
parent[pRoot] = qRoot;
else if( rank[qRoot] < rank[pRoot])
parent[qRoot] = pRoot;
else{ // rank[pRoot] == rank[qRoot]
parent[pRoot] = qRoot;
rank[qRoot] += 1; // 此时, 我维护rank的值
}
}
}
测试:Main.java
import java.util.Random;
public class Main {
private static double testUF(UF uf, int m){
int size = uf.getSize();
Random random = new Random();
long startTime = System.nanoTime();
for(int i = 0 ; i < m ; i ++){
int a = random.nextInt(size);
int b = random.nextInt(size);
uf.unionElements(a, b);
}
for(int i = 0 ; i < m ; i ++){
int a = random.nextInt(size);
int b = random.nextInt(size);
uf.isConnected(a, b);
}
long endTime = System.nanoTime();
double time = (endTime - startTime) / 1000000000.0;
return time;
}
public static void main(String[] args) {
int size = 10000000;
int m = 10000000;
// UnionFind1 uf1 = new UnionFind1(size);
// System.out.println("UnionFind1 : " + testUF(uf1, m) + " s");
//
// UnionFind2 uf2 = new UnionFind2(size);
// System.out.println("UnionFind2 : " + testUF(uf2, m) + " s");
UnionFind3 uf3 = new UnionFind3(size);
System.out.println("UnionFind3 : " + testUF(uf3, m) + " s");
UnionFind4 uf4 = new UnionFind4(size);
System.out.println("UnionFind4 : " + testUF(uf4, m) + " s");
UnionFind5 uf5 = new UnionFind5(size);
System.out.println("UnionFind5 : " + testUF(uf5, m) + " s");
UnionFind6 uf6 = new UnionFind6(size);
System.out.println("UnionFind6 : " + testUF(uf6, m) + " s");
}
}
输出:递归会产生一定的开销
补充:在方式 5 中也可以变为理想状态下的情况【不是通过 递归 ,通过 循环遍历 来实现】
时间复杂度:O(h) --- 严格意思上:O(log *n)