冰茶姬

并查集

概述

并查集主要用于对于多个集合的从属关系维护
它有两个基本操作

  1. 查询当前所在集合
  2. 合并两个集合
    通过并查集,我们可以对许许多多的问题进行优化,比如树上跳链等等
    并查集能够在一张无向图里维护节点连通性,事实上,并查集擅长维护许多具有传递性的关系

基本操作

实现:
我们对于每一个集合采用代表元的方法,将每一个集合使用一个代表来表示
具体的,我们设\(f[i]\)表示\(i\)所在集合的代表元,最初,\(\forall i\in[1,n],f[i]=i\),即每一个节点自己构成集合,那么我们对于集合的维护,采用树形表示法,即将每一个集合以树的形式储存
具体的,我们在\(f\)数组里记录每一个节点的父亲节点,特别的,我们以根节点为代表元,令根节点的\(f\)值为其自己,那么我们对于每一个节点的代表元的查询就等同于是在树上跳链

int find(int x){return x==f[x]?x:find(f[x]);}

非常简单的代码
那么我们如果要合并两个集合其实也非常简单,即我们假设要合并节点\(x,y\)所在集合,只需要将\(x\)的代表元的父亲节点改为\(y\)的代表元即可

void merge(int x,int y){  
    x=find(x),y=find(y);
    if(x==y)continue;
    f[x]=y;
}

优化手段

我们发现,在极端构造的数据下,并查集的算法复杂度会被卡成\(O(n)\),这就意味着效率的极其低下,于是我们就需要进行优化

路径压缩

需要注意的是,我们对于每一个节点,都只关心其所在树的根节点,不关心树的形态,于是我们可以在查询的途中,直接令当前节点的父节点是根节点,这样的话就能以最小的代价进行后面的查询,其代码更改也非常简单

int find(int x){return x==f[x]?x:f[x]=find(f[x]);}

有严格证明,这样的并查集均摊复杂度为\(O(\log n)\)

不过在路径压缩的做法上有一个性质,即无论如何合并,一个节点到根的路径上不会重复访问任何一个节点(除根节点)

按秩合并

路径压缩是对查询的优化,而按秩合并是对合并的优化

我们采用启发式合并的思路:

启发式合并:在两个不同数据结构维护的集合进行合并时,若每一次合并只增加秩更小的那一个集合的查询代价,那么最终的均摊复杂度是\(O(\log n)\)

并查集维护的集合的秩定义不同,有的按照集合大小,有的按照最大深度,通常的,我们按照集合大小进行维护

void merge(int x,int y){  
    x=find(x),y=find(y);
    if(x==y)continue;
    if(siz[x]>siz[y])x^=y,y^=x,x^=y;//最初siz都为1
    f[x]=y;
}

在实际应用中,我们其实可以将路径压缩和按秩合并一起用,这样的复杂度会降为\(\alpha(n)\),其中\(\alpha\)是反阿克曼函数,可以作为一个常数进行对待(程序设计的数据范围的阿克曼函数不会超过4)

扩展域与边带权

扩展域与边带权的并查集是用于较为复杂的关系时的做法

边带权:

在路径压缩的前提下,我们维护一个数组\(d\)\(d[x]\)表示节点\(x\)与其父节点之间的边权,在路径压缩后,我们会把节点直接指向根节点,同时更新\(d\)值,于是我们就可以借助路径压缩的过程完成节点\(x\)到树根的路径上的一些信息统计

扩展域:

在一些题目中,许多元素之间有着多种关系,各种关系之间可以相互导出,我们便可以使用扩展域的并查集来维护这样的关系,当然边带权也可以

综合运用

例题1:真正的骗子

一个岛上存在着两种居民,一种是天神,一种是恶魔。

天神永远都不会说假话,而恶魔永远都不会说真话。

岛上的每一个成员都有一个整数编号(类似于身份证号,用以区分每个成员)。

现在你拥有 \(n\) 次提问的机会,但是问题的内容只能是向其中一个居民询问另一个居民是否是天神,请你根据收集的回答判断各个居民的身份。

输入包含多组测试用例。

每组测试用例的第一行包含三个非负整数 \(n,p1,p2\),其中 \(n\) 是你可以提问的总次数,\(p1\) 是天神的总数量,\(p2\) 是恶魔的总数量。

接下来 \(n\) 行每行包含两个整数 \(xi,yi\) 以及一个字符串 \(ai\),其中 \(xi,yi\) 是岛上居民的编号,你将向编号为 \(xi\) 的居民询问编号为 \(yi\) 的居民是否是天神,

\(ai\) 是他的回答,如果 \(ai\) 为 yes,表示他回答你“是”,如果 \(ai\) 为 no,表示他回答你“不是”。

\(xi,yi\) 可能相同,表示你问的是那个人自己是否为天神。

