并查集
并查集作为一种特殊的数据结构,主要用于解决连接问题,如网络中节点间的连接状态等,对于一组数据来说,并查集主要支持两个操作:1、Union(p,q)——将p,q两个元素合并在一同一个组中。2、find§——查找p元素在哪个组中。并查集还经常用来回答一个问题:isConnected(p,q)——p,q两个元素是否在同一个组中,即p,q两个元素是否相连接。接下来,主要介绍并查集的两种实现,QuickFind和QuickUnion。
并查集的实现
[QuickFind]
该种实现方式,可以保证并查集的find操作的时间复杂度为O(1)
[数据表示]
使用数组进行存储,例如
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |
---|---|---|---|---|---|---|---|---|---|---|
id | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 1 |
如图所示,id相同的元素连接在一起,即奇数连接在一起,偶数连接在一起
按照这种表示方法,我们实现QuickFind的并查集
[代码实现]
class UnionFind{
private:
int* id;
int count;
public:
UnionFind(int n){
id = new int[n];
count = n;
for(int i=0;i<n;i++)
id[i] = i;//初始状态下,每个元素自己跟自己一个组
}
~UnionFind(){
delete[] id;
}
int find(int p){//查找元素p所对应的id
return id[p];
}
bool isConnected(int p, int q){//判断两元素是否相连
return id[p] == id[q];
}
void unionElements(int p, int q){//将p,q两元素合并
int pId = find(p);
int qId = find(q);
if(pId == qId)
return;//如果两个元素已经相连接,那么直接return
else{
for(int i=0;i<count;i++)//遍历所有id为pId的元素,
//使他们都与qId相连接
if(pId == id[i])
id[i] = qId;
}
}
};
[优缺点]
QuickFind的实现方式,就如名字所形容的
find操作的时间复杂度为O(1),但是
union操作的时间复杂度为O(n)
[QuickUnion]
将每一个元素看作是一个节点,并查集中每个节点有一个指针,指向他的父节点,如3->2,表示2,3相连,根节点指向自己
[数据表示]
使用数组进行存储,例如
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |
---|---|---|---|---|---|---|---|---|---|---|
parent | 1 | 1 | 1 | 8 | 3 | 0 | 5 | 1 | 8 | 8 |
其中,parent[i]表示i这个元素所指向的父亲是谁,如图所示。
按照这种表示方法,我们实现QuickUnion的并查集
[代码实现]
class QuickUnion{
private:
int* parent;
int count;
public:
QuickUnion(int n){
count = n;
parent = new int[count];
for(int i=0;i<count;i++)
parent[i] = i;//初始化时让他们自己指向自己,表示谁和谁也不是一个组
}
~QuickUnion(){
delete[] parent;
}
int find(int p){
assert(p>=0 && p<count);
while(p != parent[p]){
//当p=parent[p]时,表示到达根节点
p = parent[p];
}
return p;
}
void unionElements(int p, int q){
assert(p>=0 && p<count);
assert(q>=0 && q<count);
int pRoot = find(p);
int qRoot = find(q);
if(pRoot = qRoot)
return;
parent[pRoot] = qRoot;//把p的根连接到q的根上
}
bool isConnected(int p, int q){
assert(p>=0 && p<count);
assert(q>=0 && q<count);
int pRoot = find(p);
int qRoot = find(q);
return pRoot == qRoot;
}
};
[优缺点]
QuickUnion的实现方式,find的时间复杂度虽然上升,但是union的复杂度下降,综合考虑,还是QuickUnion实现方式更为理想
并查集的优化
首先,并查集的实现方式为QuickUnion
[union优化-基于size的优化]
[优化思想]
p,q两部分进行union操作。将少的一部分的根节点指向多的一部分的根节点,防止树过深。
比如说,union(4,9),按照我们之前union的实现方式,结果如下,这时候,我们树的层数变深,会导致find操作时间变长
解决办法:我们再union操作时,不是固定的将p连接到q,而是通过比较p,q两个树的元素的多少,将元素少根节点连到元素多的根节点上。如图所示
[代码实现]
//伪代码,在QuickUnion基础上进行修改
//增加数组存每个组的元素个数
int* sz;
//构造函数,初始化sz
sz = new int[count];
for(int i=0;i<count;i++){
sz = 1;//初始时,每个组的元素个数为1
}
//析构函数,释放内存
delete[] sz;
//union操作,优化
if(sz[pRoot]>sz[qRoot]){
parent[qRoot] = pRoot;
sz[pRoot] += sz[qRoot];//维护sz数组
}
else{
parent[pRoot] = qRoot;
sz[qRoot] += sz[pRoot];//维护sz数组
}
[union优化-基于rank的优化]
[优化思想]
通过对上面的基于size的优化,我们很容易想到,更好的一种方法,就是比较p,q的层数,将层数少的连接到层数多的上面,防止树的层数过深,话不多说,直接上代码实现
[代码实现]
//伪代码,在QuickUnion基础上进行修改
//增加数组存每个组的层数
int* rank;
//构造函数,初始化rank
rank = new int[count];
for(int i=0;i<count;i++){
rank = 1;//初始时,每个组的层数为1
}
//析构函数,释放内存
delete[] rank;
//union操作,优化
if(rank[pRoot]>rank[qRoot]){
parent[qRoot] = pRoot;//将q连接到p
//注意!此时rank[p]数组的层数并没有改变
}
else if(rank[pRoot]<rank[qRoot]){
parent[pRoot] = qRoot;//将p连接到q
//注意!此时rank[p]数组的层数并没有改变
}
else{
parent[pRoot] = qRoot;//两者层数相等,谁连接到谁都一样
rank[qRoot]++;//此时注意维护rank[qRoot]
}
[find优化-路径压缩]
[优化思想]
进行find操作。我们每次不一次向上移一个节点,可以向上移动两个,或者多个,因为并查集树的特殊性(根节点指向自己)不会导致越界的情况。
比如说,find(4),按照我们之前find的实现方式,我们在向上查找的同时也遍历了一遍这个树。由于并查集树的特殊性,我们每次向上移动多个节点,并且改变待查找元素的根节点,每执行一遍find(4),操作就进行一次路径压缩。如图所示。
[代码实现]
//伪代码,在QuickUnion基础上进行修改
//find操作,路径压缩
while( p != parent[p] )
parent[p] = parent[parent[p]];//每次向上移动两个节点,并更新根节点
p = parent[p];
}
[进一步的路径压缩]
通过上面的例子,我们很容易想到,如果我们能优化成,下面的样子,那岂不是更好
[代码实现]
我们通过递归来实现这种路径压缩
//函数定义:find(p)函数返回p的根节点
//递归调用,一定要明确函数的定义
int find(int p){
if(p != parent[p])
parent[p] = find(parent[p]);
return parent[p];
}
}
想说的话
至此并查集的内容已经介绍完毕了,并查集这种数据结构应用很广泛,在后面的图论中也会通过并查集这种数据结构判断联通分量等。下期更新图这种数据结构,大家加油~