并查集
对于一组数据,主要支持两个动作:
- union(p, q):将 p 和 q 合并在一起
- find§:查看 p 在哪个组中
用来回答一个问题 - isConnected(p, q):p 和 q 是否相连在一起
实现并查集的一种思路(quick find)
查找效率很高,但是执行变的过程却不尽人意。
1. 并查集的基本数据表示
在一个数组中有 0 - 9 10个元素,它们的 id 号分别为 0 或 1,id 号相同的元素表示它们是互相连接的。
上图表示 [0 - 4] 它们之间是相互连接的,[5 - 9] 它们之间是相互连接的。
下面是 一个设计的并查集的数据结构
#ifndef UNIONFIND_H
#define UNIONFIND_H
#include <iostream>
#include <cassert>
using namespace std;
class UnionFind
{
private:
int* id; // 元素所属的组别号,id 相同,表示是相互连接的
int count; // 该并查集中的元素个数
public:
UnionFind(int n)
{
count = n;
id = new int[n];
for(int i = 0; i < n; i++)
{
id[i] = i;
}
}
virtual ~UnionFind()
{
delete[] id;
}
};
2. 并查集的功能
- 找到每个元索所属的组别的 id 号
int find(int p)
{
assert(p >= 0 && p < count);
return id[p];
}
- 判断 p 和 q 是否连接在一起
bool isConnected(int p, int q)
{
return find(p) == find(q);
}
- 合并连个元素(即把 p 和 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;
}
}
实现并查集的另一种思路(Quick Union)
- 将每个元素都看作一个节点,每个节点有一个指向父节点的指针。如果该节点为根节点,那么指针指向自己
- 如果想要将 5 和 2 连接起来,只需要将 5 的指针指向 2 即可。
实现
#include <iostream>
#include <cassert>
using namespace std;
class UnionFind2
{
private:
int *parent;
int count;
public:
UnionFind2(int n)
{
count = n;
parent = new int[n];
for(int i = 0; i < n; i++)
{
parent[i] = i;
}
}
int find(int p)
{
assert(p >= 0 && p < count);
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;
}
virtual ~UnionFind2()
{
delete[] parent;
}
protected:
};
2. 第一种和第二种性能比较
测试代码:
#include <iostream>
#include <ctime>
#include "UnionFind.h"
#include "UnionFind2.h"
using namespace std;
int n = 10000;
void test1()
{
srand(time(NULL));
UnionFind uf(n);
time_t startTime = clock();
for(int i = 0; i < n; i++)
{
int a = rand() % n;
int b = rand() % n;
uf.unionElements(a, b);
}
for(int i = 0; i < n; i++)
{
int a = rand() % n;
int b = rand() % n;
uf.isConnected(a, b);
}
time_t endTime = clock();
cout << "UF1, " << 2 * n << " ops, " << double(endTime - startTime) /CLOCKS_PER_SEC << " s" << endl;
}
void test2()
{
srand(time(NULL));
UnionFind2 uf2(n);
time_t startTime = clock();
for(int i = 0; i < n; i++)
{
int a = rand() % n;
int b = rand() % n;
uf2.unionElements(a, b);
}
for(int i = 0; i < n; i++)
{
int a = rand() % n;
int b = rand() % n;
uf2.isConnected(a, b);
}
time_t endTime = clock();
cout << "UF2, " << 2 * n << " ops, " << double(endTime - startTime) /CLOCKS_PER_SEC << " s" << endl;
}
int main()
{
test1();
test2();
return 0;
}
【运行结果】这是在 10000 的数据下的性能比较
测试一下 100000 的数据性能比较
我们可以看到两者的性能差并不是很明显。还有待于优化。
接下来,看一下下图的并查集中有没有什么问题。
下面,我们想要合并 9 和 4。
只需要将 9 的指针指向 8 即可。
那么,如果想要 union 4 和 9 的话,理论上,应该与合并 9 和 4 的道理相同,但是实际上最后实现结果却不同。因为我们总把第一个元素的根节点指向第二个元素的根节点造成的。那么合并 4 和 9 的效果如下:
可以看到,在以 9 为根节点的这棵树的高度就比较高,查找 3 和 4 这两个节点花费的时间就更多,而这只是 10 个元素,那么如果更多的元素而言,性能就可想而知了。那么怎么改善这个问题呢?
我们不应该固定地将一个元素的根节点指向另一个元素的根节点。应该在做这项操作之前先做一下判断,将元素少的集合的根节点指向元素多的根节点。换句话说吗,就在合并 4 和 9 的过程中,9 所在的集合的元素少,所以把 9 所属的集合的根节点指向 4 所属的集合的根节点即可。
其实我们只需要在上面的集合中添加一个数据结构即可。添加了一个 sz 数组,sz[i] 用来表示以 i 为根的集合中的元素个数。
private:
int *parent;
int* sz; // sz[i] 表示以 i 为根的集合中元素个数
int count;
只需要修改 unionElements 即可
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];
}
}
下面,让我们比较一下三种方式下的性能差:还是先测试 10000 个数据量,此时优化过的为 UF3,可见性能提升是相当明显的。
让我们再来测试一下 100000 个数据量:
此时,性能提升多大就不用我说了吧。就目前来说,UF3 的性能已经能够满足我们的需求了。
但是仍然有一种情况会影响集合的性能。下面请看例子:
我们想要合并 4 和 2,按照上面的实现方式,2 所属的节点的个数多,那应该把 4 所属的集合的根节点指向 2 所属的根节点(即 8 指向 7),那么就变成下列所示结果:
这两颗树之前一个层数为 2,一个为 3,经过这么一并,层数变成了 4。如果我们换一种方向,把 7 指向 8,那么就变成了如下结果:
这样层数就为 3。所以,光依赖集合的个数来决定最终谁指向谁的方法并不是很准确的,还得依赖于两个集合的层数。这就是接下来的基于 rank 的优化。rank[i] 表示根节点为 i 的树的高度。
在实现 unionElements 这个功能的实现逻辑是,首先比较两个集合中树的高度,如果两个集合中树的高度相等,在比较两个集合的元素个数,进行合并,修改代码如下:
private:
int *parent;
int* rank; // rank[i] 表示以 i 为根的集合所表示的树的层数
int count;
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[pRoot] > rank[qRoot])
{
parent[qRoot] = pRoot;
}
else
{
parent[pRoot] = qRoot;
rank[qRoot] += 1;
}
}
到这里,就要介绍最后一种优化方式了,路径压缩(Path Compression)。
下面,举个栗子。我们要 find 4。如下图所示:
按照之前的思路,我们需要先找到 4,查看 4 的指针是否指向它自己,不是,则继续向上查找,在查找的过程中,我们需要依次遍历 3,2,1,0。那么我们是不是可以做一些变化,让树的层数更少一些,答案是可以的。我们可以试图在 find 的过程中从底向上,如果没有找到根的话,我们就要想办法把这些节点再向上挪一挪,那么这个过程就叫做路径压缩。
下面请看图例:还是假设我们要 find 4
首先我们找到 4 的 parent 是 3,和 4 不一样,说明 4 不是根节点。在之前的 find 中,我们要继续向上找。现在,我们要实现路径压缩。那么我们压缩一步,也就是让 4 去连接它父亲的父亲(也就是 节点 2)。
那么,这棵树就变矮了,这就是压缩的意思。也要将 4 的父亲修改为节点 2。接下来,我们考察节点 2,节点 2 的父亲依旧没有指向他自己,说明 2 也不是根节点,此时我们需要将 节点 2 也指向它父亲的父亲。
那么接下来可以考察 0 节点,此时 0 节点即为根节点,到此 find 过程结束。
下面来看代码的修改:只需要对 find 代码进行修改:
int find(int p)
{
assert(p >= 0 && p < count);
while(p != parent[p]) // 直到找到根节点
{
parent[p] = parent[parent[p]];
p = parent[p];
}
return p;
}
在下面,我们进行性能测试:UF4 是我们刚刚优化后的代码。这是测试的 1000000 个数据量,可见性能还是非常高的。
那么,我们能不能实现下列这种情况,让所有的节点都指向根节点,那这样我们查找元素就只需要遍历一次即可。
这个实现就要借助递归实现了。修改代码如下:
int find(int p)
{
assert(p >= 0 && p < count);
if(p != parent[p])
parent[p] = find(parent[p]);
return parent[p];
}
性能比较如下:
我们可以看见这种递归的方法好像比上一种方法时间要稍稍长一点,这就是递归所带来的开销,但是递归的这种压缩方法从理论上来说,是性能更优的。但是理论和实践稍微有点出入。
并查集的操作,时间复杂度近乎是 O(1);