跳过一个景点只是把线路上的两段相邻距离换成一段,先用倍增 LCA 求出任意两点在树上的距离,再用总时间加减这三次变化就能 O(1) 得到每个答案。
OJ: luogu
题目 ID: P9245
难度:普及+/提高-
标签:树倍增LCA图论前缀和
创建: 2026-10-02 15:07
更新: 2026-10-07 12:15
形式化题目
给定一棵有
树中任意两点
对每个
即新序列中相邻两项的时间花费之和(
要求输出
暴力解法
思路
题面的定义本身就是一份算法,直接照抄即可:
- 对每个要跳过的位置
,把除 之外的结点按原顺序抄进一个新序列; - 对新序列里每一对相邻结点
,从 出发对整棵树做一次 BFS,求出 到所有点的距离,取其中 的距离作为这一段的时间; - 把新序列所有相邻段的时间加起来,就是
。
第 2 步之所以可以直接用 BFS 求距离,是因为树上两点之间的路径唯一:BFS 从
这个写法不做任何建模和优化,因此是可靠的对拍基线。用样例 1 手算一遍:跳过 2 时新序列是
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-03 11:38
* update_at: 2026-10-03 11:38
*/
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// 直接照抄题面的定义:
// 对每一个要跳过的景点 A[i],把剩下的 K-1 个景点按原顺序排成新线路,
// 再一段一段地数时间——对其中每一对相邻景点 (u, v) 都单独做一次 BFS,
// 从 u 出发遍历整棵树算出到所有点的距离,取 dist[v] 就是这一段的时间。
// 每段路径都要重新搜一遍整棵树,复杂度约 O(K^2 * N),
// 节点数稍大就会很慢,所以只适合小数据对拍。
#include <iostream>
#include <queue>
using namespace std;
const int MAXN = 1005; // 暴力只跑小树
const int MAXM = 2005;
int n, k;
int a[MAXN]; // 原定游览线路 A[1..K]
int keep[MAXN]; // 跳过某个景点后剩下的游览顺序
// 链式前向星存树
int head[MAXN], to[MAXM], nxt[MAXM], edge_cnt;
int wt[MAXM];
long long dist[MAXN]; // 本次 BFS 中,起点到每个点的路径长度;-1 表示还没访问
queue<int> q; // BFS 队列
// 加一条无向边:u <-> v,花费时间 w。
void add_edge(int u, int v, int w) {
edge_cnt++;
to[edge_cnt] = v;
wt[edge_cnt] = w;
nxt[edge_cnt] = head[u];
head[u] = edge_cnt;
}
// 求树上 u 到 v 的唯一路径长度:从 u 出发做一次 BFS。
long long path_len(int u, int v) {
for (int i = 1; i <= n; i++) {
dist[i] = -1;
}
while (!q.empty()) {
q.pop();
}
dist[u] = 0;
q.push(u);
while (!q.empty()) {
int x = q.front();
q.pop();
for (int i = head[x]; i != 0; i = nxt[i]) {
int y = to[i];
if (dist[y] != -1) continue;
dist[y] = dist[x] + wt[i];
q.push(y);
}
}
return dist[v];
}
void read_input() {
cin >> n >> k;
for (int i = 1; i <= n - 1; i++) {
int u, v, w;
cin >> u >> v >> w;
add_edge(u, v, w);
add_edge(v, u, w);
}
for (int i = 1; i <= k; i++) {
cin >> a[i];
}
}
void solve() {
for (int skip = 1; skip <= k; skip++) {
// 把跳过 A[skip] 之后剩下的景点按原顺序收集到 keep[]。
int cnt = 0;
for (int i = 1; i <= k; i++) {
if (i == skip) continue;
cnt++;
keep[cnt] = a[i];
}
// 一段一段地重新搜,累加新线路里每一对相邻景点之间的时间。
long long total = 0;
for (int i = 1; i <= cnt - 1; i++) {
total += path_len(keep[i], keep[i + 1]);
}
if (skip != 1) cout << " ";
cout << total;
}
cout << "\n";
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
read_input();
solve();
return 0;
}复杂度
空间复杂度
瓶颈
瓶颈不是某一步常数太大,而是同一份信息被反复重算:
设
- 离
足够远的那些段 ( )在新线路里原封不动地保留了下来; - 只有
左右两侧的至多两段被去掉,中间情况还补上一段 。
也就是说
只要先把
正解
思路
第一步:把 写成总时间的一次增量
令原线路的总时间为
删掉
| 位置 | 消失的段 | 新增的段 | |
|---|---|---|---|
| 无 | |||
| 无 |
中间那一行是全题的枢纽:本来线路走的是
这一步把“为每个
第二步:用 LCA 在 内求出任意两点距离
剩下的事只有一件:把
dist[x]:根到的路径长度; lca(u,v):与 的最近公共祖先。
则对任意两点有
这个公式为什么成立? 设
移项即得。所以求出
第三步:倍增求 LCA
- 先 BFS 一遍,得到每个点的
depth[x]、dist[x]和父亲up[x][0]。用 BFS 而不是递归 DFS,因为树可能是一条长的链,递归会爆栈。 - 递推倍增表:
up[x][j]表示向上跳 步到达的祖先,由 跳 步再跳 步得到,即 up[x][j] = up[up[x][j-1]][j-1]。 - 询问时先把较深的点向上提到同一深度;若两点已经相同,它就是 LCA。否则从大到小枚举
,只要两个人的 级祖先不同就一起上跳。循环结束时两人深度相同、自身不同、父亲相同,那个父亲就是 LCA。 - 对根的
up[1][0]设成它自己作为哨兵,这样任何一次跳跃都不会越出数组。
因为 LOG = 20 即可。
完整流程与样例推演
先用倍增算出原线路的每一段并求和得到
graph TD
V1(("1")) ---|1| V2(("2"))
V1 ---|1| V3(("3"))
V3 ---|2| V4(("4"))
V3 ---|2| V5(("5"))
V4 ---|3| V6(("6"))
图中每个结点是景点,边上的数字是摆渡时间。原线路依次经过
三段距离与总时间为
| 段 | 路径 | 距离 |
|---|---|---|
| — | 合计 |
按公式逐个答案:
| 跳过 | 类型 | 计算 | |
|---|---|---|---|
| 中间 | |||
| 中间 | |||
得到 10 7 13 14,与题面样例一致。对比暴力解:它把
代码
build_root() 用 BFS 求出 depth/dist/父亲,build_lift() 递推倍增表,lca() 与 path_len() 提供 solve() 先累加出原线路总时间,再按“首段 / 中间两段换一段 / 尾段”三种情况输出。
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-03 11:38
* update_at: 2026-10-03 11:38
*/
// P9245 [蓝桥杯 2023 省 B] 景区导游
// 思路:
// 1. 这是一棵树,两点之间只有一条路径,所以“从 u 到 v 花多少时间”就是
// dist[u] + dist[v] - 2 * dist[lca(u, v)],其中 dist[x] 是根到 x 的距离。
// 2. 原路线总时间 total 是相邻两点的距离之和;跳过 A[i] 只是把两段相邻距离
// a[i-1] -> a[i] 和 a[i] -> a[i+1] 合并成一段 a[i-1] -> a[i+1],
// 所以答案 = total - d(a[i-1],a[i]) - d(a[i],a[i+1]) + d(a[i-1],a[i+1])。
// 3. 总时间 total 加上首/中/尾三种情况下的增量,就得到每个答案;
// 全程只需要 O(K) 次 LCA 询问,每次 O(log N)。
#include <iostream>
#include <queue>
using namespace std;
typedef long long ll;
const int MAXN = 100005; // 景点数上限
const int MAXM = 200005; // 无向边数上限(每条边存正反两条)
const int LOG = 20; // 2^20 > 10^5,足够跳到根
int n, k;
int a[MAXN]; // 原定游览线路 A[1..K]
// 链式前向星存树
int head[MAXN], to[MAXM], nxt[MAXM], edge_cnt;
ll wt[MAXM]; // 每条边的摆渡时间
int depth[MAXN]; // 根到该点的边数
ll dist[MAXN]; // 根到该点的路径长度
int up[MAXN][LOG + 1]; // up[x][j]:x 向上跳 2^j 步到达的祖先
// 加一条无向边:u <-> v,花费时间 w。
void add_edge(int u, int v, ll w) {
edge_cnt++;
to[edge_cnt] = v;
wt[edge_cnt] = w;
nxt[edge_cnt] = head[u];
head[u] = edge_cnt;
}
void read_input() {
cin >> n >> k;
for (int i = 1; i <= n - 1; i++) {
int u, v;
ll t;
cin >> u >> v >> t;
add_edge(u, v, t);
add_edge(v, u, t);
}
for (int i = 1; i <= k; i++) {
cin >> a[i];
}
}
// 以 1 号景点为根,BFS 求出 depth、dist 和 up[x][0]。
// 用队列而不是递归,避免 n = 10^5 的链把栈撑爆。
void build_root() {
for (int i = 1; i <= n; i++) {
depth[i] = -1;
}
queue<int> q;
depth[1] = 0;
dist[1] = 0;
up[1][0] = 1; // 根的祖先设成自己,越界也不会跳出去
q.push(1);
while (!q.empty()) {
int u = q.front();
q.pop();
for (int i = head[u]; i != 0; i = nxt[i]) {
int v = to[i];
if (depth[v] != -1) continue;
depth[v] = depth[u] + 1;
dist[v] = dist[u] + wt[i];
up[v][0] = u;
q.push(v);
}
}
}
// 预处理倍增表:up[x][j] 表示 x 向上跳 2^j 步的祖先。
void build_lift() {
for (int j = 1; j <= LOG; j++) {
for (int x = 1; x <= n; x++) {
up[x][j] = up[up[x][j - 1]][j - 1];
}
}
}
// 把 x 向上提 d 步。
int jump_up(int x, int d) {
for (int j = 0; j <= LOG; j++) {
if ((d >> j) & 1) {
x = up[x][j];
}
}
return x;
}
// 倍增求最近公共祖先。
int lca(int u, int v) {
if (depth[u] < depth[v]) {
int tmp = u;
u = v;
v = tmp;
}
u = jump_up(u, depth[u] - depth[v]);
if (u == v) return u;
// 从大步到小步一起爬,直到两人的父亲相同。
for (int j = LOG; j >= 0; j--) {
if (up[u][j] != up[v][j]) {
u = up[u][j];
v = up[v][j];
}
}
return up[u][0];
}
// 树上从 u 到 v 的唯一路径长度。
ll path_len(int u, int v) {
int w = lca(u, v);
return dist[u] + dist[v] - 2 * dist[w];
}
void solve() {
build_root();
build_lift();
// 原路线的总时间:相邻两点距离之和。
ll total = 0;
for (int i = 1; i <= k - 1; i++) {
total += path_len(a[i], a[i + 1]);
}
for (int i = 1; i <= k; i++) {
if (i == 1) {
// 跳过第一个点,只要去掉第一段。
cout << total - path_len(a[1], a[2]);
} else if (i == k) {
// 跳过最后一个点,只要去掉最后一段。
cout << total - path_len(a[k - 1], a[k]);
} else {
// 中间的点:两段合并成一段。
ll erase_two = path_len(a[i - 1], a[i]) + path_len(a[i], a[i + 1]);
ll add_one = path_len(a[i - 1], a[i + 1]);
cout << total - erase_two + add_one;
}
if (i != k) cout << " ";
}
cout << "\n";
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
read_input();
solve();
return 0;
}复杂度
| 阶段 | 复杂度 |
|---|---|
BFS 求 depth、dist、父亲 |
|
| 倍增表 | |
| 原线路总时间 |
|
| 总计 |
空间复杂度 up 表约 int。答案最大约 dist、总时间和输出都要用 long long。
总结
这道题的解法是两次“把重复的事只做一次”:
- 消除重复的求和。观察到删掉中间一个点只是把线路上的两段并成一段,于是先算出原线路总时间
,每个 就变成 减去(至多)两段、再加上(至多)一段。这一层是纯观察,和树的结构无关。 - 消除重复的求距离。树上两点距离用
求,而 LCA 用倍增表 询问一次,避免了对同一棵树反复做 BFS。
两次优化合起来把