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]

示例 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问题规约成寻找链表的公共节点的问题,显然,这个问题还是比较容易解决的。

三、普通的二叉树

问题描述:

问题求解:

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

解决这个问题,其实是可以看成路径查询问题的,我们可以对每个节点进行路径的查询,并生成从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;

 

下面开始模拟过程:

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

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

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

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

表示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

表示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没有没搜过的子节点了,寻找与其有关系的点;

发现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

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

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

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

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

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

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

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

更新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问题规约到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]);
}
}

最近公共祖先问题 LCA的更多相关文章

  1. 洛谷P3379 【模板】最近公共祖先(LCA)

    P3379 [模板]最近公共祖先(LCA) 152通过 532提交 题目提供者HansBug 标签 难度普及+/提高 提交  讨论  题解 最新讨论 为什么还是超时.... 倍增怎么70!!题解好像有 ...

  2. 最近公共祖先:LCA及其用倍增实现 +POJ1986

    Q:为什么我在有些地方看到的是最小公共祖先? A:最小公共祖先是LCA(Least Common Ancestor)的英文直译,最小公共祖先与最近公共祖先只是叫法不同. Q:什么是最近公共祖先(LCA ...

  3. 洛谷 3379 最近公共祖先(LCA 倍增)

    洛谷 3379 最近公共祖先(LCA 倍增) 题意分析 裸的板子题,但是注意这题n上限50w,我用的边表,所以要开到100w才能过,一开始re了两发,发现这个问题了. 代码总览 #include &l ...

  4. P3379 【模板】最近公共祖先(LCA)

    P3379 [模板]最近公共祖先(LCA) 题目描述 如题,给定一棵有根多叉树,请求出指定两个点直接最近的公共祖先. 输入输出格式 输入格式: 第一行包含三个正整数N.M.S,分别表示树的结点个数.询 ...

  5. 洛谷P3379 【模板】最近公共祖先(LCA)(dfs序+倍增)

    P3379 [模板]最近公共祖先(LCA) 题目描述 如题,给定一棵有根多叉树,请求出指定两个点直接最近的公共祖先. 输入输出格式 输入格式: 第一行包含三个正整数N.M.S,分别表示树的结点个数.询 ...

  6. 「LuoguP3379」 【模板】最近公共祖先(LCA)

    题目描述 如题,给定一棵有根多叉树,请求出指定两个点直接最近的公共祖先. 输入输出格式 输入格式: 第一行包含三个正整数N.M.S,分别表示树的结点个数.询问的个数和树根结点的序号. 接下来N-1行每 ...

  7. 洛谷——P3379 【模板】最近公共祖先(LCA)

    P3379 [模板]最近公共祖先(LCA) 题目描述 如题,给定一棵有根多叉树,请求出指定两个点直接最近的公共祖先. 输入输出格式 输入格式: 第一行包含三个正整数N.M.S,分别表示树的结点个数.询 ...

  8. luogo p3379 【模板】最近公共祖先(LCA)

    [模板]最近公共祖先(LCA) 题意 给一个树,然后多次询问(a,b)的LCA 模板(主要参考一些大佬的模板) #include<bits/stdc++.h> //自己的2点:树的邻接链表 ...

  9. 【原创】洛谷 LUOGU P3379 【模板】最近公共祖先(LCA) -> 倍增

    P3379 [模板]最近公共祖先(LCA) 题目描述 如题,给定一棵有根多叉树,请求出指定两个点直接最近的公共祖先. 输入输出格式 输入格式: 第一行包含三个正整数N.M.S,分别表示树的结点个数.询 ...

随机推荐

  1. Thinkphp自定义工具类的使用!

    在使用Thinkphp做开发的时候,很多时候会用到一些自己写的类,为了方便管理,可以把这些类,单独放到一个文件里. 这就是自定义工具类: 首先在 Application 目录下新建 Component ...

  2. centos7 安装后,出现Please make your choice from above ['q' to quit | 'c' to continue | 'r' to refresh]

    PS:出现以上信息,是要求你阅读或者接收协议: Initial setup of CentOS Linux 7 (core)解决步骤如下: 1,输入[1],按Enter键阅读许可协议,2,输入[2], ...

  3. CRM - 销售与客户

    一.销售与客户 - 表结构 ---公共客户(公共资源) 1.没有报名 2.3天没有跟进 3.15天没有成单 客户分布表 龙泰 男 yuan 2018-5-1 3天未跟进 龙泰 男 三江 2018-5- ...

  4. Jury Compromise---poj1015(动态规划,dp,)

    题目链接:http://poj.org/problem?id=1015 大致题意: 在遥远的国家佛罗布尼亚,嫌犯是否有罪,须由陪审团决定.陪审团是由法官从公众中挑选的.先随机挑选n 个人作为陪审团的候 ...

  5. linux中gdb的可视化调试

    今天get到一个在linux下gdb调试程序的技巧和大家分享一下!平时我们利用gcc进行编程,进行程序调试时,观察程序的跳转等不是这么直观.都是入下的界面! 但是如果我们在编译连接时上加了-g命令生成 ...

  6. django-models的get与filter

    为了说明它们两者的区别定义2个models class Student(models.Model):name = models.CharField('姓名', max_length=20, defau ...

  7. 十六.MySQL存储过程

    1.创建一个没有参数的存储过程 CREATE PROCEDURE sp1() SELECT VERSION(); 调用存储过程:CALL sp1(); 2.带有IN参数的存储过程 CREATE PRO ...

  8. Openstack(四)Mysql主从

    4.1 安装mysql 4.1.1 安装依赖 # yum install vim gcc gcc-c++ wget autoconf  net-tools lrzsz iotop lsof iotop ...

  9. cocos-lua基础学习(八)Layer类学习笔记

    创建 local layer = cc.Layer:create() local layer1 = cc.LayerColor:create(cc.c4b(192, 0, 0, 255), s.wid ...

  10. vue-router的hash模式与history模式的对比

    Vue-router 中hash模式和history模式的关系在vue的路由配置中有mode选项 最直观的区别就是在url中 hash 带了一个很丑的 # 而history是没有#的mode:&quo ...