最近公共祖先简称 LCA(Lowest Common Ancestor)。两个节点的最近公共祖先,就是这两个点的公共祖先里面,离根最远的那个。
一般来讲有以下几种求解的方法。
最近公共祖先问题
向上标记法
首先是最朴素的算法——向上标记法。
算法思路:
如果我们需要查询u,v的最近公共祖先,那么我们会先让u向上遍历,边遍历边标记,直到遍历到根节点。接着让v向上遍历,如果第一次遇到了被标记过的点,那么就说明这个点是u和v的最近公共祖先。
但是这样做每次询问的复杂度都是树高,随机数据的情况下复杂度是
O
(
l
o
g
n
)
O(logn)
O(logn),而如果是一条链的话复杂度就会变成
O
(
n
)
O(n)
O(n)。
倍增算法
倍增算法是最经典的 LCA 求法,他是朴素算法的改进算法。倍增算法通过预处理
f
a
fa
fa数组,大幅度提高了跳转的时间,
f
a
i
,
j
fa_{i,j}
fai,j代表从i这个节点开始向根节点跳跃
2
j
2^j
2j步之后所到达的节点。
预处理复杂度
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn), 查询复杂度
O
(
l
o
g
n
)
O(logn)
O(logn)。
倍增的算法思路:
首先通过bfs或者dfs预处理出fa数组和depth数组(深度),然后如果要查询的两个节点是u和v,那么我们先找到深度更大的那个节点,让此节点开始往上跳,直到跳到和另外一个节点同一层。
(注意跳的时候是从大的步数枚举到小的步数,因为我们每个步数只使用一次,所以一定选大的更优,例如:如果可以跳8,那么就不会去跳1 + 2 + 4。根据二进制分解的原理可知我们一定可以跳到同一层。)
跳到同一层之后,再让两个节点同时向上跳,直到跳到他们的最近公共祖先的下一层,此时两个点的最近公共祖先就是
f
a
u
,
0
fa_{u,0}
fau,0。
void bfs(int x) { // 放入根节点
queue<int> Q;
memset(depth, 0x3f, sizeof depth);
depth[0] = 0, depth[x] = 1; //0号点是哨兵
Q.push(x);
while(Q.size()) {
int head = Q.front();
Q.pop();
for(int i = h[head]; ~i; i = ne[i]) {
int to = e[i];
if(depth[to] > depth[head] + 1) { //说明没有遍历过
depth[to] = depth[head] + 1;
Q.push(to);
f[to][0] = head;
for(int j = 1; j <= 16; ++ j)
f[to][j] = f[f[to][j - 1]][j - 1];
}
}
}
}
int lca(int a, int b) {
if(depth[a] < depth[b]) swap(a, b); //只跳最下面的点
for(int k = 16; k >= 0; -- k)
if(depth[f[a][k]] >= depth[b]) a = f[a][k];
//哨兵可以解决跳出树了的问题
//跳到同一层
if(a == b) return b; //如果是同一个点,说明b就是a的最近公共祖先。
for(int k = 16; k >= 0; -- k)
if(f[a][k] != f[b][k]) a = f[a][k], b = f[b][k];
//一起跳直到跳到他们的最近公共祖先的下一层
return f[a][0]; //a或者b向上走一层就是他们的最近公共祖先
}
Tarjan算法
是向上标记法的优化
将所有的点分成三大类:
- 1.已经遍历过的点,即回溯过的点
- 2.正在遍历的点,即当前路径中的点
- 3.还没遍历过的点
算法思路:按dfs序遍历所有节点,每遍历完一条路径,就将这一条路径上的所有点都放到与路径的根节点相同的集合中。
复杂度
O
(
n
+
m
)
O(n + m)
O(n+m)。
#include<bits/stdc++.h>
using namespace std;
const int N = 2e4 + 500, M = 2 * N; //双向边
typedef pair<int, int> PII;
int h[N], e[M], ne[M], w[M], idx;
int dist[N], st[N], p[N], ans[N]; //分别是 每个点和1号点的距离数组 标记数组 并查集数组 和答案数组
int n, m;
vector<PII> query[N];
// query[i][first][second] first存查询距离i的另外一个点j,second存查询编号idx
void init() {
for(int i = 1; i <= n; ++ i) p[i] = i;
memset(h, -1, sizeof h);
}
int find(int x) {
if(p[x] != x) p[x] = find(p[x]);
return p[x];
}
void add(int a, int b, int c) {
w[idx] = c, e[idx] = b, ne[idx] = h[a], h[a] = idx ++;
}
void dfs(int u, int fa) {
for(int i = h[u]; ~i; i = ne[i]) {
int to = e[i];
if(to == fa) continue;
dist[to] = dist[u] + w[i]; //更新dist数组
dfs(to, u);
}
}
void tarjan(int u) {
st[u] = 1; //当前路径点标记为1,即正在遍历的点
// u这条路上的根节点的左下的点用并查集合并到根节点
for(int i = h[u]; ~i; i = ne[i]) {
int to = e[i];
if(!st[to]) //等于0说明没有遍历过
tarjan(to), p[to] = u; //要注意,是在回溯的时候才开始合并的!
}
//回溯的同时开始搜索当前点u有关系的询问,尝试求解
for(auto item : query[u]) { //统计答案
int to = item.first, id = item.second;
if(st[to] == 2) { //等于2说明已经回溯过了
int ancy = find(to); //找到最近公共祖先
ans[id] = dist[to] + dist[u] - 2 * dist[ancy];
//u到to的距离 = d[u]+d[to] - 2*d[lca]
}
}
st[u] = 2; //回溯的标记
}
void solve() {
scanf("%d%d", &n, &m);
init();
for(int i = 1; i < n; ++ i) {
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
add(a, b, c);
add(b, a, c);
}
for(int i = 1; i <= m; ++ i) {
int l, r;
scanf("%d%d", &l, &r);
if(l != r) {
query[l].push_back({r, i});
query[r].push_back({l, i});
}
}
dfs(1, -1);
tarjan(1);
for(int i = 1; i <= m; ++ i) printf("%d\n", ans[i]);
}
int main() {
solve();
return 0;
}
dfs序 + RMQ
这种做法不是很常见,举个例子:
现在有这样的一棵树,我们可以得到一个dfs序列:
12484942521363731 12484942521363731 12484942521363731
如果我们现在想要找两个节点的最近公共祖先,例如:
9和5的话,我们只需要找到序列中任意一对
9
9
9和
5
5
5,然后,对于这个序列的话就是
9425
9425
9425,找到一段这样的子串,然后这个子串中最小的节点
2
2
2就是他们的最近公共祖先。
再比如我们要找4和7的最近公共祖先的话,随便找一段例如:
425213637
425213637
425213637,那最近公共祖先就是1。
这个区间最值问题可以用ST表来解决。