此博客用于个人学习,来源于算法的书籍,对知识点进行一个整理。
1. 概念:
union-find 有些地方也称为并查集,往往用于解决图上的问题,并查集只有两个操作,“并” 和 “查”,但是通过这两个操作可以派生出一些其他的应用:
- 图的连通性问题
- 集合的个数
- 集合中元素的个数
图的连通性很好理解,一个图是不是连通的是指,“如果是连通图,那么从图上的任意节点出发,我们可以遍历到图上所有的节点”, 这里我们只需要将在图上的节点放到相同的集合中去,然后去看是不是所有的节点均指向同一个集合即可。
2. 并查集的API:
对于一组数据,并查集主要需要实现下面几个功能:
- void unionElements(int p,int q):在p和q之间添加一条连接;
- int find(int p):p(0到N-1)所在的分量的标识符;
- boolean isConnected(int p,int q):如果p和q存在于同一个分量中则返回 true;
- int getSize():;连通分量的数量。
3.分析:
一开始,我们有 N 个分量,每个触点都构成了触点 i,我们讲 find() 方法用来判定它所在的分量所需的信息保存在 id[i] 中。isConnected() 方法的实现只用一条语句 find(p) == find(q),它返回一个布尔值。目前我们的代码如下:
// 我们的第一版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) {
//还没实现,后面进行讨论
}
// 查看元素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) {
//还没实现,后面进行讨论
}
}
注意此时的构造方法,初始化, 每一个id[i]指向自己, 并没有合并的元素。
接下来分析 find 方法,目的是从节点 p 中查到对应的集合编号,我们的第一版并查集本质上是一个数组,这个时候可以返回一个 id[i] ,便是我们要找的编号,注意要进行判断,如果 p 本身不属于这个集合,抛出异常提示。
// 查找元素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 属于 “同一类”,即他们所代表的编号相同,可以先通过 find 方法获得各自对应的编号,如果相同就直接 return,如果不同,就需要对整个数组进行遍历,编号是 p 对应编号的,都要将编号改成 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;
}
但是此时存在一个问题,每次合并的时候都要遍历一遍数组,一旦数据较多,所需要的时间也是一个难以计量的量,这个时候需要对该算法进行改进。
4. 改进一(树状结构):
我们的第二版 Union-Find, 使用一个数组构建一棵指向父节点的树,定义为 parent[],初始化与第一版相同,都是指向自己。
// 我们的第二版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){
//还没实现,后面进行讨论
}
// 查看元素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){
//还没实现,后面进行讨论
}
}
可以看出,目前的方法和第一版几乎一致,这个时候我们需要对 find 方法和 unionElements 方法进行讨论。与第一版不同的是,第一版的思路是合并过程中,p 对应的编号改成 q 对应的编号,而改进的话,是将这个过程类比成树的结构,下端元素指向的编号是上一端的元素,也就是父节点,即 parent[p] = p 的上一个元素,这个时候,我们就可以修改我们的 find 方法,通过循环,一层一层找到最终的根节点,也就是没有父亲的那个节点对应的编号。(根节点的父亲节点就是他本身,即 parent[i] = i)
// 查找过程, 查找元素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;
}
此时,find 的作用变成了查找到元素的根节点,于是我们在 unionElements 中调用 find 方法,可以得到 p 和 q 的根节点,如果不相同,就将 p 的父亲节点改为 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;
}
但存在一种极端情况,因为我们是直接将 p 的父亲节点改为 q 的根节点,假如 p 的节点元素很多,然后接到 q 的根节点上,这个时候就可能造成链式的结构,这个时候如果的效率并没有提高,于是我们需要再次改进,目的——将元素数目小的节点接到数目大的节点上。
5. 改进二(考虑数量):
此时,添加一个数组 size[],用于统计以 i 为根节点的集合元素数量,于是此时代码变成了:
// 我们的第三版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){
//还没实现,后面进行讨论
}
}
除了构造函数和接下来要讨论的合并函数,其他的与上一版区别并不大,先分析构造方法:一开始每个节点都是指向自己本身,所以每个节点对应的 size 自然也是1。对于合并函数,主要修改的是后面的逻辑:如果 p 的集合元素数量小于 q 的集合元素数量,此时将 p 接入 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];
}
}
但这个时候出现了新的问题,如果此时 p 是一棵元素多但比较 “矮” 的树,而 q 是元素少但比较 “高” 的树,就不太适合上面这种实现方式,于是我们再次改进。
6. 改进三(考虑层数):
与第三版的思路类似,我们需要构建一个数组 rank[] 用于存储树的层数信息,同样的,一开始的时候,所有元素的层数都为1,于是构造方法一开始需要对 rank[] 每个元素赋值为1。
以相同逻辑修改合并函数:当 p 所在的树的层数小于 q 的时候,p 接入 q,反之亦然,但如果两者的层数相同,默认 p 接入 q。这个时候需要考虑到 rank 的值,如果不相等,即层数低的树接入高的树,接入后层数不会发生改变,但如果两者层数相同,则 q 的层数加一(因为默认 p 接入 q)。
// 我们的第四版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的值
}
}
}
find() 函数在最差情况下的时间复杂度为 O(n),即所有元素的子节点只有一个,类似于链表的形状,这个时候我们可以将这棵树进行一个压缩操作,将其层数降低,提高时间效率。
7. 改进四(压缩路径):
相比于第四版,我们需要修改的是 find 函数,即在循环的过程中,使子元素的指向更高的节点,层数降低,即
parent[p] = parent[parent[p]];
观察这句语句,p 的父亲变成了自己的爷爷,从而与自己的父亲 “平辈”,此时层数就少了。
但此时,可能会产生一个疑问:需不需要修改层数的值?怎么修改?答案是不需要,一是我们不清楚压缩了几层,我们无法进行修改,二是修改层数在合并函数内的逻辑,而我们的 find 方法先进行调用,在后面的判断中才会增加或者不对层数进行操作,与 find 无关了。
// 我们的第五版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的值
}
}
}
同样的,也可以采用递归的方法进行改进:
// 我们的第六版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的值
}
}
}
8. 并查集的局限:
并查集的合并操作是不可逆的,你可以理解成只合不分,也就是说两个集合合并之后就不会再分开来了,另外并查集只会保存并维护集合和元素的关系,至于元素之间的关系,比如图上节点与节点的边,这种信息并查集是不会维护的,如果遇到题目让你分析诸如此类的问题,那么并查集并不是一个好的出发点,你可能需要往其他的算法上去考虑。