并查集
关于并查集看到一个有意思的解释,可以很好的帮助理解并查集。并查集详解(超级简单有趣~~就学会了)。
还有一篇文章对并查集有很详细的解释,写的清楚明白,Union-Find算法详解。
根据这两篇文章的内容,写写自己的理解。
一、动态连通性
先解释下什么叫做动态连通性。
动态连通性就是把原来相互独立的节点,通过某一种【等价关系】将两个节点连接起来。这里的【等价关系】就被成为“连通”。一般等价关系都具有三个性质:
- 自反性:节点 p 和 p 是连通的;
- 对称性:如果节点 p 和 q 连通,那么 q 和 p 也连通;
- 传递性:如果节点 p 和 q 连通,q 和 r 连通,那么 p 和 r 连通。
Union-find算法主要要实现这两个API:
class UnionFind{
//将p和q连接
public void union(int p,int q);
//判断p和q是否连通
public boolean connected(int p,int q);
//返回图中有多少连通分量
public int count();
}
举个例子:
假设有一个很大很大的湖,这个湖里有10个小岛,给这些岛从0~9编号。开始的时候,这10个小岛两两之间都没有桥,人无法通过步行从一个岛上走到另一个岛上(请不要考虑船之类的水上交通工具或者岛之间的距离,这里只是为了说明意思),那么这10个岛就是相互独立的,就可以说这10个岛都是不连通的。
Union-find算法中需要实现的方法可以这么理解:
- union表示:连接节点p和q,就是在两个岛之间建立一座桥,让两个岛之间建立连接。
- connected表示:判断两个岛之间是否建立了连接,建立了连接就返回true,否则返回false。
- count表示:记录湖中有几块岛屿是独立的。
注意,如果岛1和岛2之间建了桥,即调用union(1,2)建立连接,那么人就可以通过桥步行从岛1走到岛2上,岛1和岛2就不是独立的。岛3和岛1、岛2都没有建桥,那么岛3和岛1岛2之间就是独立的。
在开始的情况下,10个岛之间都没有建立连接,调用connected都会返回false,独立的岛屿数是10个,即连通分量是10。
现在调用union(0,1),即岛0和岛1建立了连接,connected(0,1)就会返回true,连通分量降为9。
再调用union(1,2),这时岛0、岛1、岛2之间建立了连接,connected(0,2)也会返回true,连通分量会降为8.
二、实现思路
我们把这个10个岛画在纸上
为了表示连通性,我们需要给每个节点设置一个指针,指向它的父亲节点,不需要复杂的数据结构,只需要创建一个长度为n的数组parent[],这里的n指的是节点数。
parent[i] = j 表示:节点 i 的父亲节点是 j 。
如果是根节点,它的指针就指向自己,即parent[root] = root;
例如:开始时,各个岛屿都是相互独立的,那么各个节点的指针都指向自己
创建UnionFind,并初始化
class UnionFind{
//记录连通分量
private int count;
//记录父亲节点的数组,节点x的父亲节点是parent[x]
private int[] parent;
//构造函数,n为节点总数,即初始状态
public UnionFind(int n){
//开始互相之间都不连通,连通量为n
this.count = n;
//初始的时候,各个节点的父亲节点都是自己,所以parent[i]=i
parent = new int[n];
for (int i = 0; i < n; i++){
parent[i] = i;
}
}
//...
}
假如我们希望两个节点之间是连通,只需要让其中一个节点的父亲节点设为另一个节点即可,就是说,假设连通节点 p 和 q ,令parent[p] = q,多次连接之后,就会发现,连通的部分就像一棵树,如图
当我们希望两个节点连通,也可以让一个节点的根节点连接到另外一个节点的根节点上即可,这是一种比较普遍的方式。这样的话,我们首先需要找到这两个节点的根节点(注意:一个节点的根节点不一定是这个节点的父亲节点,可能是它的爷爷的爷爷…节点,直到找到那个父亲节点是自己的节点),让其中一个根节点成为另一个根节点的父亲节点。
//查找节点x的根节点,如果x的父亲节点不是x本身,那么就将x的父亲节点赋值给x,一层一层地向上找,直到x的父亲节点是x本身,即parent[x] = x
private int find(int x){
while(parent[x] != x ){
x = parent[x];
}
return x;
}
//找到p,q的根节点,如果两者的根节点相同,说明两者已经是连通的了,无需再连通,直接返回;
//如果两个根节点不是同一个,那么将其中一个根节点连接到另外一个节点上,即让其中一个节点的父亲节点等于另一个节点
public void union(int p, int q){
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
//将rootP的父亲节点设置为rootQ
parent[rootP] = rootQ;
//将两个互相独立的岛屿,连接到一起,那么连通分量就会少一,即count--;
count--;
}
//返回当前的连通分量个数
public int count(){
return count;
}
至于connected的实现,如果节点 p 和节点 q 连通,那么他们肯定具有相同的根节点,即
public boolean connented(int p , int q){
int rootP = find(p);
int rootQ = find(q);
return rootP == rootQ;
}
基本上,Union-Find的算法算是基本完成了。
关于复杂度,我们可以看到union和connected的复杂度基本来源于find,find从某个子节点向上查找根节点时,其时间复杂度就是树的高度,最坏的情况下是树退化成一个链表,需要遍历所有节点,时间复杂度是O(n)的。这个复杂度并不是很好,我们期望可以降低复杂度。既然时间复杂度是树的高度,那么如果树是比较平衡点的结构,那么复杂度是不是就降低了呢?
三、优化代码
我们可以看出,导致树结构不平衡的情况是,我们利用union函数,暴力地将节点 p 所在的树连通到节点 q 的根节点上,并没有注意树结构是否平衡,容易造成某一分支过长的情况。借用labuladong的一幅图
长此以往,树只会越来越不平衡,labuladong的详解里写到,采用一个数组来记录树的‘重量’,即树的节点数。在连接根节点的时候,比较下树的节点数,节点数较小的树连接到节点数较大的树的下面,可以避免不平衡的情况。