LCA,即最近公共祖先,在图论中应用比较广泛。


LCA的定义如下:给定一个有根树,若节点$z$同时是节点$x$和节点$y$的祖先,则称$z$是$x,y$的公共祖先;在$x,y$的所有公共祖先当中深度最大的称为$x,y$的最近公共祖先。下面给出三个最近公共祖先的例子:

显然,从上面的例子可以得出,$LCA(x,y)$即为$x,y$到根节点的路径的交汇点,也是$x$到$y$的路径上深度最小的节点。


求LCA的方法通常有三种:

当然,求LCA还有其它方法,例如树剖等,请读者自行了解,本文主要讲解上面提到的三种方法。


向上标记法

向上标记法是求LCA最直接的方法,直接根据定义来求,单次查询的时间复杂度最坏为$O(n)$(看起来好像还挺快的,不过什么题会只有一次查询呢)

算法流程:

  1. 从x节点向上一直走到根节点,沿途标记经过的节点
  2. 从y节点向上一直走到根节点,当第一次遇到已标记的节点时,该节点就是$LCA(x,y)$

该方法思想是绝对简单的,实现也简单,因此在这里就不给出具体实现了。不过由于其时间复杂度过高,在实际中基本不会应用到,在这里提一下主要还是为讲解Tarjan算法做基础。


树上倍增法

树上倍增法应用非常广泛,读者可以深入地学习。用树上倍增法求LCA的时间复杂度为$O((n+m)logn)$。

树上倍增法用到了二进制拆分的思想。在求LCA时,用$f_{i,j}$存储$i$的第$2^j$辈祖先的节点编号,特别地,若该节点不存在,则将值定为0。根据这个定义,可以推出以下的递推式:

f[i][j]=f[f[i][j-1]][j-1];

这个递推式很好得出,请读者根据定义自己思考。

那么怎样对$f$数组进行递推呢?根据递推式可以发现,一个节点的$f$数组的值要通过它的祖先节点转移过来,因此我们在递推时要采用从根到叶子的遍历。普遍使用的方法有dfs和bfs,在这里我们用bfs来实现。

由于接下来的步骤还需要关系到节点的深度,因此我们定义一个数组$d_i$存储$i$节点的深度,在递推$f$数组的同时递推出来。$d$数组的递推式更简单了,就不多说了吧。

步骤:

  1. 建立一个空队列,并将根节点入队,同时存储根节点的深度
  2. 取出队头,遍历其所有出边。由于存储的时候是按照无向图存储,因此要进行深度判定,对于连接到它父亲节点的边,直接continue即可。记当前路径的另一端节点为$y$,处理出$y$的$d$、$f$两个数组的值,然后将$y$入队。
  3. 重复第2步,直到队列为空

以上部分是树上倍增法的预处理,也是比较通用的对于树上倍增的预处理,时间复杂度$O(nlogn)$。接下来是求LCA的核心部分。

步骤:

  1. 设查询的两个节点分别为$x,y$,令$d_x\leq d_y$(即,若$d_x>d_y$,则交换$x,y$)。
  2. 利用二进制拆分的思想,将$y$上移到与$x$相同的深度。具体来说,就是令$y$依次尝试向上走$2^{logn},...,2^1,2^0$步,并且使$y$的深度不低于$x$。
  3. 若此时$x==y$,则说明当前节点为$LCA(x,y)$,也就是文章开头给出的图中的第三种情况。此时直接输出即可。
  4. 再次利用二进制拆分的思想,将$x,y$同时上移并保持$x!=y$。
  5. 完成第4步后,此时$x$和$y$一定在某个节点的两个子节点上,因此它们的父亲节点就是$LCA(x,y)$。因为一个节点利用二进制拆分进行移动可以到达它的任意一个祖先节点,而在第4步中保持$x!=y$,也就是将$x,y$移动到了它们共同的祖先节点以下的最浅的节点,也就是该节点的某个子节点中。所以,此时$x,y$的父亲节点就是$LCA(x,y)$。

