LCA實現的三種不一樣的方法

LCA,最近公共祖先,實現有多種不一樣的方法,在樹上的問題中有着普遍的應用,好比說樹上的最短路之類。
LCA的實現方法有不少,好比RMQ、樹鏈剖分等。今天來說其中實現較爲簡單的三種算法:RMQ+時間戳、樹上倍增(相似二分步長)、Tarjan算法(DFS+並查集)。

【RMQ+時間戳】
什麼是時間戳?時間戳,就是被訪問到的一個次序。好比說咱們首先對一棵樹進行深搜,在深搜中訪問的相應次序就被咱們稱爲時間戳。好比說對下面這棵樹進行相應的深搜,咱們獲得時間戳,以及相應的遍歷序列:

  LCA實現的三種不一樣的方法 - wenjianwei1 - 算法的設計
 
這棵樹所獲得的DFS遍歷序列就是1 2 3 2 4 2 5 2 1 6 7 8 7 6 1。能夠看出,每一個非葉節點都被訪問了不止一次。好了,時間戳講完了,接着轉入正題:怎樣用RMQ?RMQ在這裏是咱們算法的一個極爲重要的基礎,具體有什麼不明白能夠看我之前的博客《Sparse-Table算法 - 一類RMQ問題的簡單高效解法 》。
既然咱們得出了這麼一個序列,要RMQ,就是對這一個序列進行。而這個思路是正確的。若是咱們對於每個節點,都只記這一個節點在DFS序列中第一次出現的位置,好比說節點1,第一次就是在位置1,節點6,第一次就是在位置10,以此類推。而後咱們對這麼一個DFS序列做RMQ的最小值預處理,而後對於LCA的每次詢問,只需求這兩個被詢問節點在DFS序列中第一次出現的位置構成的區間內的最小值便可。時間複雜度加上預處理,應該是O(Nlog2N+Qlog2N)的時間複雜度(雖說實際上來講RMQ的單次詢問是和O(1)至關的,但實際上的規模是O(log2N),若是另外處理一個常數表,佔用空間可能挺大的,畢竟直接實現的速度也挺快的)。舉個例子,咱們要詢問節點6和8的LCA,則節點6的首次出現位置是10,節點8的首次出現位置是12,則區間[10,12]中的最小值是6,那麼他們的LCA就是節點1。
那麼,爲何這樣會能夠呢?考慮一下咱們深搜的過程,尋求兩個節點的LCA樸素過程其實能夠轉變成這樣的形式:從其中一個節點出發,一直往上找,並不斷遍歷以當前找到的結點爲根的子樹,看有沒有同時包含兩個節點。若是第一次找到有同時包含兩個節點的子樹,則這棵子樹的根就是LCA。也就是說,至關於下面的過程:
 
  

1.輸入兩個節點A和B算法

2.設當前節點爲A數組

3.while dfs(當前節點)沒有搜索到A和B測試

4.當前節點更新爲當前節點的父節點優化

5.輸出當前節點spa

很容易看出這個過程的正確性。首先,LCA的子樹中一定有一個節點是A,一個是B,並且一定在兩個節點到根節點的惟一路徑上。
所以,咱們能夠引出定義:某兩個節點的LCA就是其在DFS遍歷整棵樹時同時訪問到這兩個節點的「回溯最近節點」。並且,在剛剛遍歷完這兩個節點時,它們的LCA一定沒有回溯!所以,咱們就經過了時間戳作到了這一點。某兩個節點的DFS間所訪問到的哪些節點中的最小值必然是這兩個節點的LCA。

【樹上倍增(相似於二分步長)】
對於下面的這棵樹,咱們先隨意指定一個編號:
LCA實現的三種不一樣的方法 - wenjianwei1 - 算法的設計
  而後,咱們對於每個節點,記錄其往上跳1個節點,2個節點,4個節點,8個節點,16個節點……2^k個節點所達到的節點編號。在這裏,往上跳2^k個節點應達到的是節點-1,也就是說,向上跳2^k個節點是不可能的,可是跳2^(k-1)個節點應該能夠跳到。這能夠用鏈表存儲,也能夠直接開一個數組,以方便索引。
那麼這個表怎樣創建呢?難道對於每個節點都要一直用while循環一直找到根節點嗎?不是的,咱們只須要稍加分析,就能夠寫出一種總時間複雜度O(Nlog2N)的優秀算法。
首先,每一個點向上跳一個點,一定是其父節點,根節點就是-1。而後,顯而易見的,向上跳2^i個節點,等價於先向上跳2^(i-1)個節點,再在向上跳的基礎上再向上跳2^(i-1)個節點。固然,這要求向上跳到的節點的表已經算過了。因而,咱們須要使用DFS,以保證這一次序。簡單分析一下就能夠得出最多往上跳log2N層,而一共有N個節點,乘在一塊就是O(Nlog2N)。具體的實現以下:
 
  

