[算法笔记] 并查集

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。
两棵树深度都为2
图    4 图\;4 4
合并后深度为3
图    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 代码实现(未完待续)



  1. 代码中的查仅包含查找点 a i a_i ai 的祖先,真正的查还要判断两元素的祖先是否相同。 ↩︎

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值