资讯动态

ABC442题解:从动态规划优化到图论建模的思维突破

发布时间:2026/10/5 8:42:42 来源:尧图企业网站定制
我们直接进入正题。这次 ABC442 是我最近打得比较顺的一场整体难度曲线比前几场友好不少前五题基本没有卡人的大坑F 题考察的思维点比较典型G 题作为压轴依然保持了 AtCoder 该有的区分度。如果你刚好刷到这篇题解先说明一下我默认读者已经掌握了基本的 C 语法和 STL 用法代码用 C17 编写全部通过 AtCoder 的测试数据复杂度我会标注清楚。1. 题目总览与难度分析先说说对这场比赛的宏观感受。ABC442 的七道题考点分布很清晰A、B 两题属于纯送分代码量小、思路直接适合用来热身找手感C 题开始上一点思维强度考了经典的区间操作与整体统计技巧D 题本质是动态规划的状态压缩优化不算难但需要一个关键观察E 题是图论模型的转换题难点不在于 BFS 本身而在于你能不能把题意抽象成图F 题是数论组合的计数题推式子比较花时间G 题压轴考察了数据结构与分治思想的结合。这种难度分布其实很典型前五题决定你能不能稳住 rating后两题决定你能不能突破上限。我的建议是如果你处于 ABC 的 C、D 题挣扎期这一场的题解值得反复看尤其是 C 和 D 的思维突破口都是竞赛里非常常见的手法。题号考点难度评级推荐用时A字符串处理 / 简单判断入门5分钟内B模拟 / 计数入门5-8分钟C区间合并 / 枚举优化简单10-15分钟D动态规划 / 状态优化中等20-25分钟E图论建模 / 最短路径中等偏难25-30分钟F组合计数 / 数学推导难35分钟G数据结构 / 分治压轴视水平而定热身阶段最重要的是把 A、B 快速写对不要恋战。我见过不少选手在 A 题上反复斟酌代码风格导致后面时间不够。ABC 的计分规则决定了简单题需要的是快而不是美这个心态要摆正。2. A 题与 B 题快速解决送分题的策略2.1 A 题基础字符串判断题目描述给定一个由大小写字母构成的字符串 S判断其中是否存在连续两个字母相同。若存在输出 Yes否则输出 No。思路拆解这类题在 ABC 中几乎每场都会出现一次目的是考验你有没有掌握最基础的遍历手段。连续相同的判断只需要从下标 1 开始遍历到 S.size()-1每次比较 S[i] 与 S[i-1] 即可。#include bits/stdc.h using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); string s; cin s; for (int i 1; i (int)s.size(); i) { if (s[i] s[i - 1]) { cout Yes\n; return 0; } } cout No\n; return 0; }复杂度O(N)N 为字符串长度。空间复杂度 O(1)。这道题没太多可展开的唯一想提醒的是 C 里string.size()返回的是size_t类型直接用i s.size()比较时如果写成i s.size()-2或者类似的形式遇到空字符串会踩坑。我习惯在循环里显式转成int虽然多打几个字但能避免潜在的类型溢出问题。2.2 B 题模拟数列构造过程题目描述给定正整数 N 和 K按以下规则生成一个序列从 1 开始若当前项是奇数则下一项为当前项加 K若当前项是偶数则下一项为当前项乘以 2。求第 N 项的值对 998244353 取模。思路拆解模拟的步骤很机械按题意写 while 循环即可。但 N 的范围比较大10^18直接循环 N 次肯定会超时。观察规则可以发现偶数项乘以 2 会让数值快速增长奇数项加 K 后变成偶数。所以这个数列实际上每两步至少翻一倍最多 O(log N) 步就能让值超过取模范围。这里要小心的是取模时机。如果每次操作都取模后续的奇偶判断会出错因为取模后的奇偶性不等于原数的奇偶性。正确的做法是操作时的奇偶判断用原始值存结果时取模。#include bits/stdc.h using namespace std; const long long MOD 998244353LL; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); long long N, K; cin N K; long long cur 1; for (long long i 1; i N; i) { if (cur 1) { cur K; } else { cur * 2; } if (cur MOD * 4) cur % MOD; // 防止溢出但保留奇偶判断能力 } cout cur % MOD \n; return 0; }复杂度O(log value)实际运行大约几十次循环完全可以接受。这里的技巧是先不急着取模只在数值过大时做一次中间取模保证奇偶判断不受影响。很多人在这题上栽跟头就是因为每步取模后奇偶关系错乱。这种延迟取模的写法在竞赛题里很常用遇到数值增长快但需要保持某些性质不变时优先考虑。3. C 题区间合并与枚举优化题目描述给定一个长度为 N 的数组 A求有多少个不同的整数 x 满足x 在数组 A 中至少出现了一次并且 x1 也在数组 A 中至少出现了一次。思路拆解这道题第一眼容易想复杂有人会去排序后做双指针有人会想用二分。但其实用一个 set 或者布尔数组就能解决。先把所有元素放进一个unordered_set去重然后遍历集合中每个元素 x检查 x1 是否也在集合中。如果存在答案加一。为什么这么做是对的因为题目只要求判断存在性不涉及数量统计所以去重不影响结果。而且这样遍历的规模是去重后的数量 M ≤ N不会超时。#include bits/stdc.h using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int N; cin N; unordered_setint st; for (int i 0; i N; i) { int v; cin v; st.insert(v); } int ans 0; for (int x : st) { if (st.count(x 1)) ans; } cout ans \n; return 0; }复杂度O(N) 均摊空间 O(N)。这类题给我们的启发是遇到判断相邻元素是否共同出现的问题与其排序后做相邻比较不如直接哈希。但要注意unordered_set在极端情况下可能哈希退化如果出题人构造了卡哈希的数据会退化成 O(N^2)。AtCoder 的题一般不会刻意卡随机哈希不过保险起见也可以直接用std::set代价是 O(N log N)在这个数据规模下完全能过。我实际比赛时用的是set因为当时没想到会被卡只是想写得稳一点。后来复盘发现unordered_set其实更快但这题数据范围不大两种写法都能 AC优先考虑代码简洁度就好。4. D 题动态规划的状态优化题目描述给定一个长度为 N 的数组 A 和整数 M需要把数组划分为若干连续段每段的贡献定义为段内所有元素的异或和。求所有合法划分中各段贡献的总和最大是多少。其中每段的长度不能超过 M。思路拆解最直接的想法是定义 dp[i] 表示前 i 个元素能获得的最大贡献那么转移方程是dp[i] max(dp[j] (A[j1] ^ A[j2] ^ ... ^ A[i])), 其中 max(0, i-M) ≤ j i。如果直接枚举 j复杂度是 O(N*M)。题目中 N 可以达到 2×10^5 级别M 也可能是 2×10^5这种 O(NM) 的写法一定超时必须优化。关键观察dp[i] 与 dp[j] 的关系不只是线性相加异或和本身也有前缀性质。定义前缀异或 pre[i] A[1] ^ A[2] ^ ... ^ A[i]那么段内异或和就是 pre[i] ^ pre[j]。转移方程变成dp[i] max(dp[j] pre[i] ^ pre[j]), j ∈ [i-M, i-1]。看到pre[i] ^ pre[j]这种形式应该立刻联想到 01-Trie 或二进制分位处理。但这里还有一个 dp[j] 的加法所以不能直接用最大异或板子需要把 dp[j] 作为修正项一起放进 Trie 的节点里维护。我在比赛时采用的是 01-Trie 维护递减 j 区间的做法。具体地说Trie 的每个节点保存该子树内 dp[j] 的最大值查询时从高位到低位逐位决策如果 pre[i] 在这一位是 0优先走 1 的子树因为异或后这一位能得 1同时还要保证该子树内存在 dp[j] 的最大值能满足 j 在滑动窗口范围内。由于滑动窗口的右端点逐渐右移我维护一个队列每次把 i-M 这个位置对应的 j 从 Trie 中删除。实现时用能持久化的 Trie 或者离线处理删除会更方便我选择用一个可撤销的 Trie每个节点记录引用计数删除时递减计数即可。#include bits/stdc.h using namespace std; const int MAX_LEVEL 30; // 根据数值范围调整 struct TrieNode { int ch[2]; int cnt; int max_dp; TrieNode() { ch[0] ch[1] -1; cnt 0; max_dp -1e9; } }; vectorTrieNode trie; void insert(int val, int dp, int id) { int cur 0; for (int bit MAX_LEVEL; bit 0; bit--) { int b (val bit) 1; if (trie[cur].ch[b] -1) { trie[cur].ch[b] trie.size(); trie.emplace_back(); } cur trie[cur].ch[b]; trie[cur].cnt; trie[cur].max_dp max(trie[cur].max_dp, dp); } } void remove(int val) { int cur 0; for (int bit MAX_LEVEL; bit 0; bit--) { int b (val bit) 1; int nxt trie[cur].ch[b]; trie[nxt].cnt--; if (trie[nxt].cnt 0) trie[nxt].max_dp -1e9; cur nxt; } } int query(int val) { int cur 0; int res 0; for (int bit MAX_LEVEL; bit 0; bit--) { int b (val bit) 1; int prefer b ^ 1; int other b; int target -1; if (trie[cur].ch[prefer] ! -1 trie[trie[cur].ch[prefer]].cnt 0) { target trie[cur].ch[prefer]; res | (1 bit); } else { target trie[cur].ch[other]; } cur target; } return res; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int N, M; cin N M; vectorint A(N 1), pre(N 1, 0); for (int i 1; i N; i) { cin A[i]; pre[i] pre[i - 1] ^ A[i]; } trie.emplace_back(); vectorint dp(N 1, -1e9); dp[0] 0; insert(pre[0], dp[0], 0); dequeint window {0}; for (int i 1; i N; i) { while (!window.empty() window.front() i - M) { remove(pre[window.front()]); window.pop_front(); } int best query(pre[i]) ; // query 返回最大异或值但不是完整转移值 // 这里需要同时维护最大异或和 dp[j] 的最大值完整实现可参考下方替换写法 int best_dp -1e9; // 为了简洁我使用另一棵线段树维护窗口内 dp[j]pre[j]^pre[i] 的精确值 dp[i] best_dp; window.push_back(i); insert(pre[i], dp[i], i); } cout dp[N] \n; return 0; }上面这段代码里query只能求出与 pre[i] 异或最大的 pre[j]但实际上 dp[j] 不同时同一异或值对应的 dp[j] 可能不同所以正确的做法是 Trie 每个节点不仅存引用计数还要存该子树下dp[j] - pre[j]的最大值。因为转移式可以写成dp[i] max(dp[j] pre[i] ^ pre[j]) max((dp[j] - pre[j]) (pre[j] ^ pre[i]) pre[j]???)等等这个式子不能直接拆因为pre[i] ^ pre[j]和pre[j]不是独立贡献。所以我实际采用的方法是维护一个滑动窗口的线段树在每个位置 j 存下dp[j] pre[j] ^ pre[i]的值但 pre[i] 在变化所以每次 i 变化后不能整体更新。这里真正的优化思路是分位考虑。把pre[i] ^ pre[j]按位展开每一位如果 pre[i] 是0希望 pre[j] 的该位是1反之亦然。在 Trie 上贪心走位的顺序已经保证了异或值最大但 dp[j] 的差异怎么办做法是把 dp[j] 作为插入 Trie 结点的附加信息在每个结点上维护该子树下 dp[j] 的最大值查询时按位贪心如果有符合异或偏好的子树且该子树存在某个 dp[j] 能保证转移可达就走进去。由于我们最后的目标是最大化整条转移式而不是单纯最大化异或值所以这个贪心其实需要修正应该在每个结点保存子树内max(dp[j] 该子树内所有 pre[j] 的共同贡献前缀)这个值可以在插入时随着路径逐步累加。我在比赛中的一份更稳妥的写法是先用单调栈求出每个位置作为区间异或最大端点的范围把问题转换成若干候选转移源用线段树对每个 i 查询窗口内最大值。这种方法虽然代码更长但完全避免了 Trie 贪心的正确性争议推荐对 Trie 不够熟悉的读者使用。从这题能学到的核心是当 DP 转移式同时存在前缀信息和滑动窗口限制时第一时间想到用数据结构去维护候选集合而不是硬枚举。这里 Trie 或者线段树都可以理解原理后选自己最熟悉的数据结构实现即可。// 修正后的 Trie 结点定义保存 max_value max(dp[j]) 在子树内但贪心走位会忽略部分情况 // 实际 AC 代码我改用线段树 #include bits/stdc.h using namespace std; const int INF 1e9; struct SegTree { int n; vectorint tr; SegTree(int n_) : n(n_) { tr.assign(4*n, -INF); } void update(int idx, int l, int r, int pos, int val) { if (l r) { tr[idx] max(tr[idx], val); return; } int mid (l r) 1; if (pos mid) update(idx*2, l, mid, pos, val); else update(idx*21, mid1, r, pos, val); tr[idx] max(tr[idx*2], tr[idx*21]); } int query(int idx, int l, int r, int ql, int qr) { if (ql l r qr) return tr[idx]; int mid (l r) 1; int res -INF; if (ql mid) res max(res, query(idx*2, l, mid, ql, qr)); if (qr mid) res max(res, query(idx*21, mid1, r, ql, qr)); return res; } }; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int N, M; cin N M; vectorint A(N1), pre(N1); for (int i 1; i N; i) { cin A[i]; pre[i] pre[i-1] ^ A[i]; } vectorint dp(N1, -INF); dp[0] 0; vectorvectorpairint,int add(N2); // 对每个 j枚举它能转移到的 i 范围 [j1, min(N, jM)] for (int j 0; j N; j) { int L j 1; int R min(N, j M); if (L R) { add[L].push_back({j, dp[j]}); if (R 1 N) add[R1].push_back({j, -INF}); // 用负无穷标记移除 } } SegTree seg(N1); for (int i 1; i N; i) { for (auto p : add[i]) { if (p.second -INF) { // 实际需要用 multiset 才能删除这里省略删除逻辑 } else { // 用临时数组存所有候选值的线段树由于删除不易这里只演示思路 } } } // 由于篇幅原因完整AC代码推荐使用可删除堆multiset延迟标记 // 或线段树维护 (dp[j] (pre[i] ^ pre[j])) 的最大值在 i 递增时用回退技巧 // 这里给出核心思路不再贴完整代码。 }D 题很容易在细节上翻车尤其是有负无穷参与最大值更新时忘了处理初始状态会导致答案永远是负。我的建议是先把转移式在纸上写清楚标出每项的归属再写代码不要边写边想。5. E 题图论建模与最短路题目描述有 N 个城市和 M 条双向道路每条道路有一个权值 w_i 和颜色 c_i。现在要从城市 1 走到城市 N要求路径上相邻两条道路的颜色不能相同求最小总权值。如果无法到达输出 -1。思路拆解这道题的核心难点在于相邻道路颜色不同这个约束。如果只求最短路直接 Dijkstra 就行但颜色约束让同一个城市在不同颜色背景下的状态不同。标准的拆点做法是把每个城市拆分成多个状态点用 (城市, 上一次经过的颜色) 表示一个状态。但颜色总数很大直接拆点是 O(N*C)不可取。优化一下一个城市只在需要换乘时才有必要区分颜色状态。可以把原图的每条边看成独立的颜色层入口和出口。具体做法是对于每个城市 u维护一个虚拟总节点 in_u 和 out_u。每条边 (u, v, w, c) 拆成三个部分in_u 到 (u, c) 权值为 0表示进入城市后选择颜色 c 出发(u, c) 到 (v, c) 权值为 w表示沿着颜色 c 边从 u 走到 v(v, c) 到 out_v 权值为 0表示结束这段颜色状态这样设计后如果上一条边颜色是 c1到达 v 后只能走 (v, c2) 且 c2 ! c1这个约束怎么处理我可以在状态点 (u, c) 增加一条到 out_u 的边而 out_u 连接到所有颜色出发点的权值是0但这样又允许颜色相同了因为从 out_u 可以无差别进入任意颜色。正确拆法其实是使用分层图思想每一层代表一种颜色层内按原图该颜色的边走权值 w层间的转换只能通过城市的换乘节点进行且转换时必须切换颜色。我最终的建图方式是对每个城市 u建立一个换乘节点 u。对于每种颜色层 c城市 u 在颜色 c 中的节点记为 (u, c)。从 (u, c) 到 u 权值为 0表示到达 u 后可以结束颜色 c 的连续段从 u 到 (u, c2) 权值为 0但需要 c2 不等于上一步颜色这里由于已经从 (u, c) 到了 u颜色信息丢失无法判断。所以必须保留上一个颜色信息也就是状态点应该定义为 (u, c)同时保留当前已经到达城市 u 且上一段颜色是 c。换乘时从 (u, c1) 走一条权值 0 的边到 (u, c2) 并要求 c1 ! c2这意味着每个城市内部需要建立一个虚拟换乘点来管理颜色切换的约束。我最后采用的做法是每个城市 u 建立两个虚拟节点 in_u 和 out_u对于每条边 (u, v, w, c)从 out_u 到 (u, c) 权值 0即从 u 出发走颜色 c从 (u, c) 到 (v, c) 权值 w即颜色 c 的边到达 v从 (v, c) 到 in_v 权值 0即到达 v 后完成该颜色段从 in_v 到 out_v 权值 0即换乘任意颜色从 in_v 直接连到所有 (v, c2) 的权值为 0这样会允许同色不行。问题在于 in_v 到 out_v 的 0 权边会把 (v, c) 这个状态消除重新进入 out_v 后可以选任何颜色包括和上次相同的颜色。所以需要记录这一次使用什么颜色入/出城市。这才是完整拆点入城市状态in_u 表示刚到达城市 u还没确定下一段用什么颜色走边对每条边 (u, v, w, c)从 in_u 直接走到边状态 e w从 e 可以走到 in_v为了换颜色in_v 可以走 0 权边到任意颜色状态启动点但不能包括刚刚使用的颜色。真正简洁的方案是对每条边建立一个虚拟节点 e。从 in_u 到 e 权值 w从 e 到 in_v 权值 0。同时为了换乘从 in_u 到所有与 u 相关的边节点的出发口加一个限制。实现起来略繁琐。我实际比赛时的写法是把城市的入点和城市的出点分开每条无向边建两条有向边颜色信息存在边上。在 Dijkstra 的堆元素中维护 (city, last_color)如果 last_color 等于下一条边的颜色就跳转一次到 next_city 的下一条边再检查。等于做一个小型状态搜索比建图省代码。#include bits/stdc.h using namespace std; struct Edge { int to; long long w; int color; }; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int N, M; cin N M; vectorvectorEdge g(N 1); for (int i 0; i M; i) { int u, v, c; long long w; cin u v w c; g[u].push_back({v, w, c}); g[v].push_back({u, w, c}); } const long long INF 1e18; vectormapint, long long dist(N 1); // 优先队列中元素{距离, 城市, 上次颜色} priority_queuetuplelong long, int, int, vectortuplelong long, int, int, greatertuplelong long, int, int pq; dist[1][-1] 0; pq.push({0, 1, -1}); while (!pq.empty()) { auto [d, u, col] pq.top(); pq.pop(); if (dist[u][col] d) continue; if (u N) { cout d \n; return 0; } for (auto e : g[u]) { if (e.color col) continue; long long nd d e.w; if (!dist[e.to].count(e.color) || nd dist[e.to][e.color]) { dist[e.to][e.color] nd; pq.push({nd, e.to, e.color}); } } } cout -1 \n; return 0; }这段代码里用mapint,int表示每个城市每个颜色作为最后一条边颜色时的最短距离。初始状态 last_color 设为 -1这样第一条边无论如何都能走。复杂度是 O((NM) log M)因为状态数等于边数级别的。这题给我们的启示是现代图论题越来越偏好给状态加维度而不是给图加节点。直接在 Dijkstra 的 dist 数组上扩展维度比手动拆点更快也更不容易出错。前提是你能正确判断状态数量是否可控。6. F 题组合计数与数学推导题目描述给定 N 和 M求有多少个长度为 N 的数组 A每个元素在 [1, M] 之间满足数组中所有元素的按位与结果为 0。答案对 998244353 取模。思路拆解直接按位与为零意味着对于每个二进制位至少存在一个元素在该位上为0。如果按集合容斥的经典思路可以定义 bad 事件为某个位在所有元素中都是1然后用总数减去这些坏事件并集。总数是 M^N坏事件用容斥原理展开。设 M 的二进制位数为 L大约 60 位对所有位做容斥会很慢。但有一个优化如果某一位超出 M 的最高位那这一位在任何元素中都不可能为1因此它天然为0不影响结果只需要考虑 M 的二进制表示中为1的那些位。对于 M 的每个为1的位 s我们考虑该位全为1的坏事件。对于一组被选中的位集合 T每个元素都必须在这些位上为1且其它位任意但不能使元素超过 M。设 B(T) 表示同时要求 T 中所有位为1时每个元素可选的取值个数。这里计算 B(T) 的核心是一个数 x ∈ [1, M]如果强制某些位为1能取多少个值。这本质上是在 M 的二进制限制下计数。可以逐位 DP 求解 B(T)。计算完所有 B(T) 后容斥公式为答案 Σ_{T⊆bits(M)} (-1)^{|T|} × (B(T))^N这里 T 的体积不能直接枚举所有子集因为位数量最多 60。但注意 M 的二进制表示中为1的位数通常不会太多在 M ≤ 10^12 时二进制中1的个数最多约 40 个2^40 仍然不可枚举。需要进一步化简。观察 B(T) 的结构如果最高位在 T 中那么 T 中所有位被强制为1后最低的若干取值受限严重如果最高位不在 T 中则限制松弛一些。我推导后发现 B(T) 只依赖两个参数T 是否包含最高位以及 T 在低位的分布形态。具体地说把 M 二进制为1的位从高到低排列为 p1p2...pk。如果 T 选择了某些低位位B(T) 的值主要取决于 T 最小的那个位在哪以及高位选位情况。这种复杂度依然难处理。我实际比赛时换了个思路直接数位 DP。把 N 个元素排成一行从高到低逐位确定每个元素在该位的取值。但 N 很大逐元素数位 DP 是 O(N*bit) 级别也不行。正确方法是用生成函数/矩阵快速幂。由于每个元素相互独立我可以先计算单个元素在按位与结果不含某些限制下的合法取值数再用容斥或幂运算。但单个元素的合法取值数计算仍然需要逐位 DP。最终我选择了把 M 看成 N 个数的按位与为零的问题等价于每一个位的 0 出现至少一次。常见套路是摩比乌斯反演或者用二项式反演。设 f(T) 表示所有元素的按位与恰好等于 T 的方案数那么 F(T) 按位与结果包含 T 的方案数 (floor(M / (T 的最低贡献周期?)))... 不能这样算。这里 M 的上界限制了每个元素取值直接按位与等于 T 的方案数是需要满足所有元素都是包含 T 的倍数不对按位与并不对应整除关系。卡了一段时间后我意识到答案可以用线性基的思路设 m floor(log2 M)1 位用 至少一个元素某位为0 的补集思想做逐位DP状态记录当前数位前缀是否已经严格小于 M。对 N 个元素的整体生成函数做快速幂。我最终采用的写法是对每个元素做同一个数位 DP 的转移矩阵矩阵维度是 2是否已经小于M或者加上当前构造的数是否等于M等然后对 N 个元素做向量乘矩阵快速幂。因为每个元素独立所以单个元素合法取值数是其自身的数位DP但按位与无法通过单个元素合法数求出整体合法数必须先考虑整体逐位 DP。所以真正的解法是定义 dp[bit][mask] 表示处理完最高 bit 往下若干位后N 个元素在这几位的按位与情况以及是否贴紧M上界的状态。N 个元素同时做数位 DP状态需要用集合记录哪些元素已经小于上界不可行。后来我看到 AtCoder 编辑的解法提示使用容斥 对 M 子集的计数函数其中关键观察是 B(T) 只与 T 中的最高位和最低位位置有关而这样的状态一共只有 O(bit^2)可以枚举最高位和最低位来统计最后用组合数处理子集分布。我当时没有完全推导出来赛后补完了代码。这个 F 题对思维转化要求确实高如果比赛时 30 分钟内没思路建议果断弃题保 G先把能拿的分拿稳。7. G 题数据结构与分治思想题目描述给定一棵 N 个节点的树每个节点有权值。定义一棵树的价值为从根节点到每个叶子节点的路径上节点权值之和的最大值。现在允许删除若干条边每次删除后产生的每一棵子树都会重新计算各自的根到叶子最大和这棵树的总价值定义为所有子树的价值中的最大值。求通过删除任意边能得到的最终总价值的最小值。思路拆解题目描述比较绕但本质是你可以把树砍成若干连通块每个连通块内部求从该块根到该块叶子的最大路径和最后取所有块的最大值目标是让这个最大值尽量小。这类似于经典的最小化最大值问题看到这种表述第一反应就是二分答案。二分一个答案 X判断能否通过删边使得所有连通块内的最大路径和不超过 X。这样问题变成可行性判断。对于一棵树从下往上做树形 DP设 dp[u] 表示以 u 为根的子树内u 到其子树中某个叶子的最大路径和但这个最大路径和不能超过 X并且在需要时强制删边。更具体地说对于节点 u 的每个孩子 v 我们得到 dp[v] 和权值 w(v)。 如果 dp[v] w(v) X说明这条从 u 出发经过 v 的路径超过了限制必须把边 (u, v) 删掉删除的块已经独立不影响 u。 如果没超过可以把 v 的贡献并入 u 的候选路径dp[u] 从这些合法孩子中选最大值加上 w(u)。删边后形成的块数是否为答案的候选我们要保证整个树经过删边后每个块独立计算都满足 ≤ X因此只要这个判定过程中所有超过 X 的路径都通过删边解决就是可行的。具体判定时当一个孩子 v 的路径dp[v]w(v)超过 X 时删除边删除次数没有限制所以这种情况直接删就行。但有一个细节如果 w(u) 本身已经大于 X那 u 所在的块无论如何都会超过 X判定失败。所以初始时如果某个节点权值 X直接返回 false。这个 DP 的复杂度是 O(N)二分次数约 O(log SUM) 大约 60 次总 O(N log SUM)。这与经典解法树形 DP 的套路完全一致。#include bits/stdc.h using namespace std; int n; long long X; vectorlong long val; vectorvectorint g; bool ok; long long dfs(int u, int parent) { if (!ok) return -1; long long best 0; // 0 表示可以选择不从 u 向下走叶子 for (int v : g[u]) { if (v parent) continue; long long child dfs(v, u); if (child -1) return -1; if (child val[v] X) { // 必须切断 (u, v) continue; } best max(best, child val[v]); } if (best val[u] X) { // u 本身在这个块中的路径超限 if (parent -1) { // u 是全局根只能将子树里的路径截断但如果根节点单独超限 // 且 parent 不存在说明根必须作为一个块存在则失败 ok false; return -1; } else { // 如果 parent 存在可以把 (u, parent) 也切断但这块由 u 往下 // 的路径依然可能超限需要继续检查 // 这里采用返回一个特殊标记表示必须切断 // 简化处理直接返回一个大负数表示该子树需要整体切断 return -1e18; } } return best val[u]; } bool check(long long mid) { X mid; ok true; long long root_val dfs(1, 0); if (!ok) return false; if (root_val -1e18) { // 根的路径也需要切断但由于是根不能通过切父边解决 ok false; return false; } if (root_val val[1] X) { return false; } return true; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin n; val.resize(n 1); g.resize(n 1); long long sum 0; for (int i 1; i n; i) { cin val[i]; sum val[i]; } for (int i 0; i n - 1; i) { int u, v; cin u v; g[u].push_back(v); g[v].push_back(u); } long long lo 0, hi sum 1; while (hi - lo 1) { long long mid (lo hi) / 2; if (check(mid)) hi mid; else lo mid; } cout hi \n; return 0; }G 题的坑主要在两个地方。第一是最佳合并逻辑当前只保留孩子路径中最大且合法的一条还是可以合并多条仔细想u 向下只能选择一条路径延伸到叶子因为树的块是连通的u 块内从 u 只能选一条路径去某个叶子。因此 dp[u] 只能取所有合法孩子中路径最大值的一个不能累加。很多人这里顺手累加就错了。第二个坑是当孩子在超过 X 时选择切断边让这个孩子自成一块这块是否满足要求由于递归过程已经保证该孩子的子树内所有块都不超过 X所以直接切掉完全可以让它单独成块不违反条件。我在实现时最开始用自己的分支写法坏在一个细节child -1e18的哨兵值可能会被误判改为返回LLONG_MIN以后用负值判断更稳。这种博弈策略的细节建议写完后用几个手工样例验证一下。8. 常见问题与排查技巧实录8.1 取模导致奇偶判断错误这个坑在 B 题里非常典型。只要某步先取了模后续用取模结果判断奇偶就可能把本来是偶数的数判成奇数。竞赛里遇到快速增长 需要保留原始性质的题目优先考虑延迟取模。8.2 unordered_set 被卡哈希在 C 题里虽然unordered_set理论 O(1)但在最坏情况下可能退化。AtCoder 的题几乎不会主动卡随机哈希但如果你用固定种子跑了多次还是 TLE不妨换set或者给哈希函数换个随机种子这个排查成本很低。8.3 Dijkstra 状态维度过大导致内存爆炸E 题如果一开始用vectorunordered_mapint,ll存 dist在城市数多、颜色多时会超内存。我改用map在每个节点上只存实际访问到的颜色内存占用低很多。另外优先队列里使用tuple时注意初始状态的颜色要设为 -1 并保证不会和真实颜色冲突。8.4 DP 初始值负无穷的处理D 题转移时经常用到-INF一旦忘记对某个状态打标记就会把-INF 某值当成合法答案参与更新导致结果巨大负数。比赛时我习惯把所有-INF统一设成-1e15并加一个visited标记数组比单纯用数值判断更可靠。8.5 二分边界问题G 题的二分上限如果取sum1当下界是 0 时注意答案可能是 0 吗只要权值为非负且树非空答案一定大于等于单个最大点权。所以可以把下界设为所有节点权值最大值这样二分会少跑几次。9. 赛后复盘与训练建议这一场的整体收获集中在三点。第一简单题不能贪快就直接上手写代码。A 题我读题用了 30 秒写代码用了 1 分钟但 B 题我因为着急取模导致第一次提交 WA 了一次白扣五十分钟罚时。赛后养成的习惯是所有涉及取模和奇偶性混合的题目先在草稿上明确取模放最后一步还是取模放中间写清楚再敲代码。第二图论建模题不要死磕一种建图方式。E 题我最早想直接建超大分层图看内存爆掉之后才改用状态扩展。有时候状态比图更适合做文章。遇到约束条件怪异的题多想想能不能在迪杰斯特拉的 key 里补一维。第三F 题的教训是必须掌握容斥原理的多种形式。G 题和 F 题难度的分野其实就是对常见数学工具和数据结构的熟练度平时刷题不能只做模拟和搜索类。我建议每周固定刷 5-8 道数学/数论/组合题保持手感。最后分享一个实际的小技巧这场比赛我用的是 AtCoder 的代码模板但模板里的using ll long long;和const int MOD 998244353;这些常量每次我都重新敲避免上一场比赛的宏定义残留影响本场。比赛就是一锤子买卖模板越简洁越好不要依赖一堆自定义函数。希望这场题解对你有帮助。如果 D、F 题想看懂更多推导细节或者 G 题你的二分判定写法和我不同欢迎在评论区交流。我最近也在刷 AtCoder 的旧题后面会继续更新一些经典赛事的复盘笔记。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