前言
大家肯定有听说过社交网络里面的六人理论吧,说是可以通过六个人的联系认识世界上的任意一个人。比如我想认识一下机械系的系花,我先找到机械系的朋友,然后通过朋友介绍认识。这样可以发现我们的社交圈子其实是有部分重叠的。当然也有可能是我的圈子太小,根本没有什么朋友认识那个圈子里面的人,也有很大可能是机械系花404。我们之间是否存在联系,即连接问题,这类问题往往可以通过并查集实现。
并查集
原理
并查集,顾名思义,我们可以对集合进行并和查两种操作,即合并两者之间的联系或者查找两者之间是否存在联系。
上面那个例子中人的姓名和关系我们就可以使用一个数组id来进行标记,索引i表示人名,id[i]则表示联系。一开始赋值的时候,我们先令id[i] = i;
表示每一个元素都和自己有关,后面需要建立联系时修改一方的id值,使它指向另一个元素。最终通过查找id,即可判断是否存在联系。
代码实现
可以看到我们的查询代码很简短,它的速度也是很快的,时间复杂度为O(1)级别的。
namespace UF1{
class UnionFind{
private:
int* id;//表示联系
int count;//数组数
public:
UnionFind(int n){
count = n;
id = new int[n];
for(int i = 0; i < n; i++)
id[i] = i;
}
~UnionFind(){
delete[] id;
}
int find(int p){
assert(p >= 0 && p < count);
return id[p];
}
bool isConnected(int p, int q){
return find(p) == find(q);
}
void unionElements(int p, int q){
int pID = find(p);
int qID = find(q);
//如果已经相等
if(pID == qID)
return;
for(int i = 0; i < count; i++)
if(id[i] == pID)
id[i] = qID;
}
};
}
优化
优化1.0
虽然我们的查询是很快的,但是我们的合并却仍有可以优化的地方。
当我们合并两个元素时,总是让后一个指向前一个,但是这样话,树的高度将会越来越高,我们的查询效率也会随之降低。那我们有什么办法降低树的高度呢?当然有,我们可以将元素直接连接到根节点处,就如下图所示,直接将9连接到8处即可,这样通过查找根节点是否相同,我们就可以判断两者是否连接。
代码如下:
我们不断查找父节点的父节点,直到parent[i] == i
就表示找到了根节点。
class UnionFind{
private:
int* parent;
int count;
public:
UnionFind(int count){
parent = new int[count];
this->count = count;
for(int i = 0; i < count; i++)
parent[i] = i;
}
~UnionFind(){
delete[] parent;
}
//找到从属的根节点
int find(int p){
assert(p >= 0 && p < count);
//直到p成为根节点
while(p != parent[p])
p = parent[p];
return p;
}
bool isConnected(int p, int q){
return find(p) == find(q);
}
void unionElements(int p, int q){
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot)
return;
parent[pRoot] = qRoot;
}
};
优化2.0
通过前面的代码我们可以看到,我们总是将后一个指向前面一个,但是有时候让前面一个指向后面一个反而树的高度更小。其实这里有两种优化,一种是通过统计以i为根的元素个数sz,还有一种是统计以i为根的树的高度rank。sz和rank大的优先作为根节点。
同时通过下面两张图片我们也可以清晰地看出,两者相比而言,rank更具合理性。
sz图示:
rank图示:
sz代码:
class UnionFind{
private:
int* parent;
int* sz; //sz[i]表示以i为根的集合中元素的个数
int count;
public:
UnionFind(int count){
parent = new int[count];
this->count = count;
for(int i = 0; i < count; i++){
parent[i] = i;
sz[i] = 1;
}
}
~UnionFind(){
delete[] parent;
delete[] sz;
}
//找到从属的根节点
int find(int p){
assert(p >= 0 && p < count);
//直到p成为根节点
while(p != parent[p])
p = parent[p];
return p;
}
bool isConnected(int p, int q){
return find(p) == find(q);
}
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{
parent[qRoot] = pRoot;
sz[pRoot] += sz[qRoot];
}
}
};
rank的话仅仅是合并处有所不同:
void unionElements(int p, int q){
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot)
return;
if(rank[pRoot] < rank[qRoot]){
parent[pRoot] = qRoot;
}
else if(rank[qRoot] < rank[pRoot]){
parent[qRoot] = pRoot;
}
else{ //rank[qRoot] == rank[pRoot]
parent[qRoot] = pRoot;
rank[pRoot] += 1;
}
}
优化3.0
很多时候评价一个算法是否优秀,我们不应该只看它一方面的表现,还得看它在很多特殊的情况下,性能是否还能保持稳定。在并查集的测试中,我们的测试都是随机的,但是当我们的样例是一个单方面依赖的树,那么搜索的效率就回下降很多。这时候,计算机科学家就发明了一种路径压缩的算法。
路径压缩算法采用两步一跳的方式,同时在搜索的过程中,将原有的结构进行修改压缩。这里大家可能会担心会不会跳出根节点,这点不用担心,因为根节点的父节点还是自己,所以并不会跳出。
路径压缩完成之后,当我们进行下一次操作时,树的高度就大大降低了。
class UnionFind{
private:
int* parent;
int* rank; //sz[i]表示以i为根的集合中元素的个数
int count;
public:
UnionFind(int count){
parent = new int[count];
this->count = count;
for(int i = 0; i < count; i++){
parent[i] = i;
rank[i] = 1;
}
}
~UnionFind(){
delete[] parent;
delete[] rank;
}
//找到从属的根节点
int find(int p){
assert(p >= 0 && p < count);
//直到p成为根节点
// while(p != parent[p]){
// parent[p] = parent[parent[p]];
// p = parent[p];
// }
//
// return p;
//跳过一个进行搜索根,同时改变结构
if(p != parent[p])
parent[p] = find(parent[p]);
return parent[p];
}
bool isConnected(int p, int q){
return find(p) == find(q);
}
void unionElements(int p, int q){
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot)
return;
if(rank[pRoot] < rank[qRoot]){
parent[pRoot] = qRoot;
}
else if(rank[qRoot] < rank[pRoot]){
parent[qRoot] = pRoot;
}
else{ //rank[qRoot] == rank[pRoot]
parent[qRoot] = pRoot;
rank[pRoot] += 1;
}
}
};
后记
经过100000条随机数据测试,我们得到了如下性能数据:
当然,由于随机Union的情况不同,UF4稍微比UF3慢了一点,这里可以谅解。但是我们可以发现,理论上最快的UF5竟然明显地慢了。经过分析我们可以发现,因为是UF5使用了递归消耗了一定的时间导致变慢。所以说,算法的设计还得考虑实际的情况。我们需要考虑的方面还有很多。
图片引用百度图片
代码实现参照liuyubobobo慕课网教程