简介
并查集主要可以用来解决一些元素分组的问题。它由一系列不相交的集合构成。并查集,顾名思义,支持“合并”和“查找”这两种操作:
- 合并(Union):把两个不相交的集合合并为一个集合。
- 查询(Find):查询两个元素是否在同一个集合中。
并查集的引入
例题
**题目背景**
若某个家族人员过于庞大,要判断两个是否是亲戚,确实还很不容易,现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。
**题目描述**
规定:x和y是亲戚,y和z是亲戚,那么x和z也是亲戚。如果x,y是亲戚,那么x的亲戚都是y的亲戚,y的亲戚也都是x的亲戚。
**输入格式**
第一行:三个整数n,m,p,(n<=5000,m<=5000,p<=5000),分别表示有n个人,m个亲戚关系,询问p对亲戚关系。
以下m行:每行两个数Mi,Mj,1<=Mi,Mj<=N,表示Mi和Mj具有亲戚关系。
接下来p行:每行两个数Pi,Pj,询问Pi和Pj是否具有亲戚关系。
**输出格式**
P行,每行一个’Yes’或’No’。表示第i个询问的答案为“具有”或“不具有”亲戚关系。
假如x和y是亲戚,y和z是亲戚,那么x和z也是亲戚,而且如果x和y是亲戚,那么x的亲戚也就是y的亲戚。所以我们可以把有亲戚关系的全部划入到一个集合中,查询两人是不是亲戚,只需要看两人是否在同一个集合中。这里便可以引入并查集进行维护。
并查集的初始化
并查集的重要思想在于,用集合中的一个元素代表集合。那么在初始化的时候,每个人的父结果点指向自己,自己单独为一个集合,这个集合的“代表”就是自己。
初始化的代码如下:
int fa[MAXN];
void init(int n)
{
for (int i = 1; i <= n; ++i)
fa[i] = i;
}
并查集的初查询
在查询的时候,一般采用递归的查询方式,代码如下:
int find(int x)
{
if(fa[x] == x)
return x;
else
return find(fa[x]);
}
上述代码中,如果该结点的父节点是自己,那么返回自己;如果父节点不是自己,那么递归查询父节点的父节点,一直寻找到该集合的代表元素,即父节点是自己的元素。那么查询两个元素是否属于同一个集合,只需要看查询到的代表元素是否相同。
并查集的合并
合并操作也很简单,先找到两个元素所属集合的代表元素,然后将前者的父节点设为后者。当然也可以将后者的父节点设为前者,这里暂时不重要。代码如下:
void merge(int i, int j)
{
fa[find(i)] = find(j);
}
路径压缩
思考下面这个场景,
假如我们要将“2”和“3”进行合并,从“2”找到“1”,然后fa[1]=3,更新之后的图为:
再合并一各“4”,从“2”找到“1”,然后fa[1]=4,更新之后的图为:
不难看出,这的合并,会使得集合的高度增长的十分迅速,那这时再查找最下面的元素时便会十分困难。怎么解决呢?我们可以使用路径压缩的方法。因为我们只关心一个元素所在集合的根节点,即代表元素,那么每个元素到跟结点的路径都是1的话,那就再好不过了,就像下图:
我们只需要在查询的时候,把沿途的每个节点的父节点都设为根节点,代码如下:
int find(int x)
{
if(x == fa[x])
return x;
else{
fa[x] = find(fa[x]); //父节点设为根节点
return fa[x]; //返回父节点
}
}
上面的代码也可以简化成这样:
int find(int x)
{
return x == fa[x] ? x : (fa[x] = find(fa[x]));
}
例题代码
至此,我们可以写出上述例题的整体代码,如下:
#include <cstdio>
#define MAXN 5005
int fa[MAXN], rank[MAXN];
void init(int n)
{
for (int i = 1; i <= n; ++i)
{
fa[i] = i;
}
}
int find(int x)
{
return x == fa[x] ? x : (fa[x] = find(fa[x]));
}
void merge(int i, int j)
{
fa[find(i)] = find(j);
}
int main()
{
int n, m, p, x, y;
scanf("%d%d%d", &n, &m, &p);
init(n);
for (int i = 0; i < m; ++i)
{
scanf("%d%d", &x, &y);
merge(x, y);
}
for (int i = 0; i < p; ++i)
{
scanf("%d%d", &x, &y);
printf("%s\n", find(x) == find(y) ? "Yes" : "No");
}
return 0;
}
并查集的优化
当我们在合并两个集合的时候,是可以将a的父节点设为b,也可以将b的父节点设为a。如果以a为根节点的树高度为10,以b为根节点的树高度为2,那么明显将b的父节点设为a效率更高。
按秩合并
我们可以在合并的时候按秩合并,在初始化的时候,每个节点的秩为1,该值在每次合并的时候会进行更新,代表以当前节点为父节点的树的高度,那么自然而然,合并的时候,我们将秩更低的节点的父节点指向秩更高的那个节点,是更合适的。
需要注意的是,按秩合并和路径压缩是无法同时使用的。因为路径压缩的时候,会破坏树的高度,再采用秩作为合并时候的标注,便会引入错误信息。所以我们更可以采取下面的方式。
按大小合并
既然路径压缩的时候,会破坏树的高度,那么有什么是不会变的呢?就是该树的结点数量。往往(不能说绝对)结点数量少的树,高度也会相对较小。而且我们在路径压缩的时候,并不会改变一棵树的节点数量。具体的方法便是,在初始化的时候,每个节点并不需要自环(fa[i]=i),将每个节点指向-1(fa[i]=-1),用来标记根节点,那么在合并的时候,将两个根节点指向的负数进行合并。例如a的根节点指向-2,b的根节点指向-5,说明a所在的集合有2个元素,b所在的集合有5个元素,所以我们将a的根节点的父节点设为b,然后将b指向的-5改为-7(-2-5=-7),代表更新后,集合含有7个元素。