浅谈并查集

最近在洛谷上看到了一道水题,是NOI2002的第一题,银河英雄传说.一看上去就是个并查集,感觉很简单的样子(心想:没想到NOI还有这么水的题!),结果一做才知道:逻辑想的挺好,实际上就是WA.在交了好几次WA之后,仔细把自己的逻辑重新整理了几遍,终于发现了问题所在,于是A掉了这道题.(明明我的逻辑基本上一点问题都没有,就是忽略了一个赋值的先后带来的影响!)

感觉自己还是没有真正掌握并查集的皮毛(好伤心),于是打算总结一下并查集,争取能温故知新.


并查集的意义(需求)

标准的并查集是为了高效的进行一系列的如下操作:

  • 将x和y所在的集合合并为一个集合
  • 查询x与y是否存在于同一集合

其中由于是集合的合并,因此我们需要重新将集合中的所有元素放入新的集合.而并查集就是为了高效的实现这些操作.

并查集的基本思想

我们采用一个标记值,具有相同的标记值的元素将被认为是存在于同一集合中.理论上这个标记值是任意的,但为了方便,我们常常将集合中的某个元素作为标记值.

前置知识需求

为了理解并查集,需要现行学习以下的知识:

  • 数组的基本操作
  • 树的基本概念
  • 递归算法
  • 结构体
  • 链表

并查集的初级实现

并查集的数组实现

显然,可以用一个一位数组(称之为f)完成所需要进行的操作.元素x与y在同一个集合当且仅当f[x]==f[y].代码很简单.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const int MAX=10010;
int f[MAX];
int n;//假设n中已经存储了当前的总元素数

void init(void)//初始化
{
for(int i=1;i<=n;i++) f[i]=i;//开始任何两个元素都不在同一集合中
return;
}
void join(int x,int y)//合并
{
if(f[x]==f[y]) return;//不必检查已经合并的,可以节省一定的时间(没啥用)
int tmp=f[x];
for(int i=1;i<=n;i++) if(f[i]==tmp) f[i]=f[y];//调整所有的与x在同一集合中的元素的标记值使其与y中元素的标记值相同,从而表示它们被合并进了进入y所在的集合
return;
}
bool check(int x,int y)//检查x与y是否在同一个集合中
{
return f[x]==f[y];
}

显然,这个方法是正确的.我们可以尝试通过洛谷的并查集模板题(记得看清数组要开多大).完整的代码像下面这样(如同以前一样,我会省略一切之前出现过的解释文字):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include<cstdio>
using namespace std;

const int MAX=10010;
int f[MAX];
int n;

void init(void)
{
for(int i=1;i<=n;i++) f[i]=i;
return;
}

void join(int x,int y)
{
if(f[x]==f[y]) return;
int tmp=f[x];
for(int i=1;i<=n;i++) if(f[i]==tmp) f[i]=f[y];
return;
}

bool check(int x,int y)
{
return f[x]==f[y];
}

int main()
{
int m,flag,x,y;
scanf("%d%d",&n,&m);
init();
for(int i=1;i<=m;i++)
{
scanf("%d%d%d",&flag,&x,&y);
if(flag==1) join(x,y);
else if(check(x,y)) printf("Y\n");
else printf("N\n");
}
return 0;
}

umm…AC了(和我的计划有些出入).好吧,我们来这里进行尝试:259. 亲戚.

仔细读题,发现这也是一个单纯的并查集.因此,按照题目的输入格式修改一下代码(请注意,这里使用的是文件读写),比赛中是没有优化的,因此我们选择 “无优化开关”(一定要选…不然…).

umm…TLE了(强行).

我们来胡乱分析一波时间复杂度.对于n个元素m次操作,假设合并和查询随机出现,而合并时间复杂度为$O(n)$,查询为$O(1)$,平均时间复杂度近似为$O(nm)$,显然太慢,因此会超时.

我们来思考另一种思路:之前的方法中时间主要浪费在合并时遍历f数组.我们想到使用链表,将同一集合的元素链在一起,那么我们修改时只需要遍历一遍其中一条链,再将两条链拼接在一起,而不会访问无意义的数据,显然提高了效率.按照这个思路,我们获得了并查集的另一种初级实现.

并查集的链表实现

