LCA最近公共祖先问题

洛谷p3379 https://www.luogu.com.cn/problem/P3379
在这里插入图片描述

最近公共祖先

对于有根树T的两个结点u、v,最近公共祖先LCA(u,v)表示一个结点x,满足x是u、v的祖先且x的深度尽可能小。
另一种理解方式是把T理解为一个无向无环图,而LCA(u,v)即u到v的最短路上深度最小的点。
例如,对于下面的树,结点4和结点6的最近公共祖先LCA(T,4,6)为结点2。
在这里插入图片描述
那么这里提供四种思路。

1.暴力至上主义

首先计算出结点u和v的深度d1和d2。如果d1>d2,将u结点向上移动d1-d2步,如果d1<d2,将v结点向上移动d2-d1步,现在u结点和v结点在同一个深度了。下面只需要同时将u,v结点向上移动,直到它们相遇(到达同一个结点)为止,相遇的结点即为u,v结点的最小公共祖先。
但这个算法对于多次询问的题目不能解决。

2.倍增法

思路:倍增法其实是在上一种方法的基础上进行了优化,我们希望向上查找更快,可以事先预处理出p[i,j],表示i往上移动2^j步到达的结点,这样在查找时将大大节约时间。
利用P数组可以快速的将结点i向上移动n步,方法是将n表示为2进制数。比如n=6,二进制为110,那么利用P数组先向上移动4步(2^2), 然后再继续移动2步(2^1),即P[ P[i][2] ][1]。
那么首先深搜预处理出所有的p[i][j],并计算每个点的深度d[i]:

void dfs(int u,int fa)//从根结点u开始dfs,u的父亲是fa{ 
	d[u]=d[fa]+1; //u的深度为它父亲的深度+1
	p[u][0]=fa; //u向上走2^0步到达的结点是其父亲
	for(int i=1;(1<<i)<=d[u];i++) p[u][i]=p[p[u][i-1]][i-1];
	//预处理p时,保证能从u走到p[u][i]
	for(int i=head[u];i!=-1;i=e[i].next)//对于u的每个儿子{ 
	int v=e[i].v; 
	if(v!=fa)dfs(v,u); //递归处理以v为根结点的子树} 
} 

然后在主函数中调用dfs(1,0)即可。接下来是查询结点a,b的LCA:

int lca(int a,int b)
{
	if(d[a]>d[b]) swap(a,b) ; //保证a点在b点上面
	for(int j=20;j>=0;j--) //将b点向上移动到和a的同一深度
		   if(d[a]<=d[b]-(1<<j)) b=p[b][j] ;
	if(a==b) return a ;//如果a和b相遇
	for(int j=20;j>=0;j--)//a,b同时向上移动
	 {
		   if(p[a][j]==p[b][j]) continue ;//如果a,b的2^j祖先相同,则不移动
		   a=p[a][j],b=p[b][j];//否则同时移动到2^j处
	 }
	 return p[a][0] ;//返回最后a的父亲结点
}

时间复杂度分析
预处理:对每一个结点找到它向上走2^logN步到达的点,所以时间是NlogN
一组询问复杂度:O(logn)。
所以总复杂度为NlogN+QlogN
空间复杂度:O(nlogn)。
完整代码:

#include<stdio.h>
#include<iostream>
#define maxn 500000
using namespace std;

int n,m,s,k=0; 
int dep[maxn+5],f[maxn+5][21];
int head[maxn+5];
struct edge{int u,v,next;}e[maxn*2+5];

inline int read()
{
   int s=0,w=1;
   char ch=getchar();
   while(ch<'0'||ch>'9'){if(ch=='-')w=-1;ch=getchar();}
   while(ch>='0'&&ch<='9') s=s*10+ch-'0',ch=getchar();
   return s*w;
}

void addedge(int u,int v)
{
	e[++k].u=u;
	e[k].v=v;
	e[k].next=head[u];
	head[u]=k;
}

void deal_first(int u,int father)//递归预处理
{
	dep[u]=dep[father]+1;
	for(int i=0;i<=19;i++)
		f[u][i+1]=f[f[u][i]][i];
	for(int i=head[u];i;i=e[i].next)
	{
		int v=e[i].v;
		if(v==father)	continue;
		f[v][0]=u;
		deal_first(v,u);
	}
} 