1.DFS(根節點),對於每一個DFS到的節點(不包括回溯到的):設計

2.設當前跳到的節點爲DFS的目前節點的父節點blog

3.設當前向上跳2^i個節點,其中i=0索引

4.while 當前跳到的節點仍然存在(即不爲-1)博客

5.當前節點的表中加入向上跳2^i跳到當前跳到的節點it

6.++i

7.當前跳到的節點更新爲當前跳到的節點向上跳2^(i-1)的節點


其實這一種算法至關於在樹上添加了一些額外的邊,有點像自動機的結構,就像AC自動機同樣都和樹(AC自動機其實是添加了狀態轉移邊的Trie樹,或者說字典樹)有關。我我的以爲這實際上是一種雙方向的狀態轉移,應該比較特殊。
迴歸正題,繼續講倍增算法。LCA不是每一次詢問都要給兩個點的編號嗎?咱們就設這兩個點爲點A和點B。首先,若是這兩個點的深度不一樣(深度能夠順便在DFS時求出),則先將較深的一個節點向上一直跳,跳到和另外一個點相同的深度,這能夠用咱們剛纔所造的表,並用二進制的lowbit優化,實現以下:
 
  

1.設要向上跳k層,當前跳到節點爲A'

2.while k != 0

3.A'向上跳lowbit(k)層

4.k -= lowbit(k)

接着相應的將相應的節點設爲A’,若是A'和另外一節點相同則LCA就是那個節點。因而,下面的思路就也很明朗了。二分LCA!好比說如今A和B在同一層上(深度相同),而且設深度都是100的話,就先向上跳64層,若是相同就只跳32層(在原來節點的基礎上),不相同則再跳32層(在跳64層達到的兩個節點的基礎上),最後便可垂手可得地二分到答案了……這一過程彷佛又稱樹上倍增算法,正確性的證實很簡單,那個表的過程就不說了,而LCA的最終二分過程應該相似於二分步長,不斷試探,最終一定可以找到答案。
最終的時間複雜度,由預處理和二分答案的過程可知,爲O(Nlog2N+Qlog2N),和RMQ至關。

【Tarjan算法:並查集的離線算法】
前兩個算法,都是在線算法,都是能夠在線處理詢問的,可是在信息學競賽中,由於是黑箱測試,咱們也能夠採起一種時間複雜度更爲優秀的算法:Tarjan。這種算法是一種 離線的算法,要求事先給出每個詢問,而處理的時候不必定按問題的給出順序回答。
Tarjan算法基於一個和RMQ那個章節中最後一部分所陳述的那些東西同樣的事實。也就是說,當咱們從根節點開始DFS時,咱們在 剛恰好訪問 兩個節點時,這兩個節點的LCA 一定沒有在DFS中回溯。
因此,咱們設back[x]爲點x回溯到的節點標號。初始時設back[x]=x,接着在DFS的過程當中依次更新。
好比說,咱們當前遍歷完了點x的全部子樹,那麼咱們就能夠設定:back[x]=father[x](father[x]表示x的父親節點)。以此類推。
而後,咱們將LCA每次詢問的一個節點對稱爲一個問題,一個節點是某個問題的節點對兩個節點中的一個,則稱之爲這一個節點涉及到了某個問題。當咱們DFS到一個被某個(或者說某些,狀況都是同樣的)問題涉及的節點時,若是該問題的另一個節點沒有被訪問,則什麼都不作,若是曾被訪問,則對當前節點x執行如下的操做(固然首先要設back[x]=father[x]):  
 
  

while (x!=back[x]){

x = back[x];

}


最後得出來的x,就是LCA!因而將LCA相應地回答那個問題。那麼,怎麼用並查集呢?   注意到剛剛那段代碼了沒有,那段代碼和並查集的代碼是否有幾分類似?因此,咱們引入路徑壓縮!
 
  

int findLCA(int x){

if (x==back[x]) return x;

return x = findLCA(back[x]);

}

由並查集的時間複雜度可知,總的時間複雜度應該是O(N+Q* α(N) )。並且,這裏的應用還不須要合併,只須要查詢便可,寫起來比普通的並查集還要簡單。

【總結】
這三種方法,在信息學奧賽中,可謂是很是實用的算法。今年NOIP2015提升組的最後一題,就有這樣的思想在裏面。所以,在樹與圖這樣的東西里,LCA幾乎就是屢見不鮮了。因此,咱們必須記住這些算法的原理和具體的實現。