1. 程式人生 > >『圖論』LCA最近公共祖先

『圖論』LCA最近公共祖先

概述篇

LCA(Least Common Ancestors),即最近公共祖先,是指這樣的一個問題:在一棵有根樹中,找出某兩個節點 uv 最近的公共祖先。

LCA可分為線上演算法離線演算法

  • 線上演算法:指程式可以以序列化的方式一個一個處理輸入,也就是說在一開始並不需要知道所有的輸入。
  • 離線演算法:指一開始就需要知道問題的所有輸入資料,而在解決一個問題後立即輸出結果。

演算法篇

對於該問題,很容易想到的做法是從 u、v 分別回溯到根節點,然後這兩條路徑中的第一個交點即為 u、v 的最近公共祖先,在一棵平衡二叉樹中,該演算法的時間複雜度可以達到\(O(logn)\)

,但是對於某些退化為鏈狀的樹來說,演算法的時間複雜度最壞為\(O(n)\),顯然無法滿足更高頻率的查詢。

本節將介紹幾種比較高效的演算法來解決這一問題,常見的演算法有三種:線上DFS+ST演算法、倍增演算法、離線Tarjan演算法。

接下來我們來一一解釋這兩種的演算法。

線上 DFS + ST 演算法

首先看到 ST 你會想到什麼呢?(腦補許久都沒有想到它會是哪個單詞的縮寫)LCA的線上演算法是可以建立在RMQ問題的基礎上的

我們設 LCA(T,u,v) 為在有根樹 T 中節點 u、v 的最近公共祖先, RMQ(A,i,j) 為線性序列 A 中區間 [i,j] 上的最小(大)值。

如下圖這棵有根樹:

我們令節點編號滿足父節點編號小於子節點編號(編號條件)

可以看出 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 ,也就是說,節點 3u、v 的最近公共祖先。

解決區間最值問題我們可以採用RMQ

問題中的 ST 演算法

但是在有些問題中給出的節點並不一定滿足我們所說的父節點編號小於子節點編號,因此我們可以利用節點間的關係建圖,然後採用前序遍歷來為每一個節點重新編號以生成線性序列 A ,於是問題又被轉化為了區間最值的查詢,和之前一樣的做法咯~

時間複雜度:\(n \times O(logn)\)預處理+$ O(1)$查詢

\(RMQ\)問題的解法


以上部分介紹了LCA如何轉化為RMQ問題,而在實際中這兩種方案之間可以相互轉化

類比之前的做法,我們如何將一個線性序列轉化為滿足編號條件的有根樹呢?

  1. 設序列中的最小值為\(A_{k}\),建立優先順序為\(A_{k}\)的根節點\(T_{k}\)
  2. \(A[1...k−1]\)遞迴建樹作為\(T_{k}\)的左子樹
  3. \(A[k+1...n]\)遞迴建樹作為\(T_{k}\)的右子樹

讀者可以試著利用此方法將之前的線性序列 A=[4,2,5,1,8,6,9,3,7] 構造出有根樹 T ,結果一定滿足之前所說的編號條件,但卻不一定唯一。

離線 Tarjan 演算法

Tarjan演算法是一種常見的用於解決LCA問題的離線演算法,它結合了深度優先搜尋與並查集,整個演算法為線性處理時間。

首先來介紹一下Tarjan演算法的基本思路:

  1. 任選一個節點為根節點,從根節點開始
  2. 遍歷該點u的所有子節點v,並標記v已經被訪問過
  3. v還有子節點,返回2,否則下一步
  4. 合併vu所在集合
  5. 尋找與當前點u有詢問關係的點e
  6. e已經被訪問過,則可以確定ue的最近公共祖先為e被合併到的父親節點

虛擬碼:

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);
}

感覺講到這裡已經沒有其它內容了,但是一定會有好多人沒有理解怎麼辦呢?

我們假設在如下樹中模擬Tarjan過程(節點數量少一點可以畫更少的圖)

存在查詢: 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 ,合併節點 3u 所在集合,標記 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 ,合併節點 4u 所在集合,標記 vis[4] = true ,未遍歷孩子集合 v = {} ,遍歷相關查詢發現存在 LCA(T,2,1) ,但是節點 1 此時標記未訪問,因此什麼也不做,該層遞迴結束。

遞迴返回,當前所在位置 u = 1 ,合併節點 2u 所在集合,標記 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 ,合併節點 6u 所在集合,並標記 vis[6] = true ,未遍歷孩子集合 v = {} ,無相關查詢因此該層遞迴結束。

遞迴返回,當前所在位置 u = 1 ,合併節點 5u 所在集合,並標記 vis[5] = true ,未遍歷孩子集合 v = {} ,遍歷相關查詢發現存在 LCA(T,2,1) ,此時該查詢的解便是節點 2 所在集合的首領,即 LCA(T,2,1) = 1 ,遞迴結束。

至此整個Tarjan演算法便結束了

總結篇

對於不同的LCA問題我們可以選擇不同的演算法。

假若一棵樹存在動態更新,此時離線演算法就顯得有點力不從心了,但是在其他情況下,離線演算法往往效率更高。

另外, LCARMQ 問題是兩個非常基礎的問題,很多複雜問題都可以轉化為這兩類問題來解決。(當然這兩類問題之間也可以相互轉化)