我们定义一个结构体,将元素的信息存储进去,然后定义一个结构体数组表示元素,命名为a,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const int MAX=10010;
struct ele
{
ele* f;//指向下标为当前元素的标记值的元素的指针
ele* next;//指向链表中下一个元素的指针
ele* ends;//指向链表末尾的指针

ele()//构造函数,用于初始化
{
f=this;//各自为一个集合,自己的下标就是自己的标记值
next=NULL;//链表只有当前一个元素,没有下一个元素
ends=this;//链表末尾就是自己
}
};
ele a[MAX];

我们在检查x和y是否在同一个集合中的时候,同样仅需检查它们的f是否相同,也就是x与y在同一集合,当且仅当a[x].f==a[y].f.写成函数:

1
2
3
4
bool check(int x,int y)
{
return a[x].f==a[y].f;
}

而合并时,我们需要遍历一遍其中一条链(链x),修改其上的所有元素的f.为了方便和直观起见,我们选用链表头元素的下标作为标记值.那么,我们就只需要在遍历并修改f之后,修改另一条链(链y)的尾的next值使其指向链x,那么链x就被连接到了链y之后,依然满足我们的约定.代码:

1
2
3
4
5
6
7
8
9
10
11
12
void join(int x,int y)
{
if(check(x,y)) return;//已经在同一集合中,不必合并
ele* tar=a[x].f;//当前元素
ele* head=tar;//保存原链的头(其实修改一下下面的代码就可以省下这一个空间,但我懒得改了)
while(tar!=NULL)//直到移出了链表
{
tar->f=a[y].f;//修改f值
tar=tar->next;//向后移动
}
a[y].f->ends->next=head;//将两条链连接
}

这样就完成了并查集的链表实现.我们再次尝试去解决259. 亲戚.完善之后的完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include<cstdio>
using namespace std;

const int MAX=20010;
struct ele
{
ele* f;
ele* next;
ele* ends;

ele()
{
f=this;
next=NULL;
ends=this;
}
};
ele a[MAX];

bool check(int x,int y)
{
return a[x].f==a[y].f;
}

void join(int x,int y)
{
if(check(x,y)) return;
ele* tar=a[x].f;
ele* head=tar;
while(tar!=NULL)
{
tar->f=a[y].f;
tar=tar->next;
}
a[y].f->ends->next=head;
}

int main()
{
freopen("relations.in","r",stdin);//文件读
freopen("relations.out","w",stdout);//文件写
int n,m,x,y,q;
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)
{
scanf("%d%d",&x,&y);
join(x,y);
}
scanf("%d",&q);
for(int i=1;i<=q;i++)
{
scanf("%d%d",&x,&y);
if(check(x,y)) printf("Yes\n");
else printf("No\n");
}

return 0;
}

依然选择无优化开关,这次成功的A掉了这道题.

但是仔细观察数据,我们注意到有好几个点是900多毫秒通过的,在超时的边缘试探.看来还是不行.我们再来分析.

这个方法中的时间主要仍是遍历一遍链表.我们注意到虽然操作两条链表,但需要遍历的只有一条.自然想到,我们应该尽量的去遍历更短的那一条,这样会花费更少的时间.如何确定那一条更短呢?我们可以在结构体中添加一个变量size,表示以该元素为表头的链表的长度.显然,合并将使链表的长度变为两链原长之和,而我们只须修改表头元素的信息即可,不会花费过多的额外时间.而初始化也很简单:只有一个元素的链表长度自然为1.

而在合并时,我们只需要判断一下,选择遍历更短的链表即可.修改后的代码只加上了为数不多的几条语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#include<cstdio>
using namespace std;

const int MAX=20010;
struct ele
{
ele* f;
ele* next;
ele* ends;
int size;//定义size

ele()
{
f=this;
next=NULL;
ends=this;
size=1;//初始化size
}
};
ele a[MAX];

bool check(int x,int y)
{
return a[x].f==a[y].f;
}

void join(int x,int y)
{
if(check(x,y)) return;
if(a[x].f->size>a[y].f->size) x^=y^=x^=y;//如果x链的长度更长,交换x和y的值,使得这行执行后x所在的链是更短的链
ele* tar=a[x].f;
ele* head=tar;
while(tar!=NULL)
{
tar->f=a[y].f;
tar=tar->next;
}
a[y].f->ends->next=head;
a[y].f->size+=head->size;//维护size的值
}

int main()
{
freopen("relations.in","r",stdin);
freopen("relations.out","w",stdout);
int n,m,x,y,q;
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)
{
scanf("%d%d",&x,&y);
join(x,y);
}
scanf("%d",&q);
for(int i=1;i<=q;i++)
{
scanf("%d%d",&x,&y);
if(check(x,y)) printf("Yes\n");
else printf("No\n");
}