树上倍增法代码:

#include<iostream>
#include<cstdio>
#include<queue>
#include<cmath>
using namespace std;
const int N=6e5;
int n,m,s,t,tot=0,f[N][20],d[N],ver[2*N],Next[2*N],head[N];
queue<int> q;
void add(int x,int y)
{
ver[++tot]=y,Next[tot]=head[x],head[x]=tot;
}//邻接表存边操作。由于只求LCA时不关心边权,因此可以不存边权
void bfs()
{
q.push(s);
d[s]=1;//将根节点入队并标记
while(q.size())
{
int x=q.front();q.pop();//取出队头
for(int i=head[x];i;i=Next[i])
{
int y=ver[i];
if(d[y])
continue;
d[y]=d[x]+1;
f[y][0]=x;//初始化,因为y的父亲节点就是x
for(int j=1;j<=t;j++)
f[y][j]=f[f[y][j-1]][j-1];//递推f数组
q.push(y);
}
}
}
int lca(int x,int y)
{
if(d[x]>d[y])
swap(x,y);
for(int i=t;i>=0;i--)
if(d[f[y][i]]>=d[x])
y=f[y][i];//尝试上移y
if(x==y)
return x;//若相同说明找到了LCA
for(int i=t;i>=0;i--)
if(f[x][i]!=f[y][i])
{
x=f[x][i],y=f[y][i];
}//尝试上移x、y并保持它们不相遇
return f[x][0];//当前节点的父节点即为LCA
}
int main()
{
cin>>n>>m>>s;
t=log2(n)+1;
for(int i=1;i<n;i++)
{
int x,y;
scanf("%d%d",&x,&y);
add(x,y),add(y,x);
}
bfs();
while(m--)
{
int a,b;
scanf("%d%d",&a,&b);
printf("%d\n",lca(a,b));
}
return 0;
}

对于树上倍增法还有一些应用,这里给出一道比较不错的树上倍增法的应用题:

读者可以先尝试解题,实在解不出来再借鉴题解的思路。


Tarjan算法

Tarjan牛逼!

Tarjan算法求LCA的本质是用并查集对向上标记法进行优化,是一种离线算法,时间复杂度$O(n+m)$。

对于并查集的基本操作,不了解的可以点击食用

求LCA的Tarjan算法主体由dfs实现,并用并查集进行优化。对于每个节点,我们增加一个标记:

  • 若该节点没有访问过,则初值为0
  • 若该节点已访问但还没有回溯,则标记为1
  • 若该节点已访问且已回溯,则标记为2

显然,对于当前访问的节点$x$,它到根节点的路径一定都被标记为1。因此对于任意一个与$x$相关的询问,设询问的另一个节点为$y$,则$LCA(x,y)$即为$y$到根节点的路径中第一个,也就是最深的标记为1的节点。

求这个节点的方法可以用并查集优化。当一个节点的标记改为2的同时,将它合并到其父节点的集合当中。显然,此时它的父节点的标记一定为1,并且单独构成一个集合,因为这个父节点还没有进行过回溯操作。

在合并过后,遍历关于当前节点$x$的所有询问,对于任意一个询问,若$y$的标记为2,说明其已经被访问过,并且它的并查集指向的那个节点,也就是$y$到根节点的路径中最深的还没有回溯的节点,一定就是$LCA(x,y)$。

对于询问,我们可以用一个不定长数组存储与每个节点相关的询问,并且每个询问用一个二元组表示,第一维存储该询问的另一个节点,第二维存储该询问输入的次序,以便按顺序输出

这样,Tarjan算法求LCA的步骤就很明了了:

  1. 从根节点开始进行dfs
  2. 将当前节点标记为1
  3. 遍历当前节点的所有出边;若当前边的终点还没有访问过,则访问它,访问过后将该节点合并到当前节点的集合中;
  4. 遍历与当前节点相关的所有询问;若当前询问的另一个节点的标记为2,则该询问的答案即为另一个节点所在集合的代表元素
  5. 将当前节点标记为2

