一、对并查集算法的简要概述
并查集是一个森林状结构,主要思想是让所有要合并的点都在同一个集合中,指向同一个根节点,从而实现极低复杂度的数据查找任务。并查集算法是一个比较高效的算法,时间复杂度大概在 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 1∼M 的由根节点组成的集合,即 T = { i : ∀ 1 ≤ i ≤ N } T=\{i:\forall 1\le i\le N\} T={i:∀1≤i≤N} 。
接下来,我们进行 M M M 次读入,每一次读入都将它的关系更新。具体更新步骤如下:
- 我们先选定一个两个节点 u , v u,v u,v ,表示将要合并这两个节点, 假设它们各有 k 1 , k 2 k_1,k_2 k1,k2 个父节点。
- 接下来,我们从点 u u u 和点 v v v 分别开始搜索,找到它们的根节点 u k 1 u_{k_1} uk1 和 v k 2 v_{k_2} vk2 。
- 最后,我们指定一个节点为另一个节点的父节点,这里我们假设 u k 1 u_{k_1} uk1 为 v k 2 v_{k_2} vk2 的父节点,则我们的森林将会变成这样:
这样,我们就合并了两个集合为一个大集合,这个集合的父节点就是 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} ∀i∈T(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) ∀1≤i≤N,fa(i) 的值,我们需要使用以下优化:
- 定义
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 ,则算法的过程可以大概表示为:
下面的一行代码是用来寻找 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 为止。
- 初始化的方法:
如第一部分所述,在 T T T 中, ∀ 1 ≤ i ≤ N , T ( i ) = { i } \forall 1\le i\le N,T(i)=\{i\} ∀1≤i≤N,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;
}
}
- 合并的方法:
具体的算法如第二部分所述。代码如下:
void merge(int u, int v)
{
int fx = getfa(u), fy = getfa(v);
if (fx == fy) continue; // 判断两个节点是否属于同一个集合
fa[fx] = fy;
}
这里,我们用到了一个优化的方法,即判断两个集合是否已经合并。如果已经合并,则不需要进行下一步的合并操作。
4. 独立的方法:
只需从原来的集合中分隔出一部分即可。如下图所示:
可见设置 f a ( i ) = i fa(i)=i fa(i)=i 即可。代码如下:
void seperate(int i)
{
fa[i] = i;
}
- 总体效果预览
并查集类的定义:
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(∀1≤i≤N) ,然后当我们合并两个集合(两个元素)的时候,假设这两个元素分别是
a
,
b
a,b
a,b ,则我们可以使用以下等式
g
b
←
g
b
+
g
a
g_b\leftarrow g_b+g_a
gb←gb+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. 使用并查集的思想进行有方向的并查集操作
一般来说,并查集只是将不同的元素合并到一个集合,并没有指定一个固定的值作为所有值的父亲。当一个元素拥有多个父亲节点时,且规定关系是单向的,由于并查集并没有指定关系的单向性,并查集很可能错误地将该节点作为根节点,引发错误,如下图。
我们可以使用 Floyed 传递闭包,从而利用并查集的原理实现判断两个节点之间的连通状态。注意 Floyed 算法是 O ( n 3 ) O(n^3) O(n3) 级别的时间复杂度,所以我们应该在不大于 n ≤ 500 n \le 500 n≤500 的数据规模内使用 Floyed 算法。
以下程序的输入格式:
第一行一个整数
N
N
N ,表示节点的数量。
第
2
∼
N
+
1
2 \sim N + 1
2∼N+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;
}