当输入为占据一行的 0 0 0 时,表示输入终止。

分析:
我们可以发现,天神回答另一个人是天神,与恶魔回答另一个人是天神,若答案为是,那么就代表着这两个居民是一类,反之若答案为不是,那么就代表这两个居民不是一类
我们分别采用两种方式解决本题

扩展域解法

对于每一个居民,我们开两个域,分别是恶魔域与天神域

若对于一次询问,回答是,那么就将两个居民的恶魔域与天神域分别合并,若回答不是,那么就将两个居民的恶魔域与天神域交叉合并

到最后,我们会得到若干个集合,设有\(x\)对(两个域为一对),对于每一对都选出一个,使得最终组成的集合大小为\(p1\),对于这个问题,我们使用背包思想来解决,将\(1\sim p1\)从小到大转移,记录当前的数能否组成即可

边带权解法
我们将阵营一样的两个点合并之后,两点之间的边权设为0,否则设为1,则每一个节点到根节点的异或和就代表了这个节点与根节点的关系,为1则不同,为0则相同,在合并的时候需要推导,这样我们就可以通过一棵树上任意两点到根节点的异或和(用\(d\)表示)进行异或来判断两点关系,最后仍然是若干个集合的背包问题
合并推导:

int n,p1,p2,fa[1010],d[1010],s[2][1010],f[1010][1010],g[1010][1010],pos[1010],cnt0[1010],cnt1[1010],ans[1010];
int find(int x){
    if(fa[x]==x)return x;
    int f=find(fa[x]);
	  d[x]^=d[fa[x]];
    return fa[x]=f;
}
void merge(int x,int y,int w){
	int u=find(x),v=find(y);
	if(u==v)return;
	fa[u]=v;
	d[u]=d[x]^d[y]^w;//这个赋值的意义在于不能影响u的子树的关系,而因为d[y]^d[x]为两个点之间的关系,而因为w=d[x]^d[y](在新树上),而在新树上,d[x]=d[u]^d[x](原本的d[x]),于是移项即得d[u]=d[x]^d[y]^w;
	s[0][v]+=s[d[u]][u];
	s[1][v]+=s[d[u]^1][u];
}
void init(){
	for(int i=1;i<1010;i++){
        fa[i]=i;
        d[i]=0;
        s[0][i]=1;
    	s[1][i]=0;//统计两个域的数量
    }	
}
int main(){
    while(scanf("%d%d%d",&n,&p1,&p2)){
    	if(!(n||p1||p2))break;
    	init();
        for(int i=1;i<=n;i++){
            int a,b;
            string x;
            cin>>a>>b>>x;
            int t=0;
            if(x[0]=='n')t=1;
			merge(a,b,t);
        }
        memset(cnt0,0,sizeof cnt0);
        memset(cnt1,0,sizeof cnt1);
        int cnt=0;
        for(int i=1;i<=p1+p2;i++){
            if(i==fa[i]){
                cnt0[++cnt]=s[0][i];
                cnt1[cnt]=s[1][i];
                pos[cnt]=i;
            }
        }
        memset(f,0,sizeof f);
        memset(g,0,sizeof g);
        f[0][0]=1;
        for(int i=1;i<=cnt;i++){
            for(int j=p1;j>=cnt0[i];j--){
                if(f[i-1][j-cnt0[i]]){
                    f[i][j]+=f[i-1][j-cnt0[i]];
                    g[i][j]=0;
                }
            }
            for(int j=p1;j>=cnt1[i];j--){
                if(f[i-1][j-cnt1[i]]){
                    f[i][j]+=f[i-1][j-cnt1[i]];
                    g[i][j]=1;
                }
            }
        }//DP求可行性
        if(f[cnt][p1]!=1){
        	puts("no");
        	continue;
		}
        int tot=0,m=p1+p2;
        while(cnt){
            for(int i=1;i<=m;i++){
                if(find(i)==pos[cnt]&&d[i]==g[cnt][p1])ans[++tot]=i;
            }
            if(g[cnt][p1])p1-=cnt1[cnt];
            else p1-=cnt0[cnt];
            cnt--; 
        }//倒推求方案
        sort(ans+1,ans+tot+1);
    	for(int i=1;i<=tot;i++)printf("%d\n",ans[i]);
    	puts("end");
    }
    return 0;
}

例题2银河英雄传说
题目描述
有一个划分为 \(N\) 列的星际战场,各列依次编号为 \(1,2,…,N\)

\(N\) 艘战舰,也依次编号为 \(1,2,…,N,\)其中第 \(i\) 号战舰处于第 \(i\) 列。

\(T\) 条指令,每条指令格式为以下两种之一:

