“并查集是面试中常问的问题,因为它有一定的思维含量,写出来却很优雅。” ——y总
并查集是一种树型数据结构,它主要用于快速地支持两个基本操作(近乎O(1)
):
- 将两个不相交集合合并
- 询问两个元素是否在同一个集合中
并查集还可以支持的其他操作:
- 拓展、维护额外信息
关于并查集的引入,这里有一篇比较生动的文章可以参考:算法学习笔记(1) : 并查集
1 基本原理及实现
并查集往往用树的形式维护所有集合,在实现并查集时有以下几个基本问题:(p[x]
为父节点)
1.1 判断树根
if(p[x] == x)
1.2 如何求x
所在集合编号
while(p[x] != x) x = p[x]
这种方法就是不断往上找,但是如果树的层数较高,该方法还是会有较高的时间复杂度。
如果有一种方法能直接知道每个结点的根节点就好了:
int find(int x){
// 返回x的根节点 + 路径压缩
if(p[x] != x) p[x] = find(p[x]);
return p[x];
}
这种方法的优点是只需要往上找一遍,只要找过一遍,路径上的所有结点的p[x]
都会等于根节点编号,比如现在的树是1
→2
→3
,那么p[3] = find(p[2]) = find(find(p[1])) = find(find(1)) = find(1) = 1,同时p[2] = 1, p[3] = 1;
1.3 如何合并两个集合
实质就是两棵树的合并操作
p[find(x)] = find(y);
// 或反过来
2 总代码
在处理并查集的题目时,我们遵循以下步骤:
- 初始化
把每个点所在集合初始化为其自身 - 查找
find()
函数的实现 - 合并
两树合并操作
题目链接:836. 合并集合
题目代码:
#include<iostream>
using namespace std;
const int N = 100010;
int p[N];
int find(int x){
if(p[x] != x) p[x] = find(p[x]);
return p[x];
}
int main(){
int n, m;
scanf("%d%d", &n, &m);
for(int i = 0; i < n; i ++) p[i] = i;
while(m --){
char op[2];
int a, b;
scanf("%s", op);
scanf("%d%d", &a, &b);
if(*op == 'M'){
p[find(a)] = find(b);
}
else{
if(find(a) == find(b)) printf("Yes\n");
else printf("No\n");
}
}
return 0;
}
3 拓展和维护额外信息的并查集
基础的并查集是没有额外信息的裸并查集,带有额外信息的并查集比如:想知道每个集合元素的个数或者其他,该怎样实现?这个就比较灵活了。
3.1 连通块中点的数量
如果想知道每个集合元素的个数,该怎样修改?
首先是初始化:
int cnt[N];
for(int i = 1; i <= n; i ++){
p[i] = i;
cnt[i] = 1;
}
其次是合并:
a = find(a), b = find(b);
if(a != b){
p[a] = b;
cnt[b] += cnt[a]; // 集合内结点总数只对根节点有意义
}
最后是询问集合内结点个数:
cnt[find(x)]
其他的不用修改。
另外分享一个我出过的bug,如果我把合并操作改成如下,为什么WA:
if (find(a) != find(b)){
p[find(a)] = find(b);
cnt[find(b)] += cnt[find(a)];
}
原因很简单,p[find(a)] = find(b)
已经将a
的根结点变为b
的根节点,当再次find(a)
时,返回的是b
的根节点,那么cnt[find(b)] += cnt[find(a)]
操作只会让b
所在集合的元素个数加倍。
题目链接:连通块中点的数量
实现代码:
#include<iostream>
using namespace std;
const int N = 100010;
int p[N];
int cnt[N];
int find(int x){
if(p[x] != x) p[x] = find(p[x]);
return p[x];
}
int main(){
int n, m;
cin >> n >> m;
for(int i = 1; i <= n; i ++){
p[i] = i;
cnt[i] = 1;
}
while(m --){
char op;
int a, b;
cin >> op;
if(op == 'C'){
scanf("%d%d", &a, &b);
a = find(a), b = find(b);
if(a != b){
p[a] = b;
cnt[b] += cnt[a]; // 集合内结点总数只对根节点有意义
}
}
else if(op == 'Q'){
int c;
scanf("%d", &c);
if(c == 1){
scanf("%d%d", &a, &b);
if(find(a) == find(b)) printf("Yes\n");
else printf("No\n");
}
else{
scanf("%d", &a);
printf("%d\n", cnt[find(a)]);
}
}
}
return 0;
}
3.2 食物链
如果想知道集合中任意两点的关系呢?我们只需要记录每个点和根节点的关系(加上权值),就可以确定任意两点的关系。
举个例子:一个单位有n个人,只需要知道每个人和领导的关系,那么任意两个人的关系就可以确定了。
因为只有三类动物,所以%3
:
- 余1:可以吃根节点
- 余2:可以被根节点吃
- 余0:与根节点是同类
初始化阶段,我们引入d[x]
表示权值(到根节点的值)
int p[N], d[N];
for(int i = 1; i <= n; i ++) p[i] = 1; // d[]是全局变量,默认为0
find()
函数的修改:
int find(int x){
if (p[x] != x)
{
int t = find(p[x]);
d[x] += d[p[x]]; // 修改权值
p[x] = t; // 修改根结点
}
return p[x];
}
分析一下这样为什么不行:(建议手动画图)
int find(int x){
if (p[x] != x)
{
d[x] += d[p[x]]; // 修改权值
p[x] = find(p[x]); // 修改根结点
}
return p[x];
}
没有进行find(p[x])
,那么d[p[x]]就这是那一小节:
O —d[x]— O —d[p[x]]— O —(少算了)— O(p[x])
如果先进行find(p[x])
,那么d[p[x]]就可以把少算的补上,因为有没有进行find(p[x])会影响p[x],从而影响d[p[x]]。希望能懂我的灵魂画图
其他的利用%3
的分析就可以看懂了。因为我这里还没完全看懂但是没时间整了所以先挖个坑以后再补
题目链接:240.食物链
代码实现:
#include <iostream>
using namespace std;
const int N = 50010;
int n, m;
int p[N], d[N];
int find(int x)
{
if (p[x] != x)
{
int t = find(p[x]);
d[x] += d[p[x]];
p[x] = t;
}
return p[x];
}
int main()
{
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i ++ ) p[i] = i;
int res = 0;
while (m -- )
{
int t, x, y;
scanf("%d%d%d", &t, &x, &y);
if (x > n || y > n) res ++ ;
else
{
int px = find(x), py = find(y);
if (t == 1)
{
if (px == py && (d[x] - d[y]) % 3) res ++ ; // 在一个集合内
else if (px != py) // 不在一个集合内
{
p[px] = py;
d[px] = d[y] - d[x]; // (d[x] + ? - d[y]) % 3 = 0, ? = d[y] - d[x]
}
}
else
{
if (px == py && (d[x] - d[y] - 1) % 3) res ++ ;
else if (px != py)
{
p[px] = py;
d[px] = d[y] - d[x] + 1; // (d[x] + ? - d[y]) % 3 = 1
}
}
}
}
printf("%d\n", res);
return 0;
}