【学习笔记】并查集

先来看百度百科上的定义:

并查集,在一些有N个元素的集合应用问题中,我们通常是在 开始时让每个元素构成一个单元素的集合,然后按一定顺序将属于同一组的元素所在的集合合并,其间要反复查找一个元素在哪个集合中。

并查集是一种树型的数据结构,用于处理一些不相交集合(disjoint sets)的合并及查询问题。常常在使用中以森林来表示。

并查集,顾名思义,并,合并;查,查找;集,集合;

普通并查集

先来看一个例题:P1551 亲戚。在这道题中,给出了一些亲戚的关系,又有几次询问询问两人是否为亲戚。这道题的重点在于:亲戚的亲戚也是亲戚

这就要用到我们的并查集了!我们令 \({fa}_i\)\(i\) 的“上一辈”。我们定义,若 \({fa}_i = i\),则它为最终的祖先祖先。由于可能有很多独立的家族,所以可能有很多个祖先。

刚开始初始化 \(fa_i=i\),即每个人都是单独的祖先,对于每个亲戚关系 \(i\)\(j\) 为亲戚,因为题目没有要求它们的大小关系,所以不妨假设 \(i\) 辈分比 \(j\) 小。对于亲戚的关系,若 \(i\)\(j\) 为一个家族,那边没有合并的必要了。否则要合并两个家族,就必须从它们的祖先合并,那如何找到 \(i\)\(j\) 的祖先呢?这里我们要用到递归的思想。

对于要查找到 \(x\) 的祖先,令 \(find(x)\)\(x\) 的祖先,若 \(fa_x=x\),则自己为自己的祖先,则 \(find(x)=x\)。否则就要调用到他的上一辈:\(fa_x\)。显然 \(fa_x\) 的祖先也为 \(x\) 的祖先,所以若 \(fa_x\ne x\),则 \(find(x)=find(fa_x)\),这一部分用递归实现,代码如下:

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

再回到上面合并的问题,对于两个人对应的两个家族合并,则需要先找到他们各自的祖先,令 \(ti\)\(tj\) 分别为他们的祖先(\(ti=find(i),tj=find(j)\)),若他们不是是一个家族,也就是 \(ti\ne tj\),则不妨设 \(tj\) 的辈分比 \(ti\) 大,把 \(ti\) 归属到 \(tj\) 家族去,也就是 \(fa_{ti}=tj\),代码:

void merge(int x,int y){
    tx=find(x);ty=find(y);
    if(tx!=ty)fa[tx]=ty;
}

但注意到,其实 if(tx!=ty) 这个判断是可以省略的,因为若 \(tx=ty\),把他的祖先设为自己也没问题。

优化

当我们把上面的代码提交上去时,就会发现跑得很慢甚至会超时,这就要我们对其进行进一步的优化了。我们发现,时间主要是消耗在 \(find\) 上的,因为要递归调用很多层,所以会比较慢。那有没有办法使递归调用的层数小一点?我们便可以使用路径压缩。

怎么个压缩法呢?我们知道 \(find\) 函数的目的就是为了寻找祖先,那我们想要减少层数,就可以使 \(fa_x\) 直接为 \(x\) 的祖先,这样只用调用一层了。我们可以在前一次寻找的过程中不断地把经过的 \(x\) 都设为最终的的祖先,这样就快多了。其实这个优化实现非常简单,只需要在返回值时 return fa[x]=find(fa[x])就好了。

路径压缩就是我们最常用的优化了,实际上,还有一个小小的优化,我们知道,在合并后,若要寻找合并的家族的某些人时就要把这些人多调用一次,我们肯定是想多调用一次的人少,所以我们就在过程中不断统计每个家族的人数(记在家族祖先那),合并时把人少的的家族合并到人多的就可以了。这个优化叫做按秩合并,也叫启发式合并。

这些优化完完全全可以对付平常的并查集题了。

带边权并查集

带边权并查集与普通并查集相差不大,只是在普通并查集的基础上同时记录一个点到自己祖先节点的信息。

以这个例题为例:P1196。我们知道,如果只是要知道战舰是否在同一列就十分简单了,现在还要知道;两两战舰之间的战舰数便有些困难了。

我们令 \(d_x\)\(x\)\(fa_x\) 的距离,初始化为 \(0\)\(x\) 的祖先为 \(x\) 所在战舰的那一列的排头。在这基础上,在寻找祖先的过程中,在路径压缩之前,我们可以将这一路经过的距离累加起来并赋值给 \(d_x\),当 \(fa_x\) 等于 \(find(x)\) 时,这时的 \(d_x\) 便是 \(x\)\(x\) 祖先的距离,在题目中的意思便是 \(x\)\(x\) 所在列的排头的距离。

