[算法学习笔记] 最近公共祖先 LCA
在讲解之前,我们先来看一道模板题:Luogu P3379 最近公共祖先(LCA)
What is LCA
LCA,即最近公共祖先。什么意思呢,我们举个例子:
将就着看吧qwq
这棵树中,0为根节点。若规定\(LCA(x,y)\)为\(x,y\)的最近公共祖先,则\(LCA(5,6)=2;LCA(4,3)=1;LCA(5,3)=0\)。还有很多,这里不一一列举了。
讲到这里我们很容易想到暴力算法,先让 \(x,y\) 跳到同一个深度。然后再让 \(x,y\) 同时向上跳一层,重复跳跃,直到 \(x = y\) 停止。
这样的复杂度是很高的,接下来我们讲解 Tarjan 和 倍增求 LCA。
Tarjan
- Tarjan算法是一种离线算法,跑一遍就能将所有需求的LCA计算完,因此效率比较高。
- Tarjan实现应用了DFS搜索树+并查集 (
相信来看的都懂吧)
算法步骤:
1.从根节点开始搜索树,如果搜到的点还有子节点则继续搜(DFS)
2.若搜到的节点没有子节点或子节点已经搜索过了,将父节点和子节点合并(并查集)
3.查找与当前节点\(now\)有询问关系的节点\(q_i\),若\(q_i\)已经回溯过则
\(LCA(now,q_i)=find(q_i)\) (\(find(q_i)\)指并查集操作,查找\(q_i\)的父节点)
需要注意,在STEP3中,\(q_i\)必须是已经回溯过而不是搜过,只有回溯过才有merge操作。(如果不理解可以先看下文模拟)
整个算法的过程非常巧妙,笔者水平有限,无法给出具体证明( 我太菜了ww),这里带着大家模拟一遍Tarjan流程吧,模拟完相信大家对Tarjan的理解就会更加深刻啦
模拟样例
我们还是模拟最先给出的那棵树:
为了简化题意,我们假设需求\(LCA(5,6)\)以及\(LCA(5,4)\)
显然,从0根节点开始DFS,先搜2,2还有子节点,先搜5,发现没有子节点。则merge(2,5)
此时,查找与5有询问关系的点:
- 关系1:6号点。显然没有回溯过,不管
- 关系2:4号点。显然也没有回溯过,不管
此时回溯,返回2号,搜索6号。发现6没有子节点。则merge(2,6)
此时,查找与6有询问关系的点:
- 关系1:5号点。已经回溯过,则\(LCA(5,6)=find(6)=2\)
此时回溯,返回0号,并且merge(2,0),搜索1号,1号有子节点,先搜4,merge(1,4)。
查找与4号有询问关系的点:
- 关系1:5号点。已经回溯过,则\(LCA(4,5)=find(5)=0\)
显然还会继续搜索下去,和上边同理,相信大家对Tarjan算法已经有了深刻的理解,这里不继续模拟了。
通过模拟样例发现,在Tarjan过程中,是边DFS边merge,这样能够确保答案正确性,比较容易理解。具体证明不谈。
具体实现
通过Tarjan,我们可以求出所有需要求的LCA,但是如何存储答案,去重,输出呢?
我们可以用vector存树(注意一定是双向边),另开一个vector存储询问顺序实现按顺输出+去重。当然去重也可以用map,只不过浪费空间。
我们来看一下Tarjan模板:
void tarjan(int now)
{
vis[now] = 1; //标记已访问
for(int i=0;i<v[now].size();i++) //这里使用了vector存树
{
if(!vis[v[now][i]]) //如果子节点没访问过
{
tarjan(v[now][i]); //dfs继续搜
fa[v[now][i]] = now; //merge操作
}
}
for(int i=0;i<q[now].size();i++) //查找与now有询问关系的点
{
int p =find(q[now][i]);
if(vis[q[now][i]] == 2) //如果询问的点回溯过
{
ans[id[now][i]] = p; //id存答案编号,即按序输出
}
}
vis[now] = 2;//所有操作完毕后标记now已回溯
}
至于刚开始给出的模板题,由于已经给出了 Tarjan 板子,其余部分自己实现一下吧!
倍增 LCA
倍增 LCA 基于暴力,我们先回忆一下暴力是如何求解的。
在暴力求 LCA 的时候,我们先使 \(x,y\) 跳到同一深度,然后再同时一层一层的跳。
根据二进制唯一分解定理,任意的一个正整数都可以划分几个不重复的二的整次幂和。我们可以每次跳 \(2\) 的整次幂。
\(x,y\) 同时跳的时候,显然 LCA 及其以上的节点都是公共祖先。如果一步跳到公共祖先就输出我们无法确保这就是 LCA。
不妨每次不跳到公共祖先,这样最后跳到的节点 \(x\) 的父节点一定是公共祖先。
证明:二进制唯一分解定理。例如 \(x,y\) 需要同时跳 \(k\) 步才是 LCA。那么 \(k\) 一定可以分解成不重复的二的整次幂和的形式。
\(x,y\) 同时跳之前,需要确保 \(x,y\) 所在深度一致。这也很简单,如果深度不一致,则用倍增跳到一致即可。
显然我们需要预处理每个节点的深度以及每个点向上跳 \(2^i\) 步所能到达的节点。处理深度非常简单,对于处理每个节点 \(m\) 向上跳 \(2^i\),类似于动态规划的思想,如果设 \(f_{m,i}\) 表示节点 \(m\) 向上跳 \(2^i\) 步所能到达的节点,则满足:
类比 \(2^i=2^{i-1}+2^{i-1}\) 。
需要注意预处理 \(f[m,0]=fa_m\) ( \(fa_m\) 表示 \(m\) 的父亲 )
提供倍增 LCA 模板。如下。
倍增 LCA
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;
const int N = 500000+2;
int depth[N*2];
int fa[N][100];
int n,m,s;
vector <int> Edge[N*2];
void dfs(int now,int fat)
{
fa[now][0] = fat; //预处理节点 now 向上跳 2^i 步所能到达的节点
depth[now] = depth[fat] + 1; //深度
for(int i=1;(1<<i) <= depth[now]; i++)
fa[now][i] = fa[fa[now][i-1]][i-1]; // dp
for(int i=0;i<Edge[now].size();i++)
{
int v = Edge[now][i];
if(v != fat) dfs(v,now); //dfs 预处理
}
}
int lca(int a,int b)
{
if(depth[a] > depth[b]) swap(a,b);
for(int i=20;i>=0;i--) //从大到小跳
{
if(depth[a] <= depth[b]-(1<<i)) b = fa[b][i]; //先跳到同一高度,倍增优化
}
if(a == b) return a; //由于跳跃过程用到的是倍增优化,所以如果是跳到了一起则一定是 lca
for(int i=20;i>=0;i--)
{
if(fa[a][i] == fa[b][i]) continue;
a = fa[a][i]; //如果不同则跳
b = fa[b][i];
}
return fa[a][0]; //返回任意节点的父亲即可。
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m>>s;
for(int i=1;i<=n-1;i++)
{
int x,y;
cin>>x>>y;
Edge[x].push_back(y);
Edge[y].push_back(x);
}
dfs(s,0);
for(int i=1;i<=m;i++)
{
int a,b;
cin>>a>>b;
cout<<lca(a,b)<<endl;
}
return 0;
}
本文作者:SXqwq,转载请注明原文链接:https://www.cnblogs.com/SXqwq/p/Lowest_Common_Ancestor.html