并查集
什么,听说你连图都没学过?还不赶紧回去学去!
1.定义
什么是并查集?
集:集合。
并:合并的意思,把集合合并在一起。
查:查询,查询某个集合。
并查集是一种树型的数据结构,用来维护某种关系,判断一个森林中有几棵树,一个节点是否属于某棵树等。
2.意义
某天, A A A和 B B B遇到了一起,他们同姓,于是他们猜想他们是否是亲戚?
如果要验证他们的猜想,那就要去翻他们的家谱(翻啊翻)。
A A A翻到了他的祖先 A 0 A0 A0, B B B翻到了他的祖先 B 0 B0 B0,结果他们的祖先是同一个人,那么由此可以得出他们是亲戚(哇,真·亲戚)。
他们是怎么认定他们是亲戚的?
显然,只要自己的祖先是同一个人,那么他们就是亲戚。
以上,如果从数据结构看,他们是连通的,在同一个集合里的元素都是连通的。
他们的祖先就是代表元(即:用集合中某个元素代表这个集合)。
代表元相同,它们就是同一个集合。
那么,看看我们如何实现并查集中的并和查。
3.查询
还是拿刚刚 A A A和 B B B的对话。
A A A发现自己没有家谱,于是……
A
A
A如果要找自己的祖先,就要问
A
A
A的父亲。
A
A
A的父亲就要去问
A
A
A的父亲的父亲。
A
A
A的父亲的父亲就要去问
A
A
A的父亲的父亲的父亲。
……
终于问到了!
A
0
A0
A0是祖先。
很熟悉,是不是?很像递归。如果用 f a i fa_i fai表示 i i i的父亲,那么由此可写出递归:
int root(int x) { //找x的祖先
return fa[x]=root(fa[x]);//root(fa[x])--->问fa的父亲
}
但很显然,这个递归需要一个边界,否则会死循环。
我们的要求是找祖先,那么找到祖先就停下来。
可我们该怎么停下来呢?
很简单,祖先没有父亲,那么祖先的父亲就是自己!
只需将
f
a
fa
fa初始化,自己六亲不认,我是齐天大圣,我就是祖先!
for(int i=1;i<=n;i++) fa[i]=i; //我父亲就是我!我是祖先!
然后,依次将关系加上去。就会发生一个现象:自己的祖先的父亲一直是他自己。
所以,递归可以改成如此:
int root(int x) { //找x的祖先
if(fa[x]!=x) //自己的父亲不是自己,即自己不是祖先
return fa[x]=root(fa[x]); //问我父亲:我祖先是谁啊
return fa[x]; //找到了,返回。
}
我们做了一个投机取巧的事情:递归是非常费时间的,如果每次只去询问,那么很可能会超时。于是在询问的过程中,顺便将 f a x fa_x fax指向祖先:
fa[x]=root(fa[x]);//指向祖先
这样,第一次的查询可能会费一点时间,之后就无须再去询问了(只是在根没有变化的情况下,否则我们需要重新寻找新的根)。
- root函数:查找根节点(代表元)
4.合并
A
A
A和
B
B
B很愉快地认了亲戚。
这时候来了个 C C C,他也同姓,于是他翻了家谱,却发现自己的祖先 C 0 C0 C0不是 A 0 A0 A0,但他做出了一个令人震惊的决定:他决定重新做家谱,将三个家谱合并起来。(大孝子)
所谓重做家谱,无非将三个家谱全部“啪”的捏成一个新家谱。
但他们需要一点联系。
前面说过:
A
A
A的祖先是
A
0
A0
A0,即
A
0
A0
A0是代表元,代表
A
A
A集合。
B
B
B的祖先是
B
0
B0
B0,即
B
0
B0
B0是代表元,代表
B
B
B集合。
A 、 B A、B A、B集合代表元相同,它们是同一个集合。但是 C C C集合的代表元 C 0 C0 C0和 A 、 B A、B A、B集合的代表元不相同,它们不是同一个集合。那么,该怎么合并起来呢?
把 C 0 C0 C0改成 A 0 A0 A0就行了!这样,三个集合的代表元相等,三家一合并,就是同一个集合了。
设有 x 、 y x、y x、y两个集合,我要把它合并,怎么办?
首先,我们需要得到 x 、 y x、y x、y集合的代表元。
void merge(int x,int y) {
int rx=root(x),ry=root(y);//获取x,y集合的代表元。
}
如果代表元相同,则它们是一个集合,无需合并。如果它们的代表元不相同,那么狸猫换太子,把其中一个集合的代表元换成另一个集合的代表元。
这样,两个集合的代表元相同,它们就是一个集合,即合并。
void merge(int x,int y) {
int rx=root(x),ry=root(y);//获取两个集合的代表元
if(rx!=ry)//两个集合的代表元不相同
fa[ry]=rx;//替换代表元,实现合并。
}
注意:在默认的情况下,我们默认是
x
x
x集合合并
y
y
y集合,即让
y
y
y集合的代表元替换成
x
x
x集合的代表元。但是,一般来讲,谁合并谁没有太大的影响,但是不排除一些恶心到极致的题目,它们的输入可能有坑。
- merge函数:合并两个集合。
5.扩展域
看到这,恭喜你!你已经掌握了并查集最最基本的功能。
我们可能在做题的时候,会发现题目让我们用并查集维护多种关系。那么,只用一个并查集来维护多种关系是不可能的。这时候就需要我们用两个甚至更多的并查集来维护两种及以上的关系,即扩展域。
某些人:呀,好呀!我照你的做!
int fa1[N];//第一个并查集
int fa2[N];//第二个并查集
非常聪明的博主:我问你,你是不是想写4个函数?
某些人:呀,好呀,我就做!
int root1(int x)//针对第一个并查集的root函数
...
int merge1(int x,int y)//针对第一个并查集的merge函数
...
int root2(int x)//针对第二个并查集的root函数
...
int merge2(int x,int y)//针对第二个并查集的merge函数
...
以上这种人我相信肯定有不少,一定是学OI学傻了。
小学三年级就学过 2 a = a + a 2a=a+a 2a=a+a,那么, 2 ∗ 2* 2∗并查集 = = =并查集 + + +并查集嘛!!!
int fa[2*N]=fa[N]+fa[N];//2*并查集=并查集+并查集
某些人:呀,坏呀!第二个并查集我怎么用?
fa[1]//第一个并查集
fa[?]//第二个并查集?
非常聪明的博主:跳过第一个并查集不就行了吗?
某些人:呀,坏呀!怎么跳过第一个并查集?
1
+
n
1+n
1+n吗?
n
+
1
n+1
n+1吗?
2
∗
n
+
1
2*n+1
2∗n+1吗?
以上这种人我相信肯定有不少,一定是学OI学傻了。
两个并查集的大小都是题目给定的 n n n,那么跳过第一个并查集,无非是下标加上 n n n嘛!!!
fa[1]//第一个并查集
fa[1+n]//第二个并查集(实际上就是fa[1],只不过跳过第一个并查集)
这样,即不用写4个函数,同时能维护关系,一举两得。
只是我们不知道如何去维护第二种关系(第一种关系很好维护),题目中的蛛丝马迹会告诉我们。
P1892 [BOI2003]团伙
题目告诉我们有两种关系:
- 我朋友的朋友是我的朋友;
- 我敌人的敌人是我的朋友;
这时如果只开一个并查集那么是完完全全的错了,我们要开两个并查集。其中一个并查集维护第一种关系,另一个则维护第二种关系。
const int N = 1000;
int fa[2*N];//两个并查集
第一种关系很好维护,因为朋友的任何一个朋友、朋友的任何一个朋友的任何一个朋友都是我的朋友。
char s[5]; int x,y; scanf("%s%d%d",s,&x,&y);
if(s[0]=='F') merge(x,y);
第二个关系可就不是很好维护的了:设我们现在有
x
x
x和
y
y
y两个人,他们是敌人,那么
y
y
y的敌人(假设是
z
z
z)和
x
x
x是什么关系?
上图很明确的告诉我们,
x
x
x和
z
z
z是朋友。
设我们的第二个并查集用来存某个人的敌人,那么一个人和这个人的敌人就是朋友,他们就可以连在一起了:
char s[5]; int x,y; scanf("%s%d%d",s,&x,&y);
if(s[0]=='F')//如果x和y是朋友
merge(x,y);//合并
else {//x和y是敌人
/*
1.x和y是敌人
2.y和y+n是敌人
3.x和y+n是朋友!
*/
merge(x,y+n);
/*
1.x和y是敌人
2.x和x+n是敌人
3.y和x+n是朋友!
*/
merge(y,x+n);
}
至此,这道题核心部分就解决了。
6.边带权
可能会有人发现,并查集其实是一种父亲表示法(数组里的值指向它的父亲)。
并查集有一个最大的缺陷:一棵树上的所有孩子都不知道他和根(后面简称为父亲)的距离。因为并查集只是简简单单的维护某种关系,不维护距离(权值)。
那么我们就要给原来的并查集添进点东西,让他认识到社会的险恶。
上图可以明确的告诉我们:A到父亲的距离(权值)是5,B到父亲的距离是3。
哎?看样子这很好办,我们只需再开一个数组 d d d, d i d_i di表示 i i i这个孩子到父亲的距离就行了嘛。
错!大错特错!一棵树很有可能会衍生出一条树枝连接到其他的树上面,树是动态的,它的父亲也时刻变化着,一旦原来的父亲被替换成其他的父亲,再加上新父亲原有的孩子, d i d_i di必须时刻变化着。
再回到之前我们所讲的,集合有一个代表元,集合与集合之间的合并实际上是被看做被合并集合的代表元连接到另一个集合的尾巴上。
合并起来,就变成了:
一棵新的树。
现在,我将这棵新的树的边添上权值,假设每条边的权值都是1吧:
请问:A到新父亲的距离是多少:是3。
3是怎么来的?我们可以把这棵树看成两部分:一部分是原来的树,另一部分是新的树。
哇,好妙啊:
d
i
d_i
di依然指向旧父亲,现在指向新父亲,只是在
d
i
d_i
di的基础上加上新的树的大小就可以了。
我们需要新开一个数组(就说是 c n t cnt cnt吧), c n t i cnt_i cnti表示代表元 i i i所在的集合的大小(反复咀嚼)
一旦父亲被替换(合并),只需在 d r x d_{rx} drx的基础上只需加上 c n t r y cnt_{ry} cntry就可以了。
void merge(int x,int y) {
int rx=root(x),ry=root(y);
if(rx!=ry) {
fa[rx]=ry;//默认是rx合并到ry上
d[rx]+=cnt[ry];//代表元的新的权值
cnt[ry]+=cnt[rx];//新的树的大小
}
}
某些人:哎不对不对!你只是将代表元到新父亲的距离更改了,他的孩子还不知道呢!
雀食。所以我们在root
函数上做了一些小变更。我们将代表元
r
x
rx
rx的所在集合的所有孩子到旧父亲的距离,一律加上
r
x
rx
rx到新根的距离(反复咀嚼)。
int root(int x) {
if(fa[x]!=x) {
//这里先不慌return,先把根存起来。
int r=root(fa[x]);
//孩子的新距离
d[x]+=d[fa[x]);
//让孩子认新父亲为爹
fa[x]=r;
}
}
(开头全部废话,看来现在题目的含垃圾量越来越高了)
如果你理解了边带权,这道题就是个模板,套上去就可以了。
初始化是万万需要注意的,每个点就是个集合,集合的大小为1,父亲到父亲的距离是0。
for(int i=1;i<=N;i++) {
fa[i]=i; cnt[i]=1; d[i]=0;
}
然后再套上并查集中的边带权:
int root(int x) {
if(fa[x]!=x) {
//这里先不慌return,先把根存起来。
int r=root(fa[x]);
//孩子的新距离
d[x]+=d[fa[x]];
//让孩子认新父亲为爹
fa[x]=r;
}
return fa[x];
}
void merge(int x,int y) {
int rx=root(x),ry=root(y);
if(rx!=ry) {
fa[rx]=ry;//默认是rx合并到ry上
d[rx]=cnt[ry];//代表元的新的权值
cnt[ry]+=cnt[rx];//新的树的大小
}
}
针对每次询问,如果是合并,那么只需将 i i i和 j j j合并起来即可。
char s[5]; int x,y;
scanf("%s%d%d",s,&x,&y);
if(s[0]=='M') {
//合并
merge(x,y);
}
针对每次询问,如果是查询,只需输出 ∣ d i − d j ∣ − 1 |d_i-d_j|-1 ∣di−dj∣−1即可。
char s[5]; int x,y;
scanf("%s%d%d",s,&x,&y);
if(s[0]=='M') {
merge(x,y);
} else {
//不在同一列上输出-1
if(root(x)!=root(y)) printf("-1\n");
else {
//坑:如果x和y相同输出0
if(x==y) printf("0\n");
//d[i]-d[j]的绝对值再减去1即可。
else printf("%d\n",abs(d[x]-d[y])-1);
}
}
7.总结
- 定义:查是查询,并是合并,集是集合。
- 意义:并查集用来维护关系。
- 查询:查询代表元(根)
- 合并:两个集合合并在一起。
- 扩展域:多个并查集维护多种关系
- 边带权:在边上维护一个权值
在真正的做题当中,题目不可能出的这么简单,他可能会隐藏的很深,抑或是和其他算法结合在一起(dp、二分等)。理解了并查集,是个关键。