LCA(学习笔记)

LCA指的是最近公共祖先,更具体的意义就不讲了.

求解LCA的方法有很多,这里讲解向上标记法,树上倍增法,tarjan求LCA.

向上标记法

1 从x向上走到根节点,并标记所有经过的节点.

2 从y向上走到根节点,第一次遇到的已标记的节点就是x和y的LCA.

但不难发现,这个算法只适用于求一个点和一些点之间的LCA,不支持询问任意两个点的LCA.而且对于每个询问时间复杂度最坏为O(N).

这种不太好的方法,本蒻就不贴代码了(懒)

树上倍增法

首先我们考虑对于两个点,求它们的LCA,首先最暴力的方法就是两个点同时一步一步往上跳,直到跳到同一个点(当然,在此之前先要跑一遍DFS预处理出每个点的深度,不然哪里来的"树上")(当然我们也可以BFS求)

这样的暴力正确性是显然地,但慢就慢在它(像蜗牛一样)一步一步往上爬,而树上倍增法恰好弥补了这个不足,它能够一次向上跳多步,从而极大地提高了时间效率.

预处理

\(f[x,k]\)表示\(x\)\(2^k\)辈祖先,即从\(x\)向根节点走\(2^k\)步到达的节点.显然,\(f[x][0]\)就是\(x\)的父节点.

因为\(x\)向根节点走\(2^k\)步等价于向根节点先走\(2^{k-1}\)步,再走\(2^{k-1}\)步,所以有\(f[x][k]=f[f[x][k-1]][k-1]\).(注意,这里是整个算法的核心思想,也是倍增的核心思想,一定要理解)

我们先对树进行DFS遍历,得到每个节点的深度,即得到\(f[x][0]\),再计算\(f\)数组的所有值.

void deal_first(int u,int father){
    deep[u]=deep[father]+1;
    for(int i=0;i<=19;i++)
		f[u][i+1]=f[f[u][i]][i];
//2^20次方已经足够满足很多题目的数据了,int才2^31
//这个核心思想只稍微变了一下,认出来了吧
    for(int i=first[u];i;i=next[i]){
		int v=to[i];
		if(v==father)continue;
		f[v][0]=u;
		deal_first(v,u);
    }
//枚举与当前遍历的点u所有相邻的点
//因为是DFS遍历,所以u是v的父亲结点
//因为u的父亲节点也与u相邻,注意忽略掉
}

查询

int LCA(int x,int y){
    if(deep[x]<deep[y])swap(x,y);
//让x点的深度较大,方便后面的操作
//用数学语言来讲就是不妨设deep[x]>deep[y];
    for(int i=20;i>=0;i--){
		if(deep[f[x][i]]>=deep[y])
    		x=f[x][i];
		if(x==y)return x;
    }
//先将x,y跳到同一个深度
//注意这里一定要倒着for
//特判如果此时x=y,LCA就是x节点.
    for(int i=20;i>=0;i--)
		if(f[x][i]!=f[y][i]){
	    	x=f[x][i];
	    	y=f[y][i];
		}
//这里也要倒着for,显然是提高时间效率
//一次跳得越多越好嘛
//因为可能无法满足跳一次就找到了LCA,所以就不同才跳
//又因为是不同才跳,所以要倒着for(我的理解)
    return f[x][0];
//因为我们之前是不同才往上跳
//所以最后x节点的父亲节点就是LCA
}

tarjan求LCA

tarjan求LCA本质上就是向上标价法的并查集优化,知道我为什么上面要简述一个没用的方法的良苦用心了吧.

这个方法最大的特点是,它将询问离线,统一计算.记得我上面评价向上标记法"不难发现,这个算法只适用于求一个点和一些点之间的LCA".tarjan求LCA就是把询问离线后,求出一个点到询问的另一些点的LCA

在深度优先遍历(tarjan其实就是在DFS的同时记录一些有用的信息)的时候,

我们把还没有访问的节点标记为0;

正在访问的节点(访问过但是还没有访问完:假设现在访问x节点,则x以及x的祖先节点都是正在访问的节点)标记为1;

已经全部访问完的节点标记为2;

再再再回顾一下向上标记法:

1 从x向上走到根节点,并标记所有经过的节点.

2 从y向上走到根节点,第一次遇到的已标记的节点就是x和y的LCA.

对于正在访问的节点x,它到根节点的路径上的点都是1号点(上面说了正在访问的x和x的祖先都会是1号点啊).如果节点y是已经访问完毕的节点(即标记为2的点),则LCA(x,y)就是从y向上走到根,第一个遇到的标记为1的节点.(我刚开始也不懂这里,直到我回顾了向上标记法...)

显然算法到这里讲了这么多,但tarjan求LCA相比向上标记法还是没有任何优化.我们需要借助路径压缩的并查集来优化.

对于一个已经访问完的节点y(即标记为2),我们可以把它(所在集合)合并到它的父节点(所在集合).(合并时,它的父亲结点标记一定是1).

合并之后,我们只需要get节点y(所在集合)的代表元素,就相当于从节点y开始一直向上走,直到一个标记为1的节点.(就是说按照上述把y(标记为2)合并到它的父节点(此时标记为1)后,如果父节点也访问完毕,则把y的父节点也标记为2,继续向上走,直到一个合并完之后仍标记为1的节点)

这个节点在y节点get向上合并之后仍被标记为1,说明它的另一颗子树中一定包括了x节点,故这个节点就是LCA(x,y);

int n,m,s,tot;
int v[500005],fa[500005],d[500005];
int ans[500005];
int to[1000005],nxt[1000005],head[1000005];
vector<int> query[500005],query_id[500005];
void add(int x,int y){
    to[++tot]=y;
    nxt[tot]=head[x];
    head[x]=tot;
}//数组模拟链式前向星存图
void add_query(int x,int y,int id){
    query[x].push_back(y);
    query_id[x].push_back(id);
    query[y].push_back(x);
    query_id[y].push_back(id);
}
//存下每一个询问,两个节点都存一次
int get(int x){
    if(x==fa[x])return x;
    return fa[x]=get(fa[x]);
}//并查集的路径压缩操作
void tarjan(int x){
    v[x]=1;
//x为正在访问的点,标记为1
    for(int i=head[x];i;i=nxt[i]){
		int y=to[i];
		if(v[y])continue;
		tarjan(y);
		fa[y]=x;
    }
//扫描与x点相连的每一条边
    for(int i=0;i<query[x].size();i++){
		int y=query[x][i],id=query_id[x][i];
		if(v[y]==2){
	    	int lca=get(y);
	    	ans[id]=lca;
		}	
    }
    v[x]=2;
//x已经全部处理完了,标记为2
}
int main(){
    n=read();m=read();s=read();
//s表示根节点
    for(int i=1;i<=n;i++)fa[i]=i;
//一定要记得并查集初始化
    for(int i=1;i<=n-1;i++){
		int x,y;x=read();y=read();
		add(x,y);add(y,x);
    }
//存边
    for(int i=1;i<=m;i++){
		int x,y;x=read();y=read();
		if(x==y)ans[i]=x;
		else add_query(x,y,i);
    }
//把每个问题都放入vector中,转为离线求解LCA
//为了最后输出,别忘了把问题编号
    tarjan(s);
//从根节点开始tarjan,如果没有根节点就任意一个点
    for(int i=1;i<=m;i++)
		printf("%d\n",ans[i]);
    return 0;
}

posted on 2018-12-22 17:08  PPXppx  阅读(143)  评论(0编辑  收藏  举报