『图论』LCA 最近公共祖先

概述篇

LCA (Least Common Ancestors) ,即最近公共祖先,是指这样的一个问题:在一棵有根树中,找出某两个节点 uv 最近的公共祖先。html

LCA 可分为在线算法离线算法git

  • 在线算法:指程序能够以序列化的方式一个一个处理输入,也就是说在一开始并不须要知道全部的输入。
  • 离线算法:指一开始就须要知道问题的全部输入数据,而在解决一个问题后当即输出结果。

算法篇

对于该问题,很容易想到的作法是从 u、v 分别回溯到根节点,而后这两条路径中的第一个交点即为 u、v 的最近公共祖先,在一棵平衡二叉树中,该算法的时间复杂度能够达到 O(logn)O(log⁡n) ,可是对于某些退化为链状的树来讲,算法的时间复杂度最坏为 O(n)O(n) ,显然没法知足更高频率的查询。算法

本节将介绍几种比较高效的算法来解决这一问题,常见的算法有三种:在线 DFS + ST 算法、倍增算法、离线 Tarjan 算法。数据结构

接下来咱们来一一解释这三种 /* 看似高深,其实也不简单 */ 的算法。函数

在线 DFS + ST 算法

首先看到 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

img

咱们令节点编号知足父节点编号小于子节点编号(编号条件)

能够看出 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×O(logn)n×O(log⁡n) 预处理 + O(1)O(1) 查询

想了解 RMQ 问题 的解法能够戳上面的连接哦~


以上部分介绍了 LCA 如何转化为 RMQ 问题,而在实际中这两种方案之间能够相互转化

类比以前的作法,咱们如何将一个线性序列转化为知足编号条件的有根树呢?

  1. 设序列中的最小值为 AkAk ,创建优先级为 AkAk 的根节点 TkTk
  2. 将 A[1…k−1]A[1…k−1] 递归建树做为 TkTk 的左子树
  3. 将 A[k+1…n]A[k+1…n] 递归建树做为 TkTk 的右子树

读者能够试着利用此方法将以前的线性序列 A = [4,2,5,1,8,6,9,3,7] 构造出有根树 T ,结果必定知足以前所说的编号条件,但却不必定惟一。

离线 Tarjan 算法

Tarjan 算法是一种常见的用于解决 LCA 问题的离线算法,它结合了深度优先搜索与并查集,整个算法为线性处理时间。

首先来介绍一下 Tarjan 算法的基本思路:

  1. 任选一个节点为根节点,从根节点开始
  2. 遍历该点 u 的全部子节点 v ,并标记 v 已经被访问过
  3. 若 v 还有子节点,返回 2 ,不然下一步
  4. 合并 v 到 u 所在集合
  5. 寻找与当前点 u 有询问关系的点 e
  6. 若 e 已经被访问过,则能够肯定 u、e 的最近公共祖先为 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);
    }
}
C++

感受讲到这里已经没有其它内容了,可是必定会有好多人没有理解怎么办呢?

咱们假设在以下树中模拟 Tarjan 过程(节点数量少一点能够画更少的图o( ̄▽ ̄)o)

存在查询: LCA(T,3,4)、LCA(T,4,6)、LCA(T,2,1)

img

注意:每一个节点的颜色表明它当前属于哪个集合,橙色线条为搜索路径,黑色线条为合并路径。

img

当前所在位置为 u = 1 ,未遍历孩子集合 v = {2,5} ,向下遍历。

img

当前所在位置为 u = 2 ,未遍历孩子集合 v = {3,4} ,向下遍历。

img

当前所在位置为 u = 3 ,未遍历孩子集合 v = {} ,递归到达最底层,遍历全部相关查询发现存在 LCA(T,3,4) ,可是节点 4 此时标记未访问,所以什么也不作,该层递归结束。

img

递归返回,当前所在位置 u = 2 ,合并节点 3u 所在集合,标记 vis[3] = true ,此时未遍历孩子集合 v = {4} ,向下遍历。

img

当前所在位置 u = 4 ,未遍历孩子集合 v = {} ,遍历全部相关查询发现存在 LCA(T,3,4) ,且 vis[3] = true ,此时获得该查询的解为节点 3 所在集合的首领,即 LCA(T,3,4) = 2 ;又发现存在相关查询 LCA(T,4,6) ,可是节点 6 此时标记未访问,所以什么也不作。该层递归结束。

img

递归返回,当前所在位置 u = 2 ,合并节点 4u 所在集合,标记 vis[4] = true ,未遍历孩子集合 v = {} ,遍历相关查询发现存在 LCA(T,2,1) ,可是节点 1 此时标记未访问,所以什么也不作,该层递归结束。

img

递归返回,当前所在位置 u = 1 ,合并节点 2u 所在集合,标记 vis[2] = true ,未遍历孩子集合 v = {5} ,继续向下遍历。

img

当前所在位置 u = 5 ,未遍历孩子集合 v = {6} ,继续向下遍历。

img

当前所在位置 u = 6 ,未遍历孩子集合 v = {} ,遍历相关查询发现存在 LCA(T,4,6) ,且 vis[4] = true ,所以获得该查询的解为节点 4 所在集合的首领,即 LCA(T,4,6) = 1 ,该层递归结束。

img

递归返回,当前所在位置 u = 5 ,合并节点 6u 所在集合,并标记 vis[6] = true ,未遍历孩子集合 v = {} ,无相关查询所以该层递归结束。

img

递归返回,当前所在位置 u = 1 ,合并节点 5u 所在集合,并标记 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

相关文章
相关标签/搜索