基础知识
最近公共祖先(Least Common Ancestors),简称 。一棵有根树上两个结点 的 指的是同为 和 的祖先中深度最大的那个结点。
如上图所示,根据定义,我们有
为了避免使用复杂度为 的暴力算法找出两个结点的 ,我们现在提出三种算法。其中第一种,倍增,在线算法,预处理复杂度 ,每次查询 ;第二种,,在线算法,预处理复杂度 ,每次查询 ;第三种, ,离线算法,复杂度 。
倍增
这个算法应该是最容易想到的,因为它本质上属于暴力算法的优化版本,只是把暴力算法的一次只跳一步变为了一次跳 步。算法使用数组 表示结点 的第 个父亲,则有递推式 即,结点 的第 个父亲是结点 的第 个父亲的第 个父亲。
算法对于每一个询问 ,首先将 和 调整到同一个深度,然后同时向上跳,第一个一样的就是它们的 。
代码实现如下:
const int MAXN=5e4+10;
const int DEG=20;
struct Edge{
int to,next;
}edge[MAXN<<1];
int head[MAXN],tot;
void addedge(int u,int v){
edge[tot]=(Edge){v,head[u]};
head[u]=tot++;
edge[tot]=(Edge){u,head[v]};
head[v]=tot++;
}
void init(){
tot=0;
memset(head,-1,sizeof(head));
}
int fa[MAXN][DEG];
int dep[MAXN];
void bfs(int r){
dep[r]=0;
fa[r][0]=r;
queue<int> Q;
Q.push(r);
while(!Q.empty()){
int u=Q.front();Q.pop();
for (int i=1;i<DEG;++i)
fa[u][i]=fa[fa[u][i-1]][i-1];
for (int i=head[u];~i;i=edge[i].next) {
int v=edge[i].to;
if (v==fa[u][0]) continue;
dep[v]=dep[u]+1;
fa[v][0]=u;
Q.push(v);
}
}
}
int LCA(int u,int v){
if (dep[u]>dep[v]) swap(u,v);
int hu=dep[u],hv=dep[v];
int uu=u,vv=v;
for (int det=hv-hu,i=0;det;det>>=1,++i)
if(det&1) vv=fa[vv][i];
if (uu==vv) return uu;
for (int i=DEG-1;i>=0;--i){
if (fa[uu][i]==fa[vv][i]) continue;
uu=fa[uu][i];
vv=fa[vv][i];
}
return fa[uu][0];
}
该算法的思想为,按照欧拉序存储结点的深度,则 就是欧拉序上 所在位置到 所在位置区间上的最小值。这可以用 表来解决,但数据结构的东西笔者暂时还不太熟,所以这个算法的细节以后有机会再补。
该算法的思想为,对于任意一个结点 ,处于 的不同子树上的两个结点 ,一定有 。这个结论非常显然。首先, 是 的共同祖先;其次,任何深度大于 的结点都只会至多存在于 的一棵子树中,不可能是既是 的祖先又是 的祖先,因而 是 的最近公共祖先。
因此,我们可以暂时忽略询问中 和 的具体位置,而只是关心它们是否在某结点的不同子树中。我们使用并查集存储不同的子树,对于不在同一个并查集中的两个结点,它们的 即为这两个并查集的根的 。需注意,并查集是动态变化的,因为子树的划分有多种,子树也有大小之分。我们应该从下往上、由小到大地将子树一棵棵合并,并在同时更新答案。
从具体的实现方式来说,算法按照 序访问和合并结点,当访问到叶子结点 时,对于询问 ,如果 已经被访问过,则用 所在并查集的根的祖先更新 ,然后一边回溯一边合并一边更新。
先不着急给出代码,我们使用一开始给出的那张图作为例子,假设我们要询问 , , , , 。
算法首先遍历 ,一路上设置 。当发现到叶子了,看询问,发现有一个 ,则更新答案 。然后回溯,合并 和 ,更新答案 和 。
接着是下一棵子树 ,同样的把 一路设过去,到了 。看询问,有个 ,但 没被访问过,直接跳过。回溯,合并 和 ,发现有个询问 ,但 也没被访问过,同样跳过。
然后是子树 , 设过去,发现询问有 ,更新答案 。回溯,合并 和 。
然后, 的子树都访问完了,合并 ,不需要更新答案,因为 仍然没有访问到。往上, 再合并 ,也没有更新操作。
接着就是 ,有个 但还没访问,合并。 ,合并。到了 ,终于更新了,答案为 。回溯,合并合并再合并,目前为止, 的子树和 的子树全和 在同一个并查集里了。
最后是子树 和 ,需要更新的就是 。
至此,图遍历完了,询问也都解答完了。使用的时间就是一趟 和若干趟对询问的遍历,因此时间复杂度 。
以下是算法的主要实现代码:
const int MAXN=5e4+10;
const int MAXQ=1e5+10;
int F[MAXN];
int root(int v){return (F[v]==v||F[v]==-1)?v:F[v]=root(F[v]);}
inline void Union(int u,int v){
int r1=root(u),r2=root(v);
if (r1!=r2) F[r1]=r2;
}
struct Edge{
int to,next;
}edge[MAXN<<1];
int head[MAXN],tot;
void addedge(int u,int v){
edge[tot]=(Edge){v,head[u]};
head[u]=tot++;
edge[tot]=(Edge){u,head[v]};
head[v]=tot++;
}
struct Query{
int to,next,idx;
}query[MAXQ<<1];
int qhead[MAXQ],qtot;
void addquery(int u,int v,int idx){
query[qtot]=(Query){v,qhead[u],idx};
qhead[u]=qtot++;
query[qtot]=(Query){u,qhead[v],idx};
qhead[v]=qtot++;
}
void init(){
tot=qtot=0;
memset(head,-1,sizeof(head));
memset(qhead,-1,sizeof(qhead));
memset(F,-1,sizeof(F));
}
int answer[MAXQ];
void Tarjan(int u){
F[u]=u;
for (int i=head[u];~i;i=edge[i].next){
int v=edge[i].to;
if (~F[v]) continue;
Tarjan(v);
Union(v,u);
}
for (int i=qhead[u];~i;i=query[i].next){
int v=query[i].to;
if (~F[v]) answer[query[i].idx]=root(v);
}
}
算法复杂度最低,代码也很简单,不易出错,对于可以离线的 询问, 算法是首选。