倍增算法求最近公共祖先
一、概述
在图论和计算机科学中,最近公共祖先 LCA(Least Common Ancestors)是指在一个树或者有向无环图中同时拥有v和w作为后代的最深的节点。在这里,我们定义一个节点也是其自己的后代,因此如果v是w的后代,那么w就是v和w的最近公共祖先。 --维基百科

上图中, LCA(11,8)=8LCA(11, 8)=8LCA(11,8)=8,LCA(11,9)=1LCA(11, 9)=1LCA(11,9)=1,LCA(7,8)=2LCA(7, 8)=2LCA(7,8)=2。求LCALCALCA有很多算法,比如倍增算法,Tarjan(离线)算法, 与RMQ问题的转换等。
二、朴素算法
求LCA(v,w)LCA(v, w)LCA(v,w)比较直观想法是,先将vvv,www中层次较深者提升到同一深度,然后一起一步一步向上爬, 直到相遇,相遇节点则为LCA(v,w)LCA(v, w)LCA(v,w)。
如下图, 求LCA(11,9)LCA(11, 9)LCA(11,9)时,先将较深节点111111提升到其祖先节点888,此时, 求LCA(11,9)LCA(11, 9)LCA(11,9)相当于求LCA(8,9)LCA(8, 9)LCA(8,9),然后节点888和999再沿着其祖先链一步一步向上爬。整个过程为,LCA(11,9)LCA(11, 9)LCA(11,9)=LCA(8,9)LCA(8, 9)LCA(8,9)=LCA(5,6)LCA(5, 6)LCA(5,6)=LCA(2,3)LCA(2, 3)LCA(2,3)=111。