思路清晰之后,实现起来不会很难

Tarjan算法代码:

#include<iostream>
#include<cstdio>
#include<vector>
#include<algorithm>
using namespace std;
const int N=6e5;
int n,m,s,tot=0,fa[N],v[N],ans[N],ver[2*N],Next[2*N],head[N];
vector< pair<int,int> > query[N];
void add(int x,int y)
{
ver[++tot]=y,Next[tot]=head[x],head[x]=tot;
}//邻接表插入操作
int get(int a)
{
return fa[a]==a?a:fa[a]=get(fa[a]);
}//并查集查找操作
void add_query(int x,int y,int id)
{
query[x].push_back(make_pair(y,id));
query[y].push_back(make_pair(x,id));
}//添加询问
void tarjan(int x)
{
v[x]=1;
for(int i=head[x];i;i=Next[i])
{
int y=ver[i];
if(v[y])
continue;//若访问过则不再访问
tarjan(y);
fa[y]=x;//将子节点合并到自己的集合中
}//遍历所有出边
for(int i=0;i<query[x].size();i++)
{
int y=query[x][i].first,id=query[x][i].second;
if(v[y]==2)
ans[id]=get(y);
}//遍历所有相关的询问
v[x]=2;
}
int main()
{
cin>>n>>m>>s;
for(int i=1;i<=n;i++)
fa[i]=i;
for(int i=1;i<n;i++)
{
int x,y;
scanf("%d%d",&x,&y);
add(x,y),add(y,x);
}
for(int i=1;i<=m;i++)
{
int a,b;
scanf("%d%d",&a,&b);
if(a==b)
ans[i]=0;//特判,若两个节点相等直接输出
else
add_query(a,b,i);
}
tarjan(s);
for(int i=1;i<=m;i++)
printf("%d\n",ans[i]);
return 0;
}

习题:


声明:本文部分内容参考lyd的蓝书。


2019.5.14 于厦门外国语学校石狮分校

