最近公共祖先(LCA)


最近公共祖先问题是树中的常考问题,比如我们有一棵树(可以是多叉的),两个点的最近公共祖先就是字面的意思,比如说6、5节点的最近公共祖先就是2,而4和7的最近公共祖先我们规定为4.
在这里插入图片描述
最近公共祖先问题就是给我们两个点让我们求他们的最近公共祖先的问题。
下面我就介绍一下我们求最近公共祖先的常用的几种方法。

向上标记法

对于给我们的两个点,比如说,在这张图中,让我们求6、3的最近公共祖先,那我们就先选任意一点往根节点搜索,并标记搜过的节点(注意,自己本身这个点也要标记),比如我们选择6,然后再选择3往根节点搜索,搜索的过程中遇到的第一个被标记过的点就是这两点的最近公共祖先。
在这里插入图片描述
这个算法比较简单,时间复杂度也比较高,这里就不给出代码实现了。

树上倍增

我们发现,如果对于深度比较深的树,我们再用向上标记法就会很慢,因此我们这里用一种倍增的思想来解决问题,我们定义根节点的深度为1,下一层中每个节点的深度都是上一层节点的深度+1,假如给我们两个点,要我们求他们的最近公共祖先,那我门就先让这两个点移动到同一层,然后再同时移动两点知道最近公共祖先的位置。而我们的倍增思想就体现在把一个点移到和另一点同样深度的过程中和将两点移到最近公共祖先的过程中。
在这里插入图片描述
我们预处理出一个fa[i][j]数组,表示从i节点向上跳2的次方后所到达的位置,depth[i]表示i节点的深度,关于这两个数组具体的求法和用法,我们在代码中体现。
板子题:洛谷P3379
代码实现:(这个问题思路并不难,主要是细节方面)

#include<iostream>
using namespace std;
const int N=5e5+10,M=1e6+10;
int n,m,s,depth[N],fa[N][20],q[N],hh,tt,idx=1,e[M],h[N],ne[M];
void add(int a,int b){
	e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}
int query(int a,int b){
	if(depth[a]<depth[b]) swap(a,b);//我们就让a往上跳,因此如果a在上面,交换下a,b
	for(int k=19;k>=0;k--)//二进制优化,循环后a与b深度一定相同
		if(depth[fa[a][k]]>=depth[b])//对于跳出界的情况,左边为0,等式不可能成立
			a=fa[a][k];
	if(a==b) return a;//深度相同先判断下是否已经在同一节点
	for(int k=19;k>=0;k--)
		if(fa[a][k]!=fa[b][k]){//如果没跳到同一点就跳,因为如果跳到了相同的节点我们无法判断是否是最近的公共祖先,这里也是二进制优化,循环后a,b两点都一定在最近公共祖先的下一层
			a=fa[a][k];
			b=fa[b][k];
		}
	return fa[a][0];//返回最近公共祖先
}
int main() {
	scanf("%d%d%d",&n,&m,&s);
	for(int i=1;i<n;i++){
		int a,b;
		scanf("%d%d",&a,&b);
		add(a,b);
		add(b,a);
	}
	depth[s]=1;//根节点深度为1
	q[0]=s;//根节点入队
	while(hh<=tt){//bfs求出每个点的depth
		auto t=q[hh++];
		for(int i=h[t];i;i=ne[i]){
			int j=e[i];
			if(!depth[j]){//如果depth为0就说明还没搜过
				depth[j]=depth[t]+1;
				fa[j][0]=t;//j跳2的0次方到j的父节点
				q[++tt]=j;
				for(int k=1;k<20;k++)//递推更新,跳出界的节点值都为0
					fa[j][k]=fa[fa[j][k-1]][k-1];//跳2的k次方相当于从j跳2的k-1次方到达的点跳2的k-1次方
			}
		}
	}
	while(m--){
		int a,b;
		scanf("%d%d",&a,&b);
		printf("%d\n",query(a,b));
	}
	return 0;
}

Tarjan发明的算法

这个算法是Tarjan发明的算法,但不是我们常说的那个tarjan算法,这个算法是离线求最近公共祖先的好方法,时间复杂度能达到O(N+M),N、M分别是点数,边数。(Tarjan在图论中发明了好多算法啊。。。)在这个算法中,我们从根节点深搜并对点进行标记,把所有点分为3类,一类是未遍历过的点,一类是正在遍历的点(这里如果我们还在遍历一个节点的子节点,那么该节点和子节点都算作是正在遍历的点),一类是遍历完的点。然后我们画出一种情况:
在这里插入图片描述
红色的是已经遍历且回溯完的点,蓝色的是正在遍历的点,其它是未遍历的点,我们发现,如果我们每遍历完一个点的子节点回溯后,就把它加入它父节点的并查集,那么对于正在遍历的点,它和已经遍历过点的最近公共祖先就是已经遍历过点的祖先,这样我们就可以求出每对点的最近公共祖先。
代码实现:

#include<iostream>
#include<vector>
#define x first
#define y second
using namespace std;
const int N=5e5+10,M=1e6+10;
typedef pair<int,int> PII;
int n,m,s,type[N],e[M],ne[M],h[N],idx=1,ans[N],p[N];//ans数组记录每个询问对应的答案 ,type数组记录每个点的状态 
//type为1代表正在遍历的点,2代表遍历完的点,0代表未遍历的点 
vector<PII> query[N];//存储每个点对应的询问里另一个点以及该询问的编号 
void add(int a,int b){
	e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}
int find(int x){
	if(p[x]!=x) p[x]=find(p[x]);
	return p[x];
}
void tarjan(int u){
	type[u]=1;//正在遍历u点 
	for(int i=h[u];i;i=ne[i]){
		int j=e[i];
		if(!type[j]){//未遍历过 ,也就是说j是u的子节点 
			tarjan(j);
			p[j]=u; //遍历完后将子节点加入父节点的并查集
		}
	}
	type[u]=2;//遍历完了 
	//处理询问 
	//如果把type[u]=2放在最后面就会有无法求解两个相同点的情况
	for(auto tmp:query[u]){
		int a=tmp.x,id=tmp.y;
		if(type[a]==2)
			ans[id]=find(a);
	}
}
int main(){
	cin>>n>>m>>s;
	for(int i=1;i<=n;i++) p[i]=i;
	for(int i=1;i<n;i++){
		int a,b;
		cin>>a>>b;
		add(a,b);
		add(b,a);
	}
	for(int i=1;i<=m;i++){
		int a,b;
		cin>>a>>b;
		query[a].push_back({b,i});
		query[b].push_back({a,i});
		if(a==b) ans[i]=a;
	}
	tarjan(s);
	for(int i=1;i<=m;i++) cout<<ans[i]<<endl;
	return 0;
}

转化为求区间最小值问题

对于一颗树,我们写出它的中序遍历,如果这棵树满足父节点的节点号小于子节点的节点号,那么两点的最近公共祖先就是中序遍历中这两点之间的最小值,然后我们就能把此问题转化为RMQ问题或者线段树问题,但这种方法基本用不到,这里就不多讲了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

_bxzzy_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值