需要注意,如果vvv,www之间存在祖先关系,比如求LCA(8,11)LCA(8, 11)LCA(8,11),节点111111提升到节点888时就已经相遇了,就不需要后面步骤了。
代码如下:
#include<bits/stdc++.h>
using namespace std;
// 最多节点数
const int maxn = 500005;
// n : 节点数
// s : 根节点编号
// fa[i] : 节点i父节点编号
// depth[i] :节点i深度
int n, s, head[maxn], fa[maxn], depth[maxn], m, v, w, cnt;
struct E{
int to, next;
} edge[2*maxn];
// 链式向前星存树模板代码
void add_edge(int from, int to){
edge[cnt].to = to;
edge[cnt].next = head[from];
head[from] = cnt++;
}
// 深度优先搜索, 预处理每个节点深度和父节点编号
// r : 当前根节点编号
// p : r节点父节点编号
void dfs(int r, int p){
// 当前节点深度为父节点深度+1
depth[r] = depth[p]+1;
fa[r] = p;
// 递归到子树
for(int i = head[r]; i != -1; i = edge[i].next){
int to = edge[i].to;
if(to != p){
dfs(to, r);
}
}
}
int main(){
memset(head, -1, sizeof(head));
cin>>n>>s;
for(int i = 0; i < n-1; i++){
cin>>v>>w;
add_edge(v, w);
add_edge(w, v);
}
dfs(s, -1);
cin>>v>>w;
// 确保depth[v] >= depth[w], 即节点v是深度较深节点
if(depth[v] < depth[w]) swap(v, w);
// 节点v一步一步向上爬到和节点w同深度
while(depth[v] > depth[w]) v = fa[v];
// 节点v和节点w一步一步沿着父节点向上爬, 直到相遇
// 如果节点v和节点w之间具有祖先关系, 则通过上一个while循环后这里v等于w,不会进入这个循环
while(v != w){
v = fa[v];
w = fa[w];
}
cout<<v<<endl;
}
神马,这就完了吗? 说好的倍增呢?
前文已经说了,这只是一个直观想法,其实这个算法可以进行优化的。
这个算法中有两个向上一步一步爬的地方:
- 节点vvv沿着父节点一步一步向上爬到和节点www同深度;
- 节点vvv和节点www沿着各自的父节点一步一步向上爬直到他们相遇;
这样一步一步向上爬是不是感觉很费经?有没有更高效的算法呢?
三、用倍增优化
接下来就是我们主角倍增上场了。
1. 倍增思想
我们知道任何一个正整数都可以用 222 的幂次方之和表示,相当于将这个数转化成222进制。那么在向上爬的过程中可不可以一次爬 222 的幂次方步长,即一次爬202^020,212^121,222^222,232^323,242^424 … 2302^{30}230这些步长,这样最多向上爬303030次左右。比如7=22+21+207=2^2+2^1+2^07=22+21+20,我们只需要爬三步,这三步步长分别为111,222,444,这样效率呈幂次方提升,这就是倍增;
但是,这里还有一个关键问题,不知道总共需要爬多少步,那怎么用222 的幂次方之和表示,总不能每个222 的幂次方都爬吧。
比如总共需要爬555步,20+21+22+23=1+2+4=7>52^0+2^1+2^2+2^3=1+2+4=7>520+21+22+23=1+2+4=7>5,已经爬过了,还得回溯。
2. 实现
虽然不知道总共需要爬多少步,但是知道一个步长能不能爬。
- 对于第一种情况,在节点vvv向上爬到和节点www同深度过程中,如果爬了这一步发现其深度小于www深度,则这一步不能爬;
例如上面这棵树,v=11v=11v=11,w=2w=2w=2时,depth[v]=6depth[v]=6depth[v]=6,depth[2]=6depth[2]=6depth[2]=6,对于一步长度为24=162^4=1624=16时,如果爬了这步,depth[v]=6−16=−10<2depth[v]=6-16=-10<2depth[v]=6−16=−10<2,所以这步不能爬, 但是,如果步长为22=42^2=422=4时,如果爬了这步,depth[v]=6−4=−10=2depth[v]=6-4=-10=2depth[v]=6−4=−10=2,这一步可以爬。 - 对于第二种情况,节点vvv和节点www沿着各自的祖先节点向上爬时,如果爬了这一步还没有到达公共祖先则能爬,否则这一步不能爬。我们的策略是要把公共祖先之前的所有步爬完,最后都停留在公共祖先前一步,那再爬一步20=12^0=120=1就到达最近公共祖先。
例如上面这棵树,v=4v=4v=4,w=5w=5w=5时,对于步长21=22^1=221=2,如果爬了这一步则v=1v=1v=1,w=1w=1w=1,爬到公共祖先上,这一步不能爬。但是对于步长20=12^0=120=1,爬了这一步后v=2v=2v=2,w=2w=2w=2,相遇了,也不能爬这一步。
知道了一个步长能不能爬有什么好处呢?
我们联想我们平时怎么把一个十进制数转换成二进制的, 当然我们可以使用除二取余倒排数这种方法来做,我们还可以试减这种方法,例如对于111111,我们可以从一个最接近111111但小于等于111111的一个222 的幂次方的数开始向下试减,不断重复,使其最终减为零;比如111111可以减掉23=82^3=823=8,不能减掉比232^323更大的222的幂次方,所以11=23+511=2^3+511=23+5,555可以减掉22=42^2=422=4,所以11=23+22+111=2^3+2^2+111=23+22+1,111只能减掉20=12^0=120=1,所以11=23+22+2011=2^3+2^2+2^011=23+22+20。
这里能不能减掉一个222 的幂次方,是不是就是上面的一个步长为222 的幂次方步能不能走,可以借鉴这种思想。
对于第一种情况,我们是知道vvv是要向上走depth[w]−depth[v]depth[w]-depth[v]depth[w]−depth[v]步的,所以我们可以从步长最接近depth[w]−depth[v]depth[w]-depth[v]depth[w]−depth[v]但小于等于depth[w]−depth[v]depth[w]-depth[v]depth[w]−depth[v]222 的幂次方即222 的⌊log2depth[w]−depth[v]⌋\lfloor log _{2}^{depth[w]-depth[v]} \rfloor⌊log2depth[w]−depth[v]⌋次方步开始向下试走,最终必定走到和www同深度。

例如上图是一个特殊例子,v=10v=10v=10和w=1w=1w=1具有祖先关系,depth[w]−depth[v]=9depth[w]-depth[v]=9depth[w]−depth[v]=9,⌊log29⌋=3\lfloor log _{2}^{9} \rfloor=3⌊log29⌋=3,所以从步长为23=82^3=823=8开始试走,先走一步232^323到达节点222,此时depth[w]−depth[v]=1depth[w]-depth[v]=1depth[w]−depth[v]=1,22=4>12^2=4 >122=4>1 不能走,21=2>12^1=2 >121=2>1 不能走,20=1=12^0=1 =120=1=1 走完这步后和节点www同深度。
对于第二种情况,节点vvv和节点www沿着各自的祖先节点向上爬,我们并不知道需要向上爬多少步,但步数肯定小于depth[v]depth[v]depth[v]或者depth[w]depth[w]depth[w],所以我们可以从步长为222的⌊log2depth[v]−1⌋\lfloor log _{2}^{depth[v]-1} \rfloor⌊log2depth[v]−1⌋的步开始试走;

