并查集是一种树型的数据结构 ,并查集可以高效地进行如下操作:
- 查询元素 p和元素q是否属于同一组
- 合并元素 p和元素q所在的组
1、并查集结构
并查集也是一种树型结构,但这棵树跟我们之前讲的二叉树、红黑树、B树等都不一样,这种树的要求比较简单:
- 每个元素都唯一的对应一个结点;
- 每一组数据中的多个元素都在同一颗树中;
- 一个组中的数据对应的树和另外一个组中的数据对应的树之间没有任何联系;
- 元素在树中并没有子父级关系的硬性要求;
2、实现
构造方法实现
- 初始情况下,每个元素都在一个独立的分组中,所以,初始情况下,并查集中的数据默认分为N个组;
- 初始化数组eleAndGroup;
- 把eleAndGroup数组的索引看做是每个结点存储的元素,把eleAndGroup数组每个索引处的值看做是该结点所在的分组,那么初始化情况下,i索引处存储的值就是i
合并方法实现
- 如果p和q已经在同一个分组中,则无需合并
- 如果p和q不在同一个分组,则只需要将p元素所在组的所有的元素的组标识符修改为q元素所在组的标识符即可
- 分组数量-1
package tree;
//并查集
public class UF {
//记录结点元素和该元素所在分组的标识
private int[] eleAndGroup;
//记录并查集中数据的分组个数
private int count;
public UF(int N){
this.count = N;
eleAndGroup = new int[N];
for(int i=0; i<N; i++){
eleAndGroup[i] = i;
}
}
//获取当前并查集中的数据有多少个分组
public int count(){
return count;
}
//元素p所在分组的标识符
public int find(int p){
return eleAndGroup[p];
}
//判断并查集中元素p和元素q是否在同一分组中
public boolean connected(int p, int q){
return find(p) == find(q);
}
//把p元素所在分组和q元素所在分组合并
public void union(int p, int q){
if(connected(p,q)){
return;
}
int pe = eleAndGroup[p];
int qe = eleAndGroup[q];
for(int i=0; i<eleAndGroup.length; i++){
if(eleAndGroup[i] == pe){
eleAndGroup[i] = qe;
}
}
this.count--;
}
}
3、案例分析
如果我们并查集存储的每一个整数表示的是一个大型计算机网络中的计算机,则我们就可以通过connected(intp,int q)来检测,该网络中的某两台计算机之间是否连通?如果连通,则他们之间可以通信,如果不连通,则不能通信,此时我们又可以调用union(int p,int q)使得p和q之间连通,这样两台计算机之间就可以通信了。
一般像计算机这样网络型的数据,我们要求网络中的每两个数据之间都是相连通的,也就是说,我们需要调用很多次union方法,使得网络中所有数据相连,其实我们很容易可以得出,如果要让网络中的数据都相连,则我们至少要调用N-1次union方法才可以,但由于我们的union方法中使用for循环遍历了所有的元素,所以很明显,我们之前实现的合并算法的时间复杂度是O(N^2),如果要解决大规模问题,它是不合适的,所以我们需要对算法进行优化。
4、算法优化
为了提升union算法的性能,我们需要重新设计find方法和union方法的实现,此时我们先需要对我们的之前数据结构中的eleAndGourp数组的含义进行重新设定:
- 我们仍然让eleAndGroup数组的索引作为某个结点的元素;
- eleAndGroup[i]的值不再是当前结点所在的分组标识,而是该结点的父结点;
find(int p)查询方法实现
- 判断当前元素p的父结点eleAndGroup[p]是不是自己,如果是自己则证明已经是根结点了;
- 如果当前元素p的父结点不是自己,则让p=eleAndGroup[p],继续找父结点的父结点,直到找到根结点为止;
union(int p,int q) 合并方法实现
- 找到p元素所在树的根结点
- 找到q元素所在树的根结点
- 如果p和q已经在同一个树中,则无需合并;
- 如果p和q不在同一个分组,则只需要将p元素所在树根结点的父结点设置为q元素的根结点即可;
- 分组数量-1
public int find(int p){
while(true){
if(p == eleAndGroup[p]){
return p;
}
p = eleAndGroup[p];
}
}
public void union(int p, int q){
int pe = find(p);
int qe = find(q);
if(pe == qe){
return;
}
eleAndGroup[pe] = qe;
count--;
}
分析:
我们优化后的算法union,如果要把并查集中所有的数据连通,仍然至少要调用N-1次union方法,但是,我们发现union方法中已经没有了for循环,所以union算法的时间复杂度由O(N^2)变为了O(N)。
但是这个算法仍然有问题,因为我们之前不仅修改了union算法,还修改了find算法。我们修改前的find算法的时间复杂度在任何情况下都为O(1),但修改后的find算法在最坏情况下是O(N):
5、路径压缩
UF_Tree中最坏情况下union算法的时间复杂度为O(N^2),其最主要的问题在于最坏情况下,树的深度和数组的大小一样,如果我们能够通过一些算法让合并时,生成的树的深度尽可能的小,就可以优化find方法。
之前我们在union算法中,合并树的时候将任意的一棵树连接到了另外一棵树,这种合并方法是比较暴力的,如果我们把并查集中每一棵树的大小记录下来,然后在每次合并树的时候,把较小的树连接到较大的树上,就可以减小树的深度。
package tree;
public class UF_Tree_Weight {
private int[] eleAndGroup;
private int count;
//用来存储每一个根结点对应的树中保存的结点的个数
private int[] sz;
public UF_Tree_Weight(int N){
this.count = N;
eleAndGroup = new int[N];
for(int i=0; i<N; i++){
eleAndGroup[i] = i;
}
this.sz = new int[N];
for(int i=0; i<N; i++){
sz[i] = 1;
}
}
public int count(){
return count;
}
public int find(int p){
while(true){
if(p == eleAndGroup[p]){
return p;
}
p = eleAndGroup[p];
}
}
public boolean connected(int p, int q){
return find(p) == find(q);
}
public void union(int p, int q){
int pe = find(p);
int qe = find(q);
if(pe == qe){
return;
}
if(sz[pe]<sz[qe]){
eleAndGroup[pe] = eleAndGroup[qe];
sz[q] += sz[p];
}else{
eleAndGroup[qe] = eleAndGroup[pe];
sz[p] += sz[q];
}
count--;
}
}
6、案例-畅通工程
某省调查城镇交通状况,得到现有城镇道路统计表,表中列出了每条道路直接连通的城镇。省政府“畅通工程”的目标是使全省任何两个城镇间都可以实现交通(但不一定有直接的道路相连,只要互相间接通过道路可达即可)。问最少还需要建设多少条道路?
下面是对数据的解释:
总共有 20个城市,目前已经修改好了7条道路,问还需要修建多少条道路,才能让这20个城市之间全部相通?
package test;
import tree.UF_Tree_Weight;
import java.util.Arrays;
public class UFTWTest {
public static void main(String[] args) {
UF_Tree_Weight uftw = new UF_Tree_Weight(20);
uftw.union(0,1);
uftw.union(6,9);
uftw.union(3,8);
uftw.union(5,11);
uftw.union(2,12);
uftw.union(6,10);
uftw.union(4,8);
System.out.println("还需要修建:"+(uftw.count()-1));
}
}