最近公共祖先
公共祖先: 在一棵有根树上,若节点 \(F\) 是节点 \(x\) 的祖先,也是节点 \(y\) 的祖先,那么称 \(F\) 是 \(x\) 和 \(y\) 的公共祖先。
最近公共祖先(LCA): 在 \(x\) 和 \(y\) 的所有公共祖先中,深度最大的称为最近公共祖先,记为 \(LCA(x,y)\)。

LCA 显然有以下性质。
- 在所有公共祖先中,\(LCA(x,y)\) 到 \(x\) 和 \(y\) 的距离都是最短的。例如,在 \(e\) 和 \(g\) 的所有祖先中,\(c\) 距离更短。
- \(x\) 与 \(y\) 之间最短的路径经过 \(LCA(x,y)\)。例如,从 \(e\) 到 \(g\) 的最短路径经过 \(c\)。
- \(x\) 和 \(y\) 本身也可以是它们自己的公共祖先。若 \(y\) 是 \(x\) 的祖先,则有 \(LCA(x,y)=y\),如图中 \(d=lca(d,h)\)。
如何求 LCA?根据 LCA 的定义,很容易想到一个简单直接的方法:分别从 \(x\) 和 \(y\) 出发,一直向根节点走,第一次相遇的节点就是 \(LCA(x,y)\)。具体实现时,可以用标记法:首先从 \(x\) 出发一直向根节点走,沿路标记所有经过的祖先节点;把 \(x\) 的祖先标记完之后,然后再从 \(y\) 出发向根节点走,走到第一个被 \(x\) 标记的节点,就是 \(LCA(x,y)\)。标记法的时间复杂度较高,在有 \(n\) 个节点的树上求一次 \(LCA(x,y)\) 的时间复杂度为 \(O(n)\)。若有 \(m\) 次查询,总的时间复杂度为 \(O(mn)\),效率太低。
倍增法求 LCA
可以把标记法换一种方式实现,分为以下两个步骤。
- 先把 \(x\) 和 \(y\) 提到相同的深度。例如,\(x\) 比 \(y\) 深,就把 \(x\) 提到 \(y\) 的高度(既让 \(x\) 走到 \(y\) 的同一高度),如果发现 \(x\) 直接就跳到 \(y\) 的位置上了,那么就停止查找,否则继续下一步。
- 让 \(x\) 和 \(y\) 继续同步向上走,每走一步就判断是否相遇,相遇点就是 \(LCA(x,y)\) 停止。
上面的两个步骤,如果 \(x\) 和 \(y\) 都一步一步向上走,时间复杂度为 \(O(n)\)。如何改进?如果不是一步步走,而是跳着走,就能加快速度。如何跳?可以按 \(2\) 的倍数向上跳,即跳 \(1,2,4,8,\cdots\) 步,这就是倍增法,倍增法用“跳”的方法加快了上述两个步骤。
步骤 1
把 \(x\) 和 \(y\) 提到相同的深度。具体任务是:给定两个节点 \(x\) 和 \(y\),设 \(x\) 比 \(y\) 深,让 \(x\) “跳”到与 \(y\) 相同的深度。
因为已知条件是只知道每个节点的父节点,所以如果没有其他辅助条件,\(x\) 只能一步步向上走,没法“跳”。要实现“跳”的动作,必须提前计算出一些 \(x\) 的祖先节点,作为 \(x\) 的“跳板”。然而,应该提前计算出哪些祖先节点呢?如何通过这些预计算出的节点准确且高效地跳到一个任意给定的 \(y\) 的深度?这就是倍增法的精妙之处:预计算出每个节点的第 \(1,2,4,8,16,\cdots\) 个祖先,即按 \(2\) 倍增的那些祖先。
有了预计算出的这些祖先做跳板,能从 \(x\) 快速跳到任何一个给定的目标深度。以从 \(x\) 跳到它的第 \(27\) 个祖先为例:
- 从 \(x\) 跳 \(16\) 步,到达 \(x\) 的第 \(16\) 个祖先 \(fa_1\);
- 从 \(fa_1\) 跳 \(8\) 步,到达 \(fa_1\) 的第 \(8\) 个祖先 \(fa_2\);
- 从 \(fa_2\) 跳 \(2\) 步到达祖先 \(fa_3\);
- 从 \(fa_3\) 跳 \(1\) 步到达祖先 \(fa_4\)。
共跳了 \(16+8+2+1=27\) 步,这个方法利用了二进制的特征:任何一个数都可以由 \(2\) 的倍数相加得到。\(27\) 的二进制是 \(11011\),其中的 \(4\) 个 \(1\) 的权值就是 \(16,8,2,1\)。
显然,用倍增法从 \(x\) 跳到某个 \(y\) 的时间复杂度为 \(O(\log n)\)。
剩下的问题是如何快速预计算每个节点的“倍增”的祖先。定义 \(fa_{x,i}\) 为 \(x\) 的第 \(2^i\) 个祖先,有非常巧妙的递推关系:\(fa_{x,i}=fa_{fa_{x,i-1},i-1}\)。分两步理解:\(fa_{x,i-1}\) 从 \(x\) 起跳,先跳 \(2^{i-1}\) 步,记这个点为 \(z\);再从 \(z\) 跳 \(2^{i-1}\) 步,一共跳了 \(2^{i-1}+2^{i-1}=2^i\) 步。
特别地,\(fa_{x,0}\) 是 \(x\) 的第 \(2^0=1\) 个祖先,就是 \(x\) 的父节点。\(fa_{x,0}\) 是递推式的初始条件,从它开始递推出了所有的 \(fa_{x,i}\)。递推的计算量有多大?从任意节点 \(x\) 到根节点,最多只有 \(\log n\) 个祖先,所以只需要递推 \(O(\log n)\) 次。所以整个 \(fa\) 的计算时间复杂度为 \(O(n \log n)\)。
步骤 2
经过上一个步骤,\(x\) 和 \(y\) 现在位于同一深度,让它们同步向上跳,就能找到它们的公共祖先。\(x\) 和 \(y\) 的公共祖先有很多,LCA(x, y) 是距离 \(x\) 和 \(y\) 最近的那个,其他祖先都更远。
从一个节点跳到根节点,最多跳 \(\log n\) 次。现在从 \(x,y\) 出发,从最大的 \(i \approx \log n\) 开始,跳 \(2^i\) 步,分别跳到 \(fa_{x,i},fa_{y,i}\),它们位于非常靠近根节点的位置(\(2^i \approx n\)),有以下两种情况:
- \(fa_{x,i}=fa_{y,i}\),这是一个公共祖先,它的深度小于或等于 LCA(x, y),这说明跳过头了,退回去换一个小的 \(i-1\) 重新跳一次。
- \(fa_{x,i} \ne fa_{y,i}\),说明还没跳到公共祖先,那么更新 \(x \leftarrow fa_{x,i}, y \leftarrow fa_{y,i}\),从新的起点 \(x,y\) 继续开始跳。由于新的 \(x,y\) 的深度比原来位置的深度减少超过一半,再跳时就不用跳 \(2^i\) 步,跳 \(2^{i-1}\) 步就够了。
以上两种情况,分别是比 LCA(x, y) 浅和深的两种位置。用 \(i\) 循环判断以上两种情况,就是从深浅两侧逐渐逼近 LCA(x, y)。每循环一次,\(i\) 减一,当 \(i\) 减为 \(0\) 时,\(x\) 和 \(y\) 正好位于 LCA(x,y) 的下一层,则 \(fa_{x,0}\) 就是 LCA(x,y)。
查询一次 LCA 的时间复杂度是多少?这里的 \(i\) 会从 \(\log n\) 递减到 \(0\),循环 \(O(\log n)\) 次。
倍增法的总计算量包括预计算 \(fa\) 和查询 \(m\) 次 LCA,总时间复杂度为 \(O(n \log n + m \log n)\)。
例题:P3379 【模板】最近公共祖先(LCA)
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 500005;
const int LOG = 19;
vector<int> tree[N];
int depth[N], fa[N][LOG];
void dfs(int cur, int pre) {
depth[cur] = depth[pre] + 1; // 深度比父节点深度多1
for (int nxt : tree[cur]) { // 遍历所有邻居节点
if (nxt != pre) { // 除了父节点以外都是子节点
fa[nxt][0] = cur; // 记录父节点fa[][0]
dfs(nxt, cur);
}
}
}
int lca(int x, int y) {
if (depth[x] < depth[y]) swap(x, y); // 保证x深度更大
// 将x和y提到相同高度
int delta = depth[x] - depth[y];
for (int i = LOG - 1; i >= 0; i--)
if (delta & (1 << i)) x = fa[x][i];
if (x == y) return x; // 如果提到相同深度后已经重合,则直接返回
// x和y同步往上跳
for (int i = LOG - 1; i >= 0; i--)
if (fa[x][i] != fa[y][i]) { // 如果祖先相等,说明跳过头了,换一个小的i继续尝试
x = fa[x][i]; y = fa[y][i]; // 如果祖先不相等,就更新x和y继续跳
}
// 最终x和y位于LCA的下一层,此时x或y的父节点就是LCA
return fa[x][0];
}
int main()
{
int n, m, s; scanf("%d%d%d", &n, &m, &s);
for (int i = 1; i < n; i++) {
int x, y; scanf("%d%d", &x, &y);
// 建树
tree[x].push_back(y); tree[y].push_back(x);
}
dfs(s, 0); // 预处理深度等信息
for (int i = 1; i < LOG; i++)
for (int j = 1; j <= n; j++)
fa[j][i] = fa[fa[j][i - 1]][i - 1]; // 从fa[][0]开始递推
while (m--) {
int a, b; scanf("%d%d", &a, &b); printf("%d\n", lca(a, b));
}
return 0;
}
常见的应用思想:
- 对于 \((x,y)\) 间的路径,拆成 \(x\) 到 \(LCA\) 和 \(y\) 到 \(LCA\) 分别倍增。
- 对于 \((x,y)\) 间的路径,拆成 \(root\) 到 \(x\) 和 \(root\) 到 \(y\),去掉 \(root\) 到 \(LCA\)。
习题:P4281 [AHOI2008] 紧急集合 / 聚会
在一个 \(n\) 个点、\(n-1\) 条边的树形结构中,进行 \(m\) 次询问。每次询问给出三个点 \(x,y,z\),要求找到一个集合点 \(p\),使得三个点到 \(p\) 的距离之和最小,输出这个集合点 \(p\) 和最小的距离和 \(c\)。
解题思路
对于树上的任意三个点 \(x,y,z\),使得它们距离之和最小的集合点 \(p\),必定是它们两两之间的最近公共祖先中,深度最大的那一个。
直观理解:连接 \(x,y,z\) 三点的路径在树上会形成一个“Y”字形或退化为一条路径,这个“Y”字形的中心交汇点就是最优的集合点,这个交汇点恰好就是三个 LCA 中深度最大的那个。
确定了集合点 \(p\) 后,总花费就是 \(c = \text{dist}(x,p) + \text{dist}(y,p) + \text{dist}(z,p)\)。利用树上距离公式 \(\text{dist}(u,v) = d_u + d_v - 2 d_{\text{lca}(u,v)}\),可以对花费进行计算。
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 5e5 + 5;
const int LOG = 19; // log2(5e5) 约等于 18.9,取 19
vector<int> g[N]; // 邻接表存树
int fa[N][LOG]; // fa[u][i] 表示节点 u 的第 2^i 个祖先
int d[N]; // d[u] 表示节点 u 的深度
/**
* @brief 通过深度优先搜索预处理 LCA 所需的信息
* @param u 当前节点
* @param from 父节点
*/
void dfs(int u, int from) {
fa[u][0] = from; // u 的第 2^0=1 个祖先是其父节点
d[u] = d[from] + 1; // 深度比父节点多 1
// 倍增预处理:计算 u 的第 2^i 个祖先
// u 的第 2^i 个祖先,等于 u 的第 2^(i-1) 个祖先的第 2^(i-1) 个祖先
for (int i = 1; i < LOG; i++) {
fa[u][i] = fa[fa[u][i - 1]][i - 1];
}
// 递归访问所有子节点
for (int v : g[u]) {
if (v != from) {
dfs(v, u);
}
}
}
/**
* @brief 使用倍增法计算两个节点的最近公共祖先 (LCA)
* @param x 节点 x
* @param y 节点 y
* @return x 和 y 的 LCA
*/
int lca(int x, int y) {
// 保证 x 是较深的节点
if (d[x] < d[y]) swap(x, y);
// 将 x 跳到和 y 同样的深度
int delta = d[x] - d[y];
for (int i = LOG - 1; i >= 0; i--) {
if ((delta >> i) & 1) { // 如果 delta 的第 i 位是 1,说明需要跳 2^i
x = fa[x][i];
}
}
// 如果 x 和 y 已经重合,那么 y (或者 x) 就是 LCA
if (x == y) return x;
// 将 x 和 y 同时向上跳,直到它们的父节点相同
for (int i = LOG - 1; i >= 0; i--) {
if (fa[x][i] != fa[y][i]) {
x = fa[x][i];
y = fa[y][i];
}
}
// 返回它们共同的父节点,即为 LCA
return fa[x][0];
}
int main()
{
int n, m;
scanf("%d%d", &n, &m);
// 读入 n-1 条边,构建树
for (int i = 1; i < n; i++) {
int a, b;
scanf("%d%d", &a, &b);
g[a].push_back(b);
g[b].push_back(a);
}
// 从根节点1开始DFS,预处理深度和祖先信息 (根的父节点设为0)
dfs(1, 0);
while (m--) {
int x, y, z;
scanf("%d%d%d", &x, &y, &z);
// 核心思想:
// 三个点的最优集合点 p,是 lca(x,y), lca(x,z), lca(y,z) 中深度最大的那一个。
// 总距离 c = dist(x,p) + dist(y,p) + dist(z,p)。
// 利用距离公式 dist(a,b) = d[a] + d[b] - 2*d[lca(a,b)] 可以化简。
int r1 = lca(x, y), r2 = lca(x, z), r3 = lca(y, z);
int ds = d[x] + d[y] + d[z];
// 寻找深度最大的 LCA 作为集合点
if (d[r2] > d[r1]) { // 如果 lca(x,z) 比 lca(x,y) 深
// 此时 lca(x,y) 是 lca(x,z) 的祖先,且 lca(y,z) = lca(x,z)
// 集合点是 r2 = lca(x,z)
printf("%d %d\n", r2, ds - 2 * d[r1] - d[r2]);
} else if (d[r3] > d[r1]) { // 如果 lca(y,z) 比 lca(x,y) 深
// 此时 lca(x,y) 是 lca(y,z) 的祖先,且 lca(x,z) = lca(y,z)
// 集合点是 r3 = lca(y,z)
printf("%d %d\n", r3, ds - 2 * d[r1] - d[r3]);
} else { // lca(x,y) 是三个LCA中深度最大(或之一)
// 集合点是 r1 = lca(x,y)
printf("%d %d\n", r1, ds - d[r1] - 2 * d[r2]);
}
}
return 0;
}
例题:P1967 [NOIP2013 提高组] 货车运输
分析:假设询问的点是 \(u\) 和 \(v\)。
如果只有一次询问,可以按边权从大到小加边,直至 \((u,v)\) 连通,那么拓展到多次询问,每次的路径一定在原图的最大生成树上。如果两个点在不同的树上就是无解,这个可以用并查集判断。
现在问题就放到树上了。如果把路径拆成 \((root, u)\) 和 \((root, v)\) 再去掉 \((root, lca)\) 会发现,对于取最小值的操作无法做去掉的那一部分计算,因此就只能把路径拆成 \((u, lca)\) 和 \((v, lca)\) 了。
在倍增求祖先时同时维护这段路径上的边权最小值,进行询问的计算时,在找 \(LCA\) 的过程中找到目前边权的最小值,别忘了最后还要考虑 \((x, fa_{x,0})\) 和 \((y, fa_{y,0})\) 这两条边的边权。
参考代码
#include <cstdio>
#include <utility>
#include <algorithm>
#include <vector>
using std::sort;
using std::min;
using std::swap;
using std::pair;
using std::vector;
using g_edge = pair<int, pair<int, int>>; // (边权,(x,y))
using t_edge = pair<int, int>; // (点,边权)
const int N = 10005;
const int M = 50005;
const int LOG = 14;
const int INF = 100000;
g_edge e[M];
vector<t_edge> tree[N];
bool vis[N];
int d[N], f[N][LOG], minw[N][LOG];
struct DSU {
int fa[N];
void init(int n) {
for (int i = 1; i <= n; i++) fa[i] = i;
}
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
bool merge(int x, int y) {
int qx = query(x), qy = query(y);
if (qx != qy) fa[qx] = qy;
return qx != qy;
}
};
DSU dsu;
void dfs(int u, int fa) {
vis[u] = true;
for (t_edge e : tree[u]) {
int v = e.first, w = e.second;
if (v == fa) continue;
d[v] = d[u] + 1; f[v][0] = u; minw[v][0] = w;
dfs(v, u);
}
}
int lca(int x, int y) { // 这个lca函数求的是路径上的最小边权
if (d[x] < d[y]) swap(x, y);
int delta = d[x] - d[y];
int res = INF;
for (int i = LOG - 1; i >= 0; i--)
if (delta & (1 << i)) {
res = min(res, minw[x][i]);
x = f[x][i];
}
if (x == y) return res;
for (int i = LOG - 1; i >= 0; i--) {
if (f[x][i] != f[y][i]) {
res = min(res, min(minw[x][i], minw[y][i]));
x = f[x][i]; y = f[y][i];
}
}
// 别忘了最后两条边
res = min(res, min(minw[x][0], minw[y][0]));
return res;
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int x, y, z; scanf("%d%d%d", &x, &y, &z);
e[i] = {z, {x, y}};
}
// 构建最大生成树
sort(e + 1, e + m + 1);
dsu.init(n);
for (int i = m; i >= 1; i--) {
int w = e[i].first;
int x = e[i].second.first, y = e[i].second.second;
if (dsu.merge(x, y)) {
tree[x].push_back({y, w});
tree[y].push_back({x, w});
}
}
// 倍增预处理
for (int i = 1; i <= n; i++) if (!vis[i]) dfs(i, 0);
for (int j = 1; j < LOG; j++) {
for (int i = 1; i <= n; i++) {
f[i][j] = f[f[i][j - 1]][j - 1];
minw[i][j] = min(minw[i][j - 1], minw[f[i][j - 1]][j - 1]);
}
}
int q; scanf("%d", &q);
for (int i = 1; i <= q; i++) {
int x, y; scanf("%d%d", &x, &y);
printf("%d\n", dsu.query(x) != dsu.query(y) ? -1 : lca(x, y));
}
return 0;
}
习题:P12393 「RiOI-6」flos
解题思路
以下称远离根为向下,接近根为向上,记 \(f_u\) 表示 \(u\) 的父节点,\(d_u\) 为 \(u\) 的深度。
当查询的 \((x,t)\) 中 \(x\) 为根节点或 \(t=0\) 时,答案是显然的。
设 \(h_u\) 表示从点 \(u\) 向下走能走到的最远距离,\(g_u\) 表示从 \(f_u\) 开始向下不经过 \(u\) 能走到的最远距离,则对于每个查询 \((x,t)\),问题相当于求 \(\max \limits_{u \in \text{anc}(x)} \{ d_x - d_{f_u} + \min(t,g_u) \}\),其中 \(\text{anc}(x)\) 指的是 \(x\) 的所有祖先节点。
\(g_u\) 怎么计算呢?由于 \(f_u\) 要避开 \(u\) 所在的那棵子树,可以将 \(h_u\) 拆分为最长路径 \(h_{u,1}\) 和(非严格)次长路径 \(h_{u,2}\),这样当 \(h_{f_u, 1}\) 正好等于 \(h_u + 1\) 时(最长路径正好是 \(u\) 的方向),\(g_u\) 即为 \(h_{f_u, 2}\)(即次长路径),否则为 \(h_{f_u, 1}\)。
如何加速 \(\max \limits_{u \in \text{anc}(x)} \{ d_x - d_{f_u} + \min(t,g_u) \}\) 这个式子的计算?注意到里面的 \(\min(t, g_u)\),考虑分类讨论:
- 若 \(g_u \lt t\),此时括号内的式子变成 \(d_x - d_{f_u} + g_u\),后两项只和 \(u\) 有关,可以预处理根节点到 \(u\) 的路径上的 \(g - d\) 的最大值。
- 若 \(g_u \ge t\),此时 \(d_{f_u}\) 越小越好,相当于找深度最小的 \(u\) 满足 \(g_u \ge t\),这可以根据 \(g_u\) 预处理出 \(\text{maxg}_u\) 表示从根节点到 \(u\) 的路径上最大的 \(g_u\) 值,利用其单调性结合树上倍增可以在 \(O(\log n)\) 时间复杂度内定位。并且出现这种情况时,定位到的这个深度最小的 \(u\) 的上方都是 \(g \lt t\) 的,同时考虑上一条中提到的 \(g-d\) 最大值的情况。
预处理加上查询的整体时间复杂度为 \(O((q+n)\log n)\)。
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 2e5 + 5;
const int LOG = 18;
const int INF = 1e9;
// --- 全局变量 ---
vector<int> tr[N]; // 邻接表
int fa[N][LOG]; // fa[i][j] 表示节点 i 的第 2^j 个祖先
int dep[N]; // 节点深度
int h1[N], h2[N]; // h1[u], h2[u] 分别表示从 u 出发向其子树能走的最长和次长路径长度
int g[N]; // g[v] 表示从 v 的父节点出发,不经过 v 的最长路径长度
int maxg[N]; // maxg[v] 表示从根到 v 的路径上,所有祖先的 g 值的最大值
int val[N]; // val[v] 表示从根到 v 的路径上,所有祖先的 g-dep 的最大值
/**
* @brief 第一次DFS,自底向上计算深度、父节点、最长/次长向下路径
*/
void dfs1(int u, int from, int depth) {
dep[u] = depth;
for (int v : tr[u]) {
if (v == from) continue;
dfs1(v, u, depth + 1);
fa[v][0] = u;
// 更新最长和次长向下路径
if (h1[v] + 1 > h1[u]) {
h2[u] = h1[u];
h1[u] = h1[v] + 1;
} else if (h1[v] + 1 > h2[u]) {
h2[u] = h1[v] + 1;
}
}
}
/**
* @brief 第二次DFS,自顶向下计算 g, maxg, val 数组,传递“向上”的路径信息
*/
void dfs2(int u, int from) {
for (int v : tr[u]) {
if (v == from) continue;
// 计算 g[v]: 从父节点u出发,不走v方向的最长路径
if (h1[v] + 1 == h1[u]) { // 如果v在u的最长路径上
g[v] = h2[u]; // 则取u的次长路径
} else {
g[v] = h1[u]; // 否则取u的最长路径
}
// maxg[v] = 从根到v的所有祖先的g值的最大值
maxg[v] = max(maxg[u], g[v]);
// val[v] = 从根到v的所有祖先的 g-dep 的最大值
val[v] = max(val[u], g[v] - dep[u]);
dfs2(v, u);
}
}
int main()
{
int n, q, d;
scanf("%d%d%d", &n, &q, &d);
for (int i = 1; i <= n; i++) {
val[i] = -INF; // 初始化为极小值
}
for (int i = 1; i < n; i++) {
int u, v;
scanf("%d%d", &u, &v);
tr[u].push_back(v);
tr[v].push_back(u);
}
// --- 预处理 ---
dfs1(1, 0, 1);
// 建立倍增数组
for (int j = 1; j < LOG; j++) {
for (int i = 1; i <= n; i++) {
fa[i][j] = fa[fa[i][j - 1]][j - 1];
}
}
dfs2(1, 0);
int ans = 0;
while (q--) {
int x, t;
scanf("%d%d", &x, &t);
if (d > 0) { // 在线查询
x ^= ans; t ^= ans;
}
if (x == 1) { // 特判:起点为根
ans = min(h1[1], t);
} else if (t == 0) { // 特判:时间为0
ans = dep[x] - 1;
} else {
// 基础答案:考虑向下走,或直接走到根
ans = max(min(h1[x], t), dep[x] - 1);
// 情况A: 时间t充裕,大于任何祖先分岔路径长度
if (maxg[x] < t) {
// 答案为 max(向下走, 向上走且时间不受限)
ans = max(ans, dep[x] + val[x]);
}
// 情况B: 时间t是限制因素
else {
// 考虑从父节点分岔的情况
ans = max(ans, 1 + min(t, g[x]));
// 用倍增找到最高的祖先y,其分岔路径长度>=t
int u = x;
for (int i = LOG - 1; i >= 0; i--) {
if (maxg[fa[u][i]] >= t) {
u = fa[u][i];
}
}
int y = fa[u][0]; // y就是分界点
// 综合考虑走到y之上(时间不受限)和走到y(时间受限)的情况
ans = max(ans, dep[x] + max(val[y], t - dep[y]));
}
}
printf("%d\n", ans);
}
return 0;
}
例题:P4180 [BJWC2010] 严格次小生成树
设一张图的最小生成树边权之和为 \(S\),则该图的严格次小生成树定义为该图所有边权之和大于 \(S\) 的生成树中边权之和最小者(可能不存在,也可能存在多棵)。
现给出一张 \(n\) 个点,\(m\) 条边的无向图,边权为 \(w_i\),求出该图的严格次小生成树边权之和。数据保证原图存在严格次小生成树。
数据范围:\(n \le 10^5, m \le 3 \times 10^5, 0 \le w_i \le 10^9\)。
分析:一种简单的思路是尝试找到原图的所有生成树,然后通过比较得出答案。但由于生成树数量过多,这样的算法显然效率很低。
由于严格次小生成树的边权和仅大于最小生成树边权和,因此可以猜测,严格次小生成树很可能就是在最小生成树上替换一条或几条边得到。事实上,可以证明,一定存在一棵严格次小生成树,使得它与某棵最小生成树仅有一条边的差距。
考虑一条不在原来的最小生成树上的边,如果把它加入最小生成树后会形成一个环,显然这个环上其他边的边权都小于等于刚加的这条边的边权(不然一开始的最小生成树就不成立了)。更进一步,这里可以把等于的情况去掉,因为如果存在等于的情况,说明是另一棵边权和相等但树的形态不同的最小生成树。所以如果严格次小生成树和最小生成树之间有两条以上的边不同,那么我们可以把这些不同的边中的其中一条改为在最小生成树上的边,剩下的不变,则此时得到的生成树边权和变小了,但还是比最小生成树的边权和要大。由此得知,最多只选 \(1\) 条边做替换。
有了这个性质,就可以考虑在建完最小生成树之后寻找那条不属于最小生成树,但属于严格次小生成树的边。枚举每一条非树边,在加入这条边之后,生成树上出现了一个环,再断掉环中其他边(环中其他的边实际上就是这条非树边的两点在最小生成树中的路径)里面边权最大的边(若该边边权与环内其他边权最大者相等,则断掉边权中严格次大的,注意有可能不存在这样的严格次大边),那么就得到了包含这条边的生成树中权值最小的。将所有这样的生成树权值和取 \(\min\) 后,就可以得到最终的答案。
可以采用树上倍增的方法,定义 \(1\) 为根,并存储每个点向上 \(2^i\) 条边的最大值与严格次大值。在寻找时,通过倍增取出这些最大值与严格次大值并依次进行更新,就可以得到需要断的边的权值。注意在存储和寻找严格次大值时的分类讨论条件。
整个算法的时间复杂度为 \(O(n \log n + m \log n)\)。
#include <cstdio>
#include <algorithm>
#include <vector>
using std::sort;
using std::swap;
using std::min;
using std::max;
using std::vector;
typedef long long LL;
const int N = 1e5 + 5;
const int M = 3e5 + 5;
const int LOG = 17;
const LL INF = 1e15;
struct Edge {
int x, y, z;
};
Edge edges[M];
bool mst[M]; // 记录每条边是否是最小生成树上的边
vector<Edge> tree[N];
// root用于并查集
// depth存储节点深度
// fa/w1/w2[u][i]代表节点u向上2的i次方层祖先/边权最大值/边权次大值
int root[N], depth[N], fa[N][LOG], w1[N][LOG], w2[N][LOG];
int query(int x) {
return root[x] == x ? x : root[x] = query(root[x]);
}
// update函数实现对两组最大、次大值合并得到新的最大、次大值
void update(int& mx1, int& mx2, int a1, int a2, int b1, int b2) {
mx1 = max(a1, b1);
mx2 = a1 == b1 ? max(a2, b2) : max(min(a1, b1), max(a2, b2));
}
void dfs(int u, int pre) {
depth[u] = depth[pre] + 1;
fa[u][0] = pre;
for (Edge e : tree[u]) {
int v = e.y, w = e.z;
if (v == pre) continue;
w1[v][0] = w;
dfs(v, u);
}
}
LL lca(int x, int y, int w, LL sum) {
// 在倍增法求lca的过程中实现最大边权和次大边权的计算
if (depth[x] < depth[y]) swap(x, y);
int delta = depth[x] - depth[y];
int res1 = 0, res2 = 0; // 最大、严格次大
for (int i = LOG - 1; i >= 0; i--)
if (delta & (1 << i)) {
update(res1, res2, res1, res2, w1[x][i], w2[x][i]);
x = fa[x][i];
}
if (x == y) {
// 有可能加的非树边与环内其他最长边相等
// 也有可能环内不存在次长边
if (res1 == w) return res2 == 0 ? INF : sum + w - res2;
else return res1 == 0 ? INF : sum + w - res1;
}
int tmp1 = 0, tmp2 = 0;
for (int i = LOG - 1; i >= 0; i--)
if (fa[x][i] != fa[y][i]) {
update(tmp1, tmp2, w1[x][i], w2[x][i], w1[y][i], w2[y][i]);
update(res1, res2, res1, res2, tmp1, tmp2);
x = fa[x][i]; y = fa[y][i];
}
update(tmp1, tmp2, w1[x][0], w2[x][0], w1[y][0], w2[y][0]);
update(res1, res2, res1, res2, tmp1, tmp2);
// 有可能加的非树边与环内其他最长边相等
// 也有可能环内不存在次长边
if (res1 == w) return res2 == 0 ? INF : sum + w - res2;
else return res1 == 0 ? INF : sum + w - res1;
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) root[i] = i;
for (int i = 1; i <= m; i++) {
scanf("%d%d%d", &edges[i].x, &edges[i].y, &edges[i].z);
}
// 先求一棵最小生成树
sort(edges + 1, edges + m + 1, [](Edge& e1, Edge& e2) {
return e1.z < e2.z;
});
LL sum = 0;
for (int i = 1; i <= m; i++) {
int x = edges[i].x, y = edges[i].y, z = edges[i].z;
int qx = query(x), qy = query(y);
if (qx != qy) {
root[qx] = qy; sum += z; mst[i] = true;
tree[x].push_back({x, y, z});
tree[y].push_back({y, x, z});
}
}
dfs(1, 0);
for (int i = 1; i < LOG; i++)
for (int j = 1; j <= n; j++) {
// 预处理倍增表
fa[j][i] = fa[fa[j][i - 1]][i - 1];
int a1 = w1[j][i - 1], a2 = w2[j][i - 1];
int b1 = w1[fa[j][i - 1]][i - 1], b2 = w2[fa[j][i - 1]][i - 1];
update(w1[j][i], w2[j][i], a1, a2, b1, b2);
}
LL ans = INF;
for (int i = 1; i <= m; i++) {
int x = edges[i].x, y = edges[i].y, z = edges[i].z;
if (x == y) continue;
if (!mst[i]) ans = min(ans, lca(x, y, z, sum));
}
printf("%lld\n", ans);
return 0;
}
例题:P1084 [NOIP 2012 提高组] 疫情控制
为了叙述方便,若从首都(根节点)到边境城市(叶子节点)\(x\) 的途中经过了军队 \(i\),就称军队 \(i\) 管辖了节点 \(x\),本题要求保证每个叶子节点都有军队管辖。显然,在不移动到根节点的前提下,军队所在的节点深度越浅,能管辖的叶子节点越多。
本题的答案满足单调性——若 \(\text{ans}\) 个小时能控制疫情,则对于任意 \(k \ge \text{ans}\),\(k\) 个小时当然也能控制疫情。因此可以考虑二分答案,把问题转化为:判定二分的值 \(\text{mid}\) 小时内能否控制疫情。
军队可分为两类,第一类是在 \(\text{mid}\) 小时内无法到达根节点的军队,对于这些军队,就让他们尽量往根节点走,\(\text{mid}\) 小时内能走到哪里,最终就驻扎在哪里即可。
记根节点的子节点集合为 \(\text{ch}(r)\),处理完第一类军队后,对每个节点 \(s \in \text{ch}(r)\),统计以 \(s\) 为根的子树中是否还有叶子节点尚未被管辖,设 \(H\) 是 \(\text{ch}(r)\) 中还有叶子节点尚未被管辖的节点组成的集合。
第二类是在 \(\text{mid}\) 小时内能够到达根节点的军队,先让这些军队移动到根节点的子节点上(差一步到根节点),用三元组 \((i,s,\text{rest})\) 代表。其中 \(i\) 表示军队编号,\(s\) 表示军队来自根节点的哪一个子节点,\(\text{rest}\) 表示军队 \(i\) 移动到 \(s\) 之后还剩余多少时间。这些军队有两种使用方法:仍然驻扎在 \(s\),或者跨过根节点去管辖其他的子树。
引理:若存在一支军队 \((i,s,\text{rest})\),满足 \(s \in H\) 并且军队 \(i\) 在 \(\text{rest}\) 时间内无法从 \(s\) 移动到根再返回 \(s\),则在最优解中,\(s\) 一定被自己子树内部的一支军队驻扎,不可能由其他子树的军队跨过根节点过来驻扎。
证明
因为 \(s \in H\),所以需要安排一支军队驻扎在 \(s\)。假设 \(s\) 由来自另一个子树 \(s'\) 的军队 \(i'\) 跨过根节点来驻扎,从 \(s'\) 到 \(s\) 所需时间为 \(\text{dist}(r, s') + \text{r, s}\)。
因为存在一支军队 \((i,s,\text{rest})\) 无法从 \(s\) 移动到根再返回 \(s\),所以这支军队要么闲置,要么只能跨过根节点驻扎在比 \(s\) 离根更近的节点 \(s''\)。故对任意的 \(s''\),根据 \(\text{dist}(r,s) \gt \text{dist}(r,s'')\) 可知,\(i'\) 一定也有足够的时间去管辖子树 \(s''\)。
综上所述,让 \(i\) 驻扎在自己的子树 \(s\),把 \(i'\) 留作他用,未来的可能性更多,答案不会更差,由贪心的决策包容性可知引理成立。
根据引理,对于 \(\forall s \in H\),若 \(s\) 上有军队,并且 \(s\) 上 \(\text{rest}\) 值最小的军队不足以移动到根再返回 \(s\),就让 \(s\) 上这支 \(\text{rest}\) 值最小的军队驻扎在 \(s\),管辖以 \(s\) 为根的子树。同时,从集合 \(H\) 中删除 \(s\)。
现在,仍未确定驻扎地点的军队都可以使用第二类方法——跨过根节点去管辖其他子树,集合 \(H\) 中剩余的节点就是需要被驻扎的位置。把集合 \(H\) 中的节点按照到根的距离从小到大排序,把剩余的军队按照“\(\text{rest}\) 减去 \(s\) 到根的距离”从小到大排序,执行一个贪心扫描算法,让剩余时间短的军队优先驻扎到离根近的节点,让剩余时间长的军队优先驻扎到离根远的节点,判断最后能否把 \(H\) 中的节点全部管辖,就知道二分的值是否可行了。
整体时间复杂度为 \(O(n \log n \log \sum w)\)。
参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <algorithm>
using std::vector;
using std::pair;
using std::sort;
using ll = long long;
using edge = pair<int, int>;
const int N = 50005;
const int LOG = 16;
int n, m;
int f[N][LOG]; // 倍增数组:f[u][j] 表示 u 向上跳 2^j 步到达的祖先节点
int city[N]; // 军队初始位置
int weight[N]; // 根节点的直接子节点 i 到根节点的边权(即 dist(1, i))
ll sum[N][LOG]; // 倍增距离数组:sum[u][j] 表示 u 向上跳 2^j 步的路径长度
bool cover[N]; // 标记数组:cover[u]=true 表示以 u 为根的子树已被完全控制
vector<edge> tree[N]; // 邻接表存储树结构
// 预处理 DFS:计算倍增祖先和距离
// u: 当前节点, fa: 父节点
void dfs1(int u, int fa) {
for (edge e : tree[u]) {
int v = e.first, w = e.second;
if (v == fa) continue;
f[v][0] = u; // v 的 2^0 祖先是 u
sum[v][0] = w; // v 到 u 的距离是 w
dfs1(v, u);
}
}
// 检查 DFS:判断节点 u 的子树是否被完全控制
// 如果一个节点的所有子节点都被控制,则该节点也被视为被控制
void dfs2(int u, int fa) {
int child = 0; // 子节点数量
bool flag = true; // 假设所有子节点都被控制
for (edge e : tree[u]) {
int v = e.first;
if (v == fa) continue;
dfs2(v, u);
if (!cover[v]) {
flag = false; // 只要有一个子节点没被控制,u 就不算被完全控制
}
child++;
}
// 如果 u 本身没有驻扎军队,但它是非叶子节点且所有子节点都被控制,则 u 也被控制
if (!cover[u] && child > 0 && flag) cover[u] = true;
}
// 二分判定函数:判断在时间限制 x 内是否能控制疫情
bool check(ll x) {
// 初始化 cover 数组
for (int i = 1; i <= n; i++) cover[i] = false;
// vec 存储那些能到达根节点的军队信息:{来源子节点, 剩余时间}
vector<pair<int, ll>> vec;
// 1. 让所有军队尽可能向上移动
for (int i = 1; i <= m; i++) {
int u = city[i];
ll cost = 0; // 已经消耗的时间
// 倍增向上跳,尝试跳到离根节点最近但不到根节点的位置
for (int j = LOG - 1; j >= 0; j--) {
int ancestor = f[u][j];
// 不能跳过根节点(1号点),ancestor == 0 表示越界
if (ancestor == 0 || ancestor == 1) continue;
// 如果剩余时间足够跳 2^j 步
if (cost + sum[u][j] <= x) {
cost += sum[u][j];
u = ancestor;
}
}
// 检查是否能跳到根节点
// f[u][0] == 1 说明 u 是根节点的直接子节点
// 如果 cost + sum[u][0] <= x,说明能到根节点。
if (f[u][0] == 1 && cost + sum[u][0] <= x) {
vec.push_back({u, x - cost - sum[u][0]});
} else {
// 无法到达根节点,只能驻扎在当前能到达的最远位置 u
cover[u] = true;
}
}
// 2. 更新整棵树的覆盖状态(自底向上)
dfs2(1, 0);
// 3. 收集空闲军队和未覆盖的需求
vector<ll> rest; // 存储空闲军队的剩余时间
// 处理到达根节点的军队
for (auto p : vec) {
// p.first: 军队来源的根节点直接子节点
// p.second: 军队在根节点的剩余时间
// 如果该军队来源的子树还需要被覆盖(!cover[p.first])
// 且该军队的剩余时间不足以从根节点再跑回该子节点(p.second < weight[p.first])
// 那么最优策略是让这个军队当初就不上来,直接留在 p.first 驻扎。
// 这相当于用“剩余时间小”的军队解决了“距离远”的需求(如果让别的军队来,至少需要 weight 的时间)。
// 这是一个关键的贪心策略:如果回不去,就别上来了,直接守家。
if (!cover[p.first] && p.second < weight[p.first]) {
cover[p.first] = true; // 守家
} else {
// 否则,该军队可以自由调度(可以去支援别的分支,或者虽然回不去但家已经有人守了),加入空闲队列
rest.push_back(p.second);
}
}
// 将空闲军队按剩余时间从小到大排序
sort(rest.begin(), rest.end(), [](ll lhs, ll rhs) {
return lhs < rhs;
});
// 4. 贪心匹配:用空闲军队去覆盖剩余未覆盖的根节点直接子节点
// tree[1] 已经在 main 中按边权(weight)排序了,即需求按距离从小到大排序
int idx = 0; // rest 数组的指针
for (edge e : tree[1]) {
// 如果该子树还未被覆盖
if (!cover[e.first]) {
bool ok = false;
int sz = rest.size();
// 在空闲军队中找一个剩余时间 >= 边权 w 的最小军队
while (idx < sz) {
if (rest[idx] >= e.second) {
idx++; // 消耗该军队
ok = true; // 需求满足
break;
} else {
idx++; // 当前军队时间不够,废弃(因为它连当前最小需求都满足不了)
}
}
if (!ok) return false; // 找不到足够的军队,失败
}
}
return true; // 所有需求都满足
}
int main()
{
scanf("%d", &n);
ll tot = 0; // 边权总和,作为二分上界
for (int i = 1; i < n; i++) {
int u, v, w; scanf("%d%d%d", &u, &v, &w);
tree[u].push_back({v, w});
tree[v].push_back({u, w});
tot += w;
}
scanf("%d", &m);
// 特判:如果军队数量少于根节点的直接子节点数量,无法完全覆盖(因为根节点不能驻扎)
int sub = tree[1].size();
if (m < sub) {
printf("-1\n");
} else {
// 预处理 DFS
dfs1(1, 0);
// 计算倍增数组
for (int j = 1; j < LOG; j++)
for (int i = 1; i <= n; i++) {
f[i][j] = f[f[i][j - 1]][j - 1];
// 注意这里距离也要倍增累加
sum[i][j] = sum[i][j - 1] + sum[f[i][j - 1]][j - 1];
}
// 记录根节点到直接子节点的距离
for (edge e : tree[1]) weight[e.first] = e.second;
// 将根节点的子节点按边权从小到大排序,方便后续贪心匹配
sort(tree[1].begin(), tree[1].end(), [](edge lhs, edge rhs) {
return lhs.second < rhs.second;
});
for (int i = 1; i <= m; i++) scanf("%d", &city[i]);
// 二分答案
ll l = 0, r = tot, ans = -1;
while (l <= r) {
ll mid = (l + r) / 2;
if (check(mid)) {
r = mid - 1; ans = mid;
} else {
l = mid + 1;
}
}
printf("%lld\n", ans);
}
return 0;
}

浙公网安备 33010602011771号