例如上图,v=18v=18v=18和w=19w=19w=19, depth[18]=depth[19]=11depth[18]=depth[19]=11depth[18]=depth[19]=11, ⌊log211−1⌋=3\lfloor log _{2}^{11-1} \rfloor=3⌊log211−1⌋=3,但vvv和www如果沿着各自祖先链向上爬步长为23=82^3=823=8一步后,在节点333相遇了,所以23=82^3=823=8这一步不能爬;vvv和www向上爬步长为22=42^2=422=4一步后,v=10v=10v=10和w=11w=11w=11,未相遇,这一步可以走;然后判断步长为21=22^1=221=2这步能不能爬,爬了这一步后v=6v=6v=6和w=7w=7w=7,未相遇,这一步可以走;最后判断步长为20=12^0=120=1这步能不能爬,爬了这一步后v=4v=4v=4和w=5w=5w=5,未相遇,这一步可以走;最终,vvv和www都到公共祖先链的下一个节点,在向上走步长为111的一步后到达最近公共祖先节点。
3. 算法核心
要实现该算法,这里有出现了两个难题:
- 对于节点vvv,距离为222的幂次方的祖先节点编号怎么求,从节点vvv出发,沿着祖先链走一步步长为222的幂次方的步就到达了该节点,这可以说是倍增核心;
- 对于任意距离ddd,⌊log2d⌋\lfloor log _{2}^{d} \rfloor⌊log2d⌋怎么求;
对于第一个问题,我们之前是使用fa[i]fa[i]fa[i]数组记录节点iii父节点的,并在从父节点递归到子节点时记录子节点的fa[]fa[]fa[],类似于递推。但是现在不仅要记录节点iii的父节点,还要记录与其距离为212^121,222^222,232^323,242^424 … 的祖先节点,所以将fa[]fa[]fa[]定义成以为数组肯定不够用,需要将其定义为二维数组fa[i][j]fa[i][j]fa[i][j],表示与节点iii相聚2j2^j2j的祖先节点编号,fa[i][0]fa[i][0]fa[i][0]和原来fa[i]fa[i]fa[i]相同,存储节点iii直接父节点。那fa[i][j]fa[i][j]fa[i][j]怎么求呢?

