LCA详解

C++ 专栏收录该内容
14 篇文章 0 订阅

LCA问题(least Common Ancestors,最近公共祖先问题),是指给定一棵有根树T,给出若干个查询LCA(u,v)(通常查询数量较大),每次求树T中两个顶点u和v的最近公共祖先,即找到一个节点,同时是u和v的祖先,并且深度尽可能的大(尽可能远离树根).

LCA问题的解法有很多,有一般解法,基于tarjan的解法,跳表解法以及RMQ和LCA互相转化的解法。下面我们依次介绍一下:

(一):一般解法
根据树的结构,树中除根节点外的每个节点有且只有一个父节点,所以我们可以记录好每一个节点的父节点,这样我们能够根据父节点的父节点,一次来遍历到每个节点的所有祖先节点(祖先节点就是节点的父节点,节点父节点的父节点,依次类推)。

然后我们要查询两个节点的最近公共祖先,只需要找到两个节点往上找时,第一个相同的祖先。

为每个节点标记好它的父节点只需要维持一个数组_father[n],然后在深度遍历的时候存在来就好。

为了方便找到第一个祖先,我们可以维持一个数组depth[n],因为它俩的祖先一定是深度相同的节点(同一个节点嘛,所以深度肯定相同),所以我们可以先将深度较大的节点u向上查找,找到它的某个祖先s,使得这个s节点的深度和另外一个节点v的深度一样,然后判断s和v是否相等,如果不相等就俩者同时向上查找祖先,这样能够保证俩这的深度一样,直到俩个节点是同一个节点,就说明找到了共同的祖先。

C++代码实现如下:

int flag[105];                                                          //深度遍历时标记节点是否访问过
int _father[105];                                                       //标记每个节点的父节点
int depth[105];                                                         //标记每个节点的深度
/*
	ntree:树的表示,二维数组
	start:深度遍历的起始节点
	l:树中节点的个数
	deep:当前遍历的节点在树中的深度
*/
void dfs(int** ntree,int start,int l,int deep){
	flag[start]=1;                                                      //标记为访问过
	depth[start]=deep;                                                  //标记好深度
 
	for(int i=0;i<l;i++){                                               //先到达一定的深度再说
		if(ntree[start][i]&&flag[i]==0){
			_father[i]=start;                                           //标记好关系
			dfs(ntree,i,l,deep+1);
		}
	}
	return;
}
int getSplitNode(int** matrix,int l, int indexA, int indexB) {
	memset(flag,0,sizeof(flag));
	memset(_father,0,sizeof(_father));
	_father[0]=0;

	int i,j;

	dfs(matrix,0,l,0);                                                 //调用dfs进行处理

	while(depth[indexA]>depth[indexB])                                 //如果indexA的深度更大,则向上找祖先,直到找到的祖先深度和节点B一样
		indexA=_father[indexA];
	while(depth[indexB]>depth[indexA])                                 //和上面一样,只是情况不同
	<span style="white-space:pre">	</span>indexB=_father[indexB];

	while(indexA!=indexB){                                             //在保证深度相同的情况下,往上查找,直到俩者是同一节点
		indexA=_father[indexA];
		indexB=_father[indexB];
	}
	
	return indexA;                                                     //返回最近祖先节点
}

(二):基于tarjan算法的解法
其实这个解法与tarjan算法的联系不是很大,只是有点相似而已,并没有用到tarjan算法。当我们深度遍历一棵树时,我们选择后序遍历它,即左右根的形式来遍历一棵树。假设我们当前需要查询节点u和节点v的公共祖先,我们在后序遍历的过程中,假设现在我们已经访问到节点u,对于节点v只有两种形式,一是被访问过,二是还没被访问。


如果节点v已经被访问过,则根据后序遍历的特点(左右根),节点u和v的最近公共祖先一定是在由v所在的集合S和节点v这个集合W(这个集合中只要u)的公共祖先。而我们根据后序遍历左右根的特点,以上两个集合S和W的祖先一定是集合W的祖先。根据后序左右根特点,假设v是右子树,u是根,那么两个集合的祖先显然集合S的祖先就是u(根);假设u是在右子树中,v是在左子树中,因为左右子树的最近公共祖先就是根,而根又是左子树集合的公共祖先,所以两个集合的祖先还是集合的祖先。假设v是左子树,u是根,则公共祖先还是根,也就是v所在集合的祖先(以上的左子树,根,右子树都是相对的,即相对于节点v所在的集合S来看的,这样做的目的就是直观阐述,当节点v被访问过的时候,节点u和节点v的最近祖先就是节点v所在集合S的祖先).


