最近公共祖先问题 LCA

时间:2022-01-25 16:56:16

2018-03-10 18:04:55

在图论和计算机科学中,最近公共祖先,LCA(Lowest Common Ancestor)是指在一个树或者有向无环图中同时拥有v和w作为后代的最深的节点。

计算最近公共祖先往往是很有用的,比如在计算树中两个节点的距离的时候,可以分别计算根到各个节点的距离,然后计算根到最近公共祖先的距离,用之前的距离和减去2倍的根到最近公共祖先的距离就可以得到两个节点的距离。

计算最近公共祖先问题是一个非常经典的问题,相关的研究也进行了很多年,这类问题的求解方法也有很多种,这里会对其中的一些主流的算法做一些介绍。

一、二叉搜索树中的LCA

问题描述:

给定一个二叉搜索树, 找到该树中两个指定节点的最近公共祖先。

百度百科中最近公共祖先的定义为:“对于有根树 T 的两个结点 p、q,最近公共祖先表示为一个结点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”

例如,给定如下二叉搜索树:  root = [6,2,8,0,4,7,9,null,null,3,5]

最近公共祖先问题 LCA

示例 1:

输入: root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 8
输出: 6 
解释: 节点 2 和节点 8 的最近公共祖先是 6。

示例 2:

输入: root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 4
输出: 2
解释: 节点 2 和节点 4 的最近公共祖先是 2, 因为根据定义最近公共祖先节点可以为节点本身。

说明:

所有节点的值都是唯一的。
p、q 为不同节点且均存在于给定的二叉搜索树中。

问题求解:

二叉搜索树中的LCA问题可以认为是普通LCA问题的简化版本,因为在二叉搜索树中可以通过比较数值的大小来确定当前节点和待查找节点的相对位置关系。具体来说如下(不妨设v < w):

  • v <= cur <= w :说明v和w位于cur的两侧,或者就是cur,那么cur就是他们的最近公共祖先;
  • cur < v :说明cur 比这两个待查找的节点都小,也就是说这两个节点都在其右子树上,因此递归的在其右子树上进行查找就可以了;
  • cur > w :同理,只需要递归的在其左子树上查找即可。
    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
int left = p.val;
int right = q.val;
if (right < left) {
int tmp = left;
left = right;
right = tmp;
} while (true) {
if (root.val < left) root = root.right;
else if (root.val > right) root = root.left;
else return root;
}
}

二、带有指向父亲的指针的树

这个条件将问题的难度大大简化,其实这个问题从这个时候开始已经可以从一个LCA问题规约成寻找链表的公共节点的问题,显然,这个问题还是比较容易解决的。

三、普通的二叉树

问题描述:

最近公共祖先问题 LCA

问题求解:

方法一、看成路径查询问题

解决这个问题,其实是可以看成路径查询问题的,我们可以对每个节点进行路径的查询,并生成从root 到当前节点的路径,比如1 - > 3 - > 5 - > 6 和 1 - > 3 - > 7 那么6 和 7的最近公共祖先就是3。这个方法的优点就是,它不用提前知道这两个节点都包含在二叉树中,如果这两个节点其中有节点不包含在二叉树中,在查找生成路径的时候,该路径的长度会是0,那么自然就不会有最近公共祖先。同时该算法的时间复杂度也是O(n),虽然需要遍历二叉树两次。但是该算法也不是没有缺点的,最大的一个问题就是这里需要保存路径,这就有空间的开销,如果想只遍历一次,并且没有额外的空间的开销的话,那么就可以试试下面的搜索的解法。

方法二、看成搜索问题

对于二叉树的LCA问题,本质上可以归约到搜索问题,首先要知道一点,如果一个节点的左右子树各包含一个节点,那么这个节点就是这两个节点的最近公共祖先。基于这一个理论,在二叉树中寻找最近公共祖先就变成了二叉树中搜索的问题。

采用后序遍历进行搜索,如果搜索到这个节点,那么直接返回这个节点,如果当前的节点的左右子树都非null,那么当前节点就是答案。

还有一种情况就是两个节点中,其中一个是另一个的祖先,其实在这种情况中,由于采用了后序遍历,那么返回的结果依然是正确的。

当然,由于看成了搜索问题,所以递归的本质其实已经变化了,因此如果说当前的二叉树中只包含了其中一个节点,那么这种算法是不能正确的返回结果的,它依然会认为找到的其中一个节点是最终的答案。因此在题目描述中特别说明了这两个节点都包含在了二叉树中,否则的话,该算法就不成立了。

    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
if (root == null) return null;
if (root == p || root == q) return root;
TreeNode l = lowestCommonAncestor(root.left, p, q);
TreeNode r = lowestCommonAncestor(root.right, p, q);
if (l != null && r != null) return root;
else if (l != null) return l;
else if (r != null) return r;
else return null;
}

四、询问式LCA

之前的所有算法都是一次性的查询,如果说是需要询问式查询,也就是会有多次查询的时候,以上的算法的表现就不是很好了,比如第二种方法的算法复杂度就达到了O(n) - O(n)。

针对这种询问式的LCA,也出现了相应的算法,下面主要就常用的离线LCA和在线LCA算法做一个介绍。

  • 离线算法,Tarjan算法

所谓离线算法,就是一次性将所有的query请求都获取到并对其作出统一的回复。如果采用刚刚的DFS算法,那么每次查询都要O(n)的复杂度,但是,如果采用Tarjan算法,那么可以在一次遍历中解决所有的query,m次查询的算法复杂度为O(n + m)。

算法伪代码:

Procedure dfs(u);
begin
设置u号节点的祖先为u
若u的左子树不为空,dfs(u - 左子树);
union(u, u的左子树)
若u的右子树不为空,dfs(u - 右子树);
union(u, u的右子树)
标记u为已经访问
访问每一条与u相关的询问u、v
-若v已经被访问过,则输出v当前的祖先t(t即u,v的LCA)
end

算法流程:

假设我们有一组数据 9个节点 8条边 联通情况如下:

1--2,1--3,2--4,2--5,3--6,5--7,5--8,7--9 即下图所示的树

设我们要查找最近公共祖先的点为9--8,4--6,7--5,5--3;

设f[]数组为并查集的父亲节点数组,初始化f[i]=i,vis[]数组为是否访问过的数组,初始为0;

最近公共祖先问题 LCA 

下面开始模拟过程:

取1为根节点往下搜索发现有两个儿子2和3;

先搜2,发现2有两个儿子4和5,先搜索4,发现4没有子节点,则寻找与其有关系的点;

发现6与4有关系,但是vis[6]=0,即6还没被搜过,所以不操作

发现没有和4有询问关系的点了,返回此前一次搜索,更新vis[4]=1

最近公共祖先问题 LCA

表示4已经被搜完,更新f[4]=2,继续搜5,发现5有两个儿子7和8;

搜7,发现7有一个子节点9,搜索9,发现没有子节点,寻找与其有关系的点;

发现8和9有关系,但是vis[8]=0,即8没被搜到过,所以不操作;

发现没有和9有询问关系的点了,返回此前一次搜索,更新vis[9]=1

表示9已经被搜完,更新f[9]=7,发现7没有没被搜过的子节点了,寻找与其有关系的点;

发现5和7有关系,但是vis[5]=0,所以不操作

发现没有和7有关系的点了,返回此前一次搜索,更新vis[7]=1

最近公共祖先问题 LCA

表示7已经被搜完,更新f[7]=5,继续搜8,发现8没有子节点,则寻找与其有关系的点;

发现9与8有关系,此时vis[9]=1,则他们的最近公共祖先find(9)=5;(find(9)的顺序为f[9]=7-->f[7]=5-->f[5]=5 return 5;)

发现没有与8有关系的点了,返回此前一次搜索,更新vis[8]=1

表示8已经被搜完,更新f[8]=5,发现5没有没搜过的子节点了,寻找与其有关系的点;

最近公共祖先问题 LCA

发现7和5有关系,此时vis[7]=1,所以他们的最近公共祖先为find(7)=5

又发现5和3有关系,但是vis[3]=0,所以不操作,此时5的子节点全部搜完了;

返回此前一次搜索,更新vis[5]=1,表示5已经被搜完,更新f[5]=2

发现2没有未被搜完的子节点,寻找与其有关系的点;

又发现没有和2有关系的点,则此前一次搜索,更新vis[2]=1

最近公共祖先问题 LCA

表示2已经被搜完,更新f[2]=1,继续搜3,发现3有一个子节点6;

搜索6,发现6没有子节点,则寻找与6有关系的点,发现4和6有关系;

此时vis[4]=1,所以它们的最近公共祖先find(4)=1

发现没有与6有关系的点了,返回此前一次搜索,更新vis[6]=1,表示6已经被搜完了;

最近公共祖先问题 LCA

更新f[6]=3,发现3没有没被搜过的子节点了,则寻找与3有关系的点;

发现5和3有关系,此时vis[5]=1,则它们的最近公共祖先find(5)=1

发现没有和3有关系的点了,返回此前一次搜索,更新vis[3]=1

最近公共祖先问题 LCA

更新f[3]=1,发现1没有被搜过的子节点也没有有关系的点,此时可以退出整个dfs了。

这里我使用Java进行了实现,依靠的了并查集优越的时间复杂度,可以将总的时间复杂度降到O(m + n)。(由于测试中只有一个query,所以做了一定的简化和修改,实际过程中,只需要将query存在query的map中即可)

    Map<TreeNode, TreeNode> parent = new HashMap<>();