如上图,对于节点101010,depth[10]=10depth[10]=10depth[10]=10, ⌊log210−1⌋=3\lfloor log _{2}^{10-1} \rfloor=3⌊log210−1⌋=3,只需要求fa[10][0]fa[10][0]fa[10][0], fa[10][1]fa[10][1]fa[10][1],fa[10][2]fa[10][2]fa[10][2],fa[10][3]fa[10][3]fa[10][3]。例如,fa[10][3]=fa[6][2]=2fa[10][3]=fa[6][2]=2fa[10][3]=fa[6][2]=2,看出什么端倪出来没?大概什么意思呢,2j=2j−1+2j−12^j=2^{j-1}+2^{j-1}2j=2j−1+2j−1,就是说如果要从节点iii跳到距离为2j2^j2j的祖先节点,可以先跳到距离为2j−12^{j-1}2j−1次方的中间节点节点,再从这个节点出发跳一步2j−12^{j-1}2j−1就到了距离iii为2j2^j2j的祖先节点。与节点iii距离为2j−12^{j-1}2j−1次方的中间节点节点是不是就是fa[i][j−1]fa[i][j-1]fa[i][j−1],在从这个几点出发跳一步2j−12^{j-1}2j−1是不是就是fa[fa[i][j−1]][j−1]fa[fa[i][j-1]][j-1]fa[fa[i][j−1]][j−1],所以fa[i][j]fa[i][j]fa[i][j]可以通过fa[fa[i][j−1]][j−1]fa[fa[i][j-1]][j-1]fa[fa[i][j−1]][j−1]递推。
对于第二个问题,我们可以用递推。假设数组lg[i]lg[i]lg[i]存值为⌊log2i⌋\lfloor log _{2}^{i} \rfloor⌊log2i⌋,那么在知道lg[i−1]lg[i-1]lg[i−1]情况下如何推出lg[i]lg[i]lg[i]?
对于iii如果可以刚好表示成222的幂次方,那么i−1i-1i−1就不能表示成222的幂次方,lg[i]lg[i]lg[i]需要将lg[i−1]lg[i-1]lg[i−1]向下取整部分收为111,即lg[i]=lg[i+1]+1lg[i]=lg[i+1]+1lg[i]=lg[i+1]+1;如果iii不能表示成222的幂次方,则直接lg[i]=lg[i+1]lg[i]=lg[i+1]lg[i]=lg[i+1]。但是iii是不是222的幂次方也不好确认,我们可以这样,让lg[i]存⌊log2i⌋+1\lfloor log _{2}^{i} \rfloor+1⌊log2i⌋+1,在推lg[i]lg[i]lg[i]时,我们看下2lg[i−1]2^{lg[i-1]}2lg[i−1]是不是等于iii,如果相等说明进入刚好遇到222的幂次方,需要+1+1+1,222的幂次方可以通过位右移可以很快算出来。
代码如下:
for(int i = 1; i <= n; ++i){
lg[i] = lg[i-1] + (1 << lg[i-1] == i);
}
for(int i = 1; i <= n; ++i) lg[i]--;
OK, 所有问题搞定,直接上代码:
#include<bits/stdc++.h>
using namespace std;
// 最多节点数
const int maxn = 500005;
// fa[i][j] : 与节点i相距2的j次方的祖先节点编码
// depth[i] : 节点i深度
// lg[i] : lg2(i) 向下取整
// n : 节点数
// s : 根节点编号
int head[maxn], fa[maxn][32], depth[maxn],lg[maxn], cnt=0, n, s;
// 链式向前星存树模板代码
struct E {
int to, next;
} edge[maxn << 1];
void add(int from, int to) {
edge[cnt].to = to;
edge[cnt].next = head[from];
head[from] = cnt++;
}
void dfs(int r, int p) {
depth[r] = depth[p] + 1;
// 直接父节点,
fa[r][0] = p;
// 递推,把与节点i相距2的1次方到2lg[depth[r]]祖先节点编码全部推出
for(int i = 1; i <= lg[depth[r]-1]; ++i)
// 倍增核心代码
fa[r][i] = fa[fa[r][i-1]][i-1];
// 递归到子树
for(int i = head[r]; i != -1; i = edge[i].next)
if(edge[i].to != p)
dfs(edge[i].to, r);
}
int LCA(int u, int w) {
if(depth[u] < depth[w]) swap(u, w);
// 倍增让u跳到和w同深度
while(depth[u] > depth[w])
u = fa[u][lg[depth[u]-depth[w]]];
if(u == w) return w;
// 倍增让u和w LCA前的距离跳2完
for(int k = lg[depth[u]]; k >= 0; --k)
if(fa[u][k] != fa[w][k])
u = fa[u][k], w = fa[w][k];
// 再跳一步到LCA
return fa[u][0];
}
int main() {
memset(head, -1, sizeof(head));
cin>>n>>s;
int x, y;
for(int i = 1; i <= n-1; ++i) {
cin>>x>>y;
add(x, y);
add(y, x);
}
for(int i = 1; i <= n; ++i)
lg[i] = lg[i-1] + (1 << lg[i-1] == i);
for(int i = 1; i <= n; ++i) lg[i]--;
dfs(s, 0);
cin>>x>>y;
cout<<LCA(x, y)<<endl;
return 0;
}
4. 时间复杂度分析
函数dfs(int r, int p)需要递归到每个节点时间复杂度为O(n)O(n)O(n),对每个节点需要求与其相距222的幂次方祖先节点编号时间复杂度O(lgn)O(lgn)O(lgn),所以这个函数总时间复杂度O(nlgn)O(nlgn)O(nlgn)。LCA(int u, int w)函数时间复杂度为O(lgn)O(lgn)O(lgn),所以总时间复杂度为O(nlgn)O(nlgn)O(nlgn);
本文介绍了如何利用倍增思想优化最近公共祖先(LCA)的计算,通过预处理节点深度和与指定距离的祖先节点关系,将原本的逐层遍历提升为幂次级的效率提升。通过递推计算与节点距离的2的幂次方祖先,大大减少了查找LCA的时间复杂度。
687

被折叠的 条评论
为什么被折叠?



