離線Tarjan演算法-最近公共祖先問題
轉載自Tarjan演算法
LCA問題(Least Common Ancestors,最近公共祖先問題),是指給定一棵有根樹T,給出若干個查詢LCA(u, v)(通常查詢數量較大),每次求樹T中兩個頂點u和v的最近公共祖先,即找一個節點,同時是u和v的祖先,並且深度儘可能大(儘可能遠離樹根)。 LCA問題有很多解法:線段樹、Tarjan演算法、跳錶、RMQ與LCA互相轉化等。本文主要講解Tarjan演算法的原理及詳細實現。
一 LCA問題
LCA問題的一般形式:給定一棵有根樹,給出若干個查詢,每個查詢要求指定節點u和v的最近公共祖先。
LCA問題有兩類解決思路:
- 線上演算法,每次讀入一個查詢,處理這個查詢,給出答案。
- 離線演算法,一次性讀入所有查詢,統一進行處理,給出所有答案。
一個LCA的例子如下。比如節點1和6的LCA為0。
二 演算法思路
Tarjan演算法是離線演算法,基於後序DFS(深度優先搜尋)和並查集。如果不熟悉並查集,可以檢視並查集及其在最小生成樹中的應用。
演算法從根節點root開始搜尋,每次遞迴搜尋所有的子樹,然後處理跟當前根節點相關的所有查詢。
演算法用集合表示一類節點,這些節點跟集合外的點的LCA都一樣,並把這個LCA設為這個集合的祖先。當搜尋到節點x時,建立一個由x本身組成的集合,這個集合的祖先為x自己。然後遞迴搜尋x的所有兒子節點。當一個子節點搜尋完畢時,把子節點的集合與x節點的集合合併,並把合併後的集合的祖先設為x。因為這棵子樹內的查詢已經處理完,x的其他子樹節點跟這棵子樹節點的LCA都是一樣的,都為當前根節點x。所有子樹處理完畢之後,處理當前根節點x相關的查詢。遍歷x的所有查詢,如果查詢的另一個節點v已經訪問過了,那麼x和v的LCA即為v所在集合的祖先。
其中關於集合的操作都是使用並查集高效完成。
演算法的複雜度為,O(n)搜尋所有節點,搜尋每個節點時會遍歷這個節點相關的所有查詢。如果總的查詢個數為m,則總的複雜度為O(n+m)。
比如上面的例子中,前面處理的節點的順序為4->7->5->1->0->…。
當訪問完4之後,集合{4}跟集合{1}合併,得到{1,4},並且集合祖先為1。然後訪問7。如果(7,4)是一個查詢,由於4已訪問過,於是LCA(7,4)為4所在集合{1,4}的祖先,即1。7訪問完之後,把{7}跟{5}合併,得到{5,7},祖先為5。然後訪問5。如果(5,7)是一個查詢,由於7已訪問過,於是LCA(5,7)為7所在集合{5,7}的祖先,即5。如果(5,4)也是一個查詢,由於4已訪問過,則LCA(5,4)為4所在集合{1,4}的祖先,即1。5訪問完畢之後,把{5,7}跟{1,4}合併,得到{1,4,5,7},並且祖先為1。然後訪問1。如果有(1,4)查詢,則LCA(1,4)為4所在集合{1,4}的祖先,為1。1訪問完之後,把{1,4,5,7}跟{0}合併,得到{0,1,4,5,7},祖先為0。然後剩下的2後面的節點處理類似。
三 演算法實現
接下來提供一個完整演算法實現。
使用鄰接表方法儲存一棵有根樹。並通過記錄節點入度的方法找出有根樹的根,方便後續處理。
const int mx = 10000; //最大頂點數
int n, root; //實際頂點個數,樹根節點
int indeg[mx]; //頂點入度,用來判斷樹根
vector<int> tree[mx]; //樹的鄰接表(不一定是二叉樹)
void inputTree() //輸入樹
{
scanf("%d", &n); //樹的頂點數
for (int i = 0; i < n; i++) //初始化樹,頂點編號從0開始
tree[i].clear(), indeg[i] = 0;
for (int i = 1; i < n; i++) //輸入n-1條樹邊
{
int x, y; scanf("%d%d", &x, &y); //x->y有一條邊
tree[x].push_back(y); indeg[y]++;//加入鄰接表,y入度加一
}
for (int i = 0; i < n; i++) //尋找樹根,入度為0的頂點
if (indeg[i] == 0) { root = i; break; }
}
使用vector陣列query儲存所有的查詢。跟x相關的所有查詢(x,y)都會放在query[x]的陣列中,方便查詢。
vector<int> query[mx]; //所有查詢的內容
void inputQuires() //輸入查詢
{
for (int i = 0; i < n; i++) //清空上次查詢
query[i].clear();
int m; scanf("%d", &m); //查詢個數
while (m--)
{
int u, v; scanf("%d%d", &u, &v); //查詢u和v的LCA
query[u].push_back(v); query[v].push_back(u);
}
}
然後是並查集的相關資料和操作。
int father[mx], rnk[mx]; //節點的父親、秩
void makeSet() //初始化並查集
{
for (int i = 0; i < n; i++) father[i] = i, rnk[i] = 0;
}
int findSet(int x) //查詢
{
if (x != father[x]) father[x] = findSet(father[x]);
return father[x];
}
void unionSet(int x, int y) //合併
{
x = findSet(x), y = findSet(y);
if (x == y) return;
if (rnk[x] > rnk[y]) father[y] = x;
else father[x] = y, rnk[y] += rnk[x] == rnk[y];
}
再就是Tarjan演算法的核心程式碼。
在呼叫Tarjan之前已經初始化並查集給每個節點建立了一個集合,並且把集合的祖先賦值為自己了,因而這裡不用給根節點x單獨建立。
int ancestor[mx]; //已訪問節點集合的祖先
bool vs[mx]; //訪問標誌
void Tarjan(int x) //Tarjan演算法求解LCA
{
for (int i = 0; i < tree[x].size(); i++)
{
Tarjan(tree[x][i]); //訪問子樹
unionSet(x, tree[x][i]); //將子樹節點與根節點x的集合合併
ancestor[findSet(x)] = x;//合併後的集合的祖先為x
}
vs[x] = 1; //標記為已訪問
for (int i = 0; i < query[x].size(); i++) //與根節點x有關的查詢
if (vs[query[x][i]]) //如果查詢的另一個節點已訪問,則輸出結果
printf("%d和%d的最近公共祖先為:%dn", x,
query[x][i], ancestor[findSet(query[x][i])]);
}
下面是主程式,再加一個樣例輸入輸出作為測試。
int main()
{
inputTree(); //輸入樹
inputQuires();//輸入查詢
makeSet();
for (int i = 0; i < n; i++) ancestor[i] = i;
memset(vs, 0, sizeof(vs)); //初始化為未訪問
Tarjan(root);
/*前面例子相關的一個輸入輸出如下:
8
0 1 0 2 0 3 1 4 1 5 5 7 3 6
7
1 4 4 5 4 7 5 7 0 5 4 3 1 6
7和4的最近公共祖先為:1
5和4的最近公共祖先為:1
5和7的最近公共祖先為:5
1和4的最近公共祖先為:1
6和1的最近公共祖先為:0
3和4的最近公共祖先為:0
0和5的最近公共祖先為:0
*/
}