定义
最近公共祖先,简称 lca (Lowest Common Ancestor)。两个节点的最近公共祖先,就是这两个点的公共祖先里面,离根节点最远的那一个。
例题
最近公共祖先有多种不同求法,根据题目要求选择适合的写法
题意
给你一个 \(n\) 个节点,\(n-1\) 条边的树,数据保证可以构成树,树的根节点为 \(s\),现在有 \(m\) 组询问,每组询问包含两个正整数 \(a,b\),表示询问 \(a\) 节点和 \(b\) 节点的最近公共祖先。
数据范围
对于 \(30\%\) 的数据,\(N\le 10\),\(M\le 10\)。
对于 \(70\%\) 的数据,\(N\le 10000\),\(M\le 10000\)。
对于 \(100\%\) 的数据,\(N\le 500000\),\(M\le 500000\)。
解法一:暴力求解
在树中,每个节点都有其对应的深度,深度是相对于根节点而言的,而要找到 \(x\),\(y\) 的 lca 深度是唯一的,当 \(x\) 与 \(y\) 的深度相同时,每一次上跳父节点可保证深度总是相同的,这是只需要保证父节点重合则说明找到了最近公共祖先。
所以在存储的时候,需要存储每个点的深度,同时要存储每个节点的父节点。
需要预处理整棵树,时间复杂度 \(O(n)\),单次查询的复杂度为 \(O(n)\),算法简单,耗时,期望得分70分。
代码如下
cpp
#include<bits/stdc++.h>
using namespace std;
const int N=5e5+5;
int n,m,s,d[N],f[N];
vector<int>e[N];
inline void dfs(int u,int fa){
d[u]=d[fa]+1;
f[u]=fa;
for(auto v:e[u]){
if (v!=fa){
dfs(v,u);
}
}
}
inline void solve(int x,int y){
if (d[x]>d[y]){
swap(x,y);
}
while(d[x]<d[y]){
y=f[y];
}
while(y!=x){
y=f[y],x=f[x];
}
if (x==0){
cout<<s<<"\n";
}else{
cout<<x<<"\n";
}
}
signed 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 u,v;
cin>>u>>v;
e[u].push_back(v);
e[v].push_back(u);
}
dfs(s,0);
while(m--){
int a,b;
cin>>a>>b;
solve(a,b);
}
return 0;
}
洛谷的数据有点水,O(n^2)的复杂度都能得100分
解法二:倍增算法
倍增算法是暴力算法的改进版,通过引入二维的 \(f\) 数组,来减少节点上跳的次数,将单次查询的复杂度降到了 \(O(logn)\),倍增思路和,ST表的思路差不多,不会 ST表 的可以参考这里
定义 \(fui\) 表示点 \(u\) 的 \(2^i\) 个祖先,\(fui\) 可以通过 dfs 预处理出来,不难发现 \(fui=ff\[ui-1]i-1\),\(u\) 的第 \(2^i\) 个祖先就是 \(u\) 的第 \(2^{i-1}\) 个祖先的 \(2^{i-1}\) 个祖先。
预处理复杂度 \(O(nlogn)\),单次查询复杂度 \(O(logn)\),总复杂度 \(O(nlogn+mlogn)\) ,轻松通过此题,期望得分100分
代码如下
cpp
#include<bits/stdc++.h>
using namespace std;
const int N=5e5+5;
int n,m,s,d[N],f[N][21];
vector<int>e[N];
inline void dfs(int u,int fa){
d[u]=d[fa]+1;
f[u][0]=fa;
for(int i=1;(1<<i)<=d[u];i++){
f[u][i]=f[f[u][i-1]][i-1];
}
for(auto v:e[u]){
if (v!=fa){
dfs(v,u);
}
}
}
inline void solve(int x,int y){
if (d[x]>d[y]){
swap(x,y);
}
for(int i=20;i>=0;i--){
if (d[x]<=d[y]-(1<<i)){
y=f[y][i];
}
}
if (x==y){
cout<<x<<"\n";
return ;
}
for(int i=20;i>=0;i--){
if (f[x][i]==f[y][i]){
continue;
}else{
x=f[x][i],y=f[y][i];
}
}
cout<<f[x][0]<<"\n";
return;
}
signed 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 u,v;
cin>>u>>v;
e[u].push_back(v);
e[v].push_back(u);
}
dfs(s,0);
while(m--){
int a,b;
cin>>a>>b;
solve(a,b);
}
return 0;
}
解法三:dfs序+RMQ算法
前置知识,ST表 怎么又是你,用于解决区间最值问题。
那么 lca 怎么与 RMQ 联系起来呢?
因为 lca 是树上问题,而对树的遍历可以使用一个桥梁,dfs序,让树的遍历成为了一个区间序列。
下面进行具体说明

