解决LCA问题的三种算法

基础知识

最近公共祖先(Least Common Ancestors),简称 LCA \text{LCA} LCA 。一棵有根树上两个结点 u , v u,v u,v LCA ( u , v ) \text{LCA}(u,v) LCA(u,v) 指的是同为 u u u v v v 的祖先中深度最大的那个结点。

解决LCA问题的三种算法_第1张图片

如上图所示,根据定义,我们有 LCA ( 1 , 2 ) = 1 LCA ( 2 , 5 ) = 2 LCA ( 7 , 8 ) = 1 LCA ( 6 , 14 ) = 3 LCA ( 10 , 15 ) = 6 \text{LCA}(1,2)=1 \\ \text{LCA}(2,5)=2 \\ \text{LCA}(7,8)=1 \\ \text{LCA}(6,14)=3 \\ \text{LCA}(10,15)=6 LCA(1,2)=1LCA(2,5)=2LCA(7,8)=1LCA(6,14)=3LCA(10,15)=6

为了避免使用复杂度为 O ( V 2 ) O(V^2) O(V2) 的暴力算法找出两个结点的 LCA \text{LCA} LCA ,我们现在提出三种算法。其中第一种,倍增,在线算法,预处理复杂度 V log ⁡ V V\log V VlogV ,每次查询 O ( log ⁡ V ) O(\log V) O(logV) ;第二种, dfs+ST \text{dfs+ST} dfs+ST,在线算法,预处理复杂度 O ( V log ⁡ V ) O(V\log V) O(VlogV) ,每次查询 O ( 1 ) O(1) O(1) ;第三种, Tarjan \text{Tarjan} Tarjan ,离线算法,复杂度 O ( V + Q ) O(V+Q) O(V+Q)

倍增

这个算法应该是最容易想到的,因为它本质上属于暴力算法的优化版本,只是把暴力算法的一次只跳一步变为了一次跳 2 k 2^k 2k 步。算法使用数组 fa [ i ] [ j ] \text{fa}[i][j] fa[i][j] 表示结点 i i i 的第 2 j 2^j 2j 个父亲,则有递推式 fa [ i ] [ j ] = fa [ fa [ i ] [ j − 1 ] ] [ j − 1 ] \text{fa}[i][j]=\text{fa}[\text{fa}[i][j-1]][j-1] fa[i][j]=fa[fa[i][j1]][j1] 即,结点 i i i 的第 2 j 2^j 2j 个父亲是结点 i i i 的第 2 j − 1 2^{j-1} 2j1 个父亲的第 2 j − 1 2^{j-1} 2j1 个父亲。

算法对于每一个询问 ( u , v ) (u,v) (u,v) ,首先将 u u u v v v 调整到同一个深度,然后同时向上跳,第一个一样的就是它们的 LCA \text{LCA} LCA

代码实现如下:

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];
}

dfs+ST \text{dfs+ST} dfs+ST

该算法的思想为,按照欧拉序存储结点的深度,则 LCA ( u , v ) \text{LCA}(u,v) LCA(u,v) 就是欧拉序上 u u u 所在位置到 v v v 所在位置区间上的最小值。这可以用 S T ST ST 表来解决,但数据结构的东西笔者暂时还不太熟,所以这个算法的细节以后有机会再补。

Tarjan \text{Tarjan} Tarjan

该算法的思想为,对于任意一个结点 r r r ,处于 r r r 的不同子树上的两个结点 u , v u,v u,v ,一定有 LCA ( u , v ) = r \text{LCA}(u,v)=r LCA(u,v)=r 。这个结论非常显然。首先, r r r u , v u,v u,v 的共同祖先;其次,任何深度大于 r r r 的结点都只会至多存在于 r r r 的一棵子树中,不可能是既是 u u u 的祖先又是 v v v 的祖先,因而 r r r u , v u,v u,v 的最近公共祖先。

因此,我们可以暂时忽略询问中 u u u v v v 的具体位置,而只是关心它们是否在某结点的不同子树中。我们使用并查集存储不同的子树,对于不在同一个并查集中的两个结点,它们的 LCA \text{LCA} LCA 即为这两个并查集的根的 LCA \text{LCA} LCA 。需注意,并查集是动态变化的,因为子树的划分有多种,子树也有大小之分。我们应该从下往上、由小到大地将子树一棵棵合并,并在同时更新答案。

