【图论】最近公共祖先 学习笔记
LCA
基本概念
对于一个有根树,如果点 \(z\) 既是点 \(x\) 的祖先,又是点 \(y\) 的祖先,则说点 \(z\) 是 \(x\) 和 \(y\) 的公共祖先。每对点的所有公共祖先里,深度最大的那个点被称作这两个或多个点的最近公共祖先(lca)。
lca 有很多优秀的性质,例如经过 lca 的路径是两点间的最近路径等。
LCA 的求法
下面简单介绍几种常用的求解 LCA 的方法。
向上标记法
暴力找出所有公共祖先求公共祖先,然后找出深度最大的即可。时间复杂度 O(n)。
倍增法
倍增的一个经典应用就是优化向上标记法求 LCA,求 LCA 的较常见算法是倍增法。
先预处理出 \(fa[u][j]\) 表示节点 \(u\) 的第 \(2^j\) 个祖先,根据倍增的性质有 \(fa[u][j] = fa[fa[u][j-1]][j-1]\) (\(2^{j-1} + 2^{j-1} = 2 \times 2^{j-1} = 2^j\)),初始条件可以通过一次 DFS 求出每个节点的直接父亲 \(fa[u][0]\) 然后以第二维为阶段递推即可。
查询时,先倍增把两个点调到同一高度,然后再倍增向上调,每次倍增时从大到小调 \(2\) 的整数幂,由二进制拆分可以知道这样是正确的。
参考代码如下:
// 洛谷 P3379 【模板】最近公共祖先(LCA)
#include <bits/stdc++.h>
using namespace std;
// #define int long long
const int N = 5e5 + 5;
vector<int> g[N];
void add(int u, int v) {
g[u].push_back(v);
g[v].push_back(u);
}
int n, q, s;
int f[N][20], d[N];
void dfs(int u, int fa) { // 一次 DFS 处理出深度和直接父亲
for (auto& v : g[u]) {
if (v == fa) continue;
f[v][0] = u;
d[v] = d[u] + 1;
dfs(v, u);
}
}
void LCA_init() {
for (int j = 1;j <= 18;j++) {
for (int i = 1;i <= n;i++) {
f[i][j] = f[f[i][j - 1]][j - 1]; // 倍增递推出 fa 数组
}
}
}
int lca(int x, int y) {
if (d[x] > d[y]) swap(x, y);
for (int j = 18;j >= 0;j--) {
if (d[f[y][j]] >= d[x]) y = f[y][j]; // 倍增调到同一高度
}
if (x == y) return x;
for (int j = 18;j >= 0;j--) {
if (f[x][j] != f[y][j]) { // 不是公共祖先就向上调
x = f[x][j], y = f[y][j];
}
}
return f[x][0]; // 由二进制拆分可知,倍增调整完后的父亲的一定是 lca
}
signed main() {
ios::sync_with_stdio(0);
#ifdef DEBUG
clock_t t0 = clock();
freopen("data.in", "r", stdin);
freopen("data.out", "w", stdout);
#endif
// Don't stop. Don't hide. Follow the light, and you'll find tomorrow.
cin >> n >> q >> s;
for (int i = 1, u, v; i <= n - 1; i++) {
cin >> u >> v;
add(u, v);
}
d[s] = 1;
dfs(s, 0);
LCA_init();
while (q--) {
int u, v;
cin >> u >> v;
cout << lca(u, v) << endl;
}
#ifdef DEBUG
cerr << "Time used:" << clock() - t0 << "ms" << endl;
#endif
return 0;
}
时间复杂度:预处理 \(O(n \log n)\),每次查询 \(O(\log n)\)。
重链剖分法
欧拉序 + ST 表法
一种 \(O(n \log n)\) 预处理,\(O(1)\) 查询的求解 LCA 方法。
LCA 的运用举例
树上前缀和、子树和、差分
类似序列上的前缀和差分,树上前缀和指的是从根节点到某个点的路径上所有点(边)权之和,子树和是某个子树内所有点(边)权之和。灵活运用树上前缀和、子树和、差分可以解决许多问题。
P1600 [NOIP2016 提高组] 天天爱跑步
对于每条路径 \(u \to v\),记 \(l = lca(u,v)\),则可以把路径分为两部分:\(u \to l\), \(l \to v\)。
当路径 \(u \to l\) 对节点 \(x\) 上的观察员有贡献时,必须当且仅当下面两个条件满足:
- 点 \(x\) 在 \(u \to l\) 上。
- 观察员在第 \(w_x\) 秒观察,则 \(d_r - d_x = w_x \implies d_x + w_x = d_r\),其中 \(d_i\) 表示节点 \(i\) 的深度(这题里深度定义为从根节点出发经过的边数,即根节点深度为 \(0\))。
其中,\(d_x + w_x\) 为节点 \(x\) 的不变属性,d_r 为路径的属性。而由条件 \(1\) 可知,路径 \(u -> r\) 只会对所经过的点产生贡献。所以记 \(c[x][i]\) 为可对节点 \(x\) 上的观察员产生贡献的路径属性(\(d_r\))为 \(i\) 的节点,对于每个观察员 \(c[x][d_x + w_x]\) 即为所有路径 \(u \to l\) 部分对这个观察员的贡献。
考虑如何计算 \(c[x][i]\):
方法一:树上差分+线段树合并。对每个点建立一颗权值线段树。对于每个 \(u \to l\),进行树上差分,在 \(u\) 与 \(fa[l][0]\) 的权值线段树上的 \(d_r\) 节点上分别 \(+1\) 与 \(-1\),最后求子树和即可(线段树的加法为线段树合并,即每个节点对应相加)。
方法二:直接进行树上差分,在每个点上记录每次修改操作,用一个全局数组来求子树和。
\(l \to v\) 的路径部分求解贡献方法类似,条件 \(2\) 为 \(d_l - d_u + d_l - d_x = w_x \implies d_x + w_x = 2d_l - d_u\)。
参考代码:
#include <bits/stdc++.h>
using namespace std;
// #define int long long
const int N = 3e5 + 5;
int n, m;
vector<int> g[N];
int w[N], d[N], fa[N][25];
struct player {
int u, v, l;
} p[N];
void dfs(int u, int in) {
for (int v : g[u]) {
if (v == in) continue;
d[v] = d[u] + 1;
fa[v][0] = u;
dfs(v, u);
}
}
void lca_init() {
d[1] = 1;
dfs(1, 0);
for (int j = 1;j <= 20;j++)
for (int u = 1;u <= n;u++)
fa[u][j] = fa[fa[u][j - 1]][j - 1];
}
int lca(int x, int y) {
if (d[x] > d[y]) swap(x, y);
for (int j = 20;j >= 0;j--)
if (d[fa[y][j]] >= d[x])
y = fa[y][j];
if (x == y) return x;
for (int j = 20;j >= 0;j--)
if (fa[x][j] != fa[y][j])
x = fa[x][j], y = fa[y][j];
return fa[x][0];
}
using pii = pair<int, int>;
int ans[N];
vector<pii> delta[N];
map<int, int> c;
void dfs2(int u, int fa, int sign) {
int cnt = c[w[u] + d[u] * sign];
for (auto pr : delta[u]) c[pr.first] += pr.second;
for (int v : g[u]) {
if (v == fa) continue;
dfs2(v, u, sign);
}
ans[u] += c[w[u] + d[u] * sign] - cnt;
}
signed main() {
ios::sync_with_stdio(0);
// Don't stop. Don't hide. Follow the light, and you'll find tomorrow.
cin >> n >> m;
for (int i = 1, u, v;i < n;i++) {
cin >> u >> v;
g[u].push_back(v), g[v].push_back(u);
}
for (int i = 1;i <= n;i++) cin >> w[i];
lca_init();
for (int i = 1;i <= m;i++) {
cin >> p[i].u >> p[i].v;
p[i].l = lca(p[i].u, p[i].v);
}
for (int i = 1;i <= m;i++) {
int u = p[i].u, l = p[i].l;
delta[u].push_back({ d[u], +1 });
delta[fa[l][0]].push_back({ d[u], -1 });
}
dfs2(1, 0, +1);
for (int i = 1;i <= n;i++) delta[i].clear();
c.clear();
for (int i = 1;i <= m;i++) {
int u = p[i].u, v = p[i].v, l = p[i].l;
delta[v].push_back({ d[u] - 2 * d[l], +1 });
delta[l].push_back({ d[u] - 2 * d[l], -1 });
}
dfs2(1, 0, -1);
for (int i = 1;i <= n;i++) cout << ans[i] << " \n"[i == n];
return 0;
}
(未完待续)