\(M\) \(i\) \(j\),表示让第 \(i\) 号战舰所在列的全部战舰保持原有顺序,接在第 \(j\) 号战舰所在列的尾部。
\(C\) \(i\) \(j\),表示询问第 \(i\) 号战舰与第 \(j\) 号战舰当前是否处于同一列中,如果在同一列中,它们之间间隔了多少艘战舰。
现在需要你编写一个程序,处理一系列的指令。

输入格式
第一行包含整数 \(T\),表示共有 \(T\) 条指令。

接下来 \(T\) 行,每行一个指令,指令有两种形式:\(M\) \(i\) \(j\)\(C\) \(i\) \(j\)

其中 \(M\)\(C\) 为大写字母表示指令类型,\(i\) 和 j 为整数,表示指令涉及的战舰编号。

输出格式
你的程序应当依次对输入的每一条指令进行分析和处理:

如果是 \(M\) \(i\) \(j\) 形式,则表示舰队排列发生了变化,你的程序要注意到这一点,但是不要输出任何信息;

如果是 \(C\) \(i\) \(j\) 形式,你的程序要输出一行,仅包含一个整数,表示在同一列上,第 \(i\) 号战舰与第 \(j\) 号战舰之间布置的战舰数目,如果第 \(i\) 号战舰与第 \(j\) 号战舰当前不在同一列上,则输出 \(−1\)

数据范围
\(N≤30000,T≤500000\)

分析
对于题目所给两种操作分别涉及到集合的有序的合并和查询两个节点的相隔节点,一共是"合并两个集合"和"查询两点树上距离",这启发我们使用并查集来维护本题
那么不可避免的,我们必须对并查集进行一些适当的扩展才可以处理本题,我们来讨论两种修改的处理

很明显发现我们需要维护\(C\)操作中距离的查询,所以基本上扩展域就作废了,因为扩展域并不好处理带权的图,所以我们很自然的就使用上了边带权

\(M\)操作合并的时候,我们需要一种合并的方式让我们可以查询到距离,于是先讨论如何维护这道题

对于二者距离的处理,因为并查集是一棵树(不可能按照题目要求维护成一条链),于是我们只能用树的方法,记录\(LCA\)不能用,因为这张图本质是链,于是我们采用链的方法,设\(d[i]\)表示第\(i\)艘战舰所在列它的前面有多少艘战舰,那么对于两个在同一列的战舰\(i,j\),它们中间隔了的战舰数量就是:\(|d[i]-d[j]|-1\),那么我们考虑如何维护这个\(d\)

同样的因为边带权的思想,我们需要在路径压缩的同时维护\(d\)数组,很明显的\(d[i]+=d[fa(i)]\),这是因为每一个\(fa(i)\)在合并后只统计一次,然后就会被更改,于是就应该加上父亲节点上离根节点的距离,而不是等于,在更新的时候由于合并的缘故我们实际上是统计了原来树根里的信息,然后合并后再更新合并后的信息,这样才可以做到不重不漏。那么我们在合并时候的处理方式是这样的,假设要把\(v\)合并到\(u\)上,因为是整个接在人家后面,所以我们\(d[v]\)就应该设置成合并前\(u\)这棵树的总大小,即\(d[v]=siz[u]\)。对于\(M\)操作,我们可以直接合并,对于\(C\)操作,我们先判断是否在同一颗树内,再输出\(|d[i]-d[j]|-1\)

#include<iostream>
#include<cstdio>
#include<algorithm>
using namespace std;
int f[50005],siz[50005],d[50005],n,m;
int find(int x){
	if(f[x]==x)return f[x];
	int fa=find(f[x]);
	d[x]+=d[f[x]],f[x]=fa;
	return fa;
}
void merge(int x,int y){
	x=find(x),y=find(y);
	if(x==y)return ;
	f[x]=y,d[x]=siz[y],siz[y]+=siz[x]; 
}
int main(){
	char a;
	for(int i=1;i<=30000;i++){
		f[i]=i,siz[i]=1;
	}
	scanf("%d",&n);
	while(n--){
		int x,y;
		cin>>a>>x>>y;
		if(a=='M')merge(x,y);
		else {
			if(x==y)puts("0");
			else if(find(x)==find(y)){
				printf("%d\n",abs(d[x]-d[y])-1);
			}
			else puts("-1");
		}
	}
	return 0;
}

例题3:石头剪刀布
题目描述
\(N\) 个小朋友(编号为 \(0,1,2,…,N−1\))一起玩石头剪子布游戏。

其中一人为裁判,其余的人被分为三个组(有可能有一些组是空的),第一个组的小朋友只能出石头,第二个组的小朋友只能出剪子,第三个组的小朋友只能出布,而裁判可以使用任意手势。

你不知道谁是裁判,也不知道小朋友们是怎么分组的。

然后,孩子们开始玩游戏,游戏一共进行 \(M\) 轮,每轮从 \(N\) 个小朋友中选出两个小朋友进行猜拳。

