静态树上的最近公共祖先问题
基本概念
树
。定义
一个点
接下来,为了方便,通常记
解决方案
朴素算法
既然是要求公共祖先,那么我们一个直接的想法就是现将一个点
一般的实现如下:
1 2 3 4 5 6 7 8 9 10 | function LCA(u, v): // mark: 大小为 n 的整数数组 // cur: 一个整型,记录该函数被调用了多少次 cur += 1 while u != 0: mark[u] = cur u = f(u) while mark[v] != cur: v = f(v) return v |
这样的算法的复杂度为
另外值得一提的是,在一般图最大匹配的带花树算法中,由于有 “缩花” 这种操作,并且本身对求最近公共祖先的复杂度要求不高,所以很多带花树算法的实现中使用朴素算法。
倍增算法
通过进一步观察,可以发现到达某个深度之后,继续往上走都会是公共祖先,而最近公共祖先就是它们中深度最深的。利用这一点,就可以使用二分这一技巧。二分一个向上走的距离
现在就需要解决两个问题:
- 找到
$v$ 向上走$x$ 步的点$p$ 。 - 确认
$p$ 是否是$u$ 的祖先。
对于第一个问题,我们有一个
转为倍增的好处在于现在只用关心每个点
1 2 3 4 5 6 | function LCA(u, v): for k = int(log(h)) to 0: p = g(v, k) if p is not an ancestor of u: v = p return f(v) |
现在需要求出
所以
接下来对于第二个问题,我们可以使用 DFS 序来解决这个判定问题。也就是记录每个点在以
当然,我们可以不用 DFS 序。想象一下,如果
这里将再次利用二进制和我们的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | // 计算 g 函数 for u = 1 to n: g(u, 0) = f(u) for k = 1 to int(log(h)): for u = 1 to n: g(u, k) = g(g(u, k - 1), k - 1) function LCA(u, v): if d(u) < d(v): u, v = v, u // 交换 u 和 v x = d(u) - d(v) for k = 0 to int(log(x)): if (x >> k) & 1: u = g(u, k) if u == v: // 需要特判 v 是 u 的祖先的情况 return u for k = int(log(n)) to 0: if g(u, k) != g(v, k): u = g(u, k) v = g(v, k) return f(u) |
最后,倍增算法的复杂度为
倍增的思想可以扩展到更多的问题上,例如,我们可以仿照
链剖分
看到这里的时候:
1 2 | if u == v: // 需要特判 v 是 u 的祖先的情况 return u |
不知道是否会想到,既然我们可以
现在我们要对付的树并不能如我们所愿的是链状,但是可不可以把
当然,为了方便,简单路径上的点的深度是单调的(即不会出现先上行后下行的情况)。这些简单路径也称为链。这样一来在同一条链上的的情况就十分简单了。为了方便称呼,链中深度最小的点叫做链顶,如果一条树边不存在于任何一条链上,则称为轻边,否则称为重边。当
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | function LCA(u, v): // top: 一个整型数组,top[u] 表示 u 所处的链的链顶 while top[u] != top[v]: // 挑选链顶深度较深的一个点向上跳 if d(top[u]) > d(top[v]): u = f(top[u]) else: v = f(top[v]) // 此时 u 和 v 在同一条链上 if d(u) < d(v): return u else: return v |
现在的目标就是找出一种链剖分的方案,使得这种 “走轻边” 的情况尽量少。由于我们链剖分的方法所决定的,每个点沿着链向下走最多能走到一个儿子处,姑且称这个儿子为重儿子。当我们为每个点选取好重儿子后,链剖分的方案也就出来了。当然,随便选取重儿子是不可取的,就像在下面这棵树中:
如果随机选取,那么长链上的每条边就有
所以需要换一种思路,我们希望树上划分出的链尽量少,一种思路就是每次挑出最长的一条链。显然对于一个点
不妨来分析一下如果已经走过了
换句话讲就是
但是这还没有结束,
如果
$T_u$ 中存在一条长度$l$ 的链,那么$u$ 所处的长链的长度至少为$l$ 。
因为这一点,上述分析中每次到达的新的长链的长度至少加
为了实现这一点,我们需要对于每一条长链记录它的长度
虽然每条链花费了
看到上面啰哩吧嗦一大堆,有的人可能一开始想法就不一样。可能会想尝试将重儿子
Aha!EXCITED!我们发现这样无论什么询问,最多走一次轻边了,显然科学了很多。在这种剖分中,如果使用之前长链剖分的
套用之前的分析过程,当一个点
接下来又是一个老生常谈的问题,有了这种剖分方法,长链剖分就没有用处了吗?并不。之前提到了用长链剖分在
与倍增算法类似,链剖分方法实质上完成了对树上任意简单路径划分为少量链的目标,从而使得线性数据结构可以在树上大显身手。由于本文只讨论最近公共祖先问题,对于链剖分的其他扩展可以去寻找其他资料。
转化为 RMQ
就目前为止,我们一直在考虑直接在树上处理问题。有的时候,将一个问题转换为另外一个等价的问题,不失为一种奇妙的思路。
现在退回到一种十分朴素的实现方法:从点
1 2 4 2 5 6 5 2 1 3 1
)假设
现在情况就十分清楚了,由于这个 DFS 序是固定的,而且因为每条无向边都被拆成了两条有向边,加上进入根节点的一次记录,所以序列的长度为
稀疏表
首先注意到区间
嗯,分成两个区间,不难想到
1 2 3 4 5 6 7 | highbit[1] = 0 for i = 2 to n: highbit[i] = highbit[i >> 1] + 1 function QUERY(l, r): k = highbit[r - l + 1] return min(f(l, k), f(r - (1 << k) + 1, k)) // 或者 max |
现在就只需要考虑如何计算
当然,我们有更简单的方法。因为每个长度为
同样,它的复杂度也为
然而一切都还没有结束,我们不禁会想,能否继续优化,从而达到
如何降低空间复杂度呢?一个奇葩的想法就是,如果只有
就上图列举的三种情况而言,实际上只有两种:
- 询问跨越的块与块之间的分界线。
- 询问没有跨越这个分界线。
此时不难发现,对于第一种情况,询问区间是由一些连续的块(可能没有)和首尾两个块的一个后缀和一个前缀构成。连续的块我们可以使用稀疏表解决,即用每个块内的最值表示这个块,构成一个序列,然后建立一个大小为
对于第二种情况,似乎就没有那么方便了。现在我们对付的是一个大小为
通过对
What a pity!我们费尽心思,却仍然不能达到线性复杂度。注意到我们的空间几乎都用在处理一堆大小为
现在回到最开始要解决的问题,也就是求最近公共祖先。DFS 序总有一些非常好的性质,就例如之前的深度序列,不难发现这个序列上相邻两个数之差总是
现在你也许可以猜到接下来要做什么了。由于差分序列种类不多,所以我们可以将差分序列做一遍前缀和处理,得到一个新的序列,第
这样一来,我们就可以使得大小为
在具体实现上,由于
笛卡尔树
虽然最近公共祖先的理论复杂度下界已经实现了,本文似乎也就没有必要继续下去了。但是之前我肯定的回答了 RMQ 能否达到理论下界,同时也为了揭示最近公共祖先和 RMQ 问题之间的一些联系,这里还是有必要说一下如何改进稀疏表的空间复杂度。
为了方便,接下来都只考虑最大值。我们知道,最大值一定是序列上的一个值,那么对于序列上的每个值,有哪些区间的最大值是它自己呢?首先来找出一个最大的区间满足这个区间的最大值就是自己,假设序列是
设
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | // a: 长度为 L 的整型数组,表示序列 Q = [] // 单调队列 for i = 1 to L: while Q is not empty and a[Q.last()] <= a[i]: r(Q.last()) = i - 1 Q.pop_back() if Q is empty: l(i) = 1 // 此时 a[i] 一路往左没有遇到更大的值 else: l(i) = Q.last() + 1 Q.push_back(i) foreach i in Q: r(i) = L |
现在我们来观察一个简单的例子:
2 3 1 6 4 5 7
的例子)此时你就会发现所有的这些区间两两之间只有包含和分离的关系。利用这种包含关系,我们实际上就可以构造一棵树:
这棵二叉树被称为笛卡尔树(Cartesian Tree)。对于知道 Treap 的人来说这个名词一定不陌生,这棵树相当于给每个位置
如果我们能够快速的构建出笛卡尔树,就可以使用上一节讨论的
假设我们现在已经有了一棵笛卡尔树,考虑在后面加入一个新的元素
经修改后的算法实现应该是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | // 现在每个元素 a[i] 有三个属性 value、left 和 right, // 分别表示权值、二叉树上的左儿子和右儿子,如果没有儿子则为 null function BUILD-CARTESIAN-TREE(a): Q = [] foreach x in a: while Q is not empty and Q.last().value <= x.value: Q.last().right = x.left x.left = Q.last() Q.pop_back() Q.push_back(x) // 最后留在队列里的点相连 while Q.size() > 1: v = Q.last() Q.pop_back() Q.last().right = v return Q.first() // 返回笛卡尔树的树根 |
至此,最近公共祖先问题和区间最值问题同时都得到了优秀的解决方案。现在我们来整理一下这两者之间的联系:
- 利用 DFS 序可以将最近公共祖先问题转为区间最值问题。
- 利用笛卡尔树可以将区间最值问题转为最近公共祖先问题。
注记
上文中实际上只介绍了在线算法,而没有介绍离线算法,一是因为离线算法应用场景比较少,二是因为我所知道的离线算法(分治法和 Tarjan 算法)的复杂度并没有达到理论下界(Tarjan 算法是一个复杂度接近常数的
- 令
$m = (l + r) / 2$ ,表示我们二分的答案。 - 将所有深度为
$m$ 的点$u$ 的子树$T_u$ 内深度不大于$r$ 的点的标号设为$u$ 。 - 对于每一个询问
$u$ 、$v$ ,如果$u$ 和$v$ 都有标号且标号相同,则说明$d(\mathrm{lca}(u,\;v)) \geqslant m$ ,将这个询问交给$\mathrm{solve}(m,\;r)$ 。否则将$u$ 和$v$ 都变为自己标号的父亲(如果没有标号则不变),并交给$\mathrm{solve}(l,\;m-1)$ 。 - 清除所有标号并且递归处理
$\mathrm{solve}(m,\;r)$ 和$\mathrm{solve}(l,\;m-1)$ 。
分治算法本质上是实现了一个集体倍增的方法,其复杂度为
在前文中留下了一个简单的思考题,就是利用长链剖分实现
RMQ 问题的实现方面,其实还有许多可以仔细思考的地方。虽然我们在理论上达到了最优,但是它付出了较大的空间代价,并且在一般情况下这种算法实际表现情况并不如想象中那么优秀。相关的讨论可以在 Wikipedia 的笛卡尔树(https://en.wikipedia.org/wiki/Cartesian_tree)的引用中找到。
最后,前面所提及的算法我基本上亲自使用 C++ 实现并测试过,这些代码可以在这里找到:https://github.com/riteme/test/tree/master/oi/algs/Graph/LCA
-
长度为
$n$ 的序列$a_i$ 经过差分可以得到一个长度为$n - 1$ 的序列$d_i$ ,其中$d_i = a_{i + 1} - a_i$ ,即记录相邻两个数之差。如果知道$a_1$ ,那么也可以通过$d_i$ 推出$a_i$ 。 ↩