LCA问题第一弹
上篇文章讲到 区间最值 RMQ 问题,今天,我们来研究一下 LCA 问题。
LCA( Least Common Ancestor)问题:中文名为“ 最近公共祖先”问题。LCA问题定义是这样的:在一个树形结构中,求解两个子节点的公共祖先中离根节点最远的那个祖先节点,换言之,分别从两个子节点开始向根节点遍历,两条路径最早交汇节点就是最近公共祖先。
为了给大家更加形象的展示一下LCA到底是什么,我用尽毕生力气画了一棵非常不美观的树,希望大家摒弃丑陋的外表来看这棵树。示例如下:
例一:求 P 和 T 的最近公共祖先
第一种理解:P 和 T 的公共祖先为 D 、B、A,其中离根节点最远的公共祖先为 D
第二种理解:P 到根节点的路径为 P->H->D->B->A , T 到根节点的路径为 T->Q->I->D->B->A ,由此可知 最早交汇点为 D,因此最近公共祖先为 D
例二:求 P 和 R 的最近公共祖先
按照例一的分析方法,最近公共祖先为 B
例三:求 P 和 S 的最近公共祖先
按照上述方法,最近公共祖先为A
知道了 LCA 的基本概念,那我们来研究一下如何求解的问题。
想必大家已经从上面的描述中看出了端倪,没错,上面给大家分析那棵颜值低的树时,已经很直接的使用了两种思维。分析一下:最近公共祖先是两个待求子节点所有公共祖先中离根节点最远的,如果根节点定义为第一层,其子节点定义为第二层,那么也就是要求得两个待求子节点的所有公共祖先节点中层数最大的,同时也是两个待求子节点到根节点路径的最早汇合点。
我们都知道一般的,我们存储树结构时,通常对于一个节点,只存储其子节点的指针(地址),并不会存储其父节点的指针(地址),这就意味着从一个节点开始,我们只可能得知该节点的子节点有哪些,而无从得知其祖先节点都有哪些,或者是,依据当前的存储结构得知祖先节点的复杂程度比较大。此时,对于如何求解某一确定位置的子节点的所有父节点,我们可以考虑将树结构存储成既有指向子节点指针也有指向父节点指针的,也可以选择另辟数组单独存储各节点的父节点。如此求出待求解两个子节点的所有祖先节点进行对比,找出离根节点最远的一个,那这个远的定义就体现在层数大了。当然在存储树的时候,需要再增加一个纪录当前节点所在层数的量。由于我们在求解两个子节点的祖先节点时,必定是由下而上(由子节点向根节点寻找)当我们找到第一个公共祖先节点时,实际就已经找到了答案,因此在实际求解中,我们通常求到第一个公共祖先节点就终止。
在展示代码部分之前,有一个现象值得我们注意:对于同一棵树的两个子节点 X 和 Y 来说,如果 X 的层数比 Y 的层数小,那么 Y 的所有祖先中,和 X 处在同一层级的祖先节点 Z 满足 X 和 Y 的最近公共祖先节点与 X 和 Z 的最近公共祖先节点相同。那么我们就可以从 X 和 Z 所在的层级开始逐级向上检验两者的祖先节点是否相同,一旦相同结束程序。
下面给大家分享一下本方法的核心代码,由于在实际问题的求解中,不一定就是二叉树的LCA 问题,因此在这里采用图的存储方式进行展示。
看了上面的代码展示,大家一定已经彻底理解了 LCA 问题的概念,分析以上代码可以得知在将两个子节点等效处理到同一层级的过程中以及将同一层级的两个子节点向上遍历找共同祖先的过程中,我们采用的都是逐个遍历的方法,这样的遍历方法其实是最容易想到的,也是比较费时间的,我们当然希望能够找到一种方法,更加精准的寻找公共祖先,这时候就需要来仔细的研究一下需要遍历的数据特征。不难发现寻找公共祖先的本质是在父节点编号组成的 father[]数组中查找满足条件的一个元素。对于区间上的查找问题,除了逐个遍历,还有一个比较经典的就是二分查找。二分查找的条件是将区间一分为二之后一定有一半区间里的值均不符合条件。回过头来再想一想树的特征:两个子节点向根节点遍历的两条路径一旦汇合便不会再分叉,也就意味着如果 father[]数组是从根节点到子节点的存储顺序,将区间一分为二,若中间值是两个待求子节点的祖先,则前半区间一定都是两个待求子节点的祖先,最近公共祖先一定会出现在后半部分区间;若中间值不是两个待求子节点的公共祖先,则后半区间一定都不是两个待求子节点的公共祖先,最近公共祖先一定会出现在前半部分区间。
因此在从同一层级向公共祖先这部分的查找过程我们就可以采用二分查找的方法。在描述第一种方法的时候我就曾提过一个现象:对于同一棵树的两个子节点 X 和 Y 来说,如果 X 的层数比 Y 的层数小,那么 Y 的所有祖先中,和 X 处在同一层级的祖先节点 Z 满足 X 和 Y 的最近公共祖先节点与 X 和 Z 的最近公共祖先节点相同。从这个现象来看,由 Y 寻找到 Z 的这个过程其实是没有值得关注的点的,因为在这个过程中,我们只关心 Z 的位置和编号,至于中间过程中的那些祖先都是谁我们丝毫不关心,这时候,我们希望有一种方法能够直接一针见血的找到 Z ,只有这样才能节省不必要的时间开支,如果不能达到,至少我们可以简化这个寻找 Z 的过程。如果这时候,我们有张表,存储了每个子节点的 i 辈祖先是谁,那就堪称完美了。
大家可能都注意到了,不仅 Y 到 Z 的过程中可以用的到这个表,X 和 Z 求公共祖先的二分过程中也是必须用的。既然两个重要的环节都得用到,不妨我们就舍出这段磨刀的功夫,毕竟实际求解问题的过程中不一定就只是求两个子节点的公共祖先,万一题目比较丧心病狂让你挨个求个遍,那有了这个表真是省事不少呢。
不知道大家还能不能回忆起 RMQ 问题,那个时候我们也是建了个表呢,隐约觉得那个建表的方法可以直接拉过来套用。不管用不用得上我们先试一下,万一就成了呢?
首先我们来研究一下隔好几辈的 father 怎么求。
假设 子节点编号为 i ,那么它的第一代父节点就是 father1[ i ] = father[i] , 它的第二代父节点就是father2[i]= father1[ father1[ i ] ] ,它的第三代父节点就是father3[i] = father1[father2[i]],它的第四代父节点就是 father4[i] = father2[father2[i]]由此往后推理就可以推出所有父节点。当然同 RMQ 问题中需要注意的问题一样,我们需要先求出所有节点的第一代父节点,才能求所有节点的第二代父节点,这样的求解顺序不能颠倒。
接下来我们按照动态规划的思想来定义 dp 数组,设 dp[ i ] [ j ] 表示 编号为 i 的节点的第 2 ^ j 代父节点。依据上面的推理关系,状态转移方程如下:
dp[ i ][ j + 1 ] = dp [ dp[ i ][ j ] ][ j ]
具体分析思路如上,接下来给大家展示核心代码部分,分析部分没有看明白的地方,希望代码可以帮助理解。
核心代码如上,图片如果看不清楚,打开网页http://paste.ubuntu.com/25406352/查看核心代码。今天的分享就到这里,希望大家如果发现文章中写的不恰当的或者有错误的地方能够积极在留言区留言。
还没有关注我的朋友,可以识别下方二维码进行关注,虽然不能保证每日更新一篇,但每篇均属作者本人原创,质量可以保证。