并查集
并查集是用来处理不相交集合的合并问题, 它的主要思路就是指定集合当中的一个元素来代指这个集合, 集合中的其他元素都间接或直接的指向这个元素, 假设我们称这个元素为 根元素 (我自己定义的, 便于下面描述).
这样判断两个元素是否在同一集合里就是看他们是不是都直接或间接的指向同一个根元素; 合并两个集合就是让一个集合的根元素指向另一个集合的根元素.下面来看具体的实现过程
并查集的实现
假设给出了编号为 1 - n 的 n 个人, 指出其中 m 对人的朋友关系, 且满足朋友的朋友也是朋友, 询问其中某两人是不是朋友.
那么我们按照并查集的思路来进行操作
我们设 p[i] 的下标为每个人的编号, p[i] 存储 i 指向的人的编号;
初始时我们把每个人都单独看做一个集合, 即每个人都指向他自己:
p[i] | 1 | 2 | 3 | 4 | 5 | … | n |
---|---|---|---|---|---|---|---|
i | 1 | 2 | 3 | 4 | 5 | … | n |
假设我么知道了 2 和 3 是朋友, 那么我们可以把 2 和 3 集合 {2, 3 }, 我们把 2 当做这个集合的根元素, 则有 p[3] = 2
p[i] | 1 | 2 | 4 | 5 | … | n | |
---|---|---|---|---|---|---|---|
i | 1 | 2 | 3 | 4 | 5 | … | n |
同样的假设我们知道 3 和 5 是朋友, 我们可以让 5 指向 3, 只相当于 {2, 3} 和 {5} 这两个集合合并, 因为 3 指向 2 , 而 5 指向 3, 所以5 间接的指向 2, 即{2, 3, 5} 的根元素是2
p[i] | 1 | 2 | 4 | … | n | ||
---|---|---|---|---|---|---|---|
i | 1 | 2 | 3 | 4 | 5 | … | n |
当然对于 3 和 5我们也可以让 3 所在集合的根元素指向 5, 即 2 指向 5, 这样{2, 3, 5} 集合的根元素就是 5
p[i] | 1 | 4 | 5 | … | n | ||
---|---|---|---|---|---|---|---|
i | 1 | 2 | 3 | 4 | 5 | … | n |
或者
这样我们每次合并确定两个人的关系就让一个人所在集合的根元素指向另一个人所在集合的根元素.
代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1005;
int p[maxn];
void init() {//初始化, 是每个人都指向他本身;担然也可以初始化为0, 即根元素不指向集合内的任何元素, 这里我们初始化为元素本身;
for (int i = 1; i < maxn; ++i) {
p[i] = i;
}
}
int find(int x) {//查询该元素所指向的根元素, 集合中只有根元素指向自己
return x == p[x] ? x:find(p[x]);
}
void unio(int x, int y) {//合并
x = find(x);
y = find(y);
if (x != y) p[x] = p[y]; //如果两人不在同一集合内即指向的根元素不同, 则让一个的根元素指向另一个
}
int main() {
int n, m, x, y, q;
cin >> n >> m >> q;
init();
for (int i = 1; i <= m; ++i) {
cin >> x >> y;
unio(x, y);
}
while(q--) {
cin >> x >> y;
if (find(x) == find(y))
cout << "Yes" << endl;//Yes 代表这两人是朋友, No代表不是
else
cout << "No" << endl;
}
}
路径压缩优化
由上面我们可以知道在一个集合里, 我们总能通过一个元素找到这个集合的根元素,例如刚开始的例子
我们可以看到假设我要知道 5 所在集合的根元素, 那我就要通过 5 找到 3 , 再通过 3 最终找到根元素 2, 但是当集合元素过多, 所要查询的长度增大, 这种间接查询的方式就会浪费很多时间, 那么我们能不能通过 5 直接或者以更短的路径查询到根元素 2 呢?
其实并查集的每个集合都可以看做一颗树, 我所假设的根元素其实就是这颗树的根节点, 而每个元素所指向的元素就是他的父亲节点, 那么路径压缩就是要将图中的 a 这颗树转化成深度更小的 b 树, 如下图:
不难看出, 通过 b 查询根节点要比通过 a 来的快, 这样我们每次查询这颗树的根节点的复杂的就是O(1), 其复杂的也由O(n) 降到了O(log n), 这样极大的提高了效率.
具体实现代码如下
int find(int x) {
if (x != p[x]) p[x] = find(p[x]);//如果x不指向它本身, 即它不是根元素, 那么就用递归的方式让它最终指向根元素.
return p[x];
}
也可以写成:
int find(int x) {
return x == p[x] ? x:p[x] = find(p[x]);
}
如果你担心数据范围太大会爆栈也可以通过循环来解决这件事:
int find(int x) {
int root = x;//假设根元素为 x
while (p[root] != root) root = p[root];//执行一次循环找到真正的根元素
int i = x, j;
while (i != root) {//通过循环将 x 及其父亲都指向根元素
j = p[i];
p[i] = root;
i = j;
}
return root;
}
合并优化(按秩合并)
通过路径压缩优化, 我们将并查集中的查询操作时间复杂度降到了O(log n), 那么我们是不是也能把合并操作也降低时间复杂度呢?
我们知道一颗树是有高度的, 它的高度就是叶子节点到根节点所经过的最大层数, 上面的图中的 a树高度是 3, b树的是 2. 那么我们就把这颗树的高度当作这颗树的秩.
之前的过程我们可以看到, 查询一颗树的根节点的复杂度就是取决于这颗树的高度, 那么我们在合并时就降低这个集合所形成的树的高度不就可以降低算法的复杂度了吗.
按秩合并就是将高度小的树的根节点连到高度大的树的根节点上, 过程如图.
这个合并过程就大大的降低了树的高度, 其具体实现代码如下:
int p[maxn], h[maxn];
void init() {//初始化, 是每个人都指向他本身;
for (int i = 1; i < maxn; ++i) {
p[i] = i;
h[i] = 1;//树的高度, 初始化为1
}
}
void unio(int x, int y) {//合并
x = find(x);
y = find(y);
if(h[x] == h[y]) {//如果高度相同, 就让一个树高度加一, 把另一个树的根节点连到这颗树的根节点上.
h[x] = h[x] + 1;
p[y] = x;
}
else {
if (h[x] < h[y]) p[x] = y;//x所在树的高度小, 就将x 所在树的根节点连到 y所在树的根节点上, 树的高度不变, 下面同理.
else p[y] = x;
}
}
这样优化后的复杂度也是O(log n).
这就是并查集的实现过程, 像即给你朋友关系又给你敌人关系让你判断两人是朋友还是敌人或者不确定时, 就可以开两倍数组, 一个记录朋友关系, 一个记录敌人关系, 这就是一种处理多种关系的并查集解决方案之一.