举个例子,如下图:当u=1,v=4时,在后序遍历的过程中,访问u时,v已经被访问过了,已访问节点集合{4,7,5}的祖先节点就是1,俩者相等,所以就是集合{4,7,5}的祖先;再当u=7,v=4时,访问7时4已经被访问,而7相对于已访问集合{4}是右子树,所以二者的祖先根就是集合{4}的祖先,也就是1.总之不管哪种情况,都是已访问几点集合的祖先。


如果节点v没有被访问过,那我们就不用做处理,等到下次访问到节点v时,节点u已经被处理了,按上面的方式进行理。


在实际实现的过程中,我们需要记录集合的祖先。对于集合,我们可以用并查集来实现,对于祖先,我们可以维持一个数组ancestor,来记录每个节点的祖先节点。当我们要查询一个集合的祖先节点时,只需要查询这个集合的代表元素r的ancestor值。比如,我们要查询节点4所在的集合的祖先节点时,只需要先找到4所在集合的代表r,然后找到ancstor[r]的值就是这个集合的祖先值。
 

C++代码实现如下:

//先实现并查集
int n;
int father[105];                            //每个节点的父节点
int rnk[105];                               //树中节点的个数
int ancestor[105];                          //已访问节点集合的祖先

void initSet(){
	for(int i=0;i<n;i++){
		father[i]=i;                      //初始化时,所在子树的祖先就是自己
		rnk[i]=1;                         //所在树的深度为0
	}
}

int findSet(int x){
	if(x!=father[x])
		father[x]=findSet(father[x]);     //压缩式的查找,在查找过程中更新每个节点的祖先
	return father[x];
}

void unionSet(int x,int y){               //合并子树,把节点数小的树合并到节点数大的树
	x=findSet(x);
	y=findSet(y);

	if(x==y)
		return;

	if(rnk[x]>=rnk[y]){
		father[y]=x;
		rnk[x]+=rnk[y];
	}
	else{
		father[x]=y;
		rnk[y]+=rnk[x];
	}
}
	
int flag[105];                             //记录点是否为访问过
vector<int> tree[105];                     //树的表示
vector<int> query[105];                    //查询的表示
void tarjan(int u){                        //访问到集合u时
    for(int i=0;i<tree[u].size();i++){
        int v=tree[u][i];                  //假设这个节点是v
	tarjan(v);
        unionSet(u,v);                     //将子节点和根节点合并,并查集的作用只是代表一个集合,仅仅当做一个集合使用
	ancestor[findSet(u)]=u;            //合并后的集合的祖先为u,只要标记这个集合的代表元素的祖先为x就行,这个集合
					           //内的其他元素能够通过findSet来找到代表,再利用代表找到祖先
    }
    flag[u]=1;
	
    for(i=0;i<query[u].size();i++){
        if(flag[query[u][i]])               //如果另外一个节点已经被访问过,则输出另外一个节点所在集合的祖先
	    cout<<u<<"和"<<query[u][i]<<"的最近公共祖先为:"<<ancestor[findSet(query[u][i])]<<endl;  //找到节点query[u][i]所在集合的代表的祖先,
								<span style="white-space:pre">		</span>//也就是这个集合的祖先,只是用代表的ancestor值标记一下而已 
    }
}


(三):跳表解法
跳表解法和普通解法其实很像,对于节点u和节点v,也是先通过father数组让u和v的深度变为一样,然后再向上跳,直到向上取到的节点值一样(即找到根节点).但是它跳的速度更快,一般解法跳需要的时间为o(n)(因为普通解法是一个一个往上跳);而跳表解法跳的时间复杂度是o(logn),因为它跳的时候是按2的次幂跳,下面详细为大家解答。

首先,我们需要记录好每一个节点的深度(根节点深度为0),然后我们再从任意两个节点的最近祖先必定深度是一样(同一个点,深度肯定一样)的这个点入手。

假设,我们要查询的两个节点是节点u和节点v,其中u的深度为p,v的深度为q。首先,我们将二者的中较深的进行调整,向上跳,跳到同一层。然后二者同时向上跳,直到跳到同一层为止。但是这个跳的方法和以往的不一样,它是以按2的次幂的形式跳,也就是跳2^0,2^1,2^2等等层。因为对于任意一个整数n,它都可以找到唯一的一组x1,x2,x3,.....

来满足一下等式:

n=2^x1+2^x2+2^x3+......