return 0;
}

再次尝试提交,在无优化开关的情况下,我们的优化成功使耗时最长的测试点从$912ms$优化到了$53ms$,而代码量只增加了4行,内存占用增加了$20kb$.

似乎到这里已经很优了.但事实上,我们还能做的更好.放松对于属于同一集合的元素标记值的限制,将给我们打开更广阔的空间.在下面的方法中,我们将不再限定属于同一集合的元素必须被相同的标记值标记.这将使我们的查找变慢,但将提高合并速度.

并查集的有根树实现

现在我们重新定义一下我们的f数组:

f[i]表示元素i在树上父亲是f[i].

特殊的,对于没有父亲的节点(如x),我们令f[x]=x,并称这样的元素为其子树中任意元素的祖先.

现在,对于两个元素x和y,它们在同一集合中当且仅当x的祖先和y的祖先相同.

按照这些定义和约定,我们分不同的操作来看如何用这种方式实现并查集.

1.如何使用

同样的,在研究如何初始化(并操作)并查集之前,我们先假设已经完成了这些操作,来研究如何正确的进行查询.

假设我们现在被询问元素x与y是否存在于同一个集合中.按照我们之前给出的定义,我们应该分别寻找x和y的祖先,然后比较这两个祖先是否相同.而祖先(设为k)的标志是f[k]==k,那么我们就可以用一个while循环完成对祖先的查找;由于一次check中要进行两次查询,我们将查询写为函数可以减小代码量.按照我们的分析,可以写出如下的代码:

1
2
3
4
5
6
7
8
9
10
//略去了对数组的定义
int find(int a)//寻找a的祖先
{
while(f[a]!=a) a=f[a];//当f[a]!=a时,说明当前的a尚不是祖先,我们要继续向上寻找
return a;//此时一定有a==f[a],也就是说我们已经找到了祖先,就是当前的a,直接返回
}
bool check(int x,int y)
{
return find(x)==find(y);//将find写为函数之后check函数就无比简单了,直接比较以x和y为参数执行的find返回的祖先是否相同就可以了
}

可以像这样形象(但不一定准确)的理解这个过程:这是一个组织,有准确的领导关系:每个人会被另一个人领导(他的父亲),但同时他可以领导多个其他人(当然,也肯能只领导一个人或是谁也不领导).其中只有一个例外:有一个老大(祖先),他是老大,他说了算,我们可以认为是他领导自己.因此,f[r]==r(r表示根,祖先,这里的老大).现在这里有很多这样的组织.

有一天有两个人碰面了,他们想知道他们是不是在一个组织中,怎么办?只要看看他们是不是受同一个老大领导就好了.

于是两个人就分别去问他们的领导,咱们老大是谁啊?如果他们的领导不是自己领导自己,那么他就不是老大.那就让领导去问领导的领导,直到问到一个人,他自己领导自己,那么他就是老大.他告诉来问自己的人:我就是老大!于是那个人知道了,再回去告诉来问自己的人:xxx就是老大.于是就这样一路传达,直到最初提问的那个人,于是他就知道了自己的老大就是xxx.

两个人都知道了自己的老大是谁,那么只要看看老大是不是一个人就知道了.

2.如何完成合并

现在假设我们被要求将x所在的集合和y所在的集合合并为一个集合.这也就意味着x所在集合中的任何元素的祖先必须被设定为与y中元素的祖先相同(反过来也可以).显然,如果逐个修改和朴素方法是没有区别的,我们考虑一个牵一发而动全身的方法:只要将x所在集合的祖先的父亲设为y所在集合的祖先,那么我们查询时的循环将不会在x的原祖先处停止,而会查询到y的祖先,而原y所在集合中的元素的祖先不会发生变化.

这样,我们在合并时不再需要遍历,只需要找到两个集合的祖先并且使其中一个(k)成为另一个(l)的父亲,那k就成为了原来k和l的子树中的所有元素的共同祖先,而这只会让我们在查询时增加不超过1次.而我们发现这里又出现了寻找祖先的工作,同样交给我们的find函数.

按照我们的分析,代码同样不难写出:

1
2
3
4
5
6
7
8
9
//省略掉find函数
void join(int x,int y)
{
int fx=find(x);
int fy=find(y);
if(fx==fy) return;//不进行无意义的合并
f[fx]=fy;//反过来,f[fy]=fx也可以,看个人喜好
return;
}

