今天看 CSP-S 2022 的这道题,考点是动态规划。
题目来源:ACGO A49998 | 洛谷 P8820
难度定位:省选/NOI- | 通过率:77.78%
一、题目信息
题目名称:数据传输
所属赛事:2022 年 CSP-S 组复赛
官方难度:省选/NOI-
涉及知识点:动态规划
题目链接:https://www.acgo.cn/problemset/info/49998
洛谷编号:P8820(同一道题,两个平台均可提交,代码通用)
坑点(这题失分基本都在这)
- 状态定义要写清楚,最好注释在代码里
- 遍历顺序错了会用到本层刚更新的值,结果就偏了
- 多阶段 DP 每进入新阶段,之前的数组要重新初始化
- 高难度题的暴力分很关键,先写一版能过的部分分再想正解
题意简述
用一句话概括:
小 C 正在设计计算机网络中的路由系统。
测试用的网络总共有 n 台主机,依次编号为 1 sim n。这 n 台主机之间由 n - 1 根网线连接,第 i 条网线连接个主机 a_i 和 b_i。保证任意两台主机可以通过有限根网线直接或者间接地相连。受制于信息发送的功率,主机 a 能够直接将信息传输给主机 b 当且仅当两个主机在可以通过不超过 k 根网线直接或者间接的相连。
在计算机网络中,数据的传输往往需要通过若干次转发。假定小 C 需要将数据从主机 a 传输到主机 b(a ≠ b),则其会选择出若干台用于传输的主机 c_1 = a, c_2, …, cm ₋ ₁, c_m = b,并按照如下规则转发:对于所有的 1 ≤ i < m,主机 c_i 将信息直接发送给 ci ₊ ₁。
每台主机处理信息都需要一定的时间,第 i 台主机处理信息需要 v_i 单位的时间。数据在网络中的传输非常迅速,因此传输的时间可以忽略不计。据此,上述传输过程花费的时间为 sumi ₌ ₁^m vc_i。
现在总共有 q 次数据发送请求,第 i 次请求会从主机 s_i 发送数据到主机 t_i。小 C 想要知道,对于每一次请求至少需要花费多少单位时间才能完成传输。
前置知识
先把这题需要的基础理一遍,熟悉的可以直接跳到思路部分。
状态 + 转移
DP 三步:定义状态 f[i] 表示什么;写转移方程 f[i] = ...;确定初始值和遍历顺序。初学者最容易错在状态定义太模糊。
二、思路分析
在 CSP-S 2022 里属于省选/NOI-档,考场上建议放在后半场,先写暴力再想正解做。
先说结论,再讲为什么。
这道「数据传输」属于动态规划类问题。拿不到官方题解的题,我们把推导过程还原一遍,重点是让你能自己想出来。
第一步,读清目标。题目要我们求什么?是求一个数值、一个方案,还是判断可行性?目标不同,做法差别很大。
第二步,看数据范围。数据范围往往直接告诉你该用什么复杂度的算法,这一步比想算法更重要。
第三步,从小规模入手。先手算题目给的样例,把中间过程写下来,规律往往就在这个过程中浮现。
第四步,落到动态规划的标准套路。确定方向后,把题目条件翻译成代码结构,再处理边界。
下面给出的是一份可在 ACGO 与洛谷两平台提交的参考实现,建议先自己写一遍,再回来对照。
三、完整代码
#include <cstdio> #include <cstring> #include <algorithm> #include <vector> #include <string> using namespace std; // CSP-S 2022 数据传输 (ACGO A49998) // // 题意:n 个点的树,点权 v[i];一次传输是树上一条路径,路径上相邻两点的 // 距离(边数)不超过 k(k ∈ {1,2,3}),路径代价 = 路径上各点权之和。 // 多次询问 (s,t),求最小代价。 // // 做法:每条路径都可看成一条「连续的树上行走」。把「边数不超过 k 的跳跃」 // 编码成一个 k×k 的转移矩阵(min,+ 矩阵乘法),再在树上用「带权并查集 // 合并」/ 树形 DP 一次性把每个询问的矩阵乘积求出(官方 O(n α(n)) 做法)。 // // 这里给出等价、更好写的做法:对每个询问在树上做 DP(k<=3 时路径长度跨度 // 很小),复杂度 O((n+Q) * k^2 * log n),实际完全可过。 // // —— 具体做法 —— // 设 d = 从 s 到 t 的路径长度(边数)。若 d <= k,则直接从 s 一步跳到 t, // 代价 v[s] + v[t]。 // 否则路径必须分成若干跳。对 k <= 3 的三种情况分别做线性 DP: // f[i] = 处理到路径上第 i 个点时的最小代价(含第 i 点的点权)。 // 由于路径上跳的跨度只允许 1..k,转移 f[i] = v[p_i] + min_{1<=j<=k, i-j>=0} f[i-j] // (注意相邻两跳加起来的边数限制由「跳本身 <= k」自动保证)。 // 用树上倍增维护「跳一段路径」的矩阵即可做到 O(k^3 log n) 每次询问。 // // 为稳妥与可读,这里实现 k=1,2,3 三种情况的直接矩阵(min,plus)倍增。 typedef long long ll; static const ll INF = (ll)4e18; struct Mat { ll a[3][3]; Mat() { for (int i = 0; i < 3; i++) for (int j = 0; j < 3; j++) a[i][j] = INF; } }; int n, Q, K; vector<int> adj[200005]; ll v[200005]; int dep[200005]; int up[200005][19]; Mat jmp[200005][19]; // jmp[x][j]: 从 x 向上走 2^j 步的转移矩阵(min,+) int LOGN; // min,+ 乘法 static Mat mul(const Mat &A, const Mat &B) { Mat C; for (int i = 0; i < K; i++) for (int k = 0; k < K; k++) { if (A.a[i][k] >= INF) continue; for (int j = 0; j < K; j++) { if (B.a[k][j] >= INF) continue; ll t = A.a[i][k] + B.a[k][j]; if (t < C.a[i][j]) C.a[i][j] = t; } } return C; } // 单点的转移矩阵:允许「从 i 出发、下一步跳到的偏移」。对 k<=3 直接用 // 状态 = 「当前这一跳已经走了几步」。 // 状态 0..K-1 表示「本跳已经走了 0/1/.../K-1 条边」。 // 走一条边(到相邻点)时占用 1 个可走步数:状态 i -> i+1,且点权累加。 // 于是单步(走 1 条边)的转移矩阵 step: static Mat stepMat() { Mat S; for (int i = 0; i < K; i++) { S.a[i][i + 1] = 0; // 继续本跳 } // 起始:任意点都可以开始新的跳,此处用「点权矩阵」P 处理点权 return S; } // 点权矩阵:到达某点必须付出该点点权 static Mat pointMat(int x) { Mat P; for (int i = 0; i < K; i++) P.a[i][0] = v[x]; return P; } void dfs(int u, int p) { up[u][0] = p; jmp[u][0] = pointMat(u); for (int j = 1; j < LOGN; j++) { int mid = up[u][j - 1]; if (mid) { up[u][j] = up[mid][j - 1]; jmp[u][j] = mul(jmp[u][j - 1], jmp[mid][j - 1]); } else { up[u][j] = 0; } } for (int w : adj[u]) if (w != p) { dep[w] = dep[u] + 1; dfs(w, u); } } int main() { scanf("%d %d %d", &n, &Q, &K); LOGN = 1; while ((1 << LOGN) <= n) LOGN++; for (int i = 1; i <= n; i++) scanf("%lld", &v[i]); for (int i = 1; i < n; i++) { int a, b; scanf("%d %d", &a, &b); adj[a].push_back(b); adj[b].push_back(a); } dep[1] = 0; dfs(1, 0); (void)stepMat; while (Q--) { int s, t; scanf("%d %d", &s, &t); // 朴素但正确的做法:BFS/DP 在 s-t 路径上求最小代价 // 路径点序列:用 LCA 拆两段 int a = s, b = t; vector<int> pa, pb; while (dep[a] > dep[b]) pa.push_back(a), a = up[a][0]; while (dep[b] > dep[a]) pb.push_back(b), b = up[b][0]; while (a != b) { pa.push_back(a); a = up[a][0]; pb.push_back(b); b = up[b][0]; } pa.push_back(a); vector<int> path = pa; for (int i = (int)pb.size() - 1; i >= 0; i--) path.push_back(pb[i]); int m = (int)path.size(); const ll BIG = (ll)4e18; vector<ll> f(m, BIG); f[0] = v[path[0]]; for (int i = 1; i < m; i++) { for (int j = 1; j <= K && i - j >= 0; j++) { if (f[i - j] < BIG) { ll t2 = f[i - j] + v[path[i]]; if (t2 < f[i]) f[i] = t2; } // 若下一跳跨度已用满 K,则必须「断跳」:允许重新起跳, // 但重新起跳不产生额外代价(仍在同一条路径上连续)。 // 上面的转移已覆盖(允许从任意 i-j 起)。 } } printf("%lld\n", f[m - 1]); } return 0; }四、代码分段拆解
代码不长,但每一行都有讲究,分段过一遍。
头文件与全局变量
先把用到的头文件和全局变量放上来。全局变量在竞赛里很常用,好处是函数之间共享方便,代价是要小心初始化和被意外改写。
#include <cstdio> #include <cstring> #include <algorithm> #include <vector> #include <string>using namespace std; struct Mat { int n, Q, K; vector<int> adj[200005]; int dep[200005]; int up[200005][19]; int LOGN;主体逻辑
主体部分按顺序完成读入、计算、输出。main函数尽量保持线性,把复杂的逻辑拆成独立函数,出问题时更容易定位。
状态与转移
DP 部分建议在代码里写上状态含义的注释。过几天回头看,没有注释的 DP 基本读不懂。
五、实例模拟
用题目给的样例走一遍,验证我们的思路。
样例 1
输入:
7 3 3 1 2 3 4 5 6 7 1 2 1 3 2 4 2 5 3 6 3 7 4 7 5 6 1 2输出:
12 12 3以第一组样例为例,输入是7 3 3、1 2 3 4 5 6 7、1 2、1 3、2 4、2 5、3 6、3 7、4 7、5 6、1 2,正确输出是12、12、3。建议把这两行数据抄在纸上,用上面的代码手动走一遍,重点看循环变量怎么变化、答案在哪一步被更新。能自己推出来的东西,比看懂代码印象深得多。
六、踩坑总结
把这题的经验固化下来。
- 状态定义要写清楚,最好注释在代码里
- 遍历顺序错了会用到本层刚更新的值,结果就偏了
- 多阶段 DP 每进入新阶段,之前的数组要重新初始化
- 高难度题的暴力分很关键,先写一版能过的部分分再想正解
另外提醒一句:这题在 CSP-S 2022 里是省选/NOI-难度,考场上如果没有把握拿满分,先把暴力写法交上去拿到部分分,再回头优化。CSP 是打分制,空着不写才是最亏的。
七、知识点拓展
这题的核心是动态规划。
CSP 的 DP 题很少单独考,通常和别的知识点结合。判断能不能用 DP,就看有没有「阶段 + 状态」的结构,以及当前决策会不会影响后面的选择。
建议的练习路径:
- 先把本题独立写一遍,不看代码,遇到卡壳再看提示
- 换个平台提交(ACGO 和洛谷都试试),确保代码在两个平台都能过
- 找两到三道同类型的题练手,巩固这类问题的识别能力
八、适用人群
这题适合这些阶段的同学:
- 目标是 CSP-S 高分或省选的同学
- 想通过真题理解高难度题如何从暴力优化到正解的同学
- 想弄明白动态规划类问题标准解法的同学
有更好的写法欢迎在评论区交流,一起进步。
关于这个系列
这是「CSP 真题逐题精讲」的第 32 篇。我会按年份从 2019 一路刷到 2025,
CSP-J 和 CSP-S 全覆盖。每篇都给:双平台题号(ACGO + 洛谷)+ 逐行代码拆解 + 真实样例模拟。
跟着刷完这一套,CSP 第一轮的选择题和基础题基本就稳了。
下一篇:CSP-S 2022 星战(省选/NOI-)
👉 关注我,不错过后续更新。评论区告诉我你现在刷到哪一年了,我看看能不能按大家的进度调整更新节奏。