2022-11-14 Acwing每日一题

本系列所有题目均为Acwing课的内容,发表博客既是为了学习总结,加深自己的印象,同时也是为了以后回过头来看时,不会感叹虚度光阴罢了,因此如果出现错误,欢迎大家能够指出错误,我会认真改正的。同时也希望文章能够让你有所收获,与君共勉!

今天来看看并查集,顾名思义,并查集的本质就是一个集合,支持快速合并集合,时间复杂度为\(\,O(1)\),以及查找某一元素属于某个集合,时间复杂度近乎为\(\,!O(1)\)(路径压缩后的时间复杂度),除此之外,并查集还有一大优势就是支持定义额外数组来存储额外的信息,如定义数组d表示当前元素到根节点的路径长度,形成带权重的并查集,增加数组size来记录每个集合内有多少个元素,这也是后面这两道模板题所需要维护的数组。
那就先通过这道题来认识一下并查集吧!

连通块中点的数量

给定一个包含 n 个点(编号为 1∼n)的无向图,初始时图中没有边。
现在要进行 m 个操作,操作共有三种:
C a b,在点 a 和点 b 之间连一条边,a 和 b 可能相等;
Q1 a b,询问点 a 和点 b 是否在同一个连通块中,a 和 b 可能相等;
Q2 a,询问点 a 所在连通块中点的数量;
输入格式
第一行输入整数 n 和 m。
接下来 m 行,每行包含一个操作指令,指令为 C a bQ1 a bQ2 a 中的一种。
输出格式
对于每个询问指令 Q1 a b,如果 a 和 b 在同一个连通块中,则输出 Yes,否则输出 No。
对于每个询问指令Q2 a,输出一个整数表示点 a 所在连通块中点的数量
每个结果占一行。
数据范围
1≤n,m≤105
输入样例:

5 5
C 1 2
Q1 1 2
Q2 1
C 2 5
Q2 5

输出样例:

Yes
2
3

算法原理

并查集的建立很简单,就是定义数组fa(Father),fa[i]表示数字i的父节点为fa[i],使用fa[i] = i进行赋值,即每个数字的父节点最开始都是他自己,也就是每个数字代表的集合只有它自己一个元素,这个集合的祖宗节点也是他自己。

路径压缩

并查集最重要的操作就是寻找集合的祖宗节点。这里我们把并查集想象成一棵树,我们要做的就是如何找到这个树的根节点。
这个操作的主要思想就是不断访问节点的父节点看他什么时候等于x本身,如果fa[x] == x,则找到该集合的祖宗节点,并将p[x](此时p[x]与x相同)返回,如果找不到则不断搜索父节点,这个过程的时间复杂度往往与并查集的深度有关,在数据量较大时时间复杂度为\(O(n)\),那么我们就可以使用路径压缩去优化查找祖宗节点,优化后查找的时间复杂度为\(O(1)\),那么该怎么优化呢?
我们可以在找到根节点后,将其赋值给查找路径上的每一个结点,即fa[x] = find(fa[x]),具体的实现其实就是在递归的回归过程中把找到的祖宗节点依次赋值给路径上的每个结点,让这些路径结点的父节点直接指向祖宗节点,这时fa[x]就是祖宗节点,而不是原来的那个父节点了,也就是变成了一个深度为一层的N叉树,如图所示。
image

合并两个并查集

合并两个并查集可以看成是合并了两棵树,那么树该怎么合并呢,很容易就可以想到,只要让其中一个集合的父节点是另一个结合就可以啦,如对于ab两个集合的祖宗节点,那么fa[a] = b就代表合并两个集合,也就是将集合a指向集合b的祖宗节点。

查找连通块中元素的个数

实际上就是维护了一个size数组,size[i]表示第i个集合中存在的元素个数,那么如何维护size数组呢,我们只需要初始化size中元素全为1,在合并两个并查集时顺便把集合a中元素的个数size[a]加到集合bsize[b]中就行了,即size[b] += size[a]

代码实现

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1000010;
int p[N],cnt[N];
int n,m;

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

int main(){
    cin >> n >> m;
    for(int i=1 ; i<=n ; ++i) {
        p[i]=i;
        cnt[i] = 1;
    }
    while(m--){
        string s;
        int a,b;
        cin >> s ;
        if(s == "Q1"){
            cin >> a >> b;
            if(find(a) == find(b)){
                cout << "Yes" << endl;
            }
            else{
                cout << "No" << endl;
            }
        }
        else if(s == "Q2"){
            cin >> a;
            cout << cnt[find(a)] << endl;
        }
        else if(s == "C"){
            cin >> a >> b;
            a = find(a),b = find(b);
            if(a!=b){
                p[a] = b;
                cnt[b] += cnt[a];   // 用+=会先find(a)再find(b),这样b的位置会错
            }
        }
    }
    return 0;
}