查询是比较简单的,那合并又怎么做?我们知道,并查集只能知道一个集合的祖先是谁,而不能知道一个集合中有的元素,这里是要求把 \(i\) 所在列连到 \(j\) 所在列,我们令 \(i'\)\(i\) 列所在的头,\(j'\)\(j\) 所在列的头,\(j''\)\(j\) 所在列的尾。正常来说,我们是要把 \(i'\) 连到 \(j''\),但并查集不能查询尾,我们不妨换一个想法:既然连到尾不行,查询又之和 \(d_x\) 也就是 \(x\) 到头的距离,那我们直接把 \(i'\) 连到 \(j''\) 的头不就可以了吗?

但现在便有一个问题了。如果是连到尾的话,距离好算,但如果是连到头,\(d\) 数组不就乱套了吗?不急,我们先来看连到尾,先查询一遍 \(find()\),这时的 \(i'\)\(j'\) 的距离为 \(1+d_{j''}\),换句话说,如果 \(i'\) 连到 \(j'\),要想 \(d\) 数组还是正确的,我们就得令 \(i'\)\(j'\) 的距离也就是 \(d_{i'}\)\(1+d_{j''}\),这时,你就会惊奇地发现,这道题就这样解决了!

代码:

#include <bits/stdc++.h>
using namespace std;
const int N = 30005;
int fa[N], d[N], siz[N];
int find(int x)
{
	if (x == fa[x]) return x;
	int t = find(fa[x]);
	d[x] += d[fa[x]];
	fa[x] = t;
	return t;
}
void combine(int x, int y)
{
	int tx = find(x), ty = find(y);
	fa[tx] = ty;
	d[tx] += siz[ty];
	siz[ty] += siz[tx];
}
int main()
{
	int T;
	cin >> T;
	for (int i = 1; i <= 30000; i++)
		fa[i] = i, siz[i] = 1;
	while (T --)
	{
		char op;
		int x, y;
		cin >> op >> x >> y;
		if (op == 'M') combine(x, y);
		else
		{
			if (find(x) != find(y)) cout << -1 << endl;
			else cout << abs(d[x] - d[y]) - 1 << endl;
		}
	}
	return 0;
}

扩展域并查集

有的时候,需要维护更多的信息时,我们可以把并查集“扩展”开来。

例题:CF766D。在这题中,我们不仅要维护同义词,还要维护反义词,我们就可以把并查集“扩展”出一个域来,其中 \(1,2,\cdots,n\) 维护同义词,\(n+1,n+2,\cdots,n+n\) 维护反义词。简单来说,对于第 \(i\) 个单词,\(i\) 是记录它的同义词的关系的,而 \(i+n\) 是记录反义关系的。

我们先来考虑合并。令 \(i\)\(j\) 为一组单词。若它们为同义词,我们根据常识知道:

  • 朋友的朋友还是朋友 \(\rightarrow\) 同义词的同义词还是同义词 \(\rightarrow\) merge(i,j)
  • 敌人的敌人是朋友 \(\rightarrow\) 同义词的同义词还是同义词 \(\rightarrow\) merge(i+n,j+n)

若它们为反义词:

  • 朋友的敌人是敌人 \(\rightarrow\) 同义词的反义词是同义词 \(\rightarrow\) merge(i+n,j)
  • 敌人的朋友还是敌人 \(\rightarrow\) 反义词的同义词还是反义词 \(\rightarrow\) merge(i,j+n)

同理,对于查询,如果 \(find(i)=find(j)\)\(find(i+n)=find(j+n)\) 则为同义,否则若 \(find(i+n)=find(j)\)\(find(i)=find(j+n)\) 则为反义,否则为没有关系。代码:

#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 5;
map<string, int> p;
int fa[N];
int find(int x)
{
	if (x == fa[x]) return x;
	else return fa[x] = find(fa[x]);
}
int main()
{
	int n, m, k;
	cin >> n >> m >> k;
	for (int i = 1; i <= n; i++)
	{
		string x;
		cin >> x;
		p[x] = i;
		fa[i] = i; fa[i + n] = i + n;
	}
	while (m--)
	{
		int op;
		string x, y;
		cin >> op >> x >> y;
		int tx = p[x], ty = p[y];
		int t1 = find(tx), t2 = find(tx + n);
		int t3 = find(ty), t4 = find(ty + n);
		if (op == 1)
		{
			if (t1 == t4 || t2 == t3) cout << "NO" << endl;
			else fa[t1] = t3, fa[t2] = t4, cout << "YES" << endl;
		}
		else
		{
			if (t1 == t3 || t2 == t4) cout << "NO" << endl;
			else fa[t2] = fa[t3], fa[t1] = fa[t4], cout << "YES" << endl;
		}
	}
	while (k--)
	{
		string x, y;
		cin >> x >> y;
		int tx = p[x], ty = p[y];
		if (find(tx) == find(ty) || find(tx + n) == find(ty + n)) cout << 1 << endl;
		else if (find(tx + n) == find(ty) || find(tx) == find(ty + n)) cout << 2 << endl;
		else cout << 3 << endl;
	}
	return 0;
}
posted @ 2023-07-25 22:13  happy_zero  阅读(28)  评论(0编辑  收藏  举报