【最近公共祖先】 LCA 详解(倍增,trajan算法)


一、简单介绍 (Lowest Common Ancestors)

在一棵二叉搜索树中,任意两个结点的最近公共祖先,是指以这两个结点为后代的深度最大的那个结点。需要通过比较两个结点的值,来判断它们在二叉搜索树中的位置关系。如果两个结点的值都小于当前结点的值,那么它们一定在当前结点的左子树中;如果两个结点的值都大于当前结点的值,那么它们一定在当前结点的右子树中;如果一个结点的值小于当前结点的值,另一个结点的值大于当前结点的值,那么当前结点就是它们的最近公共祖先。如果两个结点中有一个等于当前结点,那么当前结点也是它们的最近公共祖先
对于有根树 T T T 的两个结点 u 、 v u 、v uv最近公共祖先 LCA (T,u,v) 表示一个结点 x x x,满足 x x x u u u v v v 的祖先且 x x x深度尽可能大。在这里,一个节点也可以是它自己的祖先

蒟蒻第一次画树,希望谅解

转载自 百度 最近公共祖先


二、代码实现

离线算法(离线)

①.插入函数

邻接表存读入的一棵,代码实现较好理解,解决代码:

struct Edge {
	int v,n;
}edge[N<<1];     //创建邻接表
int head[N],idx;	//邻接表存储树

void connec(int x,int y) {
	edge[++idx].v=y;
	edge[idx].nxt=head[x];
	head[x]=idx;1
}

②.并查集查找函数

这里也不用多讲,就是普通并查集查找的使用:
如需深入了解,可以参考本博客用户的并查集文章…

int fa[N];		    //并查集数组
for(int i=1;i<=n;i++) fa[i]=i;    //并查集初始化 

int findp(int x) {  //并查集查找根节点
	if(x==fa[x]) return x;
	return fa[x]=findp(fa[x]); 
}

③.trajan 算法

  • v i s [ i ] : i 号节点是否被访问过。 vis[i] :i 号节点是否被访问过。 vis[i]i号节点是否被访问过。
  • f a [ r ] : r 的祖先节点。 fa[r] :r 的祖先节点。 fa[r]r的祖先节点。
  • r e s [ i ] :第 i 个询问的答案。 res[i]:第 i 个询问的答案。 res[i]:第i个询问的答案。
  • 看回上面开头的图,打个比例,如果我们要寻找 A , C A ,C AC的最近公共祖先,
    按照 d f s dfs dfs 的先序遍历易知 A A A 肯定是先被查询标记到的,并且 f a [ A ] fa[A] fa[A] 在返回的过程中,指向了父亲节点的序号。
  • 提前进行对于每个询问点的处理。
typedef pair<int,int>pll;  //第一个元素为节点编号,第二个元素为询问编号
vector <pll> vec[N];      //存储下来所有的询问 vec[x]:代表和x节点有关的询问,即第vec[x].second

for(int i=1;i<=m;i++) {        //把 m次询问存下来 
		int x,y;
		scanf("%d%d",&x,&y);
		vec[x].push_back({y,i});
		vec[y].push_back({x,i});
	}
  • 接下来,我们访问到 C C C 节点,这时 A , C A,C A,C 两个节点都被访问过了,我们只需找到关于询问 C C C 节点的所有询问, f i n d p ( y ) findp(y) findp(y) 查询该节点(例子即 A A A )的祖先,即 A , C A,C A,C 的最近公共祖先。
  • 注意,由于 r o o t root root 的左节点还未返回,所以 f a r o o t l s o n = r o o t l s o n fa_{root_lson}=root_lson farootlson=rootlson 即自己还指向自己,自己为自己的父亲。
void trajan(int u) {
	vis[u]=1;      //标记访问过 
	for(int i=head[u];i;i=edge[i].nxt) {
		int j=edge[i].v;
		if(!vis[j]) {
			trajan(j);
			fa[j]=u; 
		} 
	} 
	for(int i=0;i<vec[u].size();i++) {
		int y=vec[u][i].first;
		int id_=vec[u][i].second;
		if(vis[y]) res[id_]=findp(y);
	}
}

Code

