最近公共祖先(Least Common Ancestors,LCA)問題詳解

問題描述與分析
求有根樹的任意兩個節點的最近公共祖先。node

解答這個問題以前,我們得先搞清楚到底什麼是最近公共祖先。最近公共祖先簡稱LCA(Lowest Common Ancestor),所謂LCA,是當給定一個有根樹T時,對於任意兩個結點u、v,找到一個離根最遠的結點x,使得x同時是u和v的祖先,x 即是u、v的最近公共祖先。(參見:http://en.wikipedia.org/wiki/Lowest_common_ancestor )原問題涵蓋通常性的有根樹,本文爲了簡化,多使用二叉樹來討論。ios

舉個例子,如針對下圖所示的一棵普通的二叉樹來說:git

結點3和結點4的最近公共祖先是結點2,即LCA(3,4)=2 。在此,須要注意到當兩個結點在同一棵子樹上的狀況,如結點3和結點2的最近公共祖先爲2,即 LCA(3,2)=2。同理:LCA(5,6)=4,LCA(6,10)=1。github

明確了題意,我們便來試着解決這個問題。直觀的作法,多是針對是否爲二叉查找樹分狀況討論,這也是通常人最早想到的思路。除此以外,還有所謂的Tarjan算法、倍增算法、以及轉換爲RMQ問題(求某段區間的極值)。後面這幾種算法相對高級,不那麼直觀,但思路比較有啓發性,瞭解一下也有裨益。算法

解法一:暴力對待
1.一、是二叉查找樹
在當這棵樹是二叉查找樹的狀況下,以下圖:數組

那麼從樹根開始:數據結構

若是當前結點t 大於結點u、v,說明u、v都在t 的左側,因此它們的共同祖先一定在t 的左子樹中,故從t 的左子樹中繼續查找;
若是當前結點t 小於結點u、v,說明u、v都在t 的右側,因此它們的共同祖先一定在t 的右子樹中,故從t 的右子樹中繼續查找;
若是當前結點t 知足 u <t < v,說明u和v分居在t 的兩側,故當前結點t 即爲最近公共祖先;
而若是u是v的祖先,那麼返回u的父結點,同理,若是v是u的祖先,那麼返回v的父結點。
代碼以下所示:ide

public int query(Node t, Node u, Node v) {    
    int left = u.value;    
    int right = v.value;    

    //二叉查找樹內,若是左結點大於右結點,不對,交換  
    if (left > right) {    
        int temp = left;    
        left = right;    
        right = temp;    
    }    

    while (true) {    
        //若是t小於u、v,往t的右子樹中查找  
        if (t.value < left) {    
            t = t.right;    
        //若是t大於u、v,往t的左子樹中查找  
        } else if (t.value > right) {    
            t = t.left;    
        } else {    
            return t.value;    
        }    
    }    
}

1.二、不是二叉查找樹
但若是這棵樹不是二叉查找樹,只是一棵普通的二叉樹呢?若是每一個結點都有一個指針指向它的父結點,因而咱們能夠從任何一個結點出發,獲得一個到達樹根結點的單向鏈表。所以這個問題轉換爲兩個單向鏈表的第一個公共結點。函數

此外,若是給出根節點,LCA問題能夠用遞歸很快解決。而關於樹的問題通常均可以轉換爲遞歸(由於樹原本就是遞歸描述),參考代碼以下:this

node* getLCA(node* root, node* node1, node* node2)  
{  
    if(root == null)  
        return null;  
    if(root== node1 || root==node2)  
        return root;  

    node* left = getLCA(root->left, node1, node2);  
    node* right = getLCA(root->right, node1, node2);  

    // node1 和 node2 不存在祖先關係
    if(left != null && right != null)  
        return root; 
    // node1 和 node2 其中一個是另外一個的祖先
    else if(left != null)  
        return left;  
    else if (right != null)  
        return right;  
    else   
        return null;  
}

不管是針對普通的二叉樹,仍是針對二叉查找樹,上面的解法有一個很大的弊端就是:如需N 次查詢,則整體複雜度會擴大N 倍,故這種暴力解法僅適合一次查詢,不適合屢次查詢。

接下來的解法,將再也不區別對待是否爲二叉查找樹,而是一致當作是一棵普通的二叉樹。整體來講,因爲能夠把LCA問題當作是詢問式的,即給出一系列詢問,程序對每個詢問儘快作出反應。故處理這類問題通常有兩種解決方法:

一種是在線算法,至關於按部就班處理;
另一種則是離線算法,如Tarjan算法,至關於一次性批量處理,一開始就知道了所有查詢,只待詢問。
解法二:Tarjan算法
如上文末節所述,不論我們所面對的二叉樹是二叉查找樹,或不是二叉查找樹,均可以把求任意兩個結點的最近公共祖先,當作是查詢的問題,若是是隻求一次,則是單次查詢;若是要求多個任意兩個結點的最近公共祖先,則至關因而批量查詢。

涉及到批量查詢的時候,我們能夠借鑑離線處理的方式,這就引出瞭解決此LCA問題的Tarjan離線算法。

2.一、什麼是Tarjan算法
Tarjan算法 (以發現者Robert Tarjan命名)是一個在圖中尋找強連通份量的算法。算法的基本思想爲:任選一結點開始進行深度優先搜索dfs(若深度優先搜索結束後仍有未訪問的結點,則再從中任選一點再次進行)。搜索過程當中已訪問的結點再也不訪問。搜索樹的若干子樹構成了圖的強連通份量。

應用到我們要解決的LCA問題上,則是:對於新搜索到的一個結點u,先建立由u構成的集合,再對u的每顆子樹進行搜索,每搜索完一棵子樹,這時候子樹中全部的結點的最近公共祖先就是u了。

舉一個例子,以下圖(不一樣顏色的結點至關於不一樣的集合):

假設遍歷完10的孩子,要處理關於10的請求了,取根節點到當前正在遍歷的節點的路徑爲關鍵路徑,即1-3-8-10,集合的祖先即是關鍵路徑上距離集合最近的點。

好比:

1,2,5,6爲一個集合,祖先爲1,集合中點和10的LCA爲1
3,7爲一個集合,祖先爲3,集合中點和10的LCA爲3
8,9,11爲一個集合,祖先爲8,集合中點和10的LCA爲8
10,12爲一個集合,祖先爲10,集合中點和10的LCA爲10
得出的結論即是:LCA(u,v)即是根至u的路徑上到節點v最近的點。

2.二、Tarjan算法如何而來
但關鍵是 Tarjan算法是怎麼想出來的呢?再給定下圖,你是否能看出來:分別從結點1的左右子樹當中,任取一個結點,設爲u、v,這兩個任意結點u、v的最近公共祖先都爲1。

於此,咱們能夠得知:若兩個結點u、v分別分佈於某節點t 的左右子樹,那麼此節點 t即爲u和v的最近公共祖先。更進一步,考慮到一個節點本身就是LCA的狀況,得知:

若某結點t 是兩結點u、v的祖先之一,且這兩結點並不分佈於該結點t 的一棵子樹中,而是分別在結點t 的左子樹、右子樹中,那麼該結點t 即爲兩結點u、v的最近公共祖先。
這個定理就是Tarjan算法的基礎。

一如上文1.1節咱們獲得的結論:「若是當前結點t 知足 u <t < v,說明u和v分居在t 的兩側,故當前結點t 即爲最近公共祖先」。

而對於本節開頭咱們所說的「若是要求多個任意兩個結點的最近公共祖先,則至關因而批量查詢」,即在不少組的詢問的狀況下,或許能夠先肯定一個LCA。例如是根節點1,而後再去檢查全部詢問,看是否知足剛纔的定理,不知足就忽視,知足就賦值,所有弄完,再去假設2號節點是LCA,再去訪問一遍。

可此方法須要判斷一個結點是在左子樹、仍是右子樹,或是都不在,都只能遍歷一棵樹,而屢次遍歷的代價實在是太大了,因此咱們須要找到更好的方法。這就引出了下面要闡述的Tarjan算法,即每一個結點只遍歷一次,怎麼作到的呢,請看下文講解。

2.三、Tarjan算法流程
Tarjan算法流程爲:

Procedure dfs(u);
begin
設置u號節點的祖先爲u
若u的左子樹不爲空,dfs(u - 左子樹);
若u的右子樹不爲空,dfs(u - 右子樹);
訪問每一條與u相關的詢問u、v
-若v已經被訪問過,則輸出v當前的祖先t(t即u,v的LCA)
標記u爲已經訪問,將全部u的孩子包括u自己的祖先改成u的父親
end
普通的dfs 不能直接解決LCA問題,故Tarjan算法的原理是dfs + 並查集,它每次把兩個結點對的最近公共祖先的查詢保存起來,而後dfs 更新一次。如此,利用並查集優越的時空複雜度,此算法的時間複雜度能夠縮小至O(n+Q),其中,n爲數據規模,Q爲詢問個數。
2.四、Tarjan算法C++實現示例
#include <iostream>
#include <vector>
#include <map>
#include <set>
#include <cstring>
using namespace std;

struct Node
{
    int val;
    vector<int> chlidren;
    Node(int v):val(v) {}
};

class UnSet
{
public:
    int *father;

    UnSet(int n): capacity(n)
    {
        father = new int[n];
        for (int i = 0; i < n ;i++)
            father[i] = i;
    }

    ~UnSet()
    {
        delete[] father;
    }

    int find(int i)
    {
        return (father[i] == i) ? i : (father[i] = find(father[i]));
    }

    void unionSet(int a, int b)
    {
        a = find(a);
        b = find(b);
        father[b] = a;
    }

    int size()
    {
        return capacity;
    }

private:
    int capacity;
};

class LCA
{
public:
    LCA(Node tree[], int n): fatSet(n) //root is tree[0]
    {
        hasVisit = new bool[n];
        memset(hasVisit, false, n * sizeof(bool));
        this->tree = tree;
    }

    map<pair<int, int>, int> calLCA(vector<pair<int, int> >& query)
    {
        map<int, set<int> > queryMap;
        map<pair<int, int>, int> result;
        for (int i =0; i< query.size(); i++)
        {
            int u = query[i].first;
            int v = query[i].second;
            set<int> temp;
            if (queryMap.count(u) == 0)
                queryMap[u] = temp;
            if (queryMap.count(v) == 0)
                queryMap[v] = temp;
            queryMap[u].insert(v);
            queryMap[v].insert(u);
        }
        tarjan(0, queryMap, result);
        return result;
    }

    void tarjan(int u, map<int, set<int> >& queryMap, map<pair<int, int>, int>& result)
    {
        fatSet.father[u] = u;
        hasVisit[u] = true;
        cout << "Visit " << u << endl;
        for (set<int>::iterator it = queryMap[u].begin(); it != queryMap[u].end(); it++)
        {
            int v = *it;
            if (hasVisit[*it])
            {
                pair<int,int> temp(v, u);
                result[temp] = fatSet.find(v);
            }
        }
        for (int i = 0; i < tree[u].chlidren.size(); i++)
        {
            int v = tree[u].chlidren[i];
            if (false == hasVisit[v])
            {
                tarjan(v, queryMap, result);
                fatSet.unionSet(u, v);
            }
        }
    }

private:
    bool *hasVisit;
    UnSet fatSet;
    Node *tree;
};

int main()
{
    Node testTree[5] = {0, 0, 0, 0, 0};
    testTree[0].chlidren.push_back(1);  
    testTree[0].chlidren.push_back(2);  
    testTree[1].chlidren.push_back(3);  
    testTree[1].chlidren.push_back(4);  

    vector<pair<int,int> > query;
    pair<int, int> a(2,3), b(3,4), c(4,1);
    query.push_back(a);
    query.push_back(b);
    query.push_back(c);

    LCA l(testTree, 5);
    map<pair<int, int>, int> ret = l.calLCA(query);

    cout << "Result: ";
    for (map<pair<int, int>, int>::iterator it = ret.begin(); it != ret.end(); it++)
        cout << it->first.first << "," << it->first.second << "->" << it->second << "\t";   
    cout << endl;

    return 0;
}

解法三:轉換爲RMQ問題
解決此最近公共祖先問題的還有一個算法,即轉換爲RMQ問題,用Sparse Table(簡稱ST)算法解決。

3.一、什麼是RMQ問題
RMQ,全稱爲Range Maximum/Minimm Query,顧名思義,則是區間最值查詢,它被用來在數組中查找兩個指定索引中最大/小值的位置。咱們以區間最小值查詢爲例,即RMQ至關於給定數組A[0, N-1],找出給定的兩個索引如 i、j 間的最小值的位置。

假設一個算法預處理時間爲 f(n),查詢時間爲g(n),那麼這個算法複雜度的標記爲<f(n), g(n)>。咱們將用RMQA(i, j) 來表示數組A 中索引i 和 j 之間最小值的位置。 u和v的離樹T根結點最遠的公共祖先用LCA T(u, v)表示。

以下圖所示,RMQA(2,7 )則表示求數組A中從A[2]~A[7]這段區間中的最小值:

3.二、如何解決RMQ問題
3.2.一、Trivial algorithms for RMQ
下面,咱們對對每一對索引(i, j),將數組中索引i 和 j 之間最小值的位置 RMQA(i, j) 存儲在M[0, N-1][0, N-1]表中。 RMQA(i, j) 有不一樣種計算方法,你會看到,隨着計算方法的不一樣,它的時空複雜度也不一樣:

普通的計算將獲得一個 <O(N^3), 0(1)> 複雜度的算法。儘管如此,經過使用一個簡單的動態規劃方法,咱們能夠將複雜度下降到 <O(N^2), 0(1)>。如何作到的呢?方法以下代碼所示:
void process1(int M[MAXN][MAXN], int A[MAXN], int N)
{
int i, j;
for (i =0; i < N; i++)
M[i][i] = i;

for (i = 0; i < N; i++)  
        for (j = i + 1; j < N; j++)  
            //若前者小於後者,則把後者的索引值付給M[i][j]  
            if (A[M[i][j - 1]] < A[j])  
                M[i][j] = M[i][j - 1];  
            //不然前者的索引值付給M[i][j]  
            else  
                M[i][j] = j;  
}

一個比較有趣的點子是把向量分割成sqrt(N)大小的段。咱們將在M[0,sqrt(N)-1]爲每個段保存最小值的位置。如此,M能夠很容易的在O(N)時間內預處理。

一個更好的方法預處理RMQ 是對2^k 的長度的子數組進行動態規劃。咱們將使用數組M[0, N-1][0, logN]進行保存,其中M[ i ][ j ] 是以i 開始,長度爲 2^j 的子數組的最小值的索引。這就引出了我們接下來要介紹的Sparse Table (ST) algorithm。
3.2.二、Sparse Table (ST) algorithm

在上圖中,咱們能夠看出:

在A[1]這個長度爲2^0的區間內,最小值即爲A[1] = 4,故最小值的索引M[1][0]爲1;
在A[1]、A[2] 這個長度爲2^1的區間內,最小值爲A[2] = 3,故最小值的索引爲M[1][1] = 2;
在A[1]、A[2]、A[3]、A[4]這個長度爲2^2的區間內,最小值爲A[3] = 1,故最小值的索引M[1][2] = 3。
爲了計算M[i][j]咱們必須找到前半段區間和後半段區間的最小值。很明顯小的片斷有着2^(j-1)長度,所以遞歸以下

根據上述公式,能夠寫出這個預處理的遞歸代碼,以下:

void process2(int M[MAXN][LOGMAXN], int A[MAXN], int N)  
{  
    int i, j;  
    //initialize M for the intervals with length 1  

    for (i = 0; i < N; i++)  
        M[i][0] = i;  

    //compute values from smaller to bigger intervals  
    for (j = 1; 1 << j <= N; j++)  
        for (i = 0; i + (1 << j) - 1 < N; i++)  
            if (A[M[i][j - 1]] < A[M[i + (1 << (j - 1))][j - 1]])  
                M[i][j] = M[i][j - 1];  
            else  
                M[i][j] = M[i + (1 << (j - 1))][j - 1];  
}

通過這個O(N logN)時間複雜度的預處理以後,讓咱們看看怎樣使用它們去計算 RMQA(i, j)。思路是選擇兩個可以徹底覆蓋區間[i..j]的塊而且找到它們之間的最小值。設k = log(j - i + 1)。

則 i 到 j 之間的子數組能夠分爲兩部分:

以 i 開始,長度爲2^k的一段
以 j 結束,長度爲2^k的一段(能夠計算獲得起始位置爲 j - 2^k + 1)
爲了計算 RMQA(i, j),咱們可使用下面的公式:

故,綜合來看,我們預處理的時間複雜度從O(N^3)下降到了O(N logN),查詢的時間複雜度爲O(1),因此最終的總體複雜度爲:<O(N logN), O(1)>。
3.三、LCA與RMQ的關聯性
如今,讓咱們看看怎樣用RMQ來計算LCA查詢。事實上,咱們能夠在線性時間裏將LCA問題規約到RMQ問題,所以每個解決RMQ的問題均可以解決LCA問題。讓咱們經過例子來講明怎麼規約的:

注意LCAT(u, v)是在對T進行dfs過程中在訪問u和v之間離根結點最近的點。所以咱們能夠考慮樹的歐拉環遊過程u和v之間全部的結點,並找到它們之間處於最低層的結點。爲了達到這個目的,咱們能夠創建三個數組:

E[1, 2N-1] - 對T進行歐拉環遊過程當中全部訪問到的結點;E[i]是在環遊過程當中第i個訪問的結點
L[1, 2
N-1] - 歐拉環遊中訪問到的結點所處的層數;L[i]是E[i]所在的層數
H[1, N] - H[i] 是E中結點i第一次出現的下標(任何出現i的地方都行,固然選第一個不會錯)
假定H[u]<H[v]。能夠很容易的看到u和v第一次出現的結點是E[H[u]..H[v]]。如今,咱們須要找到這些結點中的最低層。爲了達到這個目的,咱們可使用RMQ。所以 LCAT(u, v) = E[RMQL(H[u], H[v])] ,RMQ返回的是索引,下面是E,L,H數組:

注意L中連續的元素相差爲1。

3.四、從RMQ到LCA
咱們已經看到了LCA問題能夠在線性時間規約到RMQ問題。如今讓咱們來看看怎樣把RMQ問題規約到LCA。這個意味着咱們實際上能夠把通常的RMQ問題規約到帶約束的RMQ問題(這裏相鄰的元素相差1)。爲了達到這個目的,咱們須要使用笛卡爾樹。

對於數組A[0,N-1]的笛卡爾樹C(A)是一個二叉樹,根節點是A的最小元素,假設i爲A數組中最小元素的位置。當i>0時,這個笛卡爾樹的左子結點是A[0,i-1]構成的笛卡爾樹,其餘狀況沒有左子結點。右結點相似的用A[i+1,N-1]定義。注意對於具備相同元素的數組A,笛卡爾樹並不惟一。在本文中,將會使用第一次出現的最小值,所以笛卡爾樹看做惟一。能夠很容易的看到RMQA(i, j) = LCAC(i, j)。

下面是一個例子:

如今咱們須要作的僅僅是用線性時間計算C(A)。這個可使用棧來實現。

初始棧爲空。
而後咱們在棧中插入A的元素。
在第i步,A[i]將會緊挨着棧中比A[i]小或者相等的元素插入,而且全部較大的元素將會被移除。
在插入結束以前棧中A[i]位置前的元素將成爲i的左兒子,A[i]將會成爲它以後一個較小元素的右兒子。
在每一步中,棧中的第一個元素老是笛卡爾樹的根。

若是使用棧來保存元素的索引而不是值,咱們能夠很輕鬆的創建樹。因爲A中的每一個元素最多被增長一次和最多被移除一次,因此建樹的時間複雜度爲O(N)。最終查詢的時間複雜度爲O(1),故綜上可得,我們整個問題的最終時間複雜度爲:<O(N), O(1)>。

如今,對於詢問 RMQA(i, j) 咱們有兩種狀況:

i和j在同一個塊中,所以咱們使用在P和T中計算的值
i和j在不一樣的塊中,所以咱們計算三個值:從i到i所在塊的末尾的P和T中的最小值,全部i和j中塊中的經過與處理獲得的最小值以及從j所在塊i和j在同一個塊中,所以咱們使用在P和T中計算的值j的P和T的最小值;最後咱們咱們只要計算三個值中最小值的位置便可。
RMQ和LCA是密切相關的問題,由於它們之間能夠相互規約。有許多算法能夠用來解決它們,而且他們適應於一類問題。

解法四:線段樹
解決RMQ問題也能夠用所謂的線段樹Segment trees。線段樹是一個相似堆的數據結構,能夠在基於區間數組上用對數時間進行更新和查詢操做。咱們用下面遞歸方式來定義線段樹的[i, j]區間:

第一個結點將保存區間[i, j]區間的信息
若是i<j 左右的孩子結點將保存區間[i, (i+j)/2]和[(i+j)/2+1, j] 的信息
注意具備N個區間元素的線段樹的高度爲[logN] + 1。下面是區間[0,9]的線段樹:

因爲線段樹是徹底二叉樹(線段樹和堆具備相同的結構),咱們能夠用數組來存儲線段樹。所以咱們定義x是一個非葉結點,那麼左孩子結點爲2x,而右孩子結點爲2x+1。想要使用線段樹解決RMQ問題,咱們則要要使用數組 M[1, 2 * 2[logN] + 1],這裏M[i]保存結點i區間最小值的位置。初始時M的全部元素爲-1。樹應當用下面的函數進行初始化(b和e是當前區間的範圍):

void initialize(int node, int b, int e, int M[MAXIND], int A[MAXN], int N)  
{  
    if (b == e)  
        M[node] = b;  
    else  
    {  
        //compute the values in the left and right subtrees  
        initialize(2 * node, b, (b + e) / 2, M, A, N);  
        initialize(2 * node + 1, (b + e) / 2 + 1, e, M, A, N);  

        //search for the minimum value in the first and  
        //second half of the interval  
        if (A[M[2 * node]] <= A[M[2 * node + 1]])  
            M[node] = M[2 * node];  
        else  
            M[node] = M[2 * node + 1];  
    }  
}

上面的函數映射出了這棵樹建造的方式。當計算一些區間的最小值位置時,咱們應當首先查看子結點的值。調用函數的時候使用 node = 1, b = 0和e = N-1。

如今咱們能夠開始進行查詢了。若是咱們想要查找區間[i, j]中的最小值的位置時,咱們可使用下一個簡單的函數:

int query(int node, int b, int e, int M[MAXIND], int A[MAXN], int i, int j)  
{  
    int p1, p2;  
    //if the current interval doesn't intersect  
    //the query interval return -1  
    if (i > e || j < b)  
        return -1;  

    //if the current interval is included in  
    //the query interval return M[node]  
    if (b >= i && e <= j)  
        return M[node];  

    //compute the minimum position in the  
    //left and right part of the interval  
    p1 = query(2 * node, b, (b + e) / 2, M, A, i, j);  
    p2 = query(2 * node + 1, (b + e) / 2 + 1, e, M, A, i, j);  

    //return the position where the overall  
    //minimum is  
    if (p1 == -1)  
        return M[node] = p2;  
    if (p2 == -1)  
        return M[node] = p1;  
    if (A[p1] <= A[p2])  
        return M[node] = p1;  
    return M[node] = p2;  
}

你應該使用node = 1, b = 0和e = N - 1來調用這個函數,由於分配給第一個結點的區間是[0, N-1]。

能夠很容易的看出任何查詢均可以在O(log N)內完成。注意當咱們碰到完整的in/out區間時咱們中止了,所以數中的路徑最多分裂一次。用線段樹咱們得到了<O(N), O(log N)>的算法

線段樹很是強大,不只僅是由於它可以用在RMQ上,還由於它是一個很是靈活的數據結構,它可以解決動態版本的RMQ問題和大量的區間搜索問題。

其他解法
除此以外,還有倍增法、重鏈剖分算法和後序遍歷也能夠解決該問題。其中,倍增思路至關於層序遍歷,逐層或幾層跳躍查,查詢時間複雜度爲O(log n),空間複雜度爲nlogn,對於每一個節點先存儲向上1層2層4層的節點,每一個點有depth信息。

Reference
https://en.wikipedia.org/wiki/Tarjan%27s_off-line_lowest_common_ancestors_algorithm
https://github.com/julycoding/The-Art-Of-Programming-By-July/blob/master/ebook/zh/03.03.md

http://dongxicheng.org/structure/lca-rmq/

http://dongxicheng.org/structure/segment-tree/

http://dongxicheng.org/structure/union-find-set/

https://zh.wikipedia.org/zh-cn/%E5%B9%B6%E6%9F%A5%E9%9B%86

相關文章
相關標籤/搜索