以上图的树为例
序号 :01 02 03 04 05 06 07 08 09 10 11 12 13
深度 :01 02 03 02 03 04 03 04 03 02 01 02 01
节点 :01 02 04 02 05 06 05 07 05 02 01 03 01
得到这张表后,你就能在表中发现有意思的东西,例如你想求 lca(4,6)时,只需要找到从4号节点到6号节点的随便一个区间,再找出区间中深度最小的节点,该点即为 lca(4,6)。
序号 \(1\sim 13\) 是整个区间,找到的区间可以是 \(3\sim 6\),其中深度最小为 \(2\),所对的节点即为 \(2\) 号节点。证明自己搜去吧
用二维数组 \(st\) 维护 dfs序 中的区间最小值,同时用二维数组\(isdt\)存储区间最小值所对的节点,\(dfn\) 数组记录 dfs序 ,\(iddfn\) 记录某个节点第一次遍历到时的 dfs序。
注意:
-
\(st\)存储的是dfs序的区间最值,要注意边界和初始化问题
-
\(dfn\)数组虽然叫dfs序,但是 \(dfni\) 表示时间戳为 \(i\) 时所对的节点编号
-
由于 \(st\) 存的是一段 dfs 序的区间信息,所以传入的参数应该也是 dfs 序
-
搞明白每个变量所代表的是什么,不然就会像我一样调 \(2\) 小时
时间复杂度为 \(O(nlogn+m)\),但是空间开销大,预处理常数大,期望得分100分
cpp
#include<bits/stdc++.h>
using namespace std;
const int N=5e5+5;
int n,m,s;
int st[N*2][21],idst[N*2][21],lg[N*2];
int dfn[N*2],d[N*2],times,iddfn[N];
vector<int>e[N];
inline void dfs(int u,int fa){
dfn[++times]=u;
if (iddfn[u]==0)iddfn[u]=times;
d[u]=d[fa]+1;
for(auto v:e[u]){
if (v==fa)continue;
dfs(v,u);
dfn[++times]=u;
}
}
inline void init(){
lg[2]=1;
for(int i=3;i<=2*n;i++){
lg[i]=lg[i/2]+1;
}
for(int i=1;i<=times;i++){
st[i][0]=d[dfn[i]];
idst[i][0]=dfn[i];
}
for(int j=1;j<=20;j++){
for(int i=1;i+(1<<j)-1<=2*n;i++){
if (st[i][j-1]<st[i+(1<<(j-1))][j-1]){
st[i][j]=st[i][j-1];
idst[i][j]=idst[i][j-1];
}else{
st[i][j]=st[i+(1<<(j-1))][j-1];
idst[i][j]=idst[i+(1<<(j-1))][j-1];
}
}
}
}
inline void solve(int l,int r){
if (l>r)swap(l,r);
int p=lg[r-l+1];
if (st[l][p]<st[r-(1<<p)+1][p]){
cout<<idst[l][p]<<"\n";
}else{
cout<<idst[r-(1<<p)+1][p]<<"\n";
}
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m>>s;
for(int i=1;i<n;i++){
int u,v;
cin>>u>>v;
e[u].push_back(v);
e[v].push_back(u);
}
dfs(s,0);
init();
while(m--){
int l,r;
cin>>l>>r;
solve(iddfn[l],iddfn[r]);
}
return 0;
}
解法四:tarjan算法
上面几种算法都属于在线算法,而tarjan解决 lca 属于离线算法。
tarjan算法相较于上面的算法要更稳定一些,时间复杂度也比较居住,前置知识,并查集
基本思路:
- 读入数据的过程中将每组询问像建图那样存起来,正反建两次
- 从根节点开始进行遍历
- 遍历节点 \(u\) 的所有子节点 \(v\),并标记这些点以被访问过
- 若 \(v\) 还有子节点,则返回 3.,否则进行下一步
- 合并 \(v\) 到 \(u\) 上,注意顺序不能颠倒,因为 \(u\) 是 \(v\) 的父亲节点
- 寻找所有与 \(u\) 相连的,需要进行查询的节点 \(v'\)。
- 若 \(v'\) 以访问过,则可以确定 \(u\) 和 \(v\) 的最近公共祖先即为 \(find(v')\)。
时间复杂度 \(O(n+m)\),期望得分100分
代码如下
cpp
#include<bits/stdc++.h>
using namespace std;
//#define int long long
const int N=5e5+5;
int n,m,s;
int ans[N];
int fa[N];
bitset<N>vis;
vector<int>e[N];
vector<pair<int,int>>q[N];
inline int find(int x){
return x==fa[x] ? x : fa[x]=find(fa[x]);
}
inline void tarjan(int u,int father){
vis[u]=true;
for(auto v:e[u]){
if (v==father or vis[v])continue;
tarjan(v,u);
fa[v]=u;
}
for(auto i:q[u]){
int v=i.first,id=i.second;
if (vis[v]){
ans[id]=find(v);
}
}
}
signed 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 u,v;
cin>>u>>v;
e[u].push_back(v);
e[v].push_back(u);
}
for(int i=1;i<=n;i++)fa[i]=i;
for(int i=1;i<=m;i++){
int x,y;
cin>>x>>y;
if (x==y){
ans[i]=x;
continue;
}
q[x].push_back({y,i});
q[y].push_back({x,i});
}
tarjan(s,0);
for(int i=1;i<=m;i++){
cout<<ans[i]<<"\n";
}
return 0;
}