#include<bits/stdc++.h>
using namespace std;
const int N=5e5+10;
typedef pair<int,int>pll;    //第一个元素为节点编号,第二个元素为询问编号
struct Edge {
	int v,n;
}edge[N<<1];
int head[N],idx;	//邻接表存储树
int fa[N];		    //并查集数组
int n,m,root;      	//分别表示节点个数,询问个数,根节点标号
vector <pll> vec[N];      //存储下来所有的询问 vec[x]:代表和x节点有关的询问,即第vec[x].second
int res[N];         //结果,把第i次询问的结果放进res[i]里面
bool vis[N];        //标记数组
void connec(int x,int y) {
	edge[++idx].v=y;
	edge[idx].nxt=head[x];
	head[x]=idx;
}
int findp(int x) {  //并查集查找根节点
	if(x==fa[x]) return x;
	return fa[x]=findp(fa[x]); 
}
void trajan(int u) {
	vis[u]=1;      //标记访问过 
	for(int i=head[u];i;i=edge[i].nxt) {
		int j=edge[i].v;
		if(!vis[j]) {
			trajan(j);
			fa[j]=u; 
		} 
	} 
	for(int i=0;i<vec[u].size();i++) {
		int y=vec[u][i].first;
		int id_=vec[u][i].second;
		if(vis[y]) res[id_]=findp(y);
	}
}
int main() {
	scanf("%d%d%d",&n,&m,&root);
	for(int i=1;i<n;i++) {    //读入一棵树,用邻接表储存起来 
		int x,y;
		scanf("%d%d",&x,&y);
		connec(x,y);
		connec(y,x);
	} 
	for(int i=1;i<=n;i++) fa[i]=i;    //并查集初始化 
	for(int i=1;i<=m;i++) {        //把 m次询问存下来 
		int x,y;
		scanf("%d%d",&x,&y);
		vec[x].push_back({y,i});
		vec[y].push_back({x,i});
	}
	trajan(root);      //调用trajan算法 
	for(int i=1;i<=m;i++) 
		printf("%d\n",res[i]);
	return 0;
}

算法小总结

tarjan 的算法是离线算法,其中用到并查集,关键是 dfs 的主循环比较重要。
时间复杂度 :时间复杂度为 O(n+Q) n n n数据规模 Q Q Q询问个数
由于用了 vector 动态数组,所以在时间运行效率上会一点,但可以优化空间相较于下面讲述的倍增算法,相当于空间换时间


倍增法(在线)

I.插入函数

无论如何,我们还是要用到邻接表,废话不多说:

void connec(int x,int y) {
	edge[++idx].v=y;
	edge[idx].nxt=head[x];
	head[x]=idx;1
}

II.dfs 函数

d e p [ u ] :点前节点 u 到树根节点的深度(距离)。 dep[u] :点前节点 u 到树根节点的深度(距离)。 dep[u]:点前节点u到树根节点的深度(距离)。
f [ u ] [ k ] :节点 u 向根节点跳 1 k 个节点(保持在树的范围之内) . f[u][k] :节点 u 向根节点跳 1^k 个节点(保持在树的范围之内). f[u][k]:节点u向根节点跳1k个节点(保持在树的范围之内).

  • 注意:在此的跳跃 f o r for for从小到大。
void dfs(int u,int fa) {
	dep[u]=dep[fa]+1;
	for(int i=head[u];i;i=edge[i].nxt) {    //遍历节点u的所有孩子 
		int j=edge[i].v;
		if(j==fa) continue;
		f[j][0]=u;
		for(int k=1;(1<<k)<=dep[u];k++)
			f[j][k]=f[f[j][k-1]][k-1];      //计算f数组 它最多到跟节点 
		dfs(j,u);     //继续往下搜 
	}
}

III. LCA查找函数

①.为了 x x x y y y 的跳跃距可以同步跳到他们的最近公共祖先,我们先让两个节点跳到同样的深度,程序为了实现简便,使 x x x 号节点在 y y y 号节点的深度之下。

if(dep[x]<dep[y]) swap(x,y);

②.如果 x = y x=y x=y , 所以 y y y x x x 的祖先,直接返回 y y y

if(x==y) return y;

③.否则 x x x y y y 跳到其最近公共祖先的下一层。

if(f[x][i]!=f[y][i]) {     
	x=f[x][i];
	y=f[y][i];
}

④.最后只用再跳一步即 2 0 2^0 20 就到它们的最近公共祖先。