所以对于任意一个整数,都能通过按2的次幂跳来实现,这个时候规定从高次幂到低次幂的跳。举个例子,对于6,我们知道它等于6=2^1+2^2,当跳的时候,是先跳2^2,再跳2^1。另一方面,我们知道每一个节点的层数,所以我们就能通过logn来求出那个2的次幂的上限。但我们可能会考虑到一个问题,那就是如果当节点u和节点v已经再同一层的时候,可能它们先跳一次高次幂的层数时就已经找到了公共祖先,但这个却不是最近的公共祖先。对于这个问题我们这样解决,我们不去找公共祖先,而是去找最近公共祖先下一层的那个节点,也就是最近公共祖先的子节点,这样就能避免一次跳过头,通过判断条件就能实现。

为了实现在跳了2的多少次幂后能够很快的定位到节点,我们为每个节点维持一个数组anc[i][j],表示节点i跳了2^j次幂层之后到达的节点。如下图,anc[7][1]就是节点1

对于anc[i][j]数组的初始化问题:
1.当j=0时,显然anc[i][j]就是节点i的父节点,就像普通解法的father数组意义一样
2.当j>0时,我们不能直接求出跳多层的值,然后我们可以转化,因为anc[i][j]=i+2^j=i+2^(j-1)+2^(j-1)=
(i+2^(j-1))+2^(j-1)=anc[anc[i][j-1]][j-1],所以就能这样地退出来。也就是anc[i][j]=anc[anc[i][j-1]][j-1]

所以具体实现代码如下:
#include<iostream>
#include<vector>
#include"string.h"
#include<math.h>
using namespace std;
int n;                                         //树中的节点
vector<int> tree[105];
int anc[105][7];                               //支持跳表的数组
int depth[105];                                //记录好每一个节点在树中的深度
void inputTree()                               //输入树
{
	cin>>n;                                    //树的顶点数
	for(int i=0;i<n;i++)                       //初始化树,顶点编号从0开始
	tree[i].clear();

	for(i=1;i<n;i++)						   //输入n-1条树边
	{
		int x, y; 
		cin>>x>>y;							   //x->y有一条边
		tree[x].push_back(y); 
	}
}

//深度遍历,为每个节点计算深度和其父节点
void dfs(int u,int deep){					   //初始化,利用深度遍历
	depth[u]=deep;                             //标记好深度
	for(int i=0;i<tree[u].size();i++){
		anc[tree[u][i]][0]=u;                  //标记好父节点
		dfs(tree[u][i],deep+1);
	}
	return;
}

//计算anc[][]数组的函数
void calAnc(){                                 //然后就是计算各种anc的关系
	int max=int(log(n)/log(2));                       //最大的可能层数

	for(int j=1;j<=max;j++){                   //根据anc[i][j]=anc[anc[i][j-1]][j-1]可知是渐进的,所以得一层一层来
		for(int i=0;i<n;i++){                  //对于每一个节点,都求
			if(anc[i][j-1]!=-1){                //即如果没有越界
				anc[i][j]=anc[anc[i][j-1]][j-1];//所以这个数组初始化是得全部赋值为-1
			}
		}
	}
		}

//初始化函数
void init(){
	memset(anc,-1,sizeof(anc));
	dfs(0,0);
	calAnc();

}
		
//求2^i的函数
int getTwo(int i){
	int res=1;

	while(i>0){
		res*=2;
		i--;
	}
	return res;
}
		
//查询函数
int query(int u,int v){
	if(depth[u]<depth[v]){                      //令u为较深的,然后u向上调整到同一层
		int tmp=u;
		u=v;
		v=tmp;
	}

	int i;

	int logs=int(log(depth[u])/log(2));

	//将u和v跳到同一层
	for(i=logs;i>=0;i--){
		if(depth[u]-getTwo(i)>=depth[v])
			u=anc[u][i];
	}
	if(u==v)                                    //相等则直接返回
		return u;

	//公共跳到最近公共祖先的下面
	for(i=logs;i>=0;i--){                        //向上找,找到最后一个u!=v的,这时的u和v,就是原始的u和v的最近祖先的子节点
		if(anc[u][i]!=-1&&anc[u][i]!=anc[v][i]){
			u=anc[u][i];
			v=anc[v][i];
		}

	}

	return anc[u][0];                           //则此时u一个是最近公共节点的子节点
}

int main()
{
	inputTree();								//输入树
	init();					//初始化
	int m; scanf("%d", &m); //查询个数
	while (m--)
	{
		int u, v; scanf("%d%d", &u, &v);//查询u和v的LCA
		printf("%d和%d的最近公共祖先为:%d\n", u, v, query(u, v));
	}
	return 0;
}

(四):由于关于RMQ的解法过于复杂,将在以后为大家解答。


 

  • 27
    点赞
  • 5
    评论
  • 82
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2021 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值