LCA (Least Common Ancestors) ,即最近公共祖先,是指這樣的一個問題:在一棵有根樹中,找出某兩個節點 u
和 v
最近的公共祖先。html
LCA 可分爲在線算法與離線算法git
對於該問題,很容易想到的作法是從 u、v
分別回溯到根節點,而後這兩條路徑中的第一個交點即爲 u、v
的最近公共祖先,在一棵平衡二叉樹中,該算法的時間複雜度能夠達到 O(logn)O(logn) ,可是對於某些退化爲鏈狀的樹來講,算法的時間複雜度最壞爲 O(n)O(n) ,顯然沒法知足更高頻率的查詢。算法
本節將介紹幾種比較高效的算法來解決這一問題,常見的算法有三種:在線 DFS + ST 算法、倍增算法、離線 Tarjan 算法。數據結構
接下來咱們來一一解釋這三種 /* 看似高深,其實也不簡單 */ 的算法。函數
首先看到 ST
你會想到什麼呢?(腦補許久都沒有想到它會是哪一個單詞的縮寫).net
看過前文 『數據結構』RMQ 問題 的話你即可以明白 ST算法
的思路啦~3d
So ,關於 LCA 的這種在線算法也是能夠創建在 RMQ 問題的基礎上咯~code
咱們設 LCA(T,u,v)
爲在有根樹 T
中節點 u、v
的最近公共祖先, RMQ(A,i,j)
爲線性序列 A
中區間 [i,j]
上的最小(大)值。htm
以下圖這棵有根樹:blog
咱們令節點編號知足父節點編號小於子節點編號(編號條件)
能夠看出 LCA(T,4,5) = 2, LCA(T,2,8) = 1, LCA(T,3,9) = 3
。
設線性序列 A
爲有根樹 T
的中序遍歷,即 A = [4,2,5,1,8,6,9,3,7]
。
由中序遍歷的性質咱們能夠知道,任意兩點 u、v
的最近公共祖先總在以該兩點所在位置爲端點的區間內,且編號最小。
舉個栗子:
假設 u = 8, v = 7
,則該兩點所肯定的一段區間爲 [8,6,9,3,7]
,而區間最小值爲 3
,也就是說,節點 3
爲 u、v
的最近公共祖先。
解決區間最值問題咱們能夠採用 RMQ 問題中的 ST 算法
。
可是在有些問題中給出的節點並不必定知足咱們所說的父節點編號小於子節點編號,所以咱們能夠利用節點間的關係建圖,而後採用前序遍從來爲每個節點從新編號以生成線性序列 A
,因而問題又被轉化爲了區間最值的查詢,和以前同樣的作法咯~
時間複雜度: n×O(logn)n×O(logn) 預處理 + O(1)O(1) 查詢
想了解 RMQ 問題
的解法能夠戳上面的連接哦~
以上部分介紹了 LCA 如何轉化爲 RMQ 問題,而在實際中這兩種方案之間能夠相互轉化
類比以前的作法,咱們如何將一個線性序列轉化爲知足編號條件的有根樹呢?
讀者能夠試着利用此方法將以前的線性序列 A = [4,2,5,1,8,6,9,3,7]
構造出有根樹 T
,結果必定知足以前所說的編號條件,但卻不必定惟一。
Tarjan 算法是一種常見的用於解決 LCA 問題的離線算法,它結合了深度優先搜索與並查集,整個算法爲線性處理時間。
首先來介紹一下 Tarjan 算法的基本思路:
僞代碼:
Tarjan(u) // merge 和 find 爲並查集合並函數和查找函數 { for each(u,v) // 遍歷 u 的全部子節點 v { Tarjan(v); // 繼續往下遍歷 merge(u,v); // 合併 v 到 u 這一集合 標記 v 已被訪問過; } for each(u,e) // 遍歷全部與 u 有查詢關係的 e { if (e 被訪問過) u, e 的最近公共祖先爲 find(e); } } C++
感受講到這裏已經沒有其它內容了,可是必定會有好多人沒有理解怎麼辦呢?
咱們假設在以下樹中模擬 Tarjan 過程(節點數量少一點能夠畫更少的圖o( ̄▽ ̄)o)
存在查詢: LCA(T,3,4)、LCA(T,4,6)、LCA(T,2,1)
。
注意:每一個節點的顏色表明它當前屬於哪個集合,橙色線條爲搜索路徑,黑色線條爲合併路徑。
當前所在位置爲 u = 1
,未遍歷孩子集合 v = {2,5}
,向下遍歷。
當前所在位置爲 u = 2
,未遍歷孩子集合 v = {3,4}
,向下遍歷。
當前所在位置爲 u = 3
,未遍歷孩子集合 v = {}
,遞歸到達最底層,遍歷全部相關查詢發現存在 LCA(T,3,4)
,可是節點 4
此時標記未訪問,所以什麼也不作,該層遞歸結束。
遞歸返回,當前所在位置 u = 2
,合併節點 3
到 u
所在集合,標記 vis[3] = true
,此時未遍歷孩子集合 v = {4}
,向下遍歷。
當前所在位置 u = 4
,未遍歷孩子集合 v = {}
,遍歷全部相關查詢發現存在 LCA(T,3,4)
,且 vis[3] = true
,此時獲得該查詢的解爲節點 3
所在集合的首領,即 LCA(T,3,4) = 2
;又發現存在相關查詢 LCA(T,4,6)
,可是節點 6
此時標記未訪問,所以什麼也不作。該層遞歸結束。
遞歸返回,當前所在位置 u = 2
,合併節點 4
到 u
所在集合,標記 vis[4] = true
,未遍歷孩子集合 v = {}
,遍歷相關查詢發現存在 LCA(T,2,1)
,可是節點 1
此時標記未訪問,所以什麼也不作,該層遞歸結束。
遞歸返回,當前所在位置 u = 1
,合併節點 2
到 u
所在集合,標記 vis[2] = true
,未遍歷孩子集合 v = {5}
,繼續向下遍歷。
當前所在位置 u = 5
,未遍歷孩子集合 v = {6}
,繼續向下遍歷。
當前所在位置 u = 6
,未遍歷孩子集合 v = {}
,遍歷相關查詢發現存在 LCA(T,4,6)
,且 vis[4] = true
,所以獲得該查詢的解爲節點 4
所在集合的首領,即 LCA(T,4,6) = 1
,該層遞歸結束。
遞歸返回,當前所在位置 u = 5
,合併節點 6
到 u
所在集合,並標記 vis[6] = true
,未遍歷孩子集合 v = {}
,無相關查詢所以該層遞歸結束。
遞歸返回,當前所在位置 u = 1
,合併節點 5
到 u
所在集合,並標記 vis[5] = true
,未遍歷孩子集合 v = {}
,遍歷相關查詢發現存在 LCA(T,2,1)
,此時該查詢的解即是節點 2
所在集合的首領,即 LCA(T,2,1) = 1
,遞歸結束。
至此整個 Tarjan 算法便結束啦~
PS:不要在乎最終根節點的顏色和其餘節點顏色有一點點小小差距,多是在染色的時候沒仔細看,總之就這樣咯~
PPS:所謂的首領就是、就是首領啦~
哇!還有一個倍增算法之後繼續補充吧!
對於不一樣的 LCA 問題咱們能夠選擇不一樣的算法。
倘若一棵樹存在動態更新,此時離線算法就顯得有點力不從心了,可是在其餘狀況下,離線算法每每效率更高(雖然不能保證獲得解的順序與輸入一致,不過咱們有 sort 呀)
總之,喜歡哪一種風格的 code
是咱們本身的意願咯~
另外, LCA 和 RMQ 問題是兩個很是基礎的問題,不少複雜問題均可以轉化爲這兩類問題來解決。(固然這兩類問題之間也能夠相互轉化啦~)
OI wiki https://oi-wiki.org/graph/lca/
http://www.javashuo.com/article/p-umzjucpi-nm.html
https://wizardforcel.gitbooks.io/the-art-of-programming-by-july/content/03.03.html