接下来看一道关于并查集应用的题,认真理解哦

食物链

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

A 吃 B,B 吃 C,C 吃 A。

现有 N 个动物,以 1∼N 编号。

每个动物都是 A,B,C 中的一种,但是我们并不知道它到底是哪一种。

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

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

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

此人对 N 个动物,用上述两种说法,一句接一句地说出 K 句话,这 K 句话有的是真的,有的是假的。

当一句话满足下列三条之一时,这句话就是假话,否则就是真话。

当前的话与前面的某些真的话冲突,就是假话;
当前的话中 X 或 Y 比 N 大,就是假话;
当前的话表示 X 吃 X,就是假话。
你的任务是根据给定的 N 和 K 句话,输出假话的总数。

输入格式
第一行是两个整数 N 和 K,以一个空格分隔。

以下 K 行每行是三个正整数 D,X,Y,两数之间用一个空格隔开,其中 D 表示说法的种类。

若 D=1,则表示 X 和 Y 是同类。

若 D=2,则表示 X 吃 Y。

输出格式
只有一个整数,表示假话的数目。

数据范围
1≤N≤50000,
0≤K≤100000
输入样例:

100 7
1 101 1
2 1 2
2 2 3
2 3 3
1 1 3
2 3 1
1 5 5

输出样例:

3

算法原理

主要思路

第一个难点:如何建立吃与被吃的关系

还是先来理解一下题意吧,A吃B,B吃C,C吃A,如图
image
容易知道当存在a吃b,而b吃c时,我们就能推断出c吃a。当先出现a吃b时,就不会再出现b吃a。那么我们就可以找到如图这样的关系image
我们可以维护一个表示结点到根节点的距离的数组d把食物链的传递关系抽象成路径长度,假设存在一个三层的树,若某一结点到根节点的路径距离为1,则说明这个结点可以吃根节点,距离根节点长度为2的结点,说明这个结点可以吃距离为1的结点,同时也可以被根节点吃(也就对应了距离为3的结点),那么距离根节点长度为3的结点不就是根节点的同类嘛,它是可以吃距离为2的结点,这样就把食物链的传递关系转化为路径长度的循环,每3个结点就是一轮循环,即(d[x]-d[y])%3就表示x和y之间的关系,0为同类,1为x吃y,2为y吃x

那么为什么会这样呢,我们可以把%3给放进括号里,变成d[x]%3-d[y]%3d[x]%3即x与根节点的距离模3,d[y]%3同理,你看这不就跟上面的循环对上了吗?当(d[x]%3-d[y]%3) = 0时,即(d[x] - d[y])%3 == 0也就是x与y距离祖宗节点的距离之差为0,x与y是同类,当(d[x]%3-d[y]%3) = 1时,即(d[x]-d[y])%3 == 1也就是x与y距离祖宗节点的距离之差为1,x的父亲是y,即x吃y,那么当(d[x]%3-d[y]%3) = 2,即(d[x] - d[y])%3 == 2也就是x与y距离祖宗节点的距离之差为2,x的父节点的父节点才是y,说明x与根节点是同类,y吃x。也就是说只要将结点加入到并查集中就建立了结点与这个集合所有节点的关系,我们就能通过分别比较他们与根节点的距离之差来得到他们之间的关系,因此属于同一个集合里面所有的结点关系都是可以推断出的。后面更多是从代数的角度逆推为什么要这么做的,而另一篇大佬写的博客(地址在这)是从向量运算的角度写的,让人耳目一新(虽然我看不懂)


第二个难点:分辨清什么时候话为真,什么时候话为假

接下来就让我们看看这道题的具体求解思路是什么?读入说法t,与ab两个动物,很明显对于规则中的第二条和第三条,我们可以分别使用if(a>n || b>n)if(t == 2 && a == b)进行表示,最后就剩下当前的话与之前的真话是否冲突,那么之前的真话都有什么呢?这就分为两类,一类是XY是同类,另一类是XY

我们通过并查集实现,谁第一个出现关系谁就是真话,每个动物编号第一次出现时都属于新的集合,这时只能合并集合建立联系,第一次建立关系的一定是真话,第二个是x,y都是之前出现过的编号并且属于不同集合的可以直接建立联系,也就是两个集合并没有合并,一个集合内部的关系不会联系另一个集合,这时合并两个集合也是第一次建立联系,也就说明这句话是真话。

