并查集:合并多个元素为一个集合的操作方法

一、对并查集算法的简要概述

并查集是一个森林状结构,主要思想是让所有要合并的点都在同一个集合中,指向同一个根节点,从而实现极低复杂度的数据查找任务。并查集算法是一个比较高效的算法,时间复杂度大概在 O ( α ( n ) ) O(\alpha(n)) O(α(n)) 左右。并查集可以用来实现合并和独立操作的高效处理,从属关系的询问,以及有依赖背包问题的优化解决方案。我们在本文中将会介绍并查集的几种常见处理的问题。

在并查集算法中,我们定义以下的几个状态:设 f a ( i ) fa(i) fa(i) 表示第 i i i 个点的父节点,而且假设该节点是它的真正根节点,即整棵树的根节点,而非它的祖先。注意,在推导的过程中, f a ( i ) fa(i) fa(i) 可能不仅代表一个固定的值,而最后的 f a ( i ) fa(i) fa(i) 才是正确答案, f a ( i ) fa(i) fa(i) 的值在一次次不断地查找中不断优化,最终取得最优根节点的值。

二、并查集的算法框架

在开始前,我们先定义一个函数 getfa(int x) ,表示求出 x x x 号节点的真正根节点,并通过改进不断优化 getfa() 函数的准确程度。令我们的节点数量为 N N N ,关系的数量为 M M M ,并查集结构的森林为 T T T T ( x ) T(x) T(x) 表示 x x x 所在节点在 T T T 中的对应的树。

在第一部分,我们说过并查集是一个森林状结构。首先,我们的森林是一个 1 ∼ M 1\sim M 1M 的由根节点组成的集合,即 T = { i : ∀ 1 ≤ i ≤ N } T=\{i:\forall 1\le i\le N\} T={i:∀1iN}

接下来,我们进行 M M M 次读入,每一次读入都将它的关系更新。具体更新步骤如下:

  1. 我们先选定一个两个节点 u , v u,v u,v ,表示将要合并这两个节点, 假设它们各有 k 1 , k 2 k_1,k_2 k1,k2 个父节点。
k_1 个父节点
k_2 个父节点
u_k_1
u
v_k_2
v
  1. 接下来,我们从点 u u u 和点 v v v 分别开始搜索,找到它们的根节点 u k 1 u_{k_1} uk1 v k 2 v_{k_2} vk2
  2. 最后,我们指定一个节点为另一个节点的父节点,这里我们假设 u k 1 u_{k_1} uk1 v k 2 v_{k_2} vk2 的父节点,则我们的森林将会变成这样:
k_1 个父节点
k_2 个父节点
u_k_1
v_k_2
u
v

这样,我们就合并了两个集合为一个大集合,这个集合的父节点就是 u k 1 u_{k_1} uk1 。换言之,对于 ∀ i ∈ T ( U ) , T ( V ) , f a ( i ) = u k 1 \forall i\in T(U),T(V),fa(i)=u_{k_1} iT(U),T(V),fa(i)=uk1

三、具体实施方案

为了实现在 O ( α ( n ) ) O(\alpha(n)) O(α(n)) 的时间内计算出 ∀ 1 ≤ i ≤ N , f a ( i ) \forall 1\le i\le N,fa(i) ∀1iN,fa(i) 的值,我们需要使用以下优化:

  1. 定义 getfa() 函数如下:
int getfa(int x)
{
	if (x == fa[x]) return x;
	return fa[x] = getfa(fa[x]);
}

这个函数的意义值得讨论,因为它是并查集的全部算法中最重要的一部分:“并”和“查”。
首先,这个函数判断是否到达了递归出口,即如果一个节点的父节点是它本身,则我们判定该节点为根父节点,则我们一路返回这个节点的编号,让它下面的所有节点的父节点都更新为该节点。这样,在同一个集合内的节点都有了一个唯一的根节点,使得这个根节点能够代表整个集合。图例如下:
假设我们有一个节点 u u u ,它的父节点分别是 u 1 , u 2 , u 3 , . . . , u k u_1,u_2,u_3,...,u_k u1,u2,u3,...,uk ,则算法的过程可以大概表示为:

找到根父节点,返回 u_k
省略 k-1 个节点
u_k
u_2
u

