28

【并查集】一种与时间赛跑的巧妙算法

 3 years ago
source link: http://www.cnblogs.com/pqh-/p/13699925.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

【并查集】一种与时间赛跑的巧妙算法

引入:(NOIP模拟题)极端寒冬

(不要求刚刚接触并查集的读者完全明白本题)

先了解一下并查集是个什么东西:

合并两点所在集合和 查找两点是否在同一集合 的算法

那有什么用处呢?

我们先来看一道NOIP模拟题 ABVRn2f.png!mobile

我们先来看一下题意是什么:

首先,有一个n*m的地图,k个障碍,每个障碍会按顺序在不同的时间冒出

问地图上最多能容纳多少个障碍,使得从(1,1)到(n,m)仍保持联通

那我们可以怎么做呢?

最先也是最容易想到的就是 搜索

每次添加一个障碍,就搜索判断地图的连通性

但是,时间复杂度约为O(k(n*m)) [口胡]

3 * 10^4^ * 3 * 10^4^ * 10^5^ = 9 * 10^13^

超时,但小数据应该勉强能拿2、30分

然后我们会想到做 分块 (分块思想: 从疫情中的体温测量到分块思想的运用

如果给k个障碍,那么我们把他分成√k堆,每堆√k个障碍

每次添加√k个障碍,然后搜索判断连通性

如果不连通,再对√k个障碍,挨个搜索判断连通性

那么,时间复杂度约为O(√k(n m) 2) [口胡]

时间还是不够,必定会超时,勉强拿40分

之后我们会想到更快的暴力: 二分答案

如果给k个障碍,那么我们把他分成2堆,每堆k/2个障碍

添加k/2个障碍,之后搜索判断连通性

左侧不连通就二分再次左侧,右侧不连通就二分再次右侧

最后就能找到最多能容纳的障碍

所以时间复杂度约为O(㏒(k)*(n *m)) [口胡]

所用时间也还是较大,估计拿50分左右

那么正解是什么呢?

我们会发现所用时间较大与去搜索连通性有密切关系

地图越大,就算优化枚举障碍放置,搜索连通性也耗时过多

那么,我们可以 不去搜索地图的连通性 吗?

答案是可以的

我们可以知道,地图如果不连通

那么障碍一定是从地图左/下边界一直连通到右、上侧边界的

q22iqiy.png!mobile 这很容易证明,过程略

所以题目就转化为了 障碍是否具有连通性

(从地图左/下边界一直连通到右/上侧边界是否具有连通性)

可以枚举+染色,也不复杂的暴力

但时间还没有最优

所以我们想到 并查集

去合并障碍,看障碍的连通性

卖个关子,我们先讲一下并查集的主要内容

并查集

刚刚说过,并查集是 合并两点所在集合查找两点是否在同一集合 的算法

了解

并查集本质是树状结构

把每个集合看成一棵树

初始时,每个点就是一棵树

每次合并是将两棵树的根节点连接

合并集合像归顺之类的一样: 树A和树B,A和B有关系,A就加入B,A的根节点就是B的根节点

查找节点是否在同一集合就去查找: 节点X和Y,X所在树的根节点 是否等于 Y的所在树根节点

网上已经说得很详细了,就不赘述了

江湖上散落着各式各样的大侠,有上千个之多。他们没有什么正当职业,整天背着剑在外面走来走去,碰到和自己不是一路人的,就免不了要打一架。但大侠们有一个优点就是讲义气,绝对不打自己的朋友。而且他们信奉“朋友的朋友就是我的朋友”,只要是能通过朋友关系串联起来的,不管拐了多少个弯,都认为是自己人。这样一来,江湖上就形成了一个一个的帮派,通过两两之间的朋友关系串联起来。而不在同一个帮派的人,无论如何都无法通过朋友关系连起来,于是就可以放心往死了打。但是两个原本互不相识的人,如何判断是否属于一个朋友圈呢?

我们可以在每个朋友圈内推举出一个比较有名望的人,作为该圈子的代表人物。这样,每个圈子就可以这样命名“中国同胞队”美国同胞队”……两人只要互相对一下自己的队长是不是同一个人,就可以确定敌友关系了。

但是还有问题啊,大侠们只知道自己直接的朋友是谁,很多人压根就不认识队长抓狂要判断自己的队长是谁,只能漫无目的的通过朋友的朋友关系问下去:“你是不是队长?你是不是队长?”这样,想打一架得先问个几十年,饿都饿死了,受不了。这样一来,队长面子上也挂不住了,不仅效率太低,还有可能陷入无限循环中。于是队长下令,重新组队。队内所有人实行分等级制度,形成树状结构,我队长就是根节点,下面分别是二级队员、三级队员。每个人只要记住自己的上级是谁就行了。遇到判断敌友的时候,只要一层层向上问,直到最高层,就可以在短时间内确定队长是谁了。由于我们关心的只是两个人之间是否是一个帮派的,至于他们是如何通过朋友关系相关联的,以及每个圈子内部的结构是怎样的,甚至队长是谁,都不重要了。所以我们可以放任队长随意重新组队,只要不搞错敌友关系就好了。于是,门派产生了。

下面我们来看并查集的实现。 int fa[1000]; 这个数组,记录了每个大侠的上级是谁。大侠们从1或者0开始编号(依据题意而定),fa[15]=3就表示15号大侠的上级是3号大侠。如果一个人的上级就是他自己,那说明他就是掌门人了,查找到此为止。也有孤家寡人自成一派的,比如欧阳锋,那么他的上级就是他自己。每个人都只认自己的上级。比如胡青牛同学只知道自己的上级是杨左使。张无忌是谁?不认识!要想知道自己的掌门是谁,只能一级级查上去。

再来看看加入门派,就是在两个点之间连一条线,这样一来,原先它们所在的两个板块的所有点就都可以互通了。这在图上很好办,画条线就行了。但我们现在是用并查集来描述武林中的状况的,一共只有一个fa[]数组,该如何实现呢? 还是举江湖的例子,假设现在武林中的形势如图所示。虚竹帅锅与周芷若MM是我非常喜欢的两个人物,他们的终极boss分别是玄慈方丈和灭绝师太,那明显就是两个阵营了。我不希望他们互相打架,就对他俩说:“你们两位拉拉勾,做好朋友吧。”他们看在我的面子上,同意了。这一同意可非同小可,整个少林和峨眉派的人就不能打架了。这么重大的变化,可如何实现呀,要改动多少地方?其实非常简单,我对玄慈方丈说:“大师,麻烦你把你的上级改为灭绝师太吧。这样一来,两派原先的所有人员的终极boss都是师太,那还打个球啊!大笑反正我们关心的只是连通性,门派内部的结构不要紧的。”玄慈一听肯定火大了:“我靠,凭什么是我变成她手下呀,怎么不反过来?我抗议!”于是,两人相约一战,杀的是天昏地暗,风云为之变色啊,但是啊,这场战争终究会有胜负,胜者为王。弱者就被吞并了。反正谁加入谁效果是一样的,门派就由两个变成一个了。这段函数的意思明白了吧?

再来看看路径压缩算法。建立门派的过程是用加入函数两个人两个人地连接起来的,谁当谁的手下完全随机。最后的树状结构会变成什么样,我也无法预知,一字长蛇阵也有可能。这样查找的效率就会比较低下。最理想的情况就是所有人的直接上级都是掌门,一共就两级结构,只要找一次就找到掌门了。哪怕不能完全做到,也最好尽量接近。这样就产生了路径压缩算法。

设想这样一个场景:两个互不相识的大侠碰面了,想知道能不能干一场。 于是赶紧打电话问自己的上级:“你是不是掌门?” 上级说:“我不是呀,我的上级是谁谁谁,你问问他看看。” 一路问下去,原来两人的最终boss都是东厂曹公公。 “哎呀呀,原来是自己人,有礼有礼,在下三营六组白面葫芦娃!” “幸会幸会,在下九营十八组仙子狗尾巴花!” 两人高高兴兴地手拉手喝酒去了。 “等等等等,两位大侠请留步,还有事情没完成呢!”我叫住他俩。 “哦,对了,还要做路径压缩。”两人醒悟。 白面葫芦娃打电话给他的上级六组长:“组长啊,我查过了,其实偶们的掌门是曹公公。不如偶们一起结拜在曹公公手下吧,省得级别太低,以后查找掌门麻烦。” “唔,有道理。” 白面葫芦娃接着打电话给刚才拜访过的三营长……仙子狗尾巴花也做了同样的事情。 这样,查询中所有涉及到的人物都聚集在曹公公的直接领导下。每次查询都做了优化处理,所以整个门派树的层数都会维持在比较低的水平上。路径压缩的代码,看得懂很好,看不懂可以自己模拟一下,很简单的一个递归而已。总之它所实现的功能就是这么个意思。

写的还挺好,我们再详细说说

实现

初始化

首先初始化每个节点,使得他们自成一树

void in(){
	for(int i=1;i<=n;i++){
		fa[i]=i;//树的根节点
		h[i]=1;//树的深度
	}
}

所以得到:

UNvEBjY.jpg!mobile

合并

然后我们对他们进行合并操作: nU32Unn.jpg!mobile直接将有关系的点进行连接

int get(int x,int y){
	f[f[x]]=y;//将X点的根节点挂到Y上
}

但我们会发现,最坏的情况是出现了一条链,fa指向自己上一个节点

查找根节点就会很慢,时间复杂度为O(h)

(下图注:例图,与刚刚数据无关)

qmuYRjm.jpg!mobile

那么,我们可以每次合并两棵树时,树A挂在树B的根节点上

aENz223.jpg!mobile 以刚刚的数据为例 ZfiQ32R.jpg!mobile

4和2的关系:

理论上应该连接2和4

但为了防止链,导致find的查找根节点变慢

树A根节点连接到树B根节点

我们再来说一下 树A根节点连B根节点的方法

Efumi2N.jpg!mobile

如图,是A挂B好还是B挂A好?

(h大为深,h小为浅)

当然是 浅的树挂在深的树 上好呗

浅的树挂在深的树的根节点上,浅的树一侧深度+1(因为顶上多了个深的树的根节点)

h[浅的树]+1<=h[深的树]

深的树挂在浅的树的根节点 上,深的树一侧深度+1(因为顶上多了个浅的树的根节点)

h[深的树]+1>=h[深的树]

∴h[浅的树]+1<h[深的树]+1

∵h越小查找时间越低

∴浅的树挂在深的树更优

void get(int x,int y){
	x=find(x);
	y=find(y);
	if(x==y)return ;//x,y是同一父亲节点 ,不需要合并 
	else{
		if(h[x]>h[y]){
			fa[y]=x;
		}else if(h[x]<h[y]){
			fa[x]=y;
		}else if(h[x]==h[y]){//两树深度相同
			fa[x]=y;
			h[x]++;
		}
	}
	
}

我们再来说一下代码,代码考虑到 两树深度相同

那么,就随便挂了,反正最后深度都会+1(因为一棵树顶上多了个另一棵树的根节点)

然后,我们发现

其实最优的应该是 所有节点都挂在根节点 上(建立直接联系)

我们把这种想法称为 路径压缩

最后查找的时间可以达到O(1) (最理想状态)

zYjAJjq.jpg!mobile

但不是所有都需要路径压缩,有很多时候我们合并过后并不会去查找他

压缩反而会浪费时间,那我们 就在查找的时候顺便合并 ,方便以后的查找

怎么实现呢?

查找

int find(int i){
	if(i==fa[i]){//找到i的根节点
		return i;
	}else{
		find(f[i]);//找i的根节点
	}
}

先看代码, 查找的实质就是递归

这是与刚刚最原始的代码配合使用,但这其实是伪的并查集

//伪的并查集
void in(){
	for(int i=1;i<=n;i++){
		fa[i]=i;
		//h[i]=1;
	}
}
int get(int x,int y){
	f[f[x]]=y;
}
int find(int i){
	if(i==fa[i]){
		return i;
	}else{
		find(f[i]);
	}
}

刚刚我们说道在查找的时候顺便合并,方便以后的查找,怎么实现呢?

int find(int i){
	if(i==fa[i]){//找到i的根节点
		return i;//返回i的根节点
	}
	else{
		fa[i]=find(fa[i]);//把i挂到根节点上
		return find(fa[i]);//结束
	}
}

这里还有一个细节可以优化

将find(fa[i])=x,这样else的地方find只用调用一次,而不用调用两次

时间也能减少不少

int find(int i){
	if(i==fa[i]){//找到i的根节点
		return i;//返回i的根节点
	}
	else{
		int x=find(fa[i]);
		fa[i]=x;//把i挂到根节点上
		return x;//结束
	}
}

是否在同一集合

很简单,就看X点和Y点是否有同一根节点

bool same(int x,int y){
	return find(x)==find(y);
}

模板

#include<bits/stdc++.h>
using namespace std;
int fa[1010];
int h[1010];

void in(){//初始化 
	for(int i=1;i<=n;i++){
		fa[i]=i;
		h[i]=1;
	}
}
int find(int i){//查(查找根节点 
	if(i==fa[i]){
		return i;
	}
	else{
		int x=find(fa[i]);
		fa[i]=x;
		return x;
	}
}
void get(int x,int y){//并(合并 
	x=find(x);
	y=find(y);
	if(x==y)return ;
	else{
		if(h[x]>h[y]){
			fa[y]=x;
		}else if(h[x]<h[y]){
			fa[x]=y;
		}else if(h[x]==h[y]){
			fa[x]=y;
			h[x]++;
		}
	}
	
}

bool same(int x,int y){//集(是否在同一集合 
	return find(x)==find(y);
}

int main(){
	//……其他操作 
	
	
	return 0;
}

(NOIP模拟题)极端寒冬

(见:引入:(NOIP模拟题)极端寒冬

这个题目用并查集来做

把每个障碍看做一个点

然后按时间T的变化,看当前时间

障碍的八个方向是否有其他已生成的障碍

AfQve2e.png!mobile

若有一个已生成的障碍B,那就把当前障碍A连入B所在的集合

若有更多已生成的障碍(以B和C为例),那就将当前障碍A连入B所在的集合,C所在的集合也连入

如果 地图左/下边界右/上侧边界任意两障碍在同一集合

说明障碍具有连通性,地图无法连通

那么就输出当前的障碍值

拓展:带权值的并查集

基于路径压缩,我我们可以让并查集带有权值

带权值的并查集用v[]记录权值,用find()来实现

所以find()的过程中,需要加上权值的更新操作

做find()时

从根到节点权值=从根节点开始返回上层的权值累加到当前这个权值

然后再将节点挂到根上

int find(int i){
	if(i==fa[i]){//找到i的根节点
		return i;//返回i的根节点
	}
	else{
		int x=find(fa[i]);
		fa[i]=x;//把i挂到根节点上
		v[i]+=v[x];//权值更新
		return x;//结束
	}
}

END


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK