解决LCA问题的三种算法

基础知识

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

在这里插入图片描述

如上图所示,根据定义,我们有 LCA(1,2)=1LCA(2,5)=2LCA(7,8)=1LCA(6,14)=3LCA(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

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

倍增

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

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

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

Tarjan\text{Tarjan}

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

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

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

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

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

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

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

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

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

最后是子树 484\rightarrow899 ,需要更新的就是 LCA(8,7)=root(7)=1\text{LCA}(8,7)=\text{root}(7)=1

至此,图遍历完了,询问也都解答完了。使用的时间就是一趟 dfs\text{dfs} 和若干趟对询问的遍历,因此时间复杂度 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} 算法复杂度最低,代码也很简单,不易出错,对于可以离线的 LCA\text{LCA} 询问, Tarjan\text{Tarjan} 算法是首选。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章