Map<TreeNode, TreeNode> query = new HashMap<>();
Set<TreeNode> set = new HashSet<>();
List<TreeNode> res = new ArrayList<>(); TreeNode find(TreeNode node) {
if (parent.get(node) != node) {
parent.put(node, find(parent.get(node)));
}
return parent.get(node);
} void union(TreeNode t1, TreeNode t2) {
TreeNode root1 = find(t1);
TreeNode root2 = find(t2); if (root1 != root2) parent.put(root2, root1);
} public void tarjan(TreeNode root, TreeNode p, TreeNode q) {
if (root != null) {
parent.put(root, root);
tarjan(root.left, p, q);
if(root.left != null) union(root, root.left);
tarjan(root.right, p, q);
if(root.right != null) union(root, root.right);
set.add(root); if (root == p) {
if (set.contains(q)) res.add(find(q));
}
if (root == q) {
if (set.contains(p)) res.add(find(p));
}
}
} public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
tarjan(root, p, q);
return res.get(0);
}
  • 在线算法,RMQ

所谓在线算法就是指那些不用提前将所有查询一次读入,而是可以一次完成预处理,然后针对每个查询给出相应的解答,可以看成是流处理算法。

这里的RMQ是指Range Minimum Query,顾名思义,则是区间最值查询,它被用来在数组中查找两个指定索引中最小值的位置。即RMQ相当于给定数组A[0, N-1],找出给定的两个索引如 i、j 间的最小值的位置。

当然了,这里的RMQ问题也是一种使用在线算法去解决区间最值问题,如果只是单次查询,那么完全可以直接遍历就可以。不过要想在多次查询的时候依然保证好的效率,就需要采用一些特殊的技巧。

RMQ问题

很容易想到的是用一个表来保存[i,j]之间的最小值,以后的查询中只需要查表就可以了。

很单纯的,我们可以想到d[i][j] = d[i][j - 1] where nums[d[i][j - 1]] < nums[j] 或者 d[i][j] = j,利用这种方法,可以在O(n^2)内完成预处理。

void process1(int M[MAXN][MAXN], int A[MAXN], int N)
{
int i, j;
for (i =0; i < N; i++)
M[i][i] = i; for (i = 0; i < N; i++)
for (j = i + 1; j < N; j++)
//若前者小于后者,则把后者的索引值付给M[i][j]
if (A[M[i][j - 1]] < A[j])
M[i][j] = M[i][j - 1];
//否则前者的索引值付给M[i][j]
else
M[i][j] = j;
}

事实上,可以使用一种非常巧妙的Sparse Table,稀疏表在O(nlogn)的时间复杂度内完成预处理,之后的查询只需要O(1)的时间复杂度。

ST表本质上是一种动态规划,在ST表中保存的是长度为2的幂的区间的极值。很容易的写出dp的式子:

最近公共祖先问题 LCA

那么现在的问题就是如何将LCA问题规约到RMQ问题。

换一个角度看问题,v和w的最近公共祖先也可以看成v到w的最短路径上深度最小的结点。

基于这个思想,我们可以记录下每个结点的深度信息,这里记录的时候需要采用欧拉回路的方式进行记录,也就是说v,w的最近公共祖先的问题就转化成了深度数组中求区间极值问题。为了更方便的求解,在生成深度矩阵的同时我们也记录下结点信息和每个结点第一次出现的位置,目的是在获取到区间极值的index的时候能够很方便的找到对应的结点。

以下是我使用Java实现的RMQ * LCA.

    // 计算ST表
void ST(int[][] M, List<Integer> nums) {
for (int i = 0; i < nums.size(); i++) {
M[i][0] = i;
} for (int i = 1; i < M[0].length; i++) {
for (int j = 0; j + (1 << i) - 1 < nums.size(); j++) {
if (nums.get(M[j][i - 1]) < nums.get(M[j + (1 << i - 1)][i - 1])) {
M[j][i] = M[j][i - 1];
}
else M[j][i] = M[j + (1 << i - 1)][i - 1];
}
}
} List<TreeNode> ERoute = new ArrayList<>();
List<Integer> d = new ArrayList<>();
Map<TreeNode, Integer> first = new HashMap<>(); void dfs(TreeNode root, int depth) {
ERoute.add(root);
d.add(depth);
first.put(root, d.size() - 1);
if (root.left != null) {
dfs(root.left, depth + 1);
ERoute.add(root);
d.add(depth);
}
if (root.right != null) {
dfs(root.right, depth + 1);
ERoute.add(root);
d.add(depth);
}
} public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
dfs(root, 1);
int n = d.size();
int logn = (int) (Math.log(n) / Math.log(2));
int[][] M = new int[n][logn + 1];
ST(M, d);
int start = first.get(p);
int end = first.get(q);
if (end < start) {
int tmp = start;
start = end;
end = tmp;
}
int off = (int) (Math.log(end - start + 1) / Math.log(2));
if (d.get(M[start][off]) < d.get(M[end + 1 - (1 << off)][off])) {
return ERoute.get(M[start][off]);
}
else {
return ERoute.get(M[end + 1 - (1 << off)][off]);
}
}