利用完全二叉树快速求解LCA

来源:互联网 发布:微信投票系统源码 编辑:程序博客网 时间:2024/05/22 05:07

以下内容是从AekdyCoin的一篇文章里看到的,源处处不明,只是感到很神奇,尚未发现用武之地。

下面所说的算法由于相比于Tarjan和nlogn的做法会复杂一些,因此用的也不多。不过出于好奇,我还是研究了一下。

众所周知,lca和rmq就像情侣一样关系紧密。lca可以由dfs一次转化为+1rmq问题,而+1rmq是可以用O(nlogn)-O(1)或O(n)-O(1)做出来的,不过写起来会比较麻烦而且细节很多,具体的做法就不赘述了。

接下来要讲的O(n)-O(1)在线lca的算法和rmq没有丝毫关系,而且算法简洁,实现简单,除了证明复杂之外。不过证明再复杂也没影响,因为在用的时候直接套是不需要考虑太多证明的,所以理解即可。
首先介绍一下完全二叉树上的lca的求解。

上面这个图给出了15个点的完全二叉树,所有节点按照树的中序遍历分配编号,编号从1开始。在这棵树上的lca是怎样的形态?

注意到,这棵树从根节点往下,每个节点末尾连续的0个数依次递减。对于任意一个点x,设它末尾连续的0的个数是k个,那么它的子树有2k+1-1个节点,而且左右子树分别是2k-1个。同时,每个孩子的编号除了后k+1位可能和x不同外,前缀完全相同。其中,左子树的第k位是0,右子树的第k为是1。即如果x = “a1b”,a是任意01串,b是连续的k个0,那么x的子树中的节点的结构式”ac”,c是长度为k+1的任意01串。
哈!聪明的你肯定可以发现,在这样的完全二叉树上,任意两个点的的lca可以用位运算很快的求出来:设两点的中序编号为x和y以及他们的lca为r,令z = x ^ y(按位异或),k为z二进制表示中最靠左的1的位置,那么r的k左边的部分是和x、y相同的(因为异或),而第k位是1,k右边都是0。
上面所说的那个规律很好发现,只是描述起来很复杂而已- -|||。即,如果认为位运算的操作时间是常数时间,那么在完全二叉树中任意两个节点的lca就可以在O(1)内解决。但是通常的lca问题是任意给定的树,结构不一定是二叉的。这个完全二叉树的做法能对我们有什么帮助呢?

如果我们能够找到一种映射关系将两者联系起来,即将lca的查询转化到完全二叉中,也就可以做到O(1)了。这里要说的映射方法是这样的:
首先定义函数h(x)为数x的二进制表示中末尾连续的0的个数,也称之为x的高度。
       1.    对整棵树进行先序遍历(为什么?)分配编号;
       2.    对于每个点x,求出以它为根的子树中所有节点的最大的h函数值,设这个值为I(x)。

这幅图给了一个分配编号以及求I(x)的例子。注意到在图中有红色标记的路径上的每个点的I值是相同的,都等于路径中深度最大的那个点的h函数值,这个值同时也是它的I值。为什么是这样的?有个比较明显的结论,父亲的I值始终不小于儿子的I值。那么红色路径的性质的原因也就不难理解了。需要注意的是,在一棵子树中h函数值最大的点有且只有一个。

到这一步就说我们将一般lca问题和完全二叉树lca结合了起来还为时尚早。下面给出一个极为重要的结论:
如果z是x的祖先,那么I(z)在完全二叉树中也是I(x)的祖先(树中一个节点也可以理解为自己的祖先)。
证明:    首先有I(z) ≥ I(x)。如果I(z) = I(x),那么结论显然成立。
对于I(z) > I(x)的情况,令h(I(z)) = i。假设在第i位的左边存在某一个位置k(k > i),I(z)和I(x)在k的左边每一位相同,而在第k位它们俩不一样,那么因为I(z) > I(x),所以I(z)在第k位是1,而I(x) 在这一位是0。那么肯定存在一个N使得I (x) < N < I(z),这个N的第k位以及其左边与I(z)相同,而N的第k位右边全部为0。因为我们的编号分配过程是先序编号,那么N肯定出现在z的子树中,根据I值的定义,有h(N) <= h(I(N)) < h(I(z))。可是之前假设有h(N) = k > i = h(I(z)),产生了矛盾。
因此不存在这样的k,即,I(z)和I(x)在第i位左边完全一样。由于I(z) 在第i位上是1,I(x)是0,因此根据之前给的完全二叉树的性质可以得知I(z)是I(x)的祖先。

