[蓝桥杯 2023 省 B] 景区导游

GitHub跳转原题关系图返回列表

跳过一个景点只是把线路上的两段相邻距离换成一段,先用倍增 LCA 求出任意两点在树上的距离,再用总时间加减这三次变化就能 O(1) 得到每个答案。

OJ: luogu

题目 ID: P9245

难度:普及+/提高-

标签:树倍增LCA图论前缀和

创建: 2026-10-02 15:07

更新: 2026-10-07 12:15

形式化题目

给定一棵有 NN 个结点、N−1N-1 条边的带权树(边权 tt 表示通行时间),以及一个长度为 KK 的结点序列

A1,A2,…,AK(Ai 两两不同).A_1, A_2, \dots, A_K \qquad (A_i \text{ 两两不同}).

树中任意两点 u,vu, v 之间路径唯一,记 d(u,v)d(u,v) 为这条唯一路径上的边权之和,称它是 uu 到 vv 的时间花费。

对每个 i∈[1,K]i \in [1, K],把 AiA_i 从序列中删掉,令剩下的 K−1K-1 个结点保持原相对顺序构成新序列 BB,求

Si=∑j=1K−2d(Bj,Bj+1),S_i = \sum_{j=1}^{K-2} d(B_j, B_{j+1}),

即新序列中相邻两项的时间花费之和(K=2K = 2 时这个和为 00)。

要求输出 S1,S2,…,SKS_1, S_2, \dots, S_K。

暴力解法

思路

题面的定义本身就是一份算法,直接照抄即可:

  1. 对每个要跳过的位置 ii,把除 AiA_i 之外的结点按原顺序抄进一个新序列;
  2. 对新序列里每一对相邻结点 (u,v)(u, v),从 uu 出发对整棵树做一次 BFS,求出 uu 到所有点的距离,取其中 vv 的距离作为这一段的时间;
  3. 把新序列所有相邻段的时间加起来,就是 SiS_i。

第 2 步之所以可以直接用 BFS 求距离,是因为树上两点之间的路径唯一:BFS 从 uu 扩散时一旦到达 vv,走过的边权和就已经是全部可能中最小的,也就是 d(u,v)d(u,v)。

这个写法不做任何建模和优化,因此是可靠的对拍基线。用样例 1 手算一遍:跳过 2 时新序列是 6,5,16,5,1,两段分别是 77 和 33,合计 1010;跳过 1 时新序列是 2,6,52,6,5,两段分别是 77 和 77,合计 1414。

代码

