目录
什么是并查集?
并查集(Disjoint-Set)是一种可以动态维护若干个不重叠的集合,并支持合并与查询的数据结构,它名字的含义为“合并”、“查找”、“集合”,包括两种基本操作:
1. 查找:查询一个元素属于哪个集合以及两个元素是否属于同一个集合;
2. 合并:将两个集合合并成一个大集合。
怎么实现并查集?
在这篇博客里,我将从并查集最朴素的实现方法开始展示,并进行逐步优化。
首先,为了具体实现并查集,我们需要定义集合的表示方法。在并查集中,我们采用代表元法,即为每个集合选择一个固定的元素,作为整个集合的“代表”。你可以认为这是给每个集合选出一位班长,用它来代表这个集合。
其次,我们需要定义归属关系的表示方法。
实现方法一:QuickFind
查找快,合并慢的一种方法。
第一种思路,是维护一个数组f,用f[x]保存元素x所在集合的“代表”,即朴素解法。
set[i] | 1 | 2 | 1 | 4 | 2 | 6 | 1 | 6 | 2 | 2 |
i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
在上面表格中,我们的实现方法如下:
1. 用set[i]来表示元素i所属的集合;
2.并用该集合中最小的元素作为代表来标记这个集合
你不难发现,上面的集合是{1, 3, 7},{4},{2, 5, 9, 10},{6, 8}。
查找与合并操作
代码如下:
void Find(x){ //查找操作
return Set[x]; //非常简单,直接查找
}
void Merge(a, b){ //合并操作,a和b为两个集合的班长(代表)
i = min(a, b); //合并之后,新的班长为两位班长中较小的那个
j = max(a, b); //较大的那个变成了普通成员
for(k = 1; k <= N; ++k){
if(Set[k] == j) //如果原来的班长是较大的那个
Set[k] = i; //换班长
}
}
易得查找操作时间复杂度为O(1),合并操作时间复杂度为O(n)。
实现方法二:QuickUnion
查找慢,合并快的一种方法。
实现方法一看起来还不错,为什么要换呢?
问题出在合并操作上,当数据量过大的时候,O(n)的时间复杂度显然无法满足我们
怎么更快的合并呢?
我们注意到,方法一中每次合并都要将所有数据都搜索一遍,再把两个集合中的一个集合中所有元素的set值依次修改一遍,这无疑是低效的。这个问题存在的本质,是因为每个集合的内部都是一个线性结构,我们顺藤摸瓜式的寻找必须遍历所有元素。
那么,我们就可以使用一个树形结构来存储每个集合,树上的每个结点都是一个元素,树根(根结点,有且仅有一个)是集合的代表元素。这样,每个集合都是一棵树,并查集则是若干棵树组成的森林。我们先来看看怎么实现它:
我们依然可以用一个数组int father[N]来实现集合,其中father[i]代表元素i的父亲结点,而父亲结点本身也是这个集合中的元素。
如你所见,这是一棵树,也是我们正在讨论的集合结构。father[1] = 1说明元素1的父亲结点是自己,也就是说元素1是整个集合的根结点;father[4] = 1说明元素4的父亲结点是元素1;father[5] = 4与father[6] = 4说明元素5和6的父亲结点都是元素4...以此类推,就得到了一个集合。
对于这个并查集,需要先初始化father数组,再进行查找与合并操作。
1. 初始化
一开始,每个元素都是独立的一个集合,因此需要让所有father[i] = i:
void init(int N){
for(int i = 1; i <= N; ++i){
fa[i] = i;
}
}
/*
我们需要让每个元素的父亲结点不存在或指向自己
所以father[i] = -1也可以
*/
2. 查找
由于我们规定同一个集合中只存在一个根结点,因此查找操作就是对给定的结点寻找其根结点的过程。实现的方式可以是递归或递推,思路都是一样的,即反复寻找父亲结点,直到找到根结点。
递推代码:
//Find函数返回元素x所在集合的根结点
int Find(int x){
while(x != fa[x]){ //如果不是根结点,继续循环
x= fa[x]; //变成自己的父亲结点继续追根溯源
}
return x;
}
递归代码:
int Find(int x){
if(x == fa[x]) return x;
else return Find(fa[x]);
}
3. 合并
在题目中,通常会给出两个元素,要求把这两个元素所在的集合进行合并。具体实现是先判断两个元素是否属于一个集合(调用查找函数),不属于同一集合的话再进行合并,合并的方法是把其中一个集合的根结点的父亲指向另一个集合的根结点。
void Merge(int a, int b){
int faA = Find(a); //查找a的根节点
int faB = Find(b); //查找b的根节点
if(faA != faB){ //如果不属于同一集合
fa[faA] = faB; //合并它们
}
}
在这种实现方式中,查找速度变慢了,但合并效率提高。它们的时间复杂度均为O(h),h为树高。
实现方法三——路径压缩
实现方法二采用的树是没有经过优化的,极端情况下效率极低(就是与方法一一样形成了一条链),那么此时方法二所用查找方式的效率便会极度低下。
那么该怎么优化呢?
不难想见,问题的根源出在我们的树可能会过高,形成光秃秃的一条树干,这样寻找根结点的过程便会漫长而费劲。换过来想一想,我们在操作中只关心能不能从当前元素快速找到根结点,而不关心这棵树具体是什么形状。
那么,我们可以在每次执行查找操作时,顺便把访问过的所有结点(也就是所查询元素的全部祖先)都指向树根,构造出一个枝繁叶茂而低矮的树,即路径压缩。
代码如下:
int Find(int x){
if(x == father[x])
return x;
else{
father[x] = Find(fa[x]); //把当前结点原先的父结点改成根结点
return father[x];
}
}
当然我们也可以一行秒杀:
int Find(int x){
return x == father[x] ? x : (father[x] = Find(father[x]));
}
//注意? :运算符优先级较高,要加上括号
实现方法四——按秩合并
读者往往会以为,路径压缩会让自己的树变的只有两层(就像一朵菊花那样),但其实我们的路径压缩只发生在查询过程中,即一次查询顺带压缩一条路径,并查集实际的结构可能仍然较为复杂。这就需要进一步的优化方案。
我们来看下面两个集合合并的问题:
对于这两个集合合并,我们是把8设为7的父结点好,还是7设为8的父节点好?
显然是后者。采用前者的合并方式会使树的深度变深,查询的路径也会变长。虽然有路径压缩这个buff,但还是会耗费时间。相反,后者不存在这个问题,因为8的父节点设为7这件事本身不会影响其他任何路径。
OK,这个例子启发我们,要把简单的树往复杂的树上合并,这样合并后距离根结点变长的结点数目会相对较少。
我们用一个数组rank[]记录每个根节点对应的树的深度(如果不是根节点,其rank相当于以它作为根节点的子树的深度)。一开始,把所有元素的rank(秩)设为1。合并时比较两个根节点,把rank较小者往较大者上合并。
路径压缩和按秩合并如果一起使用,时间复杂度接近 O(n) ,但是很可能会破坏rank的准确性。
来看按秩合并的代码:
1. 初始化
void init(int N){
for(int i = 1; i < N; ++i){
father[i] = i;
rank[i] = 1; //初始秩为1
}
}
2. 按秩合并
void Merge(int i, int j){
int x = Find(i), y = Find(j); //先找到两个根结点
if(rank[x] <= rank[y])
father[x] = y;
else
father[y] = x;
if(rank == rank[y] && x != y) //如果根结点不同秩却相同
rank[y]++;
}
注意:
1. 按秩合并和路径压缩同时使用的时候,时间复杂度O(α(n)),内函数为一个比log增长还慢的函数;
2. 对于根结点不同秩相同的情况,谁合并谁都是一样的,而且深度都是+1;
3. 一般来说,路径压缩已经足够我们应用,按秩合并并非必须。
并查集的应用
例一
题目描述
如题,现在有一个并查集,你需要完成合并和查询操作。
输入格式
第一行包含两个整数 N, M, 表示共有 N 个元素和 M 个操作。
接下来 M 行,每行包含三个整数 Zi,Xi,Yi 。
当 Zi=1 时,将 Xi 与 Yi 所在的集合合并。
当 Zi=2 时,输出 Xi 与 Yi 是否在同一集合内,是的输出Y;否则输出N。
输出格式
对于每一个 Zi=2 的操作,都有一行输出,每行包含一个大写字母,为Y或者N。
题目分析
本题是一道模板题,直接上板子就行。
代码如下:
#include<iostream>
using namespace std;
const int maxn = 10003;
int fa[maxn];
void init(int N){
for(int i = 0; i < N; i++)
fa[i] = i;
}
int Find(int x){
return x == fa[x] ? x : (fa[x] = Find(fa[x]));
}
int main(){
int N, M, Z, X, Y;
cin>>N>>M;
init(N);
while(M--){
scanf("%d%d%d", &Z, &X, &Y);
if(Z == 1){
fa[Find(X)] = Find(Y);
}else{
if(Find(X) == Find(Y))
printf("Y\n");
else
printf("N\n");
}
}
return 0;
}
例二
题目描述
小希非常喜欢玩迷宫游戏,现在她自己设计了一个迷宫游戏。在她设计的迷宫中,首先她认为所有的通道都应该是双向连通的,就是说如果有一个通道连通了房间A和B,那么既可以通过它从房间A走到房间B,也可以通过它从房间B走到房间A,为了提高难度,小希希望任意两个房间有且仅有一条路径可以相通(除非走了回头路)。小希现在把她的设计图给你,让你帮忙判断她的设计图是否符合她的设计思路。比如下面的例子,前两个是符合条件的,但是最后一个却有两种方法从5到达8。
输入格式
输入包含多组数据,每组数据是一个以0 0结尾的整数对列表,表示了一条通道连接的两个房间的编号。房间的编号至少为1,且不超过100000。每两组数据之间有一个空行。
整个文件以两个-1结尾。
输出格式
对于输入的每一组数据,输出仅包括一行。如果该迷宫符合小希的思路,那么输出"1",否则输出"0"。
题目分析
基于并查集的思想,我们可以将题目进行如下解析:
1. 我们可以把房间看成一个个元素,相互连通的房间看成一个集合;
2. 在一组数据中,每输入一对房间编号,都代表着建立了一条新的通路,将这两个房间代表的集合合并起来;
3. 如果这两个房间本就属于一个集合,集合内两元素的连接会使集合从一个树变成了环,也 就是存在两个房间之间有多条路径连通。那么对于这组数据,直接输出0,进入下一组数据;
4. 如果这两个房间不属于一个集合,则合并两集合;
5. 很多读者会忽略一个要求:任意两个房间有且仅有一条通路,所以在数据输入完成后我们需要判断所有元素是否属于且仅属于一个集合。
OK,题目分析就是这样,来看看具体实现代码:
#include<bits/stdc++.h>
using namespace std;
const int maxn = 100001;
bool j[maxn], flag;
int fa[maxn], a, b, sum;
//j[maxn]与sum判断是否仅有一个集合,flag判断是否形成环
void init(){ //初始化操作
for(int i = 0; i < maxn; ++i){
fa[i] = i;
}
memset(j, 0, sizeof(j));
sum = 0;
flag = true;
}
int Find(int x){
return x == fa[x] ? x : (fa[x] = Find(fa[x]));
}
int main(){
init();
while(1){
scanf("%d%d", &a, &b);
if(a == -1 && b == -1) break;
if(a == 0 && b == 0){
if(flag == true && sum == 1)
printf("1\n");
else
printf("0\n");
init();
continue;
}
if(!j[a]) sum++;
if(!j[b]) sum++; //如果为0,说明集合数+1
j[a] = j[b] = true;
if(Find(a) == Find(b)) flag = false;
else{
fa[Find(a)] = Find(b);
sum--;
}
}
return 0;
}
这个方法有点像最小生成树问题的Kruskal算法,这里不详细展开。最小生成树的解法有很多,我们可以单独开一篇博客来聊聊这件事。
OK,以上便是关于并查集的全部内容了,如果你需要处理很多元素,不妨试着用并查集来管理。