对于第一种冲突,如何辨别他们在并查集中是同类呢,我们只需要知道他们到祖宗节点的距离模3后的值相同就可以认为他们是同类(注意ab的祖宗结点必须用同一个祖宗节点才能作为参考点,而具有同一个祖宗节点的前提就是他们都是出现过的,区别只在于是不是处于同一个集合,也就是祖宗节点是否相同),即(d[a]-d[b])%3 == 0,对于不符合(d[a]-d[b])%3 == 0这个条件的,说明ab就不是同类,那么对于不具有同一个祖宗节点的动物ab来说,他们是要么有只出现过一次的动物,要么就是出现过但是不在同一个集合,但不管哪一个他们两个之间都没有建立联系,这时我们只需要合并两个集合p[px] = py,将a的祖宗结点连接到b的祖宗结点上,这时px就不再是x的祖宗结点,自然而然的我们就要维护px到祖宗节点的距离d[px] = d[y] - d[x],至于为什么是这个样子,把d[x]移到左边就知道了。

对于第二种冲突,我们该如何在并查集中探究到底是谁吃谁呢?这时我们很容易的就知道,当他们是同一个祖宗节点的两个动物a,b时,我们可以通过(d[a] - d[b] - 1) % 3来表示ab(看不懂的可以自己推一下),当然也可以用(d[a] - d[b] - 2)% 3来表示ab吃。对于不是同一个祖宗节点的,那就说明a,b一定有一个是第一次出现的动物,这时我们需要将其加入到同一个并查集中去,即p[px] = py,并且维护距离d[px] = d[y] - d[x] + 1来表示xy的关系,至于为什么是这个样子,把d[x]移到左边就知道了(注意:这里的+1就表示x吃y,他们到根节点的距离之差为1)。

一些实现主要思路的补充

主要的思路都讲完了,只剩下关于在查找祖宗节点时需要维护那些关系的操作了,在这个过程中需要实现两个操作,一个就是路径压缩p[x] = find(p[x]),这里就不提了,另一个就是维护距离信息d,在搜索过程中我们需要得到这个动物距离祖宗节点的距离d[x],这里使用了动态规划的思想得到的递推式,d[x] = d[x] + d[p[x]],即当前这个结点到祖宗节点的距离就是这个结点目前的状态加上父结点到祖宗节点的距离,当某个动物第一次进入集合时x=px=p[x]d[px] = d[y] - d[x] + 1就让d[x]的初始状态为1,因此d[x]直接+d[p[x]]就可以直接得到x到根节点的距离d[x]最后一定记得要路径压缩哟,因为之前的操作都是不改变父节点p[x]的情况下进行的,即并没有进行路径压缩,不然会使得父节点直接变成祖宗节点的

代码实现

#include <iostream>

using namespace std;

const int N = 50010;

int n,m;

int p[N],d[N];
    	int res = 0;

int find(int x)
{
    if (p[x] != x)
    {
        int t = find(p[x]);
        d[x] += d[p[x]];
        p[x] = t;
    }
    return p[x];
}

int main()
{
    scanf("%d%d",&n,&m);
    for(int i=1 ; i<=n ; ++i)	p[i] = i;
    while(m--){
	int t,x,y;
	scanf("%d%d%d",&t,&x,&y);
	if(x>n || y>n)	res++;
		else{
			int px = find(x),py = find(y);
			if(t == 1){
				if(px == py && (d[x]-d[y])%3) res++;	// 不是同类的
				else{	// 出现过并且属于不同的集合没建立联系
					p[ px ] = py;	// 把px连到py后面
					d[ px ] = d[y] - d[x];	// x与y是同类,所以把d[x]移到左边就能理解了
				}
			}
			else{
				// 说明a吃b
				// 先判断是否为假话,并且已经出现过
				if(px == py && (d[x]-d[y]-1)%3) res++;	// a不吃b时d[x]-d[y]1结果不为0,b吃a,说明为假话,res++
				else{	
				// 没有出现过a或b,这里第一次出现,说明为真话,或者出现过但不是同一个集合里的,但不管哪一个他们都属于不同集合,这是第一次描述这个关系,是真话
					p[ px ] = py;	// 把px连到py后面
					d[ px ] = d[y] + 1 - d[x];	// x吃y把d[x]移到左边就得到祖宗节点的值应该是多少
				}
			}
		}
	}
    cout << res << endl;
    return 0;
}





难难难!太难了,实在是有点抽象,把人都写自闭了,这篇博客是我边思考边记的,所以难免会有些词不达意,说活啰里啰唆的问题,希望大佬们能够谅解,如果有意思表达不清或出现错误的地方也欢迎留言进行交流,及时改正吧

posted @ 2022-11-14 23:11  ZmQmZa  阅读(22)  评论(1编辑  收藏  举报