继续按上文的例子理解一下:现在x和y所在的组织被要求合并成一个组织了.怎么办?我们只能让其中的一个老大屈尊,被另一个老大领导,那么就成为一个组织了.

3.如何初始化

这是最简单的一步了.一开始任意两个元素都不在同一个集合中,那么各自都是自己的祖先(-_-||),只要让f[i]=i就可以了.

1
2
3
4
5
6
//省略n的定义并假设n中存储了总元素的量
void init(void)
{
for(int i=1;i<=n;i++) f[i]=i;
return;
}

umm…各自为战,我一个人就是一个组织…

将之前的三部分中的所有函数组合在一起,定义数组f和变量n,我们就完成了并查集的初级实现,已经能正确的完成前面提到的操作了!为了美观和方便,我们将并查集写成一个结构体,将数组作为成员变量,并用private关键字保护,函数作为方法出现(由于没有直接查询祖先的需求,find函数将同样被加以保护):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const int MAX=10010;
int n;
struct uf
{
private://一般不能从结构体以外访问到
int f[MAX];

int find(int a)
{
while(f[a]!=a) a=f[a];
return a;
}

public://允许从结构体外访问
void init(void)
{
for(int i=1;i<=n;i++) f[i]=i;
return;
}

bool check(int x,int y)
{
return find(x)==find(y);
}

void join(int x,int y)
{
int fx=find(x);
int fy=find(y);
if(fx==fy) return;
f[fx]=fy;
return;
}
};

完成了.可以正确的书写主函数,再去水一次259. 亲戚.

像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include<cstdio>
using namespace std;

const int MAX=20010;
int n;
struct uf
{
private:
int f[MAX];

int find(int a)
{
while(f[a]!=a) a=f[a];
return a;
}

public:
void init(void)
{
for(int i=1;i<=n;i++) f[i]=i;
return;
}

bool check(int x,int y)
{
return find(x)==find(y);
}

void join(int x,int y)
{
int fx=find(x);
int fy=find(y);
if(fx==fy) return;
f[fx]=fy;
return;
}
};
uf f;

int main()
{
freopen("relations.in","r",stdin);
freopen("relations.out","w",stdout);
int m,x,y,q;
scanf("%d%d",&n,&m);
f.init();
for(int i=1;i<=m;i++)
{
scanf("%d%d",&x,&y);
f.join(x,y);
}
scanf("%d",&q);
for(int i=1;i<=q;i++)
{
scanf("%d%d",&x,&y);
if(f.check(x,y)) printf("Yes\n");
else printf("No\n");
}

return 0;
}

ummm…TLE…似乎受到欺骗,不是说我们能做的更好了吗?

没错,所以我们这是基本实现.

胡乱分析一下,显然存在一些特定的数据可以使我们的树变得很丑:长的像一条链.那么我们的运行速度就变得比未优化的链表的速度还慢:链表只需要遍历其中一条,而我们需要遍历两条.下面,我们将针对这种情况进行优化,我们可以看到如何用简洁的几条语句完成优化,甚至使我们的查询和合并的时间复杂度同时被优化到近似为$O(1)$.


高效并查集的实现

相比并查集的初级实现,我们考虑从两个思路上出发进行优化,一个是之前使用过的,通过维护额外信息使得每次合并遍历尽量少的点,另一个则是之前没出现但同样直观的.

带权合并

显然,由于每次find都需要找到祖先才会停止,而每次find运行时间正比于其进行循环的次数,而循环次数又取决于树的高度.因此,我们可以维护一个高度值.同样,我们规定一个元素所存储的高度是以这个元素为祖先的树的高度.

合并时有两种情况:当两棵树高度不同时,我们选择将矮一些的树合并高一些的树,其总高度不会发生变化(由于合并时将一棵树的根作为另一棵树的根的父亲,被合并进来的树中的节点到根的距离将全部加1,因此选择将更矮的树和并进更高的树,这样访问这棵树,寻找祖先时最多寻找的次数不会发生改变,也就是总高度不变);而当两棵树高度相同时,可以任意合并,但其高度会增加1.