int LCA(int x,int y)//查询x,y的LCA
{
	if(dep[x]<dep[y])	swap(x,y);//保证x的深度更大
	//先暴力,将x和y的深度调至一样
	for(int i=20;i>=0;i--)//x先跳的幅度大一点
	{
		if(dep[f[x][i]]>=dep[y])	x=f[x][i];
		//x向上移动,使得x,y在同一深度 
		if(x==y)	return x;
		//如果恰好x的祖先就是y,直接返回x,不过一般不可能	
	}	
	for(int i=20;i>=0;i--)//x,y同时向上移动,找公共祖先
		if(f[x][i]!=f[y][i])//x,y的父亲不同才往上跳	
			x=f[x][i],y=f[y][i];
	return f[x][0];//最后x和y的父亲一样,返回x的父亲	
} 

int main()
{
//	ios::sync_with_stdio(false);
	n=read(),m=read(),s=read();
	int x,y;
	for(int i=1;i<=n-1;i++)
	{
		x=read(),y=read();
		addedge(x,y);
		addedge(y,x);
	}
	deal_first(s,0);//从根节点开始预处理 
	for(int i=1;i<=m;i++)
	{
		x=read(),y=read();
		printf("%d\n",LCA(x,y));
	}
	return 0;
}

3.转为RMQ问题

思路:DFS遍历树T,将遍历到的结点按照顺序记下,得到一个长度为2N – 1的序列,称之为T的欧拉序列F。每个结点都在欧拉序列中出现,我们记录结点u在欧拉序列中第一次出现的位置为pos(u)。如下图:
在这里插入图片描述
根据DFS的性质,对于结点u、v,从pos(u)遍历到pos(v)的过程中经过LCA(u, v)至少一次,且是深度序列B[pos(u)…pos(v)]中值最小的元素在F中对应的节点。那么我们只需要去找在深度序列B中,pos(u)到pos(v)中最小的位置的欧拉序列值。
即LCA( u, v) = RMQ( pos(u), pos(v))。

用occur[]记录T的欧拉序列,depth[]记录深度,first[]记录各结点第一次出现在欧拉序列中的位置,深搜时:

void dfs(int u,int deep) 
 { 
 	occur[++cnt]=u;//进入该点时进行记录  	
 	depth[cnt]=deep; 	
 	if(!first[u]) first[u]=cnt; 	
 	for(int i=head[u];i!=-1;i=e[i].next)    
 	{	
 		dfs(e[i].v,deep+1);  					
 		occur[++cnt]=u;//访问子树也要标记  	
 		depth[cnt]=deep; 
 	} 
 } 

在这之后还需要进行一次预处理,打一次ST表。
时间复杂度分析:
dfs时间为2N,RMQ预处理时间为NlogN,询问Q次,所以总时间复杂度为NlogN+Q
完整代码:

#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<cmath>
#include<algorithm>
#include<cstring>
#define maxn 500000
using namespace std;

int n,m,s,cnt=0,k=0,head[maxn+10];
struct edge{int u,v,next;}e[maxn*2+10];
int occur[maxn*2+10],depth[maxn*2+10],first[maxn+10];
int minnum[maxn*2+10][20],mindep[maxn*2+10][20];
//mindep记录的是区间内深度最小值
//minnum记录的是区间内深度最小值的编号

inline int read()//快读 
{
   int s=0,w=1;
   char ch=getchar();
   while(ch<'0'||ch>'9'){if(ch=='-')w=-1;ch=getchar();}
   while(ch>='0'&&ch<='9') s=s*10+ch-'0',ch=getchar();
   return s*w;
}

void addedge(int x,int y)//建边 
{
	 e[++k].u=x;
	 e[k].v=y;
	 e[k].next=head[x];
	 head[x]=k;
}

void dfs(int u,int deep)//深搜预处理 
{
	cnt++;
	occur[cnt]=u;
	depth[cnt]=deep;
	first[u]=cnt;//记录首次出现的位置 
	for(int i=head[u];i!=-1;i=e[i].next)
		if(!first[e[i].v])
		{
			dfs(e[i].v,deep+1);//深搜该节点的枝 
			occur[++cnt]=u;//回溯后,仍要将该节点放入欧拉数列 
			depth[cnt]=deep;//记录深度
		} 
}

void st()//st表求RMQ
{
	for(int i=1;i<=cnt;i++)
		minnum[i][0]=occur[i],mindep[i][0]=depth[i];
	for(int j=1;j<=log(cnt)/log(2);j++){
		for(int i=1;i<=cnt-(1<<j)+1;i++)
		{
			if(mindep[i][j-1]<mindep[i+(1<<(j-1))][j-1])
				mindep[i][j]=mindep[i][j-1],minnum[i][j]=minnum[i][j-1];
			else	
				mindep[i][j]=mindep[i+(1<<(j-1))][j-1],minnum[i][j]=minnum[i+(1<<(j-1))][j-1];
		}
	}
} 

