问题引入
已知一棵含有 n n n 个结点的树,现在要求其中编号为 a a a 和 b b b 的两个结点的最近公共祖先(Least Common Ancestor)。应该怎么办?
思路
朴素算法
我们一般能够想到的是这样一种算法:先把两个结点移动到同一层,然后两个结点再同时往上移动。
举个例子,结点
a
a
a 在第
5
5
5 层,结点
b
b
b 在第
9
9
9 层。我们不难想到,这两个结点的最近公共祖先只有可能出现在第
5
5
5 层及以上的层数。所以我们首先做的事情是:对
b
b
b 进行
4
4
4 次求祖先操作(目的是到达和
a
a
a 同一层的祖先结点),得到处于第
5
5
5 层的结点
c
c
c。那么显然有 LCA(a,b) == LCA(a,c)
。随后我们做的事情是:同时对
a
a
a 和
c
c
c 进行求祖先操作,直到它们的祖先是同一个结点。
上面的朴素算法的时间复杂度是
O
(
n
)
O(n)
O(n),因为两步的时间复杂度均为
O
(
n
)
O(n)
O(n)。有没有更快的算法?
倍增算法
求解 LCA 问题的一个快速算法是倍增算法,时间复杂度是
O
(
log
n
)
O(\log n)
O(logn)。这种方法需要构造一个数组
f
[
i
]
[
j
]
f[i][j]
f[i][j],它表示编号为
j
j
j 的结点往祖先方向走
2
i
2^i
2i 得到的结点编号。数组的初始化可以利用下面的状态转移方程:
f
[
i
]
[
j
]
=
f
[
i
−
1
]
[
f
[
i
−
1
]
[
j
]
]
(1)
f[i][j]=f[i-1][f[i-1][j]]\tag 1
f[i][j]=f[i−1][f[i−1][j]](1)
它的含义显而易见,从
j
j
j 往祖先方向先走
2
i
−
1
2^{i-1}
2i−1 步,再走
2
i
−
1
2^{i-1}
2i−1 步,等同于直接往祖先方向走
2
i
2^i
2i 步。
我们的树含有
n
n
n 个节点,那么数组
f
f
f 的第
2
2
2 维大小就是
n
n
n。一个结点往祖先方向,最多走
n
n
n 步,所以数组的第
1
1
1 唯可以是
⌈
log
2
n
⌉
\lceil \log_2n\rceil
⌈log2n⌉。这下我们再考虑具体算法:
首先,我们仍然是将
a
a
a 和
b
b
b 统一到一层。考虑利用现有的
f
f
f 数组,每次走的步数都是
2
2
2 的幂次方倍(这也是算法叫做倍增算法的原因),因此将相差的层数拆解为
2
2
2 的幂次方的和。
比如,
a
a
a 在第
4
4
4 层,而
b
b
b 在第
114
114
114 层。
b
b
b 需要往祖先方向走
110
110
110 层。那么我们有如下拆解:
110
=
2
1
+
2
2
+
2
3
+
2
5
+
2
6
110=2^1+2^2+2^3+2^5+2^6
110=21+22+23+25+26。因此,只需要五次操作,即可让
b
b
b 到第
4
4
4 层:
b = f[1][b];
b = f[2][b];
b = f[3][b];
b = f[5][b];
b = f[6][b];
如果要向上走
k
k
k 层,由于
k
k
k 最多有
⌈
log
2
k
⌉
\lceil\log_2k\rceil
⌈log2k⌉ 个二进制位,所以最多需要借助
f
f
f 数组进行
⌈
log
k
⌉
\lceil\log k\rceil
⌈logk⌉ 次操作。由于最多有
n
n
n 层,所以这一步的时间复杂度是
o
(
log
n
)
o(\log n)
o(logn)。
其次,让
a
a
a 和
b
b
b 一起往上走。对于一个
k
=
⌈
log
2
n
⌉
k = \lceil\log_2 n\rceil
k=⌈log2n⌉,进行如下伪代码操作:
while(true){
while(k >=0 && f[k][a] == f[k][b])
k--;
if(k == -1){
a 和 b是同一个父亲的两个儿子,f[0][a] 或者 f[0][b] 都是 LCA(a,b);
return;
}
a = f[k][a];
b = f[k][b];
}
显然最外层的循环最多执行
k
k
k 次左右,所以可以在
O
(
k
)
O(k)
O(k) 的时间复杂度内完成 LCA 的查找,也即时间复杂度
O
(
log
n
)
O(\log n)
O(logn)。
所以,倍增算法初始化
f
f
f 数组的时间复杂度是
O
(
n
log
n
)
O(n\log n)
O(nlogn),进行一次查询的时间复杂度是
O
(
log
n
)
O(\log n)
O(logn)。当查询次数较多时(例如达到了
n
n
n 的数量级),倍增算法的效率要远远由于朴素算法。
样例代码
针对这个模版,可以参考下面的代码:
#include<iostream>
using namespace std;
int n,m,s,ecnt=1,fedge[500005],ledge[500005],f[20][500005],layer[500005];
struct {
int end,next;
}edge[1000005];
void buildarc(int begin,int end){
if(!begin)
return;
if(!fedge[begin])
fedge[begin]=ledge[begin]=ecnt;
else{
edge[ledge[begin]].next=ecnt;
ledge[begin]=ecnt;
}
edge[ecnt++].end=end;
}
void dfs(int cur,int l,int fa){
layer[cur]=l;
for(int e=fedge[cur];e;e=edge[e].next){
if(edge[e].end==fa)
continue;
f[0][edge[e].end]=cur;
dfs(edge[e].end,l+1,cur);
}
}
void look(int a,int b){
int temp=layer[b]-layer[a];
for(int base=0;temp;base++,temp/=2){
if(temp%2)
b=f[base][b];
}
if(a==b){
printf("%d\n",a);
return;
}
temp = 19;
while(true){
while(temp>=0 && f[temp][a]==f[temp][b])
temp--;
if(temp==-1){
printf("%d\n",f[0][a]);
return;
}
a=f[temp][a];
b=f[temp][b];
}
}
int main(){
int u,v;
cin>>n>>m>>s;
for(int i=1;i<n;i++){
scanf("%d%d",&u,&v);
buildarc(u,v);
buildarc(v,u);
}
dfs(s,1,0);
for(int i=1;i<=19;i++)
for(int j=1;j<=n;j++){
f[i][j]=f[i-1][f[i-1][j]];
}
for(int i=1;i<=m;i++){
scanf("%d%d",&u,&v);
if(layer[u]>layer[v])
look(v,u);
else
look(u,v);
}
return 0;
}
顺便说明一下,把数组 f f f 的小维放在第一维,可以有效减少 cache miss 的几率,提高程序运行效率。