和链表实现的优化一样,这个优化同样只需要为数不多的语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
const int MAX=20010;
int n;
struct uf
{
private:
int f[MAX];
int h[MAX];//新定义一个额外信息的数组

int find(int a)
{
while(f[a]!=a) a=f[a];
return a;
}

public:
void init(void)
{
for(int i=1;i<=n;i++)
{
f[i]=i;
h[i]=1;//初始化高度为1
}
return;
}

bool check(int x,int y)
{
return find(x)==find(y);
}

void join(int x,int y)
{
int fx=find(x);
int fy=find(y);
if(fx==fy) return;
if(h[fx]>h[fy]) fx^=fy^=fx^=fy;//保证h[fx]<=h[fy]
f[fx]=fy;
if(h[fx]==h[fy]) h[fy]++;//如果两棵树高度相同那么合并后高度会增加1
return;
}
};

再次尝试259. 亲戚.(由于主函数没有修改,不再给出主函数的代码)

我们又A掉了这道题.花费的时间和带优化的链表实现近似.

路径压缩

我们考虑我们这种表示的最优情况:我们当然希望任何的元素都的父亲都直接就是它的祖先.这似乎很难,但我们考虑一下,是否可以在查找的过程中完成这个操作.

在查找时,我们会一路查找到祖先.那么,我们是否可能在找到祖先后将沿途经过的节点的父亲都设为找到的祖先?
不难想到这么一个东西:递归.

接着用那些迷之组织来描述这一个过程.由于之前的方法找老大要经过他的手下,太麻烦了,老大希望精简管理层:所有人都由他直接领导.怎么办呢?他要求来询问自己(root)老大是谁的人(mid)在回答mid的手下(leaf)的时候,告诉leaf:以后我就不是你的领导了,我的领导以后就是你的领导了.这样,一次询问后,经过的所有人的领导就都变成了老大.計画通り.

按照这个过程,运用递归,代码很好写,只用修改find方法就行了:

1
2
3
4
5
int find(int a)
{
if(f[a]!=a) return f[a]=find(f[a]);//当前的被询问的人不是老大,那么让他的领导再去询问,并在得知老大是谁后将自己的领导更改为原来的领导的领导(模拟一下其实就是老大)
return a;//"我就是老大"
}

还是两行,但运用递归完成了路径的压缩.现在,只要一个节点被查询过一遍,那么在下次合并之前,就可以在$O(1)$的时间内找到它的祖先.

我们再去虐一下这道题.

同样A掉,时间和带权合并近似.

应用两种优化

我们再来尝试一下同时应用两种优化.大概比只用一种优化快了$2ms$.

实践证明,一般来说只要应用一种优化就足够了.其中路径压缩代码量极小,一半推荐使用,而带权合并可以提供额外信息,更适合要求回答或是进行其他操作时需要额外信息的情况使用.(当然,代码量也不大,我倾向于都用).

完整代码如下(只有结构体的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
struct uf
{
private:
int f[MAX];
int h[MAX];

int find(int a)
{
if(f[a]!=a) return f[a]=find(f[a]);
return a;
}

public:
void init(int n)//为了增强泛用性,体现数据隐藏的严谨性,我们将n作为参数传入而不是使用全局变量
{
for(int i=1;i<=n;i++)
{
f[i]=i;
h[i]=1;
}
return;
}

bool check(int x,int y)
{
return find(x)==find(y);
}

void join(int x,int y)
{
int fx=find(x);
int fy=find(y);
if(fx==fy) return;
if(h[fx]>h[fy]) fx^=fy^=fx^=fy;
f[fx]=fy;
if(h[fx]==h[fy]) h[fy]++;
return;
}
};

至此,这样一种高效的数据结构的皮毛就已经基本被掌握了.我很喜欢这个数据结构,因为它强大高效但是代码量很小(而且不会写的太丑对于我来说简直就是福音w).


例题

1.裸并查集

由于这些题目都是最基本的,没花样的并查集应用,一套代码就可以水个遍,就没有代码了.


后记

简单的并查集是很简单的.而经过一些技巧性的操作,它可以很强大.但是它最大的特点仍是代码量极小而高效(即使是加上了技巧性的操作).

更高级的操作?可持久化?ummm…还没研究,大概是有生之年了.

请路过的大犇如果发现了我文中的错误或是不严谨的地方一定在评论中告诉我,感激不尽.

本蒟蒻在此鞠躬.


本文标题:浅谈并查集

文章作者:Snake

发布时间:2018年03月25日 - 15:03

最后更新:2018年04月28日 - 20:04

原始链接:https://snake.moe/2018/03/25/浅谈并查集/

许可协议: 署名-非商业性使用-相同方式共享 4.0 国际 转载请保留原文链接及作者。