下面的一行代码是用来寻找 f a ( x ) fa(x) fa(x) 的最优节点。因为我们要设置 f a ( x ) fa(x) fa(x) 为它的真正根节点,则我们需要将 f a ( x ) fa(x) fa(x) 指向 T ( x ) T(x) T(x) 的根节点。通过递归,这可以理解为将 f a ( x ) fa(x) fa(x) 设置为 f a ( f a ( x ) ) fa(fa(x)) fa(fa(x)) ,并不断叠加层数,直到 f a ( f a ( … f a ( x ) …   ) ) = x fa(fa(\dots fa(x) \dots))=x fa(fa(fa(x)))=x 为止。

找到根父节点,返回 u_k
省略 k-1 个节点
u_k
father: u_k
u_2
father: u_k
u
father: u_k
  1. 初始化的方法:
    如第一部分所述,在 T T T 中, ∀ 1 ≤ i ≤ N , T ( i ) = { i } \forall 1\le i\le N,T(i)=\{i\} ∀1iN,T(i)={i} ,则我们可以初始化 f a ( i ) = i fa(i)=i fa(i)=i 。这比较容易实现,代码如下:
void init(int n)
{
	for (int i = 1; i <= n; ++i)
	{
		fa[i] = i;
	}
}
  1. 合并的方法:
    具体的算法如第二部分所述。代码如下:
void merge(int u, int v)
{
	int fx = getfa(u), fy = getfa(v);
	if (fx == fy) continue; // 判断两个节点是否属于同一个集合
	fa[fx] = fy;
}

这里,我们用到了一个优化的方法,即判断两个集合是否已经合并。如果已经合并,则不需要进行下一步的合并操作。
4. 独立的方法:
只需从原来的集合中分隔出一部分即可。如下图所示:

切断
u
v

可见设置 f a ( i ) = i fa(i)=i fa(i)=i 即可。代码如下:

void seperate(int i)
{
	fa[i] = i;
}
  1. 总体效果预览
    并查集类的定义:
class UnionFindSet
{
public:
	int fa[100007];
	
	void init(int n)
	{
		for (int i = 1; i <= n; ++i)
		{
			fa[i] = i;
		}
	}
	
	void seperate(int i)
	{
		fa[i] = i;
	}
	
	void merge(int u, int v)
	{
		int fx = getfa(u), fy = getfa(v);
		if (fx == fy) continue; // 判断两个节点是否属于同一个集合
		fa[fx] = fy;
	}

	int getfa(int x)
	{
		if (x == fa[x]) return x;
		return fa[x] = getfa(fa[x]);
	}
};

三、并查集的变形应用

1. 并查集查找对象是否在同一个集合内
我们可以直接使用并查集的模版算法,通过使用并查集的 “并” 和 “查” 操作,实现多个集合的处理。比如,我们在每个读入中输入得知两个相同集合内的元素 a , b a,b a,b ,则我们可以查找 f a ( a ) fa(a) fa(a) f a ( b ) fa(b) fa(b) ,如果 f a ( a ) = f a ( b ) fa(a)=fa(b) fa(a)=fa(b) ,则说明两个元素在同一个集合里,无需再次合并;如果 f a ( a ) ≠ f a ( b ) fa(a) \neq fa(b) fa(a)=fa(b) ,则令 f a ( f a ( a ) ) = f a ( b ) fa(fa(a))=fa(b) fa(fa(a))=fa(b) f a ( f a ( b ) ) = f a ( a ) fa(fa(b))=fa(a) fa(fa(b))=fa(a) 。查询 i , j i,j i,j 是否在同一个集合时,只需查询 f a ( i ) fa(i) fa(i) 是否等于 f a ( j ) fa(j) fa(j) 即可。
代码如下:

#include <bits/stdc++.h>

using namespace std;

const int N = 1e5 + 10;

int fa[N];

int getfa(int x)
{
	if (x == fa[x]) return x;
	return fa[x] = getfa(fa[x]);
}

int main()
{
	int n;
	cin >> n;

	for (int i = 1; i <= n; ++i)
	{
		fa[i] = i;
	}

	for (int i = 1, a, b; i <= n; ++i)
	{
		cin >> a >> b;
		
		int fx = getfa(a), fy = getfa(b);
		if (fx == fy) continue;
		fa[fx] = fy;
	}
	
	int q;
	cin >> q;
	for (int i = 1, a, b; i <= q; ++i)
	{
		cin >> a >> b;
		
		if (getfa(a) == getfa(b))
		{
			cout << "YES" << endl;
		}
		else
		{
			cout << "NO" << endl;
		}
	}
}

