【学习笔记】最近公共祖先(LCA)
建议结合板题食用
【1】暴力做法
我们先用 DFS 预处理所有节点的深度,每次查询,将深度大的节点向上提到和另一节点深度相同的位置,然后同时向上提,直到两节点碰面。
在极端情况下,树只有两条链构成,那么查询链末端的节点的最近公共祖先复杂度可达到
【2】倍增解法:
在暴力做法中,每次向上提一个节点速度太慢,可以考虑倍增做法。
每次向上提一个节点很慢,我们可以预处理出每个节点的
具体来说,
对于其它
对于每次查询,首先,还是将深度较深的节点往上提。从
接下来同时提两节点,如果两节点已经重合,直接返回。否则,按照刚才的思路,如果它们的
但此时两个节点应该在它们最近公共祖先的子节点位置,所以返回其中一节点的父节点(即
显然倍增的做法为
#include<bits/stdc++.h>
using namespace std;
const int N = 5e5 + 9,LOGN = 18;
struct egde{
int to,nex;
} e[N << 1];
int ecnt,head[N];
int depth[N];
int anc[N][LOGN + 9];
int n,m,s;
void addegde(int u,int v){
ecnt++;
e[ecnt] = (egde){v,head[u]};
head[u] = ecnt;
}
void dfs(int u,int fa){
for(int i = head[u];i;i = e[i].nex){
int v = e[i].to;
if(v == fa)
continue;
depth[v] = depth[u] + 1;
anc[v][0] = u;
dfs(v,u);
}
}
void init(){
for(int j = 1;j <= LOGN;j++)
for(int i = 1;i <= n;i++)
anc[i][j] = anc[anc[i][j - 1]][j - 1];
}
int LCA(int u,int v){
if(depth[u] < depth[v])
swap(u,v);
for(int i = LOGN;i >= 0;i--)
if(depth[anc[u][i]] >= depth[v])
u = anc[u][i];
if(u == v)
return u;
for(int i = LOGN;i >= 0;i--){
if(anc[u][i] != anc[v][i]){
u = anc[u][i];
v = anc[v][i];
}
}
return anc[u][0];
}
int main(){
scanf("%d%d%d", &n, &m, &s);
for(int i = 1;i <= n - 1;i++){
int x,y;
scanf("%d%d", &x, &y);
addegde(x,y);
addegde(y,x);
}
depth[s] = 1;
dfs(s,0);
init();
for(int i = 1;i <= m;i++){
int a,b;
scanf("%d%d",&a ,&b);
printf("%d\n",LCA(a,b));
}
return 0;
}
【3】树剖做法:
在重链剖分中,我浅浅的提到了用树剖做最近公共祖先的做法。每次把当前节点所在重链链头深度较大的点跳到链头的父节点,直到两节点链头相同,此时一个肯定是另一个的祖先节点,返回深度较小的节点即为两节点的 LCA。
根据重链剖分中的分析,这种做法的复杂度也是
#include<bits/stdc++.h>
using namespace std;
const int N = 5e5 + 9;
struct egde{
int to,nex;
} e[N << 1];
int ecnt,head[N];
int top[N],dep[N];
int fa[N],weight_child[N],siz[N];
int n,m,s;
void addegde(int u,int v){
ecnt++;
e[ecnt] = (egde){v,head[u]};
head[u] = ecnt;
}
void dfs1(int cur,int father){
fa[cur] = father;
siz[cur] = 1;
dep[cur] = dep[father] + 1;
for(int i = head[cur];i;i = e[i].nex){
int v = e[i].to;
if(v != father){
dfs1(v,cur);
siz[cur] += siz[v];
if(siz[v] > siz[weight_child[cur]])
weight_child[cur] = v;
}
}
}
void dfs2(int cur,int link_top){
top[cur] = link_top;
if(weight_child[cur]){
dfs2(weight_child[cur],link_top);
for(int i = head[cur];i;i = e[i].nex){
int v = e[i].to;
if(v != fa[cur] && v != weight_child[cur])
dfs2(v,v);
}
}
}
int LCA(int u,int v){
while(top[u] != top[v]){
if(dep[top[u]] < dep[top[v]])
swap(u,v);
u = fa[top[u]];
}
return (dep[u] < dep[v]) ? u : v;
}
int main(){
scanf("%d%d%d", &n, &m, &s);
for(int i = 1;i <= n - 1;i++){
int x,y;
scanf("%d%d", &x, &y);
addegde(x,y);
addegde(y,x);
}
dep[s] = 1;
dfs1(s,0);
dfs2(s,0);
for(int i = 1;i <= m;i++){
int a,b;
scanf("%d%d",&a ,&b);
printf("%d\n",LCA(a,b));
}
return 0;
}
【4】Tarjan算法:
我们知道,暴力提节点的速度很慢,上述两种方法都是在加速“提节点”方面作文章,Tarjan 算法的不同之处在于,先读入所有查询,对求 LCA 之前的整棵树的 DFS 进行修改,然后在 DFS 中完成所有查询。
首先,显然,如果有两个节点
同时,这个问题具有比较突出的递归特性:一个节点的子树一定是它所有祖先的子树的一部分。也就是说,当递归处理完一个节点的询问并返回,可以将已经访问的该子树直接划归为父节点的一部分。以此类推,所有节点最终都会变成根节点的子树。
上述“划归”做法,可以考虑用并查集。
所以整个算法流程如下:
-
建树并存储全部查询。
-
从根节点开始 DFS。
-
在遍历到一个节点后,首先把它在并查集中的父节点设为其在树上的父节点。如果它还有子节点没有遍历,按照同样的方式遍历子节点。
-
遍历完所有子节点,回到这个节点后,处理与该节点相关的所有询问。如果询问中另一节点已被访问,那么另一节点此时在并查集中的祖先节点即为两节点的 LCA。
-
将该节点与其父节点合并。
#include<bits/stdc++.h>
using namespace std;
const int N = 5e5 + 9,M = 5e5 + 9;
int n,m,s;
struct egde{
int to,nex;
} e[N << 1];
int head[N],ecnt;
bool vis[N];
struct query{
int to,id;
};
vector<query>Q[M];
int ans[M];
void addegde(int u,int v){
ecnt++;
e[ecnt] = (egde){v,head[u]};
head[u] = ecnt;
}
int fa[N];
int father(int x){
return x == fa[x] ? x : fa[x] = father(fa[x]);
}
void join(int x,int y){
int fa_x = father(x),fa_y = father(y);
fa[fa_x] = fa_y;
}
void dfs(int cur) {
fa[cur] = cur;
vis[cur] = true;
for(int i = head[cur]; i; i = e[i].nex){
int v = e[i].to;
if(!vis[v]){
dfs(v);
//join(cur,v);
fa[v] = cur;
}
}
for(int i = 0;i < (int)Q[cur].size();i++){
int v = Q[cur][i].to;
if(vis[v])
ans[Q[cur][i].id] = father(v);
}
}
int main(){
scanf("%d%d%d", &n, &m, &s);
for(int i = 1;i < n;i++){
int u,v;
scanf("%d%d", &u, &v);
addegde(u,v);
addegde(v,u);
}
for(int i = 1;i <= m;i++){
int a,b;
scanf("%d%d", &a, &b);
Q[a].push_back((query){b,i});
Q[b].push_back((query){a,i});
}
dfs(s);
for(int i = 1;i <= m;i++)
printf("%d\n",ans[i]);
return 0;
}
不难发现,Tarjan 是一个离线算法,因为路径压缩并查集均摊复杂度
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具