你将被告知两个小朋友猜拳的胜负结果,但是你不会被告知两个小朋友具体使用了哪种手势。

比赛结束后,你能根据这些结果推断出裁判是谁吗?

如果可以的话,你最早在第几轮可以找到裁判。

输入格式
输入可能包含多组测试用例

每组测试用例第一行包含两个整数 \(N\)\(M\)

接下来 \(M\) 行,每行包含两个整数 \(a,b\),中间夹着一个符号\((>,=,<)\),表示一轮猜拳的结果。

两个整数为小朋友的编号,\(a>b\) 表示 \(a\) 赢了 \(b\)\(a=b\) 表示 \(a\)\(b\) 平手,\(a<b\) 表示 \(a\) 输给了 \(b\)

输出格式
每组测试用例输出一行结果,如果找到裁判,且只能有一个人是裁判,则输出裁判编号和确定轮数。

如果找到裁判,但裁判的人选多于 1 个,则输出 Can not determine

如果根据输入推断的结果是必须没有裁判或者必须有多个裁判,则输出 Impossible

具体格式可参考样例。

数据范围
\(1≤N≤500,\)
\(0≤M≤2000\)

分析

方便维护的话,这道题使用扩展域并查集,每个人开三个域,分别是win,fail,self三个域,赢域中的所有都输给当前这个,其余同理
那么对应着三个信息:

  1. \(a>b\),合并\((a_{win},b_{self}),(a_{fail},b_{win}),(a_{self},b_{fail})\)
  2. \(a=b\)合并\((a_{win},b_{win}),(a_{self},b_{self}),(a_{fail},b_{fail})\)
  3. \(a<b\),当作\(b>a\)处理

当产生矛盾的时候,就证明裁判出现,那么到了最后的时候,仍然没有矛盾,就证明无法判断,输出Impossible,只不过这道题很烦人的事情是每个人都有着可能性,产生矛盾又不能准确滴判断,所以说又因为本题数据范围很宽松,于是我们可以采用枚举滴办法

那么偶们枚举的时候假设我们枚举第\(i\)个小P友当裁判,那么因为他随心所欲,所以说如果出现矛盾,偶们就需要分类讨论,若矛盾者之一是\(i\)直接不管,否则证明\(i\)不是裁判

到了最后,我们发现可能的裁判数量就直接按照要求输出即可

int n,m,f[2005],sum,cnt,b[2005],tot,maxx;
struct node{
	int a,b,w;
}ask[2005];
int ans[2005];
int find(int x){
	return x==f[x]?x:f[x]=find(f[x]);
}
void merge(int x,int y){
	x=find(x),y=find(y);
	if(x==y)return ;
	f[x]=y;	
}
int main(){
	while(~scanf("%d%d",&n,&m)){
		if(m==0&&n==1){puts("Player 0 can be determined to be the judge after 0 lines");continue;}
		memset(b,-1,sizeof b);
		ans[1]=0;
		sum=tot=cnt=maxx=0;
		char x;
		int a,b2;
		for(int i=1;i<=m;i++){
			cin>>a>>x>>b2;
			++a,++b2;
			if(x=='<')swap(a,b2),x='>';
			ask[i]=(node){a,b2,x=='>'};
		}
		for(int j=1;j<=n;j++){
			for(int i=1;i<=n<<2;i++)f[i]=i;
		    int flag=0;
			/*1~n:win n+1~2n:beat 2n+1~3n:self*/ 
			for(int i=1;i<=m;i++){
				if(ask[i].a==j||ask[i].b==j)continue;
				if(ask[i].w){//a>b 
					if(find(ask[i].a+n+n)==find(ask[i].b+n+n) || find(ask[i].a+n)==find(ask[i].b+n+n)){
						//矛盾
						flag=i;
						break; 
					}
					merge(ask[i].a+n,ask[i].b);
					merge(ask[i].a,ask[i].b+n+n);
					merge(ask[i].a+n+n,ask[i].b+n);
				}
				else{
					if(find(ask[i].a+n)==find(ask[i].b+n+n)||find(ask[i].a)==find(ask[i].b+n+n)){
						flag=i;
						break;
					}
					merge(ask[i].a,ask[i].b);
					merge(ask[i].a+n,ask[i].b+n);
					merge(ask[i].a+n+n,ask[i].b+n+n);
				}
			}
			if(!flag)ans[++cnt]=j;
			else b[j]=flag,maxx=max(maxx,flag);
		}
		if(cnt==0)printf("Impossible\n");
		else if(cnt==1){printf("Player %d can be determined to be the judge after %d lines\n",ans[1]-1,maxx);} 
		else printf("Can not determine\n");
	}
}
posted @ 2022-11-30 22:26  spdarkle  阅读(21)  评论(0编辑  收藏  举报