LCA详解的更多相关文章

  1. POJ 1330 Nearest Common Ancestors (最近公共祖先LCA + 详解博客)

    LCA问题的tarjan解法模板 LCA问题 详细 1.二叉搜索树上找两个节点LCA public int query(Node t, Node u, Node v) { int left = u.v ...

  2. dfs序+RMQ求LCA详解

    首先安利自己倍增求LCA的博客,前置(算不上)知识在此. LCA有3种求法:倍增求lca(上面qwq),树链剖分求lca(什么时候会了树链剖分再说.),还有,标题. 是的你也来和我一起学习这个了qwq ...

  3. 树上倍增求LCA详解

    LCA(least common ancestors)最近公共祖先 指的就是对于一棵有根树,若结点z既是x的祖先,也是y的祖先(不要告诉我你不知道什么是祖先),那么z就是结点x和y的最近公共祖先. 定 ...

  4. trie字典树详解及应用

    原文链接    http://www.cnblogs.com/freewater/archive/2012/09/11/2680480.html Trie树详解及其应用   一.知识简介        ...

  5. [动图演示]Redis 持久化 RDB/AOF 详解与实践

    Redis 是一个开源( BSD 许可)的,内存中的数据结构存储系统,它可以用作数据库.缓存和消息中间件.它支持的数据类型很丰富,如字符串.链表.集 合.以及散列等,并且还支持多种排序功能. 什么叫持 ...

  6. Linq之旅:Linq入门详解(Linq to Objects)

    示例代码下载:Linq之旅:Linq入门详解(Linq to Objects) 本博文详细介绍 .NET 3.5 中引入的重要功能:Language Integrated Query(LINQ,语言集 ...

  7. 架构设计:远程调用服务架构设计及zookeeper技术详解(下篇)

    一.下篇开头的废话 终于开写下篇了,这也是我写远程调用框架的第三篇文章,前两篇都被博客园作为[编辑推荐]的文章,很兴奋哦,嘿嘿~~~~,本人是个很臭美的人,一定得要截图为证: 今天是2014年的第一天 ...

  8. EntityFramework Core 1.1 Add、Attach、Update、Remove方法如何高效使用详解

    前言 我比较喜欢安静,大概和我喜欢研究和琢磨技术原因相关吧,刚好到了元旦节,这几天可以好好学习下EF Core,同时在项目当中用到EF Core,借此机会给予比较深入的理解,这里我们只讲解和EF 6. ...

  9. Java 字符串格式化详解

    Java 字符串格式化详解 版权声明:本文为博主原创文章,未经博主允许不得转载. 微博:厉圣杰 文中如有纰漏,欢迎大家留言指出. 在 Java 的 String 类中,可以使用 format() 方法 ...

随机推荐

  1. layui实现图片上传

    页面代码: <style> .uploadImgBtn2{ width: 120px; height: 92px; cursor: pointer; position: relative; ...

  2. 错误记录:MIME type may not contain reserved characters

    最近遇到个问题,随手记录一下! 新做了一个项目,要通过HTTP请求发送ZIP文件到OSS平台,但上传过程中,总是出现下面错误提示: 初步判定,应该是包冲突原因!于是,分析MIME-TYPE获取源码发现 ...

  3. Docker学习日记-安装Docker

    Docker是什么: 简单理解就是基于go语言开发的开源的应用容器引擎. 对进程进行封装隔离,属于操作系统层面的虚拟化技术. Docker的优势: 1.更高效的利用系统资源 2.更快速的启动时间 3. ...

  4. 【SDOI2010】猪国杀 题解(模拟)

    前言:嗅到了一丝头秃的味道…… ------------------ 题目链接 题目实在太长,变量也很多.建议至少读个三五遍再做题.不要忽略任何细节,不要想当然.(因为真正玩三国杀肯定不像猪一样出牌啊 ...

  5. 【BZOJ2724】蒲公英 题解(分块+区间众数)

    题目链接 题目大意:给定一段长度为$n$的序列和$m$次询问,每次询问区间$[l,r]$内的最小的众数.$n\leq 40000,a_i\leq 10^9$ --------------------- ...

  6. 8月1日起全部无版号游戏下架,ios手游想上架看这里!

      在苹果至中国游戏开发者的邮件中声明:如果开发者不能在7月31日前提交版号及相关文件,付费游戏将不可以在中国AppStore供应.也就是说:   从8月1日开始,苹果将正式下架全部.所有的ios付费 ...

  7. Eclipse RCP:多平台部署

    1 问题 在使用Eclipse RCP IDE进行开发时,它自带的PDE(插件开发环境)工具仅能够导出相同平台的部署包,比如win32的仅能导出win32的,linux64仅能够导出linux64的. ...

  8. 90行代码让微信地球转起来,太酷了!(python实现)

    1.微信地球 手机重启后打开微信的一瞬间,会看到一幅有名的图片,上面站着一个 张小龙 . 很多人学习python,不知道从何学起.很多人学习python,掌握了基本语法过后,不知道在哪里寻找案例上手. ...

  9. 使用Spock 单元测试

    一.什么是Spock Spock 是一个测试框架,甚至可以说是一门语言他是基于Groovy开发的.它的语法完全遵循 BDD(行为驱动开发) 风格的结构.它是基于 Junit test runner 上 ...

  10. Consul服务治理发现学习记录

    Consul 简介 Consul是一个服务网格(微服务间的 TCP/IP,负责服务之间的网络调用.限流.熔断和监控)解决方案,它是一个一个分布式的,高度可用的系统,而且开发使用都很简便.它提供了一个功 ...