最近公共祖先LCA
定义
最近公共祖先简称 LCA(Lowest Common Ancestor)。两个节点的最近公共祖先,就是这两个点的公共祖先里面,离根最远的那个。
例题
这里先举例具体题目,下面根据不同方法给出不用题解代码。
洛谷 【模板】最近公共祖先(LCA)
题目描述
如题,给定一棵有根多叉树,请求出指定两个点直接最近的公共祖先。
输入格式
第一行包含三个正整数 N,M,S 分别表示树的结点个数、询问的个数和树根结点的序号。
接下来 N-1 行每行包含两个正整数 x, y表示 x 结点和 y 结点之间有一条直接连接的边(数据保证可以构成树)。
接下来 M 行每行包含两个正整数 a, b 表示询问 a 结点和 b 结点的最近公共祖先。
输出格式
输出包含 M 行,每行包含一个正整数,依次为每一个询问的结果。
说明/提示
对于 30% 的数据,N ≤10,M ≤10 。
对于 70% 的数据,N≤10000,M≤10000。
对于 100% 的数据,N≤500000,M≤500000。
朴素算法 (暴力求解)
在树中,每个节点都有其的深度,深度是相对于根节点而言的,而要找到x和y的LCA深度是唯一的,当x与y的深度相同时,每一次上跳父节点可保证深度是相同的,这时候只需要保证父节点重合了则找到了LCA。
所以在存储的时候,需要存储每个节点的深度,同时要存储每个节点的父节点。
需要预处理整棵树,时间复杂度O(n),单次查询复杂度O(n),算法简单、耗时。
#include<bits/stdc++.h>
using namespace std;
const int MAX_N = 500000+7, MAX_M = 500000+7;
int n,m,s;
int d[MAX_N]; //保存节点深度
int f[MAX_N]; //保存节点的父节点
int head[MAX_N], cnt=-1;
struct Edge{
int to , next;
}e[MAX_N*2];
void add(int u,int v){
e[++cnt].to = v;
e[cnt].next = head[u];
head[u] = cnt;
}
void dfs(int u,int fa) // u为当前节点,fa为父节点
{
f[u] = fa;
d[u] = d[fa]+1;
for(int i=head[u];i!=-1;i=e[i].next){
int v = e[i].to;
if(v!=fa)
dfs(v,u);
}
}
int lca(int x,int y)
{
if(d[x]>d[y]) //令y为更深节点
swap(x,y);
while(d[x]<d[y])
y = f[y];
while(x!=y)
x = f[x],y = f[y];
if(x==0)
x=s;
return x;
}
int main()
{
memset(head,-1,sizeof(head));
int a,b;
scanf("%d%d%d",&n,&m,&s);
for(int i=1;i<n;i++)
{
scanf("%d%d",&a,&b);
add(a,b);
add(b,a); //无向图,要加两次
}
dfs(s,0);
for(int i=1;i<=m;i++)
{
scanf("%d%d",&a,&b);
printf("%d\n",lca(a,b));
}
return 0;
}
倍增算法(在线)
倍增算法是朴素算法的改进算法,通过引入二维的 f 数组,来减少节点上跳次数。倍增思路和ST表思路差不多。
使用 f[x][i] 来表示 点x 的第 2i个祖先,而 f[x][i] 数组可以通过dfs预处理出来。f[u][i] = f[f[u][i-1]][i-1] , u 的第2i个祖先,就是 u 的2i-1个祖先的2i-1个祖先。 这里你细品。
for(int i=1;(1<<i)<=d[u];i++)
f[u][i] = f[f[u][i-1]][i-1]; //预处理f数组
进行初始化后,需要查询lca,思想与朴素算法相同,不过其上跳不再是线性的,而是倍增的。
预处理复杂度0(nlogn),单次查询O(logn) , 总复杂度 O(nlogn+mlogn)
#include<bits/stdc++.h>
using namespace std;
const int MAX_N = 500000+7, MAX_M = 500000+7;
int n,m,s;
int d[MAX_N]; //保存节点深度
int f[MAX_N][21]; //保存节点的不同倍数的父节点
int head[MAX_N], cnt=-1;
struct Edge{
int to , next;
}e[MAX_N*2];
void add(int u,int v){
e[++cnt].to = v;
e[cnt].next = head[u];
head[u] = cnt;
}
void dfs(int u,int fa) // u为当前节点,fa为父节点
{
f[u][0] = fa;
d[u] = d[fa]+1;
for(int i=1;(1<<i)<=d[u];i++)
f[u][i] = f[f[u][i-1]][i-1]; //预处理f数组
for(int i=head[u];i!=-1;i=e[i].next){
int v = e[i].to;
if(v!=fa)
dfs(v,u);
}
}
int lca(int x,int y)
{
if(d[x]>d[y]) //令y为更深节点
swap(x,y);
for(int i=20;i>=0;i--){ //这里使得x与y处于同一深度
if(d[x]<=d[y]-(1<<i))
y=f[y][i];
}
//特判
if(x==y)
return x;
for(int i=20;i>=0;i--)
{
if(f[x][i]==f[y][i])
continue; //这里是从最远的公共祖先开始找,所以需要continue
else
x=f[x][i],y=f[y][i]; //上跳
}
return f[x][0];
}
int main()
{
memset(head,-1,sizeof(head));
int a,b;
scanf("%d%d%d",&n,&m,&s);
for(int i=1;i<n;i++)
{
scanf("%d%d",&a,&b);
add(a,b);
add(b,a); //无向图,要加两次
}
dfs(s,0);
for(int i=1;i<=m;i++)
{
scanf("%d%d",&a,&b);
printf("%d\n",lca(a,b));
}
return 0;
}
ST+RMQ算法(在线)
使用这种算法前需要先去知道ST算法,用于求区间最值问题。
那么LCA又该如何与RMQ扯上关系呢?
因为LCA是属于树上问题,而对树的遍历可以使用一个桥梁,dfs序,让树的遍历成为了一个区间序列。
给出这个dfs序(其实是欧拉序):
1 2 4 2 5 7 5 8 5 2 1 3 1
再加上深度的话,可以制出下表:
得到这张表后,你就能在表中发现有意思的东西,当你想求lca(4,7)时,只需要找到4号节点到7号节点的随便一个区间,再找出区间中深度最小的节点,该点即为lca(4,7),序号(1~13)为整个区间,找的区间可以是(3 ~ 6),其中最小深度为1,就是2号节点。wow ~ 是不是感觉特别神奇。
那这个方法的科学性呢?
其实在给出欧拉序时,连续的区间其实是可以表示一颗子树的,这在上篇博文中有介绍。所以通过欧拉序得出这样的序列后,就不必再去通过原树来进行查找,而转换为查找这张表。因为一段连续区间表示了一颗子树,那么在找两个节点的lca时,其实就是在找包含这两个点的子树的根节点,结合深度来判断,那么一颗子树中,深度最小的节点那不就是该子树的根节点吗。
如此一个树上问题就转换成了区间问题,这就是LCA与RMQ扯上的关系。
那后面的操作就显得很简单了。
上面这张表使用三个数组: fir[] 节点在序列中首次出现的序号、 d[] 节点深度表 、 dfn[] dfs序 , 需要注意的是欧拉序会重复加入节点,所以数组大小需要扩大一倍。
预处理复杂度0(nlogn),ST表单次查询O(1) ,总时间复杂度 O(nlogn+m),不过其在空间上的开销要显得比倍增大一些。
#include<bits/stdc++.h>
using namespace std;
const int MAX_N = 500000+7, MAX_M = 500000+7;
int n,m,s;
//dfs序数组
int tot = 0;
int fir[MAX_N];
int dfn[MAX_N*2];
int d[MAX_N*2];
//ST表数组
int st[20][MAX_N*2]; //st表
int ist[20][MAX_N*2]; //记录最值下标
int Log[MAX_N*2];
void getLog()
{
Log[1] = 0;
for(int i=2;i<=MAX_N*2;i++)
Log[i] = Log[i/2]+1;
}
void init()
{
for(int i=1;i<=tot;i++){
st[0][i]=d[i];
ist[0][i] = dfn[i];
}
for(int j=1;(1<<j)<=tot;j++){
for(int i=1;i+(1<<(j-1))<=tot;i++){
if(st[j-1][i]<st[j-1][i+(1<<(j-1))]){
st[j][i] = st[j-1][i];
ist[j][i] = ist[j-1][i];
}else{
st[j][i] = st[j-1][i+(1<<(j-1))];
ist[j][i] = ist[j-1][i+(1<<(j-1))];
}
}
}
getLog();
}
int Query(int l,int r)
{
int k=Log[r-l+1];
int ans;
if(st[k][l]<st[k][r-(1<<k)+1])
ans = ist[k][l];
else
ans = ist[k][r-(1<<k)+1];
return ans;
}
int head[MAX_N], cnt=-1;
struct Edge{
int to , next;
}e[MAX_N*2];
void add(int u,int v){
e[++cnt].to = v;
e[cnt].next = head[u];
head[u] = cnt;
}
void dfs(int u,int dep) // u为当前节点,dep为深度
{
fir[u] = ++tot;
dfn[tot] = u;
d[tot] = dep;
for(int i=head[u];i!=-1;i=e[i].next){
int v = e[i].to;
if(!fir[v]){
dfs(v,dep+1);
dfn[++tot]=u;
d[tot]=dep;
}
}
}
int lca(int x,int y)
{
int l = fir[x] , r = fir[y];
if(l>r) swap(l,r);
return Query(l,r);
}
int main()
{
memset(head,-1,sizeof(head));
int a,b;
scanf("%d%d%d",&n,&m,&s);
for(int i=1;i<n;i++)
{
scanf("%d%d",&a,&b);
add(a,b);
add(b,a); //无向图,要加两次
}
dfs(s,0);
init();
for(int i=1;i<=m;i++)
{
scanf("%d%d",&a,&b);
printf("%d\n",lca(a,b));
}
return 0;
}
Tarjan算法(离线)
上面介绍的两种算法都是在线算法,所以这里再讲解一种离线算法。
Tarjan算法相较于上面的算法要更稳定一些,时间复杂度也比较稳定居中,结合并查集使用也很容易理解。
基本思路
- 任选一个点为根节点,从根节点开始。
- 遍历该点u所有子节点v,并标记这些子节点v已被访问过。
- 若是v还有子节点,返回2,否则下一步。
- 合并v到u上。
- 寻找与当前点u有询问关系的点v。
- 若是v已经被访问过了,则可以确认u和v的最近公共祖先为v被合并到的父亲节点a。
算法结构很清晰,这里给出伪代码:
Tarjan(u)//marge和find为并查集合并函数和查找函数
{
for each(u,v) //访问所有u子节点v
{
Tarjan(v); //继续往下遍历
marge(u,v); //合并v到u上
标记v被访问过;
}
for each(u,e) //访问所有和u有询问关系的e
{
如果e被访问过;
u,e的最近公共祖先为find(e);
}
}
原理
Tarjan算法也是使用dfs对树进行遍历,可以发现在遍历过程中,每颗子树节点的遍历顺序都是连续的(从左子树开始遍历的话,要先遍历完5,才能遍历完2,最后才能遍历完3),找lca其实就是找子树的根节点对吧,所以由于其遍历过程中其实隐藏包含了子树信息,那只需要利用这种遍历信息就可以找到节点的lca。也正是因为需要在遍历过程中找lca,所以要事先知道需要找哪些lca,所以其是离线的算法。
那么在dfs中的子树信息怎么使用呢,并查集。 每当遍历完一颗子树后,都使用并查集记录该子树的根节点,然后查看是否有询问该子树的lca,若有,则输出该子树根节点即可。而并查集恰好能简单的实现这一逻辑,并查集的祖先则改成原树深度较低的节点即可(遍历就是深度越深,实际上不需要判断)。
算法执行过程图解可参考:Tarjan(离线)算法的基本实现
由于该算法对于每个点和每个查询仅遍历一次,所以其时间复杂度为O(n+m) 。
下面是给出题解代码:
#include<bits/stdc++.h>
using namespace std;
const int MAX_N = 500000+7, MAX_M = 500000+7;
int n,m,s;
int head[MAX_N], cnte=0;
struct Edge{
int to , next;
}e[MAX_N*2];
void add_e(int u,int v)
{
e[++cnte].to = v;
e[cnte].next = head[u];
head[u] = cnte;
}
int que[MAX_M*2],cntq=0;
struct questions
{
int to,next,same,num;
bool flag;
questions(){flag=false;}
}q[MAX_M*2];//询问的储存,flag=false表示还没回答,num表示是第几个询问,same储存与这个询问相同的询问序号。
void add_q(int x,int y,int k)
{
q[++cntq].to=y;
q[cntq].same = cntq+1;
q[cntq].next = que[x];
q[cntq].num = k;
que[x] = cntq;
//查询正反都需加入
q[++cntq].to=x;
q[cntq].same = cntq-1;
q[cntq].next = que[y];
q[cntq].num = k;
que[y] = cntq;
}
int father[MAX_N],ans[MAX_M];// father为并查集父节点,ans记录各查询答案
bool vis[MAX_N]; //vis记录访问情况
//并查集
int find(int x)
{
if(father[x]!=x)
father[x]=find(father[x]);
return father[x];
}
void unionn(int x,int y)
{
father[find(y)]=find(x);
}
void Tarjan(int u,int fa) // u为当前节点,fa为父节点
{
for(int i=head[u];i!=0;i=e[i].next){
int v = e[i].to;
if(v!=fa&&!vis[v]){
Tarjan(v,u);
unionn(u,v); // 注意这里位置不能交换, 原树上u为v的父节点
vis[v]=true; // 标记访问
}
}
for(int i=que[u];i!=0;i=q[i].next){
int v = q[i].to;
if(!q[i].flag&&vis[v]){
ans[q[i].num] = find(v);
q[i].flag = true; //完成询问则把镜像问题也打上标记
q[q[i].same].flag = true;
}
}
}
int main()
{
int a,b;
scanf("%d%d%d",&n,&m,&s);
for(int i=1;i<n;i++)
{
father[i] = i;
scanf("%d%d",&a,&b);
add_e(a,b);
add_e(b,a); //无向图,要加两次
}
father[n] = n;
for(int i=1;i<=m;i++)
{
scanf("%d%d",&a,&b);
add_q(a,b,i);
}
Tarjan(s,0);
for(int i=1;i<=m;i++)
printf("%d\n",ans[i]);
return 0;
}