解決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} 算法是首選。

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