并查集

简介

并查集是一种树形数据结构,是支持合并及查询操作的森林。

所谓合并,即将并查集中的两棵树合并为一棵;

而查询,即查询并查集中的一个节点当前属于哪一棵树。

每一棵树是森林的一个子集,森林本身为全集。

这片森林就是一个支持合并与查询的集合,并查集的名字由此而来。


初始化

起初,集合中的每个点均未合并过,各为并查集中的一棵独立的树,所以我们将它的父亲也初始化为自身,这样在合并过后也能判断一个节点是否为它所在树的树根。

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

时间复杂度为 O(n)


查询

因为并查集中的每一棵树之间互无交集,所以我们只需要确定一个节点所在树的树根就能确定它所在的树。

那么我们要如何确定一个节点的祖先( 所在树的树根 )呢?

这就像你处在一个多世同堂的大家族里,但你只认识你的父亲。

如果你想知道你的祖先是谁,只需要让你的父亲去问他的父亲,这样逐层问上去就好了,朴素的并查集查询算法正是如此。

int find(int x)
{
	while (x!=fa[x]) x=fa[x];
	return x;
}

均摊复杂度为 O(logn) ,最坏复杂度为 O(n)


合并

因为查询操作只需要确定当前节点所属的树,并不在乎它从何而来,所以合并时只要让一个节点的祖先成为另一个节点的祖先的父亲即可,相当于一棵树原封不动地接到了另一棵树的树根下。( 当然,这并不代表不能维护它从何而来,譬如你可以用动态开点线段树来维护一个可持久化并查集。 )

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

更改父亲的操作显然是 O(1) 的,但因为还调用了 find() 函数,所以复杂度与查询相同。

均摊复杂度为 O(logn) ,最坏复杂度为 O(n)


路径压缩

显然,上面朴素的操作方法可以被出题人精心准备来恶心你的数据卡成 O(nm) 的。

为了打败万恶的出题人,我们需要对上面的操作进行优化。

介绍查询操作时,我们说过:

"这就像你处在一个多世同堂的大家族里,但你只认识你的父亲。"

你看看你,记性都这么差了,反正我都只问你祖先是谁,你直接记你祖先不就完事了?

没错,这就是并查集的查询操作的优化,美其名曰:“路径压缩” 。

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

每次查询祖先时,顺手将沿路的节点的 fa 也改为它的祖先,实际上 fa 数组记录的就是每个节点的祖先了。

这样的操作相当于将每个节点都直接接到了它祖先下,成为它祖先的儿子,整棵树变成了一张 “菊花图”,在求祖先时跳过了它到祖先中间的节点,实现了路径的压缩,所以叫做 “路径压缩” 。

只进行路径压缩的并查集,单次操作均摊复杂度为 O(α(n)),最坏复杂度为 O(logn)

后文有对 α 函数的解释。


按秩合并

除了从查询操作优化,我们还可以从合并入手。

在查询中,我们举了家族的例子,这里我们再用一次。

两个关系好的家族要搬到一起,显然较小的家族搬向较大的家族,代价较小。

在树的合并中,我们更看重的是两颗树的深度。

为什么?我也不知道,想知道就看看这个吧。( 摊手 )

好,现在你已经能接受通过比较树高来确定合并主次的想法了。

因为树的高度就叫秩,所以这种优化叫作 “按秩合并” 。

没有码,因为我不写按秩合并。


启发式合并

但是,真的不能用子树大小来比较吗。( 渴求的目光 )

好吧,其实用子树大小 siz 来比较也是可行的。

根据上面链接中的复杂度证明,秩能够保证按秩合并的复杂度,原因有以下三点:

  1. 每次合并,最多有一个节点的秩上升,而且最多上升 1。

  2. fa(x) 的秩 x 的秩 +1

  3. 节点的秩不减。

我们尝试用 siz 代替秩,有:

siz(fa(x))siz(x)+1

合并后节点的 siz 不减。

但第一条不能保证,因为合并后的祖先节点的 siz 上升了 siz(x)

所以,我们失败了 555 。

"好吧,其实用子树大小 siz 来比较也是可行的。"

但是,难道我忍心欺骗屏幕前的你吗?

我们可以发现,根据子树大小进行合并,根节点以 2 为底的对数上升至多为1,即两棵树的 siz 相等,合并后的树 siz 为原来的 2 倍。

所以,我们可以尝试用 log2siz 来代替秩:

第一条性质已证明过,满足。

根据比较子树大小合并的方法可得:

siz(fa(x))2siz(x)

又有:

log2siz(x)+1=log2 (2siz(x))

故:

log2 siz(fa(x))log2 (2siz(x))

所以第二条性质也满足。

第三条性质显然同样满足。

