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. Python os.closerange() 方法

    概述 os.closerange() 方法用于关闭所有文件描述符 fd,从 fd_low (包含) 到 fd_high (不包含), 错误会忽略.高佣联盟 www.cgewang.com 语法 clo ...

  2. PHP fileinode() 函数

    定义和用法 fileinode() 函数返回指定文件的 inode 编号. 如果成功,该函数返回指定文件的 inode 编号.如果失败,则返回 FALSE. 语法 fileinode(filename ...

  3. NOI On Line 提高组题解

    (话说其实我想填的是去年CSP的坑...但是貌似有一道题我还不会写咕咕咕... 先写一下这一次的题解吧. T1:序列.题意省略. 两种操作.这种题要先分析部分分 给出了全部都是2操作的子任务. 发现A ...

  4. 畅购商城(四):Lua、OpenResty、Canal实现广告缓存与同步

    好好学习,天天向上 本文已收录至我的Github仓库DayDayUP:github.com/RobodLee/DayDayUP,欢迎Star,更多文章请前往:目录导航 畅购商城(一):环境搭建 畅购商 ...

  5. MyBatis-Plus使用(2)-CRUD接口

    参考文档:https://mybatis.plus/guide/crud-interface.html MyBatis-Plus自带的CRUD方法分为Mapper层和Service层,大多数功能是重叠 ...

  6. ios textView跟随键盘的移动

    实现效果: textview 能够跟随键盘的移动而移动 效果图如下: 下边贴上主要的代码: 1.创建textview @interface ViewController ()<UITextVie ...

  7. day23:单继承&多继承&菱形继承&__init__魔术方法

    1.单继承 1.1 关于继承的一些基本概念 1.2 子类可以调用父类的公有成员 1.3 子类无法调用父类的私有成员 1.4 子类可以改写父类的方法 2.多继承 2.1 多继承的基本语法 2.2 sup ...

  8. CNN 小结

    CNN 小结 目录 CNN特征提取过程(卷积核描述的是特征信息, 此特征可能就是原图像中的某些像素, 但是卷积核并不找相似的地方在原始图像的哪里, 所以需要将卷积核不断地滑动, 得到的feature ...

  9. mongodb学习这篇你就成功入门了,springboot2.0整合mongodb

    本文演示以window10系统. 一:环境搭建: 1:mongodb下载和安装: http://www.mongodb.org/官网下载压缩包或者exe傻瓜式安装都行. 2:mongodb配置和搭建服 ...

  10. Docker 搭建 SonarQube

    Docker 搭建 SonarQube Docker 搭建 SonarQube 步骤 创建项目目录 mkdir -p /usr/local/sonarqube && cd /usr/l ...