从具体的实现方式来说,算法按照 dfs \text{dfs} dfs 序访问和合并结点,当访问到叶子结点 u u u 时,对于询问 LCA ( u , v ) \text{LCA}(u,v) LCA(u,v) ,如果 v v v 已经被访问过,则用 v v v 所在并查集的根的祖先更新 LCA ( u , v ) \text{LCA}(u,v) LCA(u,v) ,然后一边回溯一边合并一边更新。

先不着急给出代码,我们使用一开始给出的那张图作为例子,假设我们要询问 ( 1 , 2 ) (1,2) (1,2) ( 2 , 5 ) (2,5) (2,5) ( 7 , 8 ) (7,8) (7,8) ( 6 , 14 ) (6,14) (6,14) ( 10 , 15 ) (10,15) (10,15)

算法首先遍历 1 → 2 → 5 1\rightarrow2\rightarrow5 125 ,一路上设置 root [ u ] = u \text{root}[u]=u root[u]=u 。当发现到叶子了,看询问,发现有一个 ( 5 , 2 ) (5,2) (5,2) ,则更新答案 LCA ( 5 , 2 ) = root ( 2 ) = 2 \text{LCA}(5,2)=\text{root}(2)=2 LCA(5,2)=root(2)=2 。然后回溯,合并 5 5 5 2 2 2 ,更新答案 LCA ( 2 , 5 ) = root ( 5 ) = 2 \text{LCA}(2,5)=\text{root}(5)=2 LCA(2,5)=root(5)=2 LCA ( 2 , 1 ) = root ( 1 ) = 1 \text{LCA}(2,1)=\text{root}(1)=1 LCA(2,1)=root(1)=1

接着是下一棵子树 3 → 6 → 10 3\rightarrow6\rightarrow10 3610 ,同样的把 root \text{root} root 一路设过去,到了 10 10 10 。看询问,有个 ( 10 , 15 ) (10,15) (10,15) ,但 15 15 15 没被访问过,直接跳过。回溯,合并 10 10 10 6 6 6 ,发现有个询问 ( 6 , 14 ) (6,14) (6,14) ,但 14 14 14 也没被访问过,同样跳过。

然后是子树 11 → 15 11\rightarrow15 1115 root \text{root} root 设过去,发现询问有 ( 15 , 10 ) (15,10) (15,10) ,更新答案 LCA ( 15 , 10 ) = root ( 10 ) = 6 \text{LCA}(15,10)=\text{root}(10)=6 LCA(15,10)=root(10)=6 。回溯,合并 15 15 15 11 11 11

然后, 6 6 6 的子树都访问完了,合并 6 , 11 6,11 6,11 ,不需要更新答案,因为 14 14 14 仍然没有访问到。往上, 再合并 6 , 3 6,3 6,3 ,也没有更新操作。

接着就是 7 → 12 7\rightarrow12 712 ,有个 ( 7 , 8 ) (7,8) (7,8) 但还没访问,合并。 13 13 13 ,合并。到了 14 14 14 ,终于更新了,答案为 LCA ( 14 , 6 ) = root ( 6 ) = 3 \text{LCA}(14,6)=\text{root}(6)=3 LCA(14,6)=root(6)=3 。回溯,合并合并再合并,目前为止, 3 3 3 的子树和 2 2 2 的子树全和 1 1 1 在同一个并查集里了。

最后是子树 4 → 8 4\rightarrow8 48 9 9 9 ,需要更新的就是 LCA ( 8 , 7 ) = root ( 7 ) = 1 \text{LCA}(8,7)=\text{root}(7)=1 LCA(8,7)=root(7)=1

至此,图遍历完了,询问也都解答完了。使用的时间就是一趟 dfs \text{dfs} dfs 和若干趟对询问的遍历,因此时间复杂度 O ( V + Q ) O(V+Q) O(V+Q)

以下是算法的主要实现代码:

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);
    }
}

Tarjan \text{Tarjan} Tarjan 算法复杂度最低,代码也很简单,不易出错,对于可以离线的 LCA \text{LCA} LCA 询问, Tarjan \text{Tarjan} Tarjan 算法是首选。

你可能感兴趣的:(算法笔记)