這篇筆記說明 Tarjan's offline Lowest Common Ancestor (LCA) algorithm。 對於一個rooted tree,有許多查詢,對每一個查詢(u,v),要找出u與v的LCA,offline的意思是想要查詢的對象事先已經知道。Tarjan用一個簡單的DFS就完成此事。 在Tree的問題中,LCA是一個重要的問題,最常見到的運用就是查詢兩點之間的距離,例如我們有很多對(u,v)要查詢兩點在樹上的距離。 若對所有點v皆以計算出$v$點的深度 $depth[v]$,也就是$v$點到root的距離,那麼 $d(u,v) = depth[u]+depth[v]-2\times depth[lca(u,v)]$。 ![image](https://hackmd.io/_uploads/HJ4mmMU0lx.png) 以這個例子來看,$depth[1]=3$、$depth[4]=7$,$lca(1,4)=3$、$depth[3]=1$。 $d(1,4) = 3+7-2\times 1 = 8$。 所以,要進行距離的查詢,我們只需要計算所有點的深度,以及所要查詢點對的LCA。 計算深度可以很簡單的用一個DFS來完成,以下來說明如何用一個DFS完成LCA的計算。Tarjan's algorithm的原理很簡單,首先,我們要知道,我DFS的過程中,我們其實有意或無意的維護了一個迴圈不變性:當走訪到點$v$時,我們其實維護了root到$v$的那條path。有時候我們會把這路徑建出來,雖然有時並不明顯的建立,但這條路徑就是那些遞迴走訪到而尚未走訪完成的點。為了方便讀者理解,以下我們先用一個程式示範基本的以DFS計算depth以及把這條路徑建出來。 ```python= edges = [[0,3,1],[0,5,2],[3,1,2],[3,6,4],[6,2,3],[4,6,2]] n = 7 queries = [[0,6],[6,4],[1,5],[1,4]] # build adj adj = [[] for i in range(n)] for u,v,w in edges: adj[u].append((v,w)) adj[v].append((u,w)) # root = 0 depth = [-1]*n # -1 for unvisited depth[root] = 0 path = [] def dfs(v): path.append(v); print(f"path from {root} to {v} is {path}") for u,w in adj[v]: # (edge (v,u) with weight w if depth[u] >= 0: continue # parent of v depth[u] = depth[v]+w dfs(u) # path.pop() # dfs(root) print("depth:",depth) ``` 執行結果 ``` path from 0 to 0 is [0] path from 0 to 3 is [0, 3] path from 0 to 1 is [0, 3, 1] path from 0 to 6 is [0, 3, 6] path from 0 to 2 is [0, 3, 6, 2] path from 0 to 4 is [0, 3, 6, 4] path from 0 to 5 is [0, 5] depth: [0, 3, 8, 1, 7, 2, 5] ``` LCA演算法的原理是這樣的,當走訪到$v$時,若$(u,v)$是要查詢的點對,且**u點已經走訪過了**,那麼lca(u,v)就是「u在該path上的最低祖先」,以上面的例子來說,到4時,path=[0,3,6,4],此時已走訪過的點是(0,3,1,6,2,4),對點1來說,$lca(1,4)=3$。 要查詢u點在path上的最低祖先要怎麼做呢?直接的做法就是往上一步一步爬,爬到碰到該path時就是答案。問題是這樣爬太慢,解決之道是運用disjoint set (union and find)資料結構中的**路徑壓縮**。 使用一個陣列anc[v],初始時,anc[v]=v,當v被走訪**完畢**時,anc[v]會紀錄v的parent,所以沿著anc往上走直到anc[x]==x的點時,x即是v點在path上的最低祖先。 對於一個已經走訪過的點來說,他將來要被查詢的祖先只會往上,而不會往下,所以當他被查詢之後,我們可以將anc[v]直接改成x,也就是把v到x的那條路徑壓縮掉,好處是將來查詢v以及它的後代時,路徑就會變短(這部分如果不了解,可以再去看disjoint set的資料結構)。 程式寫起來非常簡單,以下是範例程式。 ```python= edges = [[0,3,1],[0,5,2],[3,1,2],[3,6,4],[6,2,3],[4,6,2]] n = 7 queries = [[0,6],[6,4],[1,5],[1,4]] # build adj adj = [[] for i in range(n)] for u,v,w in edges: adj[u].append((v,w)) adj[v].append((u,w)) # root = 0 depth = [-1]*n # -1 for unvisited depth[root] = 0 #path = [] # prepare for lca query other = [[] for i in range(n)] for u,v in queries: other[u].append(v) other[v].append(u) lca = {} anc = list(range(n)) # anc[v]==v is on path def find(v): if anc[v] == v: return v anc[v] = find(anc[v]) return anc[v] def dfs(v): #path.append(v); #print(f"path from {root} to {v} is {path}") for u,w in adj[v]: # (edge (v,u) with weight w if depth[u] >= 0: continue # parent of v depth[u] = depth[v]+w dfs(u) anc[u] = v # union # for u in other[v]: if depth[u] < 0: continue # unvisited x = find(u) lca[u,v] = x # path.pop() # dfs(root) print("depth:",depth) print("LCA:",lca) ``` 留意到第21 ~ 24行的find(v),這個就是路徑壓縮,利用遞迴的方式找到祖先,並且把沿路上的點都直接拉上去。DFS城市中只有第33行是在u點走訪完畢後設定anc[u],第35 ~ 38行是查詢v的對象u,其中depth[u]必須非負才是走過的點。 執行結果如下: ``` depth: [0, 3, 8, 1, 7, 2, 5] LCA: {(6, 4): 6, (1, 4): 3, (0, 6): 0, (4, 6): 6, (1, 5): 0, (6, 0): 0} ``` LeetCode中LCA的題目例如 3585, 3559, 3553。