那么可以确定的是,x和y的lca(设为z)的I值肯定是I(x)和I(y)共同的祖先。
我们将x和y到根节点的路径分别反向列出来(如上图给出了节点7和节点10的路径),可以发现它们有个公共的前缀,其实这一段就是由它们的公共祖先组成的,而最靠后的就是z。在路径序列中从前往后各个点I值的高度依次不降。在完全二叉树中,我们可以求出一个点在任意高度的祖先,那么如果我们能够确定I(z)的高度,也就可以相应得到I(z)了。I(z)的高度该怎么算呢?

按照在树中求I值的方法我们知道,树中出现的I值只是完全二叉树中的一个子集,有些数值并没有取到。比如2如果是8的父亲,那么2(0010)这个值是不会在I中出现的。那么点到根节点的路径序列中的I值并不像完全二叉树中那样连续的。因此,为了方便处理,我们需要把节点到根节点路径上所有I值的高度信息存下来。这个是很好实现的,用二进制串来表示在这个节点的祖先中某一个高度是否达到即可,由于树的规模一般不会太大,用一个32位int来存完全足够了,我们用A(x)来表示这个数。
令I(x)和I(y)的lca的高度是i,然后j是A(x)和A(y)两个01串的高位中连续相同的最靠后的位置,不难证明h(I(z)) = j ≥ i。由此,I(z)也就求出来了。

现在我们所需要做的工作就是找到这个z。I值等于I(z)可能会有很多个,但是哪一个才是我们想要的呢?之前在树上求I值的时候被特意标记过那条红色的路径就是所有I值相等的一条链,同时,那样的一个I值也只会出现在这个链上。因此,最后其实就是在I(z)的那条脸上找答案了。
节点x和y必然都是在z的子树里面的,而x和y在I(z)链上的最近的祖先,分别令为x’和y’,其实它俩就是我们的备选答案。如果I(x) = I(z),那么显然x’就等于x本身;如果不等又该怎么办呢?

可以稍微拐弯抹角一下。比如上面的7和10的情况,10在I(z)中的最近祖先是2,可是在只知道I(z) = “1000”的情况下,这个2的位置并不好确定。虽然如此,2的儿子9确是好找到的,因为9,在I值等于”1010”所对应的链中是出于最上方的位置。如果我们实现将每条I值链最上方的节点在预处理中计算出来,那么就可以很快的找到了x’和y’在各自到x和y的路径中的儿子了,因为那俩儿子的I值是很容易得到的。也就是说,我们通过先找到x’和y’的儿子就可以找到x’和y’了。而最后的答案z其实就是x’和y’中深度较小的那个。


自此,我们就将整个问题完美的解决了。下面将步骤总结一下:
1. 预处理:
a) dfs先序遍历分配编号(其实后序遍历也行,但是中序却不可以),记录每个点的父亲;
b) 计算出每个点的I值以及对应的A值,同时对每条I值对应的链记录深度最小的节点编号;
2. 对于给定询问x和y,找出I(x)和I(y)的lca并求出该数的高度i;
3. 在i的限制下利用A(x)和A(y)求出I(z)的高度j,并由此得出I(z);
4. 计算x’:如果I(x) = I(z),那么x’ = x;反之,求出x的高度低于j的最大的高度k,算出x的高度为k的祖先中深度最浅的点w,那么x’ 就是w的父亲;
5. 用和步骤4相同的方法计算y’;z就是x’和y’中深度较小的点。
上面的算法预处理用两次dfs即可实现,复杂度都是O(n),而之后的查询基于位运算以及之前与处理的结果的从而达到O(1)。代码中涉及到比较多的位运算,不过code长度很短,和离线的Tarjan一样都很简洁。



是的,这样的做法不是普通人能想出的,要么那人是神级的,要么他就是各种拼凑了N多天才搞出了这样算法。算法中的那个从一般lca问题向完全二叉树映射着实会让人摸不着头脑,而只有在知晓了所有步骤之后才会发现那样的转化是多么的精妙,然后各种感慨。另外,整个做法的正确性需要大量的证明,几乎每一次转化都需要利用很多特殊的性质。总之,这个在线的算法是各种诡异、各种神奇。不过这些都不影响我们来享受这个算法所带来的美感以及便利。至少,它给出了一种解决问题的很好的思路,就是将问题向经典问题靠拢、映射并统一,这个想法能充分利用先有知识并利用它们来学习更多
原创粉丝点击