int main()
{
	memset(head,-1,sizeof(head));
	n=read(),m=read(),s=read();
	int x,y;
	for(int i=1;i<=n-1;i++)
	{
		x=read(),y=read();
		addedge(y,x);
		addedge(x,y); 
	}
	dfs(s,1);
	st();
	for(int i=1;i<=m;i++)
	{
		x=read(),y=read();
		int op=min(first[x],first[y]),ed=max(first[x],first[y]);
		int k=0;
		k=log(ed-op+1)/log(2);
		if(mindep[op][k]<mindep[ed-(1<<k)+1][k])	
			printf("%d\n",minnum[op][k]);
		else	printf("%d\n",minnum[ed-(1<<k)+1][k]);
	}
	return 0;
}

4.Tarjan

别说了,说多了都是泪。
思路:先把所有的询问存下来,然后在DFS的过程中根据点被访问的状态求出每个询问的答案,DFS完后按询问顺序输出。需要用到并查集。
相比前两个算法,这是离线算法。优势在于稳定,且时间复杂度适中。

实现过程
DFS遍历树,利用并查集,当某个节点u及其子树遍历完成后,处理所有与u相关的查询。
1)当遍历到u时,创建u的并查集U={u},集合U的代表为u
2)对u的每一个子树进行DFS遍历,每搜索完一棵子树,将子树标记为checked,把子树所形成的集合与集合U合并,且集合U的代表仍为u
3)按步骤2)继续遍历u的下一棵子树,直到u的所有子树都遍历完,此时将u设为checked
4)处理所有与u相关的查询(u,v)
若v的状态为checked,则LCA(u,v)=present(v)
若v的状态不是checked,跳过这个查询(u,v),当遍历到节点v时,u必然为checked,同样可以求得lca(u,v)

void tarjan(int u)
{
	father[u]=u;//以u作为父亲建立一个独立并查集
	vis[u]=true;
	for(int i=head[u];i!=-1;i=e[i].next)
	{
		int v=e[i].v;
		if(!vis[v])
		{
			tarjan(v);
			father[v]=u;
		}
	} 
	//深搜寻找叶子节点,再向上回溯
	for(int i=0;i<q[u].size();i++)
		if(vis[q[u][i].first]&&!ans[q[u][i].second])	
		//如果这个点的并查集已被查询完,并且还没有得出答案
			ans[q[u][i].second]=find(q[u][i].first);
		//第i号询问就是q[u][i].first的父亲
		//同时对并查集的祖先进行刷新 
}

时间复杂度分析
Tarjan的时间为N,每个询问O(1),所以总时间为N+Q

完整代码:

#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<algorithm>
#include<cmath>
#include<cstring>
#include<vector>
#define maxn 500000
using namespace std;

int n,m,s,cnt=0,k=0,head[maxn+10];
struct edge{int u,v,next;}e[maxn*2+10];
int ans[maxn+10],father[maxn+10];
bool vis[maxn+10];
typedef pair<int,int> pii;
vector<pii> q[maxn+10];

inline int read()
{
	int ans=0,flag=1;
	char ch=getchar();
	while(ch<'0'||ch>'9')
	{
		if(ch=='-')	flag=-1;
		ch=getchar();
	}	
	while(ch>='0'&&ch<='9')
		ans=ans*10+ch-'0',ch=getchar();
	return ans*flag;
}

void addedge(int u,int v)
{
	e[++k].u=u;
	e[k].v=v;
	e[k].next=head[u];
	head[u]=k;
}

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

void tarjan(int u)
{
	father[u]=u;//以u作为父亲建立一个独立并查集
	vis[u]=true;
	for(int i=head[u];i!=-1;i=e[i].next)
	{
		int v=e[i].v;
		if(!vis[v])
		{
			tarjan(v);
			father[v]=u;
		}
	} 
	//深搜寻找叶子节点,再向上回溯
	for(int i=0;i<q[u].size();i++)
		if(vis[q[u][i].first]&&!ans[q[u][i].second])	
		//如果这个点的并查集已被查询完,并且还没有得出答案
			ans[q[u][i].second]=find(q[u][i].first);
		//第i号询问就是q[u][i].first的父亲
		//同时对并查集的祖先进行刷新 
}

int main()
{
	memset(head,-1,sizeof(head));
	n=read(),m=read(),s=read();
	int u,v;
	for(int i=1;i<=n-1;i++)
	{
		u=read(),v=read();
		addedge(u,v);
		addedge(v,u);
	}
	for(int i=1;i<=m;i++)
	{
		u=read(),v=read();
		q[u].push_back(make_pair(v,i));
		q[v].push_back(make_pair(u,i));
	}//不定长数组存储询问 
	tarjan(s);
	for(int i=1;i<=m;i++)
		printf("%d\n",ans[i]);
 	return 0;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值