基礎知識
最近公共祖先(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);
}
}
算法複雜度最低,代碼也很簡單,不易出錯,對於可以離線的 詢問, 算法是首選。