cpp
/**
 * 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;
}

复杂度

KK 个待跳过位置,每个位置要算 K−1K-1 段,每段一次 O(N)O(N) 的 BFS,总时间复杂度

O(K2N),O(K^2 N),

空间复杂度 O(N)O(N)。N=K=105N = K = 10^5 时约 101510^{15} 次松弛,完全无法运行。

瓶颈

瓶颈不是某一步常数太大,而是同一份信息被反复重算:

设 Lj=d(Aj,Aj+1)L_j = d(A_j, A_{j+1})(1⩽j<K1 \leqslant j < K)表示原线路第 jj 段的时间。删掉 AiA_i 之后,新线路和原线路相比只有 AiA_i 附近发生了变化:

  • 离 AiA_i 足够远的那些段 LjL_j(j∉{i−1, i}j \notin \{i-1,\, i\})在新线路里原封不动地保留了下来;
  • 只有 AiA_i 左右两侧的至多两段被去掉,中间情况还补上一段 d(Ai−1,Ai+1)d(A_{i-1}, A_{i+1})。

也就是说 SiS_i 与 TT 只差 O(1)O(1) 个量。但暴力为每个 ii 都把剩下的 K−2K-2 段重新搜了一遍树,于是 LjL_j 这个只由树和点对决定的量被计算了 O(K)O(K) 次。

只要先把 L1,…,LK−1L_1, \dots, L_{K-1} 各算一次并累加出总时间,SiS_i 就是一次 O(1)O(1) 的加减。

正解

思路

第一步:把 SiS_i 写成总时间的一次增量

令原线路的总时间为

T=∑j=1K−1Lj=∑j=1K−1d(Aj,Aj+1).T = \sum_{j=1}^{K-1} L_j = \sum_{j=1}^{K-1} d(A_j, A_{j+1}).

删掉 AiA_i 之后,新的相邻点对和原来相比只有三种可能:

位置 消失的段 新增的段 SiS_i
i=1i = 1 L1=d(A1,A2)L_1 = d(A_1,A_2) 无 T−L1T - L_1
1<i<K1 < i < K Li−1=d(Ai−1,Ai)L_{i-1} = d(A_{i-1},A_i)、Li=d(Ai,Ai+1)L_i = d(A_i,A_{i+1}) d(Ai−1,Ai+1)d(A_{i-1},A_{i+1}) T−Li−1−Li+d(Ai−1,Ai+1)T - L_{i-1} - L_i + d(A_{i-1},A_{i+1})
i=Ki = K LK−1=d(AK−1,AK)L_{K-1} = d(A_{K-1},A_K) 无 T−LK−1T - L_{K-1}

中间那一行是全题的枢纽:本来线路走的是 Ai−1→Ai→Ai+1A_{i-1} \to A_i \to A_{i+1},删掉 AiA_i 后就变成直接从 Ai−1A_{i-1} 走到 Ai+1A_{i+1};所以两段并成一段,总时间要减掉这两段、再加上新的一段。

这一步把“为每个 ii 重建序列并重新求和”的需求彻底消掉了,SiS_i 只依赖 TT、两个旧段和至多一个新段。

第二步:用 LCA 在 O(log⁡N)O(\log N) 内求出任意两点距离

剩下的事只有一件:把 d(u,v)d(u,v) 求快。把树根固定为 11 号结点,令

  • dist[x]:根到 xx 的路径长度;
  • lca(u,v):uu 与 vv 的最近公共祖先。

则对任意两点有

d(u,v)=dist[u]+dist[v]−2 dist[lca(u,v)].d(u,v) = dist[u] + dist[v] - 2\,dist[\mathrm{lca}(u,v)].

这个公式为什么成立? 设 w=lca(u,v)w = \mathrm{lca}(u,v)。uu 到根和 vv 到根的路径在 ww 以上完全重合,在 ww 以下互不相交;而 u→w→vu \to w \to v 正是 uu 到 vv 那条唯一路径。于是

dist[u]+dist[v]=(dist[w]+d(w,u))⏟dist[u]+(dist[w]+d(w,v))⏟dist[v]=2 dist[w]+d(u,v),dist[u] + dist[v] = \underbrace{(dist[w] + d(w,u))}_{dist[u]} + \underbrace{(dist[w] + d(w,v))}_{dist[v]} = 2\,dist[w] + d(u,v),

移项即得。所以求出 distdist 和 LCA 就够了。

第三步:倍增求 LCA

KK 最大 10510^5,总共约 2K2K 次求距离,如果每次沿着树往上爬(链上最坏 O(N)O(N)),总复杂度会退化成 O(NK)O(NK)。倍增法把单次询问降到 O(log⁡N)O(\log N):

  1. 先 BFS 一遍,得到每个点的 depth[x]、dist[x] 和父亲 up[x][0]。用 BFS 而不是递归 DFS,因为树可能是一条长 10510^5 的链,递归会爆栈。
  2. 递推倍增表:up[x][j] 表示 xx 向上跳 2j2^j 步到达的祖先,由 xx 跳 2j−12^{j-1} 步再跳 2j−12^{j-1} 步得到,即 up[x][j] = up[up[x][j-1]][j-1]。
  3. 询问时先把较深的点向上提到同一深度;若两点已经相同,它就是 LCA。否则从大到小枚举 jj,只要两个人的 2j2^j 级祖先不同就一起上跳。循环结束时两人深度相同、自身不同、父亲相同,那个父亲就是 LCA。
  4. 对根的 up[1][0] 设成它自己作为哨兵,这样任何一次跳跃都不会越出数组。

因为 220>1052^{20} > 10^5,取 LOG = 20 即可。

完整流程与样例推演

先用倍增算出原线路的每一段并求和得到 TT,再按上表逐项加减输出。以样例 1 为例,树是

2←11←13→24→362 \xleftarrow{1} 1 \xleftarrow{1} 3 \xrightarrow{2} 4 \xrightarrow{3} 6,另外 3→253 \xrightarrow{2} 5,线路是 2→6→5→12 \to 6 \to 5 \to 1。

graph TD
  V1(("1")) ---|1| V2(("2"))
  V1 ---|1| V3(("3"))
  V3 ---|2| V4(("4"))
  V3 ---|2| V5(("5"))
  V4 ---|3| V6(("6"))

图中每个结点是景点,边上的数字是摆渡时间。原线路依次经过 2→6→5→12 \to 6 \to 5 \to 1,也就是在图上先走到 44、再走到 66,然后折返到 33、55,最后回到 11。

三段距离与总时间为

段 路径 距离
2→62 \to 6 2−1−3−4−62-1-3-4-6 1+1+2+3=71+1+2+3 = 7
6→56 \to 5 6−4−3−56-4-3-5 3+2+2=73+2+2 = 7
5→15 \to 1 5−3−15-3-1 2+1=32+1 = 3
— 合计 TT 1717

按公式逐个答案:

跳过 类型 计算 SiS_i
22 i=1i=1 17−L1=17−717 - L_1 = 17 - 7 1010
66 中间 17−7−7+d(2,5)17 - 7 - 7 + d(2,5),其中 d(2,5)=1+1+2=4d(2,5)=1+1+2=4 77
55 中间 17−7−3+d(6,1)17 - 7 - 3 + d(6,1),其中 d(6,1)=3+2+1=6d(6,1)=3+2+1=6 1313
11 i=Ki=K 17−L3=17−317 - L_3 = 17 - 3 1414

得到 10 7 13 14,与题面样例一致。对比暴力解:它把 d(2,6)=7d(2,6)=7、d(6,5)=7d(6,5)=7 这些量在每个答案里都重算了一遍,而这里每一段只算一次。

代码

build_root() 用 BFS 求出 depth/dist/父亲,build_lift() 递推倍增表,lca() 与 path_len() 提供 O(log⁡N)O(\log N) 的两点距离;solve() 先累加出原线路总时间,再按“首段 / 中间两段换一段 / 尾段”三种情况输出。

cpp
/**
 * 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、父亲 O(N)O(N)
倍增表 O(Nlog⁡N)O(N \log N)
原线路总时间 TT O(Klog⁡N)O(K \log N)
KK 个答案 O(Klog⁡N)O(K \log N)
总计 O((N+K)log⁡N)O\big((N+K)\log N\big)

空间复杂度 O(Nlog⁡N)O(N \log N),其中 up 表约 2×1062 \times 10^6 个 int。答案最大约 K⋅N⋅t=1015K \cdot N \cdot t = 10^{15} 量级,dist、总时间和输出都要用 long long。

N=K=105N = K = 10^5 的链状数据实测约 0.330.33 秒。

总结

这道题的解法是两次“把重复的事只做一次”:

  1. 消除重复的求和。观察到删掉中间一个点只是把线路上的两段并成一段,于是先算出原线路总时间 TT,每个 SiS_i 就变成 TT 减去(至多)两段、再加上(至多)一段。这一层是纯观察,和树的结构无关。
  2. 消除重复的求距离。树上两点距离用 dist[u]+dist[v]−2 dist[lca(u,v)]dist[u] + dist[v] - 2\,dist[\mathrm{lca}(u,v)] 求,而 LCA 用倍增表 O(log⁡N)O(\log N) 询问一次,避免了对同一棵树反复做 BFS。

两次优化合起来把 O(K2N)O(K^2 N) 的暴力降到了 O((N+K)log⁡N)O((N+K)\log N)。这类“把每个询问写成全局量的增量” + “用倍增把树上路径询问变便宜”的组合,在树上路径问题里非常常见,值得当作一个固定的套路记住。