一、例题引入
先看题(洛谷P1551):
亲戚
题目背景
若某个家族人员过于庞大,要判断两个是否是亲戚,确实还很不容易,现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。
题目描述
规定: x x x 和 y y y 是亲戚, y y y 和 z z z 是亲戚,那么 x x x 和 z z z 也是亲戚。如果 x x x, y y y 是亲戚,那么 x x x 的亲戚都是 y y y 的亲戚, y y y 的亲戚也都是 x x x 的亲戚。
输入格式
第一行:三个整数 n , m , p n,m,p n,m,p,( n , m , p ≤ 5000 n,m,p \le 5000 n,m,p≤5000),分别表示有 n n n 个人, m m m 个亲戚关系,询问 p p p 对亲戚关系。
以下 m m m 行:每行两个数 M i M_i Mi, M j M_j Mj, 1 ≤ M i , M j ≤ n 1 \le M_i,~M_j\le n 1≤Mi, Mj≤n,表示 M i M_i Mi 和 M j M_j Mj 具有亲戚关系。
接下来 p p p 行:每行两个数 P i , P j P_i,P_j Pi,Pj,询问 P i P_i Pi 和 P j P_j Pj 是否具有亲戚关系。
输出格式
p
p
p 行,每行一个 Yes
或 No
。表示第
i
i
i 个询问的答案为“具有”或“不具有”亲戚关系。
样例 #1
样例输入 #1
6 5 3
1 2
1 5
3 4
5 2
1 3
1 4
2 3
5 6
样例输出 #1
Yes
Yes
No
二、并查集
这个题是并查集模板题
如果用求最短路径的算法来做的话,难道要用floyed吗?注意看本题对时间、空间的要求都是O(n log n)级别的,连单源最短路也不可能,那么需要一种别的算法
仔细思考求解最短路径的算法多了什么冗余的东西,我们不需要知道具体路径是什么,也不需要知道最短路径的长度,我们只需要知道能不能走过去,我们甚至不需要知道两个人互相怎么称呼对方,我们只需要知道他们是否在同一集合
那么我们可以用某个代表元素指代集合,如果没有特殊元素的情况下,这个“代表”可以随意指派,此时,这个问题由图转变为了树,使用的树形数据结构是并查集
并查集是一种树形的但实现极其简单的数据结构,只用数组足以解决
并查集 = 并 (v.合并集合) + 查 (v.查找属于哪个集合) + 集 (n.集合)
- 合并集合的时候就是合并两个集合的代表元素(让其中一个指向另一个)
- 查找的时候就是找当前元素的代表元素,通过不断递归寻找
代码如下:
#include<iostream>
using namespace std;
int n,m,p,f[5001],ranks[5001];
void init_relative()
{
for(int i=1;i<=n;i++)
f[i]=i;
}
int find(int x)
{
if(f[x]==x) return x;
else return find(f[x]);
}
void merge(int x,int y)
{
x=find(x);
y=find(y);
if(x==y) return;
f[x]=y;
}
bool is_relative(int x,int y)
{
return find(x)==find(y);
}
int main()
{
cin>>n>>m>>p;
init_relative();
for(int i=1;i<=m;i++)
{
int a,b;
cin>>a>>b;
merge(a,b);
}
for(int i=1;i<=p;i++)
{
int a,b;
cin>>a>>b;
if(is_relative(a,b)) cout<<"Yes"<<endl;
else cout<<"No"<<endl;
}
return 0;
}
三、并查集优化
并查集的优化是为了改掉并查集一直寻找祖先的耗时,如果这个树最坏情况退化成了链表,那么将会耗时到令人担忧
1.按秩合并
按秩合并分为两种,按照树的大小合并或按照树的深度合并
先讲按树的大小合并,即元素少的合并到元素多的上去
代码如下:
void init_relative()
{
for(int i=1;i<=n;i++)
{
f[i]=i;
size[i]=1;
}
}
void merge(int x,int y)
{
x=find(x);
y=find(y);
if(x==y) return;
if(size[x]<size[y]) swap(x,y);
//y合并到x
f[y]=x;
size[x]+=size[y];
}
接下来的大小指的是深度大小:
小树合并到大树上树的深度不变,那么单次查找最差情况是大树的深度
大树合并到小树上树的深度改变,那么单次查找最差情况高于原先大树的深度
可以看出大树可以完美吸纳小树,所以按树的深度合并更胜一筹,因为最差查找次数全看深度
只有两树深度一样时才会增长合并后树的深度
那么按照树的深度合并代码如下:
void init_relative()
{
for(int i=1;i<=n;i++)
{
f[i]=i;
ranks[i]=0; //树根默认深度0
}
}
void merge(int x,int y)
{
x=find(x);
y=find(y);
if(x==y) return;
if(ranks[x]>ranks[y]) f[y]=x;
else if(ranks[x]<ranks[y]) f[x]=y;
else{
f[x]=y;
ranks[y]++;
}
}
2.路径压缩
不知道你是否这样思考过,如果每个树只有两级,上面是代表元素,下面是在这个集合里的元素,这些元素与代表元素直接相连就好了,这样每次查询都是O(1)
但是这样开销巨大,我们没有必要时刻维持每一条路径上的节点都是准确的指向自己的代表元素,由于find函数
是经常使用到的函数,那么我们完全可以仅维护每次find时经过路径上的节点都是准确的指向自己的代表元素
具体实现时是利用递归的回溯,修改f[x]的值
代码如下:
//没有路径压缩
int find(int x)
{
if(f[x]==x) return x;
else return find(f[x]);
}
//路径压缩
int find(int x)
{
if(f[x]==x) return x;
else return f[x]=find(f[x]);
}
可以观察出来是很方便的,只需要简单的修改一下就起到了很好的优化效果
同时明确几点:
- 路径压缩不是把所有路径都压缩到节点与代表节点直接相连的地步
- 路径压缩发生在“查”
- 按秩合并发生在“并”
- 路径压缩和按秩合并理论上可以同时存在,但是路径压缩易于实现且效果显著,这个基础上再用按秩合并是画蛇添足
不同的实现方法下可能还有“路径减半”等说法,但是使用路径压缩已经效果很好且易于实现,通常的代码只使用路径压缩
但是上述递归版的路径压缩仍有缺点,数据规模大后容易爆栈,所以可以改成递推版本
代码如下:
int find(int x)
{
int k=x;
while(f[x]!=x) //寻找代表元素(祖先)
{
x=f[x];
}
while(f[k]!=x) //退回原来的地方再走一遍,沿路标记,路径压缩
{
int tmp=f[k];
f[k]=x;
k=tmp;
}
return x;
}