1、引言
有一种数学概念叫:集合。他说明如何表示一组无需考虑顺序的元素。并查集ADT可以表示一组无序元素,可以用来解决等价问题。并查集易于实现,使用来一个简单数组就能实现它,且每个函数也只需几行代码。
2、等价关系和等价类
1)等价关系
假定S是集合,它包含元素和定义在集合上的关系R。对于集合中的每一对元素a,b属于S,aRb要么为真,要么为假。如果aRb为真,则a与b相关,否则a与b不相关。如果一个关系R有如下性质,则改关系是等价关系:
·自反性:对于任意元素a属于S,aRa为真。
·对称性:对于任意两个元素a,b属于S,如果aRb为真,那么bRa也为真。
·传递性:对于任意三个元素a,b,c属于S,如果aRb为真,那么bRa也为真,那么aRc也为真。
简单举个例子来说:铁路连接是一种等价关系。1、满足自反关系,因为任何位置都连接它自身。2、满足对称性,a城市和b城市之间有一条连接线,那么城市b也连接城市a,所以关系也是对称的。3、满足传递性,a城市连接城市b,b城市连接城市c,那么城市a可以连接到城市c。
2)等价类。
元素a属于S的等价类是S的一个子集,该子集包含所有与a相关的的元素。等价类对集合S产生一个分割。S中的每个成员只属于一个等价类。判断aRb是否为真,需要判断a和b是否属于同一个等价类。
按照上边的例子继续说明:如果连个城市之间有铁路相连,那么他们属于同一个等价类。否则他们属于不同的等价类。
由于任意两个等价类的交集为空,所以等价类也可以叫做并查集。它有些操作如下:
·创建一个等价类(构造一个集合)
·查找等价类FIND
·合并等价类UNION
3、并查集ADT
初始时,假设输入元素为5个集合,每个集合仅有一个元素。这说明初始表示假定所有关系都是假的(除自反性外)。每个集合都有不同的元素,因此Si交Sj=空。这使得各个集合不相交。
为添加关系aRb(UNION),需要首先检查a和b是否已经相关。可以通过a和b上执行FIND操作来进行验证,并判断他们是否在同一个等价类(集合)中。如果他们不再就执行UNION操作。该操作是将每个集合都有不同的元素,要首先检查a和b是否已经相关。可以通过a和b上执行FIND操作来进行验证,并判断他们是否在同一个等价类(集合)中。如果他们不再就执行UNION操作。该操作是将包含a和b的两个等价类合并到一个新的等价类,及创建集合Sk=Si并Sj,同时删除两个集合Si和Sj。
3.1、简单原理图
用数组来表示集合:刚开始初始化自己 数组[i]=i,表示自己的集合名为自己的下标。因为集合2和集合3属于集合1,所以将2和3并到一起(将数组[2]=1,数组[3]=1),同理将4和5并到集合2中。
3.2、快速FIND实现(Quick FIND)
元素2的集合名为1,元素4的集合名为2,一次类推。利用这种方法FIND操作只需要O(1)时间。
3.3、UNION实现
将两个树连到一起:就是将 数组[c]=f;其中重要的一点是,UNION操作只改变根节点的双亲节点而不改变集合中其他元素的双亲节点。由此UNION操作的时间复杂度为O(1)。FIND(x)操作将返回包含X的树的双亲节点,执行此操作的时间复杂度与X在该树中的深度成正比。最坏情况下,操作FIND操作的运行时间是O(n),m个连续的FIND操作需要O(mn)。
public class DisjointSet {
public int[] S;
public int size;
//以自己为的下标下名为集合名将每个元素初始化
public DisjointSet(int size){
this.size=size;
S=new int[size+1];
for (int i = 1;i <=size; i++) {
S[i]=i;
}
}
public int find(int x){
if(S[x]==x){//找到根节点
return x;
}else{
return find(S[x]);
}
}
public void union(int x, int y){
if(find(x)==find(y)){//如果两个节点的根节点的属于同一个,就说明这两个元素属于同一个集合不需要合并
return;
}
S[x]=y;//合并两个节点
}
3.4、快速UNION实现(快速FIND)
上边最主要的问题是,在最坏的情况下得到一颗斜树,并且FIND操作的时间复杂度为O(n)。
如下图所示:
所以有两种改进方式:
1)基于大小的UNION(也叫做基于重量的UNION):使较小的树作为较大的树的子树。
public class DisjointSet {
public int[] S;
public int size;
public int weight[];//记录每个节点子节点的大小(重量)
//以自己为的下标下名为集合名将每个元素初始化
public DisjointSet(int size){
this.size=size;
this.weight=new int[size+1];
this.S=new int[size+1];
for (int i = 1;i <=size; i++) {
S[i]=i;
weight[i]=1;//每个节点算自身所以都为1
}
}
public int find(int x){
if(S[x]==x){//找到根节点
return x;
}else{
return find(S[x]);
}
}
public void union(int x, int y){
int root1=find(x);
int root2=find(y);
if(root1==root1){//如果两个节点的属于同一个根就不需要合并
return;
}
if(weight[root1]<weight[root2]){//将重量小的合并到重量大的集合中去
S[root1]=root2;
weight[root2]+=weight[root1];
}else{
S[root2]=root1;
weight[root1]+=weight[root2];
}
}
}
2)基于高度的UNION(基于秩的UNION):使高度较小的树作为高度较大的树的子树。
public class DisjointSet {
public int[] S;
public int size;
public int height[];//记录每个节点的树的高度
//以自己为的下标下名为集合名将每个元素初始化
public DisjointSet(int size){
this.size=size;
this.S=new int[size+1];
for (int i = 1;i <=size; i++) {
S[i]=i;
height[i]=0;
}
}
public int find(int x){
if(S[x]==x){//找到根节点
return x;
}else{
return find(S[x]);
}
}
public void union(int x, int y){
int root1=find(x);
int root2=find(y);
if(root1==root1){//如果两个节点的属于同一个根就不需要合并
return;
}
if(height[root1]>height[root2]){//将秩小的合并到秩大的集合中去
height[root2]=root1;
}else{
height[root1]=root2;
if(height[root1]==height[root2]){
height[root2]++;
}
}
}
}
3)比较基于大小的UNION和基于高度的UNION
使用基于大小的UNION,任意节点的高度永远不会大于log n。这是因为一个节点在初始化时高度为0。当由于UNION操作使其高度增加时,它会被放在至少是原来2倍大小的树中。即它的高度最多是以log n倍增的。这意味着FIND操作的运行时间为O(log n),m次连续执行该操作需要O(mlog n)时间。
使用基于高度的UNION也和上边的差不多,如果对两棵相同高度的树进行UNION操作。UNION后树的高度比之前树的高度增加1,否则就等于两颗树高度最大的那一个。这就使得n个节点的树的高度增长倍数大于O(log n),m次连续执行UNION操作和FIND操作仍然需要O(m log n)的时间。
3.5路径压缩
Find操作遍历从当前结点到根节点的一系列节点。通过将这些节点的每个父指针直接指向根节点,可以是后面的FIND操作更简单更高效。这个过程叫路径压缩。
例如,在执行FIND(x)操作时,遍历从X到树的根结点的路径上的各个结点。有效的路径压缩是将该路径上的每个节点的双亲节点直接变为树的根节点。就像下图所示:
使用路径压缩的FIND:
public int find(int x){
if(S[x]<=0){//找到根节点
return x;
}else{
return S[x]=find(S[x]);//将当前节点的父节点变为爷爷节点,递归调用
}
}
注意:路径压缩与基于大小的UNION兼容,但与基于高度的UNION不兼容,因为没有有效的方法来改变树的高度。
算法 | 最坏情况时间 |
快速FIND | mn |
快速UNION | mn |
基于大小/高度的UNION | n+m*logn |
路径压缩 | n+m*logn |
基于大小的快速UNION+路径压缩 | (n+m)logn |