Nekroz
2018-08-06 16:34:53
LCA(Least Common Ancestors),即最近公共祖先,指对于有根树
下面给出一个自己画的图,用来解释LCA及其算法,顺便举个例子。
图中,
平常在信息学竞赛中求LCA一般有四种办法:
用倍增法求解,预处理复杂度是
利用欧拉序转化为RMQ问题,用ST表求解RMQ问题,预处理复杂度
采用Tarjan算法求解,复杂度
利用树链剖分求解,复杂度预处理
但是树链剖分求解LCA中的行为(强调是行为,不是思想)与倍增法类似,加之代码量大(当然你有板子的话当我没说过),不易调试,所以仅在讲倍增法的时候有所涉及,不详细展开。
为了能够更加流畅的阅读这篇文章,你需要掌握如下内容:
能用dfs预处理出有根树中的父子关系和其到根的深度和距离(如果需要的话)
了解基础的倍增算法的思想
能够运用ST表求解RMQ问题
会使用带路径压缩的并查集
熟悉前向星存边(我在程序中都是前向星存边),代码在这里给出
struct edgetype {
int to, next, dist;
} edge[MAXN << 1];
int head[MAXN], cnt;
inline void AddEdge(int from, int to, int dist) {
edge[++cnt] = (edgetype){to, head[from], dist};
head[from] = cnt;
}
前置姿势很简单是吧QAQ(因为不用讲树链剖分啊~)
最常用,也是最简单的算法,实质就是直接对暴力使用倍增优化将复杂度降低达到需求。
预处理,先对有根树
对于每个查询
了解了倍增基本思想的话我就直接来讲做法了。
首先还是要来说明为什么可以用倍增优化暴力,因为找祖先这个操作是满足结合律的,这就是能够使用倍增算法的一个前提,如果看不懂也没关系,毕竟这个和理解程序没什么太大的关系。
我们令
有点绕?画个图就可以让自己清晰了,套回定义式就行。
这个递推的部分我们可以放在dfs内部完成,也可以在dfs结束之后再单独执行,个人比较推荐单独执行。
现在我们面临询问
考虑对这个
inline void swim(int& x, int h) {
for (int i = 0; h > 0; i++) {
if (h & 1)
x = anc[x][i];
h >>= 1;
}
}
那么我们让
按照暴力的思路,这回我们要让这两个结点一起往上跳,用倍增思想就体现为每次上跳相等的高度。但是这一回我们就不可以从低位开始跳了,因为我们不知道要上跳多少才能与让
为什么是这样呢?其实观察上面的倍增递推式我们可以知道,对于深度比根节点还要小的点,但是程序会将这个点编号赋为根,这样一来,我们同时上跳的时候要是
当这个枚举的过程结束时,我们可以得到
还是考虑模拟一下吧,因为是在线算法,所以只模拟初始化和三组询问,但是上面画的树对于倍增算法来说有点浅,不过就将就一下吧。
果然深度很浅啊,但是深一点的话接下来的欧拉序就要抗议了(表格花不下=汗=)
考虑
接下来一起往上跳,再高位我们就不枚举了,当枚举到第
枚举到第
接下来考虑
接下来一起往上跳,枚举到第
最后考虑
此时发现两个点重合了直接返回答案
以上就是模拟过程,因为树高较浅,枚举的过程比较短。如果还是不明白的话可以画一棵深度更大的树进行模拟,这里就不详细展开了。
namespace LCA {
int anc[MAXN][LOG], depth[MAXN];
int dist[MAXN];
inline void dfs(int u, int p, int d) {
anc[u][0] = p; depth[u] = d;
for (int i = head[u]; i; i = edge[i].next) {
int v = edge[i].to;
if (v == p) continue;
dist[v] = dist[u] + edge[i].dist;
dfs(v, u, d + 1);
}
}
inline void init(int root, int n) {
dist[root] = 0;
dfs(root, 0, 1);
for (int j = 1; j < LOG; j++) //递推anc数组
for (int i = 1; i <= n; i++)
anc[i][j] = anc[ anc[i][j - 1] ][j - 1];
}
inline void swim(int& x, int h) { //将x上爬h个单位
for (int i = 0; h > 0; i++) {
if (h & 1)
x = anc[x][i];
h >>= 1;
}
}
inline int query(int x, int y) {
if (depth[x] < depth[y]) std::swap(x, y); //置x为深度较深的点
swim(x, depth[x] - depth[y]); //让x和y的深度相同
if (x == y) return x; //如果相等直接返回
for (int i = LOG - 1; i >= 0; i--) //从高位开始枚举每个二进制位
if (anc[x][i] != anc[y][i]) {
x = anc[x][i];
y = anc[y][i];
}
return anc[x][0];
}
}
这个算法主要就是比较好想(毕竟是优化版的暴力嘛~),一般比较容易理解,编程也容易。
还有就是其思想与用树链剖分求
对有根树T进行深度优先遍历,无论是递归还是回溯,每次到达一个节点就把编号记录下来,得到一个长度为
按照深度优先遍历我们可以得到它的欧拉序和深度序列:
为了方便,我们定义
根据深度优先遍历的特点,我们可以知道,对于结点
从图中,我们可以清晰得看出,
这样一来,我们就可以将 LCA 问题转化为RMQ问题:
仍然以上图为例,假定
为什么要从原序列中抽取
可以看出,在这个序列中,深度最小的点的序号是
有了这个例子大家应该就很清楚了,具体实现的时候还要注意
代码中
namespace LCA {
int ST[MAXN << 1][LOG];
int value[MAXN << 1], depth[MAXN << 1], first[MAXN], len; //欧拉序及深度序列相关
int dist[MAXN];
inline int calc(int x, int y) { //利用下标进行运算
return depth[x] < depth[y] ? x : y;
}
inline void dfs(int u, int p, int d) {
value[++len] = u; depth[len] = d; first[u] = len; //递归时将结点加入欧拉序列中,并记录first
for (int i = head[u]; i; i = edge[i].next) {
int v = edge[i].to;
if (v == p) continue;
dist[v] = dist[u] + edge[i].dist;
dfs(v, u, d + 1);
value[++len] = u; depth[len] = d; //回溯时将结点加入欧拉序列中
}
}
inline void init(int root) {
len = 0; dist[root] = 0;
dfs(root, 0, 1);
for (int i = 1; i <= len; i++) ST[i][0] = i;
for (int j = 1; (1 << j) <= len; j++) //ST表倍增过程
for (int i = 1; i + (1 << j) - 1 <= len; i++)
ST[i][j] = calc(ST[i][j - 1], ST[i + (1 << (j - 1))][j - 1]);
}
inline int query(int x, int y) {
int l = first[x], r = first[y];
if (l > r) std::swap(l, r); //找到区间[l, r]
int k = log2(r - l + 1);
return value[calc(ST[l][k], ST[r - (1 << k) + 1][k])];
}
}
思想比较巧妙,虽说查询复杂度是
另外,我们可以观察到深度序列
Tarjan算法的核心思想是先进行一遍深度优先搜索,在讨论LCA与RMQ的关系 的时候,我们已经论述过
从图中,我们可以清晰地看出,
下面我们面临的问题就是如何快速求出两个给定结点去所回溯到的最远的点。
我们给每个结点
接下来开始深度优先遍历,当遍历完一个结点的所有子树时,更新
这样,一次深度优先遍历之后,我们便得到了所有问题的答案。
有根树
我们首先搜索到了
由于
回溯到
搜索到
处理
回溯到
搜索
处理
回溯到
搜索
处理与
回溯到
算法结束。
其实最后的
namespace LCA {
edgetype qedge[MAXQ << 1];
int qhead[MAXN], qcnt;
inline void AddQuery(int from, int to, int index) { //添加询问的前向星
qedge[++qcnt] = (edgetype){to, qhead[from], index};
qhead[from] = qcnt;
}
int u[MAXN], v[MAXN], ans[MAXN]; //询问相关
int f[MAXN], dist[MAXN];
bool visit[MAXN];
inline int seek(int u) { // 路径压缩
return f[u] == u ? u : f[u] = seek(f[u]);
}
inline void dfs(int u, int p) { //Tarjan算法的主要流程
for (int i = head[u]; i; i = edge[i].next) {
int v = edge[i].to;
if (v == p) continue;
dist[v] = dist[u] + edge[i].dist;
dfs(v, u);
f[v] = u; //回溯时修改 f 数组
}
visit[u] = true; //表示已访问
for (int i = qhead[u]; i; i = qedge[i].next) {
int v = qedge[i].to;
if (visit[v]) //如果另一个顶点也被访问过则处理询问
ans[qedge[i].dist] = seek(v);
}
}
inline void init() {
memset(qhead, 0, sizeof qhead); qcnt = 0;
}
inline void solve(int root, int n) {
dist[root] = 0;
memset(visit, false, sizeof visit);
for (int i = 1; i <= n; i++) f[i] = i; //并查集初始化
dfs(root, 0);
}
}
由于Tarjan算法中存询问和存边所用到的struct的内容和加边函数是一模一样的,为了省事儿,就把两个拼在一起了,否则程序会比较复杂,写起来也麻烦。其实仅仅把这个当成一个像 STL 一样的黑盒代码,只要明白有哪些内置函数和作用就行了,写完赶紧折叠起来,不然强迫症就真的要受不了了(眼不见为净QAQ)。
树链剖分求解LCA的行为与倍增法类似,都是往上跳。
但树链剖分与倍增法的思想完全不同。
对一棵树进行树链剖分之后会产生若干条重链和若干条轻边。学树链剖分的时候可以形象理解,认为重链是高速公路,而轻边是普通公路,理解的话就是重链可以一整条进行操作,而轻边只能一条一条进行操作。
那么我们可以构筑出如下算法:
有一个询问
选择链顶深度深的那个结点往上跳,这里要注意一下,不是深度深的结点往上跳,这个反例在上图中不能体现,重新画个大点的图就明白了。
其中蓝色的是重链,黑色的是轻边,考虑结点
接下来就只要重复第
由于上跳是树链剖分的基本操作,而且主要学习的是树链剖分的思想,我就不给出具体的模拟过程了,其实只要看懂了上面对算法的说明,具体的实现就很简单了。
两个板子:
数组版树链剖分
int father[MAXN], depth[MAXN], size[MAXN], dist[MAXN]; //树剖部分
int son[MAXN], top[MAXN];
bool visit[MAXN];
void dfs1(int u, int p, int d) {
father[u] = p, depth[u] = d, size[u] = 1, son[u] = 0, visit[u] = true;
int maxsize = 0;
for (int i = head[u]; i; i = edge[i].next) {
int v = edge[i].to;
if (v == p || visit[v]) continue;
dist[v] = dist[u] + edge[i].dist;
dfs1(v, u, d + 1);
size[u] += size[v];
if (size[v] > maxsize) {
maxsize = size[v];
son[u] = v;
}
}
}
void dfs2(int u, int anc) {
top[u] = anc, visit[u] = true;
if (son[u]) dfs2(son[u], anc);
for (int i = head[u]; i; i = edge[i].next) {
int v = edge[i].to;
if (v == father[u] || v == son[u] || visit[v]) continue;
dfs2(v, v);
}
}
namespace LCA {
void init(int root) {
dist[root] = 0;
memset(visit, false, sizeof visit);
dfs1(root, 0, 1);
memset(visit, false, sizeof visit);
dfs2(root, root);
}
long long query(int x, int y) {
while (top[x] != top[y]) { //如果不在同一条重链上
if (depth[top[x]] < depth[top[y]]) std::swap(x, y); //让x成为链顶深度较浅的结点
x = father[top[x]]; //x上跳
}
if (depth[x] > depth[y]) std::swap(x, y);
return x; //在同一条重链上时,深度浅的就是解
}
}
链式树链剖分(比较惊世骇俗)
struct Node { //树链剖分部分
struct Edge *head;
struct Chain *chain;
Node *father, *son;
int size, depth, number, dist;
bool visit;
} info[MAXN];
struct Edge {
Node *from, *to;
Edge *next;
int dist;
Edge(Node *from, Node *to, int dist) : from(from), to(to), next(from->head), dist(dist) {}
};
struct Chain {
Node *top;
Chain(Node *top) : top(top) {}
};
inline void AddEdge(int from, int to, int dist) { //链式专属前向星
info[from].head = new Edge(&info[from], &info[to], dist);
info[to].head = new Edge(&info[to], &info[from], dist);
}
void dfs1(Node *u) {
u->visit = true;
u->size = 1;
for (Edge *e = u->head; e; e = e->next) {
Node *v = e->to;
if (!v->visit) {
v->father = u;
v->depth = u->depth + 1;
v->dist = u->dist + e->dist;
dfs1(v);
u->size += v->size;
if (!u->son || u->son->size < v->size)
u->son = v;
}
}
}
void dfs2(Node *u) {
if (!u->father || u != u->father->son)
u->chain = new Chain(u);
else u->chain = u->father->chain;
if (u->son) dfs2(u->son);
for (Edge *e = u->head; e; e = e->next) {
Node *v = e->to;
if (v->father == u && v != u->son)
dfs2(v);
}
}
namespace LCA {
inline void init(int root, int n) {
for (int i = 1; i <= n; i++)
info[i].number = i;
info[root].dist = 0;
info[root].depth = 1;
dfs1(&info[root]); //dfs的参数是指针,所以要取地址再执行
dfs2(&info[root]);
}
int query(int x, int y) {
Node *u = &info[x], *v = &info[y]; //取出地址
while (u->chain != v->chain) {
if (u->chain->top->depth < v->chain->top->depth) std::swap(u, v);
u = u->chain->top->father;
}
if (u->depth > v->depth) std::swap(u, v);
return u->number; //返回深度浅的结点的编号
}
}
其实这里重要的是思想,而不是代码(因为每个人的树剖板子不一样啊)。
然而网上很多标题称是树剖的题解里面都是用树链剖分实现LCA,导致找树剖题目练手时一时间找不到好的题目做。
上面论述了LCA问题的四种常用的基本解法,回答询问强制在线的话可以考虑转化为RMQ问题或者使用树链剖分。如果不强制在线的话Tarjan,ST表,树剖均可,具体问题具体分析。