- 并查集介绍
- 第一种实现
- 第二种实现
- 并查集的优化方式
- 考虑size
- 基于rank的优化
- 路径压缩优化
并查集介绍
米妮 https://www.amini.net 我们之前讲的树结构,都是由父亲节点指向孩子节点,而并查集却是由孩子指向父亲的这样一种数据结构。
给出图中任意的两点,问这两点之间是否可以通过一个路径连接起来?并查集就是处理这类连接问题的很好的数据结构。即用来处理网络中节点的连接状态,这里的网络是个抽象慨念,可以是用户之间形成 的网络。其实这类连接问题我们也可以使用集合类来进行实现,即求两个集合的并集。
本文设计的并查集主要支持两个操作:
- union(p,q) 并,对传入的两个数据p和q,在并查集内部将这两个数据,以及这两个数据所在的集合合并起来。
- isConnected(p,q)查询对于给定的两个数据,他们是否属于同一个集合。
由于并查集可以有不同的实现,我们可以设计一个并查集的接口:
public interface UF {
int getSize();
boolean isConnected(int p ,int q);
void unionElements(int p,int q);
}
对于具体元素是谁,我们并不关心,我们关心的是 id=p 和 id=q 这样的两个元素是否相连,而对于p和q对应具体的值,并不关心。
第一种实现
表示每一个元素都属于不同的集合,没有任意两个元素是相连的
public class UnionFind01 implements UF {
//我们第一种实现,Union-Find本质就是一个数组
private int[] id;
//有参构造:指定并查集中元素的个数
public UnionFind01(int size){
id = new int[size];
//初始化, 每一个id[i]指向自己, 没有合并的元素
for (int i=0; i<id.length; 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是否所属一个集合
@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, 使用一个数组构建一棵指向父节点的树,如图:
我们在初始化时,有10个元素,编号分别为0至9,即可以看成一棵只含有一个根节点的树结构,这个根节点的值即为索引值0至9,连接操作图如下:
代码实现如下:
public class UnionFind02 implements UF {
//parent[i]表示第i个元素所指向的父节点
private int[] parent;
//构造函数
public UnionFind02(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为树的高度
public int find(int p){
if (p < 0 || p >= parent.length)
throw new IllegalArgumentException("p id 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;
}
}
现在让我们来简单测试下并查集的这两种实现方式的效率:
public class Test {
//简单测试两种并查集实现方式的效率
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 = 100000;
int m = 10000;
//第一种实现
UnionFind01 uf01 = new UnionFind01(size);
System.out.println("UnionFind01 -> "+ testUF(uf01,m) +"s");
//第二种实现
UnionFind02 uf02 = new UnionFind02(size);
System.out.println("UnionFind02 -> "+ testUF(uf02,m) +"s");
}
}
测试代码运行结果:
通过测试代码的运行结果可以看出,我们的第二种实现方式是要比第一种实现方式 效率高一些的,现在我们看一个关于并查集的应用,就是力扣网上的547号练习题,题目描述如下:
现在就让我们基于并查集的第二种实现方式,来解决这个问题:
public int findCircleNum(int[][] M) {
int n = M.length;
UnionFind2 uf = new UnionFind2(n);
for(int i = 0 ; i < n ; i ++)
for(int j = 0 ; j < i ; j ++)
//如果M[i][j] = 1,则表示第 i 个和 j 个学生互为朋友,即属于同一个集合
if(M[i][j] == 1)
uf.unionElements(i, j);
TreeSet<Integer> set = new TreeSet<>();
for(int i = 0 ; i < n ; i ++)
set.add(uf.find(i));
//获取不同集合的个数
return set.size();
}
并查集的优化方式
考虑size
由于我们之前的实现方式,在进行连接操作时,总是将左边的元素指向右边的元素,这样很容易就会形成一个链表,这样的查询时间复杂度就会变成O(n),为了避免这样极端的情况,我们可以在每个节点中添加一个元素个数的变量,即该节点下连接的节点个数,这样在进行连接操作时,就将节点加到节点个数少的集合中去,如图:
代码实现如下:
public class UnionFind03 implements UF {
private int[] parent; // parent[i]表示第i个元素所指向的父节点
private int[] sz; // sz[i]表示以i为根的集合中元素个数
public UnionFind03(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为树的高度
public 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];
}
}
}
我们可以通过测试方法,对这三种实现方式进行简单的比较,我们会发现,优化size之后的并查集,效率比前两种是要好很多的。
基于rank的优化
rank[i]表示根节点为i的树的高度,即根据两个元素所在的输的rank不同判断合并方向,将rank低的合并到rank高的集合上
代码实现如下:
public class UnionFind04 implements UF {
private int[] rank; // rank[i]表示以i为根的集合所表示的树的层数
private int[] parent; // parent[i]表示第i个元素所指向的父节点
// 构造函数
public UnionFind04(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的值
}
}
}
现在让我们对基于size的优化,和基于rank的优化进行简单的测试对比:
路径压缩优化
为什么需要路径压缩优化呢?基于前面的四种实现方式,我们会发现下图中的三颗并查集数,无论是find()还是isConnected()都是等效的
由于并查集的查找方法是和树得高度相关的,所以我们只要让树得高度降低,就都是对并查集的优化,理想情况下,我们树得高度为2,即只有根节点和叶子节点。但实际情况并不都是如此,所以我们只能尽可能降低树得高度,这就是我们的路径压缩优化。那我们的路径压缩是如何实现的呢?我们在查找某个节点的时候,让这个节点指向它父亲节点的父亲节点,然后判断这个节点是否是待查找节点的根节点,如不是,再使用待查找节点指向它父亲节点的父亲节点,如下图:
根据图中分析,可以看出我们的路径压缩是在查找过程中实现的,所以我们修改查找方法即可:代码 实现如下:
// 查找过程, 查找元素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;
}