upd:2023/5/2 完成算法优化。 \text{upd:2023/5/2\;完成算法优化。} upd:2023/5/2完成算法优化。
如有错误,欢迎指正。
文章目录
1.什么是并查集
并查集(union-find set),顾名思义就是可以维护两种操作的集合。
以下,设集合 a a a 为要维护的集合。
1.1 并
并查集在最初时,集内所有的
n
n
n 个元素
a
1
,
a
2
,
⋯
,
a
n
a_1,a_2,\cdots,a_n
a1,a2,⋯,an 分别各自属于
n
n
n 个不同的集合。“并”操作可以让任意两个元素
a
i
,
a
j
a_i,a_j
ai,aj 所在的两个集合合并成一个集合。
如图:
图
1
图\;1
图1
此时
n
=
3
n=3
n=3,三个元素在各自的集合中。
若我们将
1
,
2
1,2
1,2 进行合并,那么:
图
2
图\;2
图2
(我们用颜色和连线表示两个点所在的集合)
如图2所示,这时
1
,
2
1,2
1,2 在同一个集合,而
3
3
3 在另一个集合中。
1.2 查
既然可以合并,我们就一定能查询吧?
是的,“查”操作可以查询任意两个元素
a
i
,
a
j
a_i,a_j
ai,aj是否在同一个集合中。
如图2,如果我们查
1
,
2
1,2
1,2,我们会的到肯定回答,即在同一集合中。
若查
1
,
3
1,3
1,3 或
2
,
3
2,3
2,3,我们则会得知它们不在同一集合中。
2.算法实现
2.1 思路
我们可以把这个算法具象化为家谱。我们假设,家族关系中只有父子关系。若
a
i
,
a
j
a_i,a_j
ai,aj 有血缘关系,那么它们一定有一个共同的祖先。
图
3
图\;3
图3
如图3,很明显
3
,
6
3,6
3,6 在同一集合中,但是它们没有直接的关系。不过,它们有一个共同的祖先
4
4
4。所以可知它们在同一集合中。这就是“查”:如果
a
i
,
a
j
a_i,a_j
ai,aj 有一个共同的祖先
a
k
a_k
ak,则它们在同一集合中。
知道“查”了,那怎么并呢?
如果我们知道
a
i
,
a
j
a_i,a_j
ai,aj 有血缘关系,那我们可以分别找到它们的祖先
a
k
,
a
l
a_k,a_l
ak,al,使
a
k
a_k
ak 的父亲为
a
l
a_l
al(反过来也行)。这就是“并”。 这样做的原理是“五百年前是一家”,即是没有很“亲”的血缘,但至少有血缘关系了。
所以,并查集本质上是一棵树。
2.2 实现
设数组 f i f_i fi 表示 i i i 的父亲,初始化时, f i = i f_i=i fi=i。
int f[n+1];
for(int i=1;i<=n;i++)
{
f[i]=i;
}
查1:
int Find(int k)
{
if(f[k]==k)
{
return k;
}
return Find(f[k]);
}
并:
void Union(int a,int b)
{
int fa=Find(a),fb=Find(b);
if(fa==fb)
{
return;
}
f[fa]=fb;//or f[fb]=fa;
return;
}
3.算法优化
3.1 路径压缩
我们注意到,每一次查询都要爬一遍树,很麻烦,所以考虑优化。
每次找祖宗时,递归到的每一个点都是在同一个集合里的,所以干脆将经过的节点的父亲都设为祖宗,这样可以大大压缩查找的路径。
模板:
int Find(int k)
{
if(f[k]==k)
{
return k;
}
return f[k]=Find(f[k]);
}
3.2 按秩合并
查询有优化,合并怎么会没有?
在合并的过程中,选择哪一棵树的根结点作为新的根结点很重要,因为会影响接下来操作的复杂度,按秩合并就是为了避免查询的复杂度发生退化。
在合并时,如果我们将小的树并到大的树上,这样合并后的树就会相对平衡。
有两种判别树的大小的方式。
3.3.1 树的深度
设节点
i
i
i 的子树深度为
d
e
p
i
dep_i
depi,初始化时
d
e
p
i
=
1
dep_i=1
depi=1。因为我们都是把小树并到大树上,所以一般新树的深度就是大树的深度。但,如下图,当合并的两树大小相同时,树的深度就要加1。
图
4
图\;4
图4
图
5
图\;5
图5
图4中,两棵树深度都为2,但合并后如图5,深度为3。
模板:
int dep[n+1];
fill(dep,dep+n+1,1);
void Union(int a,int b)
{
int fa=Find(a),fb=Find(b);
if(fa==fb)
{
return;
}
if(dep[fa]<=dep[fb])
{
f[fa]=fb;
}
else
{
f[fb]=fa;
}
if(dep[fa]==dep[fb])
{
dep[fb]++;
}
return;
}
3.3.2 节点数量
同理,设节点 i i i 的子树深度为 s i z i siz_i sizi,初始化时 s i z i = 1 siz_i=1 sizi=1。
模板:
int siz[n+1];
fill(siz,siz+n+1,1);
void Union(int a,int b)
{
int fa=Find(a),fb=Find(b);
if(fa==fb)
{
return;
}
if(siz[fa]<=siz[fb])
{
siz[fb]+=siz[fa];
f[fa]=fb;
}
else
{
siz[fa]+=siz[fb];
f[fb]=fa;
}
return;
}
4.升级版1——带边权并查集(未完待续)
5.升级版2——扩展域并查集
有时,我们需要描述一些朋友与敌人的关系,这时候,我们就需要带边权并查集。
5.1 算法思路
我们可以用最简单的并查集处理朋友之间的关系,再利用扩展域并查集处理敌人之间的关系。
那么敌人之间的关系怎么维护呢?
我们假设每个节点都有两个面,正面和反面。 若
i
,
j
i,j
i,j 两节点为敌人,那么用
i
i
i 的反面与
j
j
j 合并,再将
j
j
j 的反面与
i
i
i 合并。这样,就可以表示
i
,
j
i,j
i,j 两节点为敌人了。
当然,在表示朋友时也要记得分别用
i
,
j
i,j
i,j 的的反面合并。
但现在又有新的问题了:怎么表示正反面呢?
很简单,对于节点
i
i
i,
i
i
i 就是它的正面,而
i
+
n
i+n
i+n(
∣
a
∣
=
n
|a|=n
∣a∣=n,
n
n
n 是集合大小)。 这样可以避免节点编号重合。
5.2 代码实现(未完待续)
代码中的查仅包含查找点 a i a_i ai 的祖先,真正的查还要判断两元素的祖先是否相同。 ↩︎