return f[x][0]; 
  • 注意:在此的跳跃 f o r for for 从大到小。
  • 分析原因:我们此处的原理类似于二进制(蒟蒻的理解
  • 9 9 9 的二进制为 ( 1001 ) B (1001)_B (1001)B ,如果我们从小到大选择,则会变成 0111 0111 0111,值为 7 7 7,最后导致错误。
  • 同样的我们跳 9 9 9 个节点,先跳 8 8 8 个,再跳 1 1 1 个。
int lca(int x,int y) {
	if(dep[x]<dep[y]) swap(x,y);    //让x成为深度最大的那个节点 
	for(int i=19;i>=0;i--)
		if(dep[f[x][i]]>=dep[y]) x=f[x][i];      //用倍增让x跳到和y同一层 
	if(x==y) return y;
	for(int i=19;i>=0;i--) {       //如果x跳过头了,就换一个小的i重跳 
		if(f[x][i]!=f[y][i]) {     //目标x和y跳到其最近公共祖先的下一层 
			x=f[x][i];
			y=f[y][i];
		}
	} 
	return f[x][0];       //返回答案 
}

Code 2

#include<bits/stdc++.h>
using namespace std;
const int N=5e5+10;
struct Edge {
	int v,nxt;
}edge[N<<1];
int n,m,x,y,root;       //分别表示节点个数,询问个数,根节点编号 
int head[N],idx;        //邻接表储存数 
int f[N][20]; 			//f(i,j)号节点往上跳2^j步的节点编号 
int dep[N];    			//节点i的深度 规定根节点的深度为 1 
void connec(int x,int y) {
	edge[++idx].v=y;
	edge[idx].nxt=head[x];
	head[x]=idx;
}
void dfs(int u,int fa) {
	dep[u]=dep[fa]+1;
	for(int i=head[u];i;i=edge[i].nxt) {    //遍历节点u的所有孩子 
		int j=edge[i].v;
		if(j==fa) continue;
		f[j][0]=u;
		for(int k=1;(1<<k)<=dep[u];k++)
			f[j][k]=f[f[j][k-1]][k-1];      //计算f数组  它最多到跟节点 
		dfs(j,u);     //继续往下搜 
	}
}
int lca(int x,int y) {
	if(dep[x]<dep[y]) swap(x,y);    //让x成为深度最大的那个节点 
	for(int i=19;i>=0;i--)
		if(dep[f[x][i]]>=dep[y]) x=f[x][i];      //用倍增让x跳到和y同一层 
	if(x==y) return y;
	for(int i=19;i>=0;i--) {       //如果x跳过头了,就换一个小的i重跳 
		if(f[x][i]!=f[y][i]) {     //目标x和y跳到其最近公共祖先的下一层 
			x=f[x][i];
			y=f[y][i];
		}
	} 
	return f[x][0];       //返回答案 
}
int main() {
	scanf("%d%d%d",&n,&m,&root);
	for(int i=1;i<n;i++) {     //读入一棵树,用邻接表储存起来 
		scanf("%d%d",&x,&y);
		connec(x,y);
		connec(y,x);
	}
	dfs(root,0);       //计算每个节点的深度 
	for(int i=1;i<=m;i++) {
		scanf("%d%d",&x,&y);
		int tmp=lca(x,y);     //计算两个节点的lca 
		printf("%d\n",tmp);
	}
	return 0;
} 

算法小总结

倍增算法是在线算法
时间复杂度 :时间复杂度为 O(nlogn) n n n数据规模
相较于刚刚所说的的trajan 算法,当然在运行效率上有很大优势。
建议多使用倍增算法,当忘了怎么打或爆空间下,才退而使用 trajan 算法。


总结

用途:

一、两个树上节点最近公共祖先
二、计算一个树上两个节点( u ⇒ v u \Rightarrow v uv)之间的最短距离
三、判断在树上是否能通过一条简单路径(即在树上找一条路径不重复走一条边),使其经过给定点集中的所有点
四、再一棵带权树上,通过枚举中间公共节点,找出两点之间的路径极(最大、小)权值和

题库

此处有配套练习,可以先浅浅练练 ,建议可以参考一下我的题单 洛谷【最近公共祖先】 ID:970993


后记

如有侵权,请联系一下我,说明情况,如属实,我会立即撤回文章!