故通过比较 siz 进行合并的方法可行。

void merge(int x,int y)
{
	x=find(x);
	y=find(y);
	if (x==y) return;
	if (siz[x]>siz[y]) swap(x,y);
	fa[x]=y;
	siz[y]+=siz[x];
}

因为这里的 siz 不是秩,所以这种合并算法也不叫 “按秩合并” 。但因为这种合并采用了启发式策略,故称为 “启发式合并” 。

需要留意的是,如果使用启发式合并,在初始化时要记得顺便把 siz 数组初始化为 1

只进行启发式合并或按秩合并的并查集,单次操作的均摊复杂度与最坏复杂度均为 O(logn)


时间复杂度

虽然上面已经给出过不同优化情况下并查集的时间复杂度了,但是因为路径压缩与启发式合并优化的操作不同,所以两种优化是可以叠加的。

同时进行路径压缩与启发式合并的并查集,单次操作的均摊复杂度与最坏复杂度均为 O(α(n))

α(n) 为阿克曼函数的反函数,而阿克曼函数 A 的定义为:

A(m,n)={n+1m=0A(m1,1)m>0  and  n=0A(m1,A(m,n1))otherwise


扩展域

有时候我们会面对一些复杂的关系,难以用简单的查询合并维护,这时候我们可以在并查集中记录更多的信息,来对复杂的关系 ( 特别是能相互导出的传递关系 ) 进行维护。

譬如说,我们可以将一个点拆成多个点,每个点都代表原来的这个点的一种关系。

以下面这道题为例:

P2024 [NOI2001] 食物链

动物王国中有三类动物 A,B,C ,这三类动物的食物链构成了有趣的环形。

ABBCCA

现有 N 个动物,以 1 ~ N 编号。每个动物都是 A,B,C 中的一种,但是我们并不知道它到底是哪一种。

有人用两种说法对这 N 个动物所构成的食物链关系进行描述:

第一种说法是 1 X Y ,表示 XY 是同类。

第二种说法是 2 X Y ,表示 XY

此人对 N 个动物,用上述两种说法,一句接一句地说出 K 句话,这 K 句话有的是真的,有的是假的。当一句话满足下列三条之一时,这句话就是假话,否则就是真话。

  1. 当前的话与前面的某些真的话冲突,就是假话。
  1. 当前的话中 XYN 大,就是假话。
  1. 当前的话表示 XX ,就是假话。

你的任务是根据给定的 NK 句话,输出假话的总数。

1N5×104 , 1K105

对于一种动物,我们可以把它分离为三个节点,分别代表它的同类,天敌与食物,每次合并时判断是否与集合中的其它信息相互冲突即可。

代码如下:

#include <cstdio>
#define MAXN (int)(2e5+233)

int n,k,a,b,opt,ans,fa[MAXN],siz[MAXN];

inline int self(int x) { return x; }

inline int eat(int x) { return x+5e4; }

inline int enemy(int x) { return x+1e5; }

inline void swap(int &x,int &y) { int t=x;x=y,y=t; }

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

void merge(int x,int y)
{
	x=find(x);
	y=find(y);
	if (x==y) return;
	if (siz[x]>siz[y]) swap(x,y);
	fa[x]=y;
	siz[y]+=siz[x];
}

void init(int x) { for (int i=1;i<=x;i++) fa[i]=i,siz[i]=1; }

inline int read()
{
	int x=0;char ch=getchar();
	while (ch<'0'||ch>'9') ch=getchar();
	while (ch>='0'&&ch<='9') {x=(x<<1)+(x<<3)+(ch^48);ch=getchar();}
	return x;
}

int main()
{
	init(MAXN-5);
	n=read(),k=read();
	while (k--)
	{
		opt=read(),a=read(),b=read();
		if (a>n||b>n) ans++;
		else if (opt==1)
		{
			if (find(eat(a))==find(self(b))) ans++;
			else if (find(self(a))==find(eat(b))) ans++;
			else
			{
				merge(self(a),self(b));
				merge(eat(a),eat(b));
				merge(enemy(a),enemy(b));
			}
		}
		else
		{
			if (find(self(a))==find(self(b))) ans++;
			else if (find(self(a))==find(eat(b))) ans++;
			else
			{
				merge(eat(a),self(b));
				merge(self(a),enemy(b));
				merge(enemy(a),eat(b));
			}
		}
	}
	printf("%d",ans);
	return 0;
}

这种将一个节点依照其关系分为多个域的算法称作 “扩展域” 。


边带权

另一种想法是让节点到 fa 的边带上权值,在路径压缩时对权值进行更新。

以下面这道题为例:

P1196 [NOI2002] 银河英雄传说

泰山压顶集团与气吞山河集团在巴米利恩星域爆发战争。

杨威利将战场划分成了 30000 列,起初杨威利的 30000 条战舰在战场的每列各有一条,排成 “一字长蛇阵” 。

在交战中,杨威利可能会根据实际情况集中战舰。

合并指令为 M i j ,意为:第 i 号战舰所在的整个战舰队列,作为一个整体(头在前尾在后)接至第 j 号战舰所在的战舰队列的尾部。

而莱因哈特掌握了杨威利的指令情报,他会在交战中询问你杨威利的战舰分布情况,以便于应对。

询问指令为 C i j ,意为:询问杨威利的第 i 号战舰与第 j 号战舰当前是否在同一列中,如果在同一列中,那么它们之间布置有多少战舰。

杨威利与莱因哈特的指令共有 T 条, 1T5×105

题目中的所有战舰成链式排布,而一条链同样是一棵树,我们同样可以使用并查集进行维护。

我们很容易想到,让并查集中的每一条边带上权值,记录 d(x) 为节点 xfa(x) 路径上的距离,在路径压缩时把压缩的边权和加到 d(x) 上即可。

需要留意的是,本题的合并题目已有给定方向,所以不能使用启发式合并。

代码如下:

#include <cstdio>
#define MAXN (int)(3e4+233)

int fa[MAXN],d[MAXN],siz[MAXN];
int a,b,T;
char opt; 

inline int abs(int x) { return x>0?x:-x; }

inline int read()
{
	int x=0;char ch=getchar();
	while (ch<'0'||ch>'9') ch=getchar();
	while (ch>='0'&&ch<='9') {x=(x<<1)+(x<<3)+(ch^48);ch=getchar();}
	return x;
}

int find(int x)
{
	if (x==fa[x]) return x;
	int root=find(fa[x]);
	d[x]+=d[fa[x]];
	return fa[x]=root;
}

void merge(int x,int y)
{
	x=find(x);
	y=find(y);
	fa[x]=y;
	d[x]=siz[y];
	siz[y]+=siz[x];
}

int main()
{
	T=read();
	for (int i=1;i<=3e4;i++) fa[i]=i,siz[i]=1;
	while (T--)
	{
		scanf(" %c",&opt);
		a=read(),b=read();
		if (opt=='M') merge(a,b);
		else
			if (find(a)==find(b)) printf("%d\n",abs(d[a]-d[b])-1);
			else printf("-1\n");
	}
	return 0;
}

这种将并查集中的边带上边权的算法称作 "边带权" ( 很废话对吧 )

而这样的并查集就叫作 "带权并查集" ( 还是废话 )

其实这种办法同样可以用于解决上面的例题【食物链】,只是维护的 d 数组记录的不是压缩前节点到根中间的节点个数,而是节点到根之间人为构造的一个"关系函数"。比如那题中的关系函数为:0self,1eat,2enemy ,运算法则为 d(x,y)=d(x,k)+d(k,y)mod3 。具体代码如下:

#include <cstdio>
#define MAXN (int)(5e4+233)

int n,m,ans,d[MAXN],fa[MAXN];

inline void swap(int &x,int &y) { int t=y; y=x,x=t; }

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

int query(int x)
{
	if (x==fa[x]) return x;
	int rt=query(fa[x]);
	d[x]=(d[x]+d[fa[x]])%3;
	return fa[x]=rt;
}

inline int read()
{
	int x=0,f=1;char ch=getchar();
	while (ch<'0'||ch>'9') {if (ch=='-') f=-1;ch=getchar();}
	while (ch>='0'&&ch<='9') {x=(x<<1)+(x<<3)+(ch^48);ch=getchar();}
	return f*x;
}

int main()
{
	n=read(),m=read();
	init();
	for (int i=1;i<=m;i++)
	{
		int opt=read(),x=read(),y=read();
		if (x>n||y>n) { ans++; continue; }
		query(x),query(y);
		if (opt==1)
		{
			if (fa[x]==fa[y]&&d[x]!=d[y]) ans++;
			else if (fa[x]!=fa[y])
				d[fa[x]]=(d[y]-d[x]+3)%3,fa[fa[x]]=fa[y];
		}
		else
		{
			if (x==y) ans++;
			else if (fa[x]==fa[y])
			{
				int tmp=(d[x]-d[y]+3)%3;
				if (tmp!=1) ans++;
			}
			else d[fa[x]]=(4-d[x]+d[y])%3,fa[fa[x]]=fa[y];
		}
	}
	printf("%d\n",ans);
	return 0;
}

习题

因为我自己的题量也很小,所以就放一点自己做过的题,如果以后有做到相关有趣的题会继续更新。

来爆A了这些水题吧!

posted @   jzcrq  阅读(100)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示