2. 使用并查集进行集合大小的查询
事实上,我们只需要在 1 的基础上,增加一个数组 g g g 记录当前以每个元素为代表的集合大小,初始化 g i = 1 ( ∀ 1 ≤ i ≤ N ) g_i=1(\forall 1\le i\le N) gi=1(∀1iN) ,然后当我们合并两个集合(两个元素)的时候,假设这两个元素分别是 a , b a,b a,b ,则我们可以使用以下等式 g b ← g b + g a g_b\leftarrow g_b+g_a gbgb+ga ,不断更新 g g g 数组,此处假设 f a ( f a ( a ) ) = f a ( b ) fa(fa(a))=fa(b) fa(fa(a))=fa(b) 。按照上一题的格式打印答案时,取 g f a ( a ) g_{fa(a)} gfa(a) g f a ( b ) g_{fa(b)} gfa(b) 即可。

#include <bits/stdc++.h>

using namespace std;

const int N = 1e5 + 10;

int fa[N], g[N];

int getfa(int x)
{
	if (x == fa[x]) return x;
	return fa[x] = getfa(fa[x]);
}

int main()
{
	int n;
	cin >> n;

	for (int i = 1; i <= n; ++i)
	{
		fa[i] = i;
		g[i] = 1;
	}

	for (int i = 1, a, b; i <= n; ++i)
	{
		cin >> a >> b;
		
		int fx = getfa(a), fy = getfa(b);
		if (fx == fy) continue;
		fa[fx] = fy;
		g[fy] += g[fx];
	}

	int q;
	cin >> q;
	for (int i = 1, a, b; i <= q; ++i)
	{
		cin >> a >> b;
		
		if (getfa(a) == getfa(b))
		{
			cout << "YES" << " " << g[getfa(a)] << endl;
		}
		else
		{
			cout << "NO" << endl;
		}
	}
}

3. 使用并查集的思想进行有方向的并查集操作

一般来说,并查集只是将不同的元素合并到一个集合,并没有指定一个固定的值作为所有值的父亲。当一个元素拥有多个父亲节点时,且规定关系是单向的,由于并查集并没有指定关系的单向性,并查集很可能错误地将该节点作为根节点,引发错误,如下图。

Error!
Error!
Error!
a
Wrong father determined
b
correct father
c
correct father
d
correct father

我们可以使用 Floyed 传递闭包,从而利用并查集的原理实现判断两个节点之间的连通状态。注意 Floyed 算法是 O ( n 3 ) O(n^3) O(n3) 级别的时间复杂度,所以我们应该在不大于 n ≤ 500 n \le 500 n500 的数据规模内使用 Floyed 算法。

以下程序的输入格式:
第一行一个整数 N N N ,表示节点的数量。
2 ∼ N + 1 2 \sim N + 1 2N+1 行:每行有若干个整数,输入每个节点的儿子,结束时为 0 0 0 。如果该节点没有儿子,则对应的一行只有一个数字 0 0 0

#include <bits/stdc++.h>

using namespace std;

const int N = 207;

int n, f[N][N], fa[N];

int getfa(int x)
{
	if (fa[x] == x) return x;
	return fa[x] = getfa(fa[x]);
}

int main()
{
	cin >> n;
	
	for (int i = 1; i <= n; ++i)
	{
		fa[i] = i;
	}
	
	for (int i = 1; i <= n; ++i)
	{
		int x = 0;
		while (cin >> x, x != 0)
		{
			f[i][x] = 1;
		}
	}
	
	for (int k = 1; k <= n; ++k)
	{
		for (int i = 1; i <= n; ++i)
		{
			for (int j = 1; j <= n; ++j)
			{
				f[i][j] |= f[i][k] & f[k][j];
			}
		}
	}
	
	for (int i = 1; i <= n; ++i)
	{
		for (int j = 1; j <= n; ++j)
		{
			if (f[i][j])
			{
				fa[j] = fa[i];
			}
		}
	}
	
	int ans = 0;
	for (int i = 1; i <= n; ++i)
	{
		if (fa[i] == i)
		{
			ans++;
		}
	}
	cout << ans << endl;
	
	return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值