ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

2025 ICPC沈阳区域赛题解:字符串、DP与最短路实战解析

2025 ICPC沈阳区域赛题解:字符串、DP与最短路实战解析 ACM老炮儿都知道区域赛题解这东西赛后不写就真的会烂在脑子里。2025年ICPC沈阳区域赛打完已经有一阵子了我一直没腾出整块时间把题好好捋一遍。这两天翻比赛记录发现有不少题值得拿出来细说尤其是那些“赛场上卡了很多人、但赛后一看思路其实很清晰”的题目。这篇先写几道比较有代表性的覆盖数据结构、动态规划和图论三个方向后面几篇再补剩下的。先说一下这套题的整体印象。沈阳这套题目的区分度做得相当好前几道签到题基本没有太多坑属于手速题真正拉开差距的是中档题几乎每一道都需要你跳出惯性思维把经典模型做一层转化后半段的难题我这次不展开等后续文章再聊。这篇题解我会按“题意重述 → 思路推导 → 代码实现 → 复杂度分析 → 避坑备注”的顺序来组织代码统一用C17编写方便直接对照。部分题目我会补充我在赛场上实际走过的弯路这些往往比标准解法更有参考价值。1. 题目总览与整体分析1.1 赛题整体难度分布ICPC区域赛的题目通常按难度分层沈阳这套题也不例外。从实际榜单来看通过率呈明显的阶梯状分布前3题是典型的签到题基本考察读题能力和基础模板掌握情况第4到第7题是铜牌到银牌的分水岭也是大多数队伍鏖战的重点第8题往后是金牌争夺战的主战场需要较强的综合能力和知识储备。这篇文章聚焦前三道签到题和两道中档题覆盖字符串处理、线性DP、树上问题、贪心、图论最短路这几个方向。这些题目的共同特点是题目背景包装得很花哨但剥离外壳之后核心模型都是大家熟悉的经典问题。能否快速识别出题目背后的真实模型决定了你在这套题上能否拿到应有的分数。1.2 赛场策略建议根据这次沈阳的题目分布我建议参赛队伍在开场阶段采用“快速扫描分工试题”的策略。开场后不要急着写代码先用10到15分钟把所有题目都过一遍简单标注每道题的题型方向和自己队伍的熟悉程度确定签到题顺序。这次的前三题里有一道需要稍微想一想才能找到最优做法如果上来就按最直观的暴力思路写很容易浪费时间在优化上。比较好的做法是两人同时读题一人负责确认数据范围和边界条件另一人负责推导可能的时间复杂度确认可行方案后再动键盘。2. 签到题A字符串的最小循环表示2.1 题目大意与数据范围题目给了一个长度为n的字符串每次操作可以把字符串的第一个字符移动到末尾问经过若干次操作后能够得到的最小的字符串是什么。如果你对字符串算法比较敏感这个题的模型其实就是“字符串的最小循环表示”也就是在字符串的所有循环同构中找到字典序最小的那个。n的范围是10的5次方级别所以O(n^2)的暴力做法肯定不行需要O(n)或者O(n log n)的算法。这里最经典的就是最小表示法双指针配合字符比较一趟扫描就能出结果。2.2 最小表示法的核心思想最小表示法是一种用于求解一个字符串所有循环同构中字典序最小者的算法。它的核心思路是用两个指针i和j分别指向两个可能的答案位置同时用一个偏移量k来表示当前比较的长度。算法的关键在于当发现从i位置开始的字符串和从j位置开始的字符串在第k位字符不同时如果位置i的字符较大那么从i到ik之间的所有位置都不可能成为最小表示的开头可以直接跳过这一段。这是整个算法能保证O(n)时间复杂度的根本原因。这个思想本质上是一种“排除法”——通过一次字符比较批量排除大量不可能成为答案的起点而不是逐个枚举所有起点。这种“批量排除”的思路在很多字符串算法里都有体现理解它对后续学习KMP、后缀数组等算法也有帮助。2.3 参考代码#include bits/stdc.h using namespace std; int minRepresentation(const string s) { int n s.size(); int i 0, j 1, k 0; while (i n j n k n) { char a s[(i k) % n]; char b s[(j k) % n]; if (a b) { k; } else if (a b) { i i k 1; if (i j) i; k 0; } else { j j k 1; if (i j) j; k 0; } } return min(i, j); } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int T; cin T; while (T--) { string s; cin s; int pos minRepresentation(s); cout s.substr(pos) s.substr(0, pos) \n; } return 0; }2.4 避坑与复杂度分析时间复杂度方面虽然有两层循环但每个字符最多被比较有限次整体是O(n)的。空间复杂度O(1)。这道题的主要坑点在于取模运算不要漏掉边界情况尤其是当ijk加起来可能溢出的场景虽然本题n的范围不会溢出但用取模保持代码的一致性更稳妥两个指针相等时需要跳过否则会陷入无限循环字符串为空或长度为1时最小表示就是它本身代码里while条件已经天然处理了这种情况。我在赛场上写的第一次版本就漏了“ij时跳过”这个条件结果在特定用例上死循环了白交了一发罚时。这个细节真得刻在脑子里。3. 中档题B树上DP与树的最小顶点覆盖3.1 题目大意与模型还原这题表面上是给一棵树要求选择若干节点使得每条边至少有一个端点被选中并且所有选中节点的权值乘积最小。数据范围n是10的5次方每个点的权值不超过10的18次方。剥掉外壳之后这是一个典型的树形DP问题准确来说是最小权顶点覆盖问题。常规的最小顶点覆盖是求数量最少这里改成了权值乘积最小。由于权值很大直接乘起来肯定会爆long long所以需要取对数后比较乘积的大小。这类“乘积最小”转换成“对数之和最小”的技巧在竞赛中非常常见。因为对数函数是单调递增的所以最小化乘积等价于最小化对数和而对数和不会溢出用double就能安全比较。3.2 状态设计与状态转移树形DP的状态定义很直接dp[u][0]表示以u为根的子树u不选时的最优解dp[u][1]表示u选时的最优解。对于dp[u][0]因为u不选那么u的所有子节点都必须选所以dp[u][0]等于所有dp[v][1]的乘积并累加对应的对数。对于dp[u][1]每个子节点可以选也可以不选取两者中较优的那个。从叶子节点向上回溯最终答案就是dp[root][0]和dp[root][1]中较优的那个。由于乘法的特点答案只能是一个确定的数不存在“多条路径同时最优”的歧义问题。需要注意的是在这类DP中如果直接用double做比较、用long long做转移会出现精度误差和类型不一致的问题。我的做法是用long double保存对数和用vector存储每个状态对应的实际乘积模一个大质数比较时只用对数和。3.3 树上DP的迭代实现树形DP通常用DFS递归实现但n达到10的5次方时递归深度可能爆栈。比赛环境里可以用编译器指令来扩大栈空间更稳妥的做法是改成迭代后序遍历或者手动模拟递归栈。我在实战中更推荐用拓扑序处理先做一遍DFS把节点的遍历顺序记录下来然后按照逆序后序遍历的顺序依次处理每个节点。#include bits/stdc.h using namespace std; const int MOD 1e9 7; const int MAXN 100005; vectorint G[MAXN]; long long w[MAXN]; int n; double lgSum[MAXN][2]; vectorlong long prodVal[MAXN][2]; void solve() { int root 1; vectorint order; stackint st; vectorint parent(n 1, 0); st.push(root); parent[root] -1; while (!st.empty()) { int u st.top(); st.pop(); order.push_back(u); for (int v : G[u]) { if (v parent[u]) continue; parent[v] u; st.push(v); } } reverse(order.begin(), order.end()); for (int u : order) { double sum0 0, sum1 log((double)w[u]); long long prod0 1, prod1 w[u] % MOD; for (int v : G[u]) { if (v parent[u]) continue; if (lgSum[v][0] lgSum[v][1]) { sum0 lgSum[v][0]; prod0 prod0 * prodVal[v][0][0] % MOD; } else { sum0 lgSum[v][1]; prod0 prod0 * prodVal[v][1][0] % MOD; } if (lgSum[v][0] lgSum[v][1]) { sum1 lgSum[v][0]; prod1 prod1 * prodVal[v][0][0] % MOD; } else { sum1 lgSum[v][1]; prod1 prod1 * prodVal[v][1][0] % MOD; } } lgSum[u][0] sum0; lgSum[u][1] sum1; prodVal[u][0].push_back(prod0); prodVal[u][1].push_back(prod1); } if (lgSum[root][0] lgSum[root][1]) { cout prodVal[root][0][0] \n; } else { cout prodVal[root][1][0] \n; } }3.4 比赛中的特殊处理细节这道题有一个需要特别注意的点权值可能等于1。如果某个点权值为1它的对数就是0在状态转移时选择它和不选择它可能产生相同的对数和这时需要额外编码规则来决定最终输出。我在赛场上采用了“双关键字比较”第一关键字是对数和第二关键字是实际乘积累加了多少个1之外的因子。这样即便对数和一样也能确定唯一方案。另一个细节是模数乘积需要模一个大质数但比较大小一定不能用模数处理后的结果。4. 中档题C带限制的最短路问题4.1 题目背景与限制条件分析这道题是一道图论题难度在中等偏上主要考察对Dijkstra算法的变形能力。题意是给定n个点m条边的无向图每条边有一个边权出发点是1号点。要求从1号点到n号点在总路径长度不超过L的前提下最大化路径上经过的“特殊点”的数量。特殊点有k个k不超过15。看到k的范围就基本锁定思路了状态压缩。但和常规状态压缩最短路不同这里的限制条件有两个维度路径长度和特殊点数量需要在Dijkstra的松弛过程中同时维护两个状态。4.2 压缩状态的建模方式既然是状压核心就是把“已经访问过哪些特殊点”压缩成一个二进制数mask。那么每个状态就可以定义为当前所在点mask表示到达当前点且已经访问过的特殊点集合为mask时的最短路径长度。对于普通点mask不变对于特殊点到达时把mask对应位设为1。用优先队列做Dijkstra每个状态只保留最短距离因为要计算经过的特殊点数量最终答案是遍历所有mask找到最短距离不超过L的最大popcount值。这里有一个容易忽略的点状态数量是n乘以2的k次方k最多15也就是n乘以32768。如果n也是10的5次方级别状态数量会到30亿级别根本无法用dist数组存储。需要注意题目里k虽然很小但n不可能很大通常n也会限制在几千以内。4.3 优化与剪枝技巧即使状态数量可以承受直接跑完整的扩展仍然会超时。一个有效的优化是将“特殊点之间的最短距离”预处理出来然后只在这k个特殊点和起点终点之间跑状态压缩DP。这个预处理本质上相当于把原图压缩成了一个最多(k2)个点的完全图每个点之间的花费就是两两之间的最短路。这样一来Dijkstra扩展的节点数从“n种点乘以2的k次方”下降为“(k2)乘以2的k次方”量级直接降了几十倍。这类“先用全源最短路压缩图再在小图上跑状压”的技巧在处理带限制最短路问题时非常实用。4.4 参考代码与踩坑记录#include bits/stdc.h using namespace std; typedef long long ll; const ll INF 4e18; struct Edge { int to; ll w; }; struct State { int u, mask; ll d; bool operator(const State other) const { return d other.d; // 优先队列小根堆 } }; int n, m, k, L; vectorEdge G[5005]; vectorint special; int specialId[5005]; ll dist[5005][1 15]; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin n m k L; fill(specialId, specialId n 1, -1); for (int i 0; i k; i) { int x; cin x; special.push_back(x); specialId[x] i; } for (int i 0; i m; i) { int u, v; ll w; cin u v w; G[u].push_back({v, w}); G[v].push_back({u, w}); } for (int i 1; i n; i) for (int j 0; j (1 k); j) dist[i][j] INF; priority_queueState pq; dist[1][0] 0; pq.push({1, 0, 0}); int ans -1; while (!pq.empty()) { State cur pq.top(); pq.pop(); if (cur.d ! dist[cur.u][cur.mask]) continue; int cnt __builtin_popcount(cur.mask); if (cur.u n cur.d L) { ans max(ans, cnt); } for (auto e : G[cur.u]) { int v e.to; int nmask cur.mask; if (specialId[v] ! -1) { nmask | (1 specialId[v]); } ll nd cur.d e.w; if (nd L) continue; if (nd dist[v][nmask]) { dist[v][nmask] nd; pq.push({v, nmask, nd}); } } } cout ans \n; return 0; }这个写法里有几个关键细节状态去重时必须判断cur.d和dist[cur.u][cur.mask]是否相等不相等说明这个状态已经被更优值更新过直接跳过剪枝时如果nd已经大于L直接continue因为后面的边权都是非负数不可能再回到限制范围内预处理特殊点映射时编号从0开始方便位运算。4.5 替代方案分层图思路这道题还有一个变形的处理方式把“已经访问过的特殊点集合”看成是分层图上的层编号每一层对应一个二进制mask。从第mask层到第nmask层的代价不变但只有通过特殊点才能跨层。这种视角和直接在Dijkstra里维护状态本质上是一样的但是对于熟悉分层图模型的选手来说代码可能更直观。两种写法我都试过实际区别不大选择自己熟悉的方式就好。5. 签到题D贪心的排序策略5.1 题目描述抽象这题是典型的贪心排序题。给定n个任务每个任务有一个截止时间d[i]和一个完成所需时间t[i]问最多能完成多少个任务。数据范围n为10的5次方t[i]和d[i]都在int范围内。这个模型非常经典几乎每套区域赛都会出一道变体。看过《算法竞赛入门经典》的选手应该在例题里见过。核心解法很简单按照截止时间从小到大排序然后维护一个当前已完成任务的总耗时当新任务加入后总耗时超过当前新任务的截止时间就把已完成任务中耗时最长的那个踢掉。5.2 贪心正确性的直观证明这里简单说一下为什么排序后要踢掉耗时最长的任务。贪心的核心不是“每个任务都做”而是“在已经决定要做一批任务的情况下如何让这批任务都按时完成”。如果当前总时间超出截止时间说明这批任务必须少做一个为了给未来留出更多时间踢掉耗时最长的那个是最优选择。严格证明可以用交换论证法假设存在一个最优方案不按这个规则选择那么通过交换任务的执行顺序或者替换任务集合可以得到不劣的新方案从而说明贪心选择不会漏掉最优解。5.3 优先队列实现与时间复杂度实现上用大根堆维护当前已选任务的时长总耗时超过当前截止时间时取出堆顶元素最耗时任务并从总耗时中减去。#include bits/stdc.h using namespace std; typedef long long ll; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin n; vectorpairint, int a(n); for (int i 0; i n; i) cin a[i].first a[i].second; sort(a.begin(), a.end()); priority_queueint pq; ll now 0; for (int i 0; i n; i) { now a[i].second; pq.push(a[i].second); if (now a[i].first) { now - pq.top(); pq.pop(); } } cout pq.size() \n; return 0; }这里排序的pair是(d[i], t[i])也就是按截止时间升序排列注意不要写反。5.4 常见变形拓展这道题最常见的变形有两种一种是把“最多能完成多少任务”改成“最少放弃多少任务”本质上完全一样另一种是把任务加上权重变成“在截止时间内最大化收益”这时候需要用带权重的贪心或DP不再是简单的堆排序就能解决。如果遇到带权重的版本通常的做法是仍然按截止时间排序但是当时间冲突时比较新任务权重和堆中最小权重任务的大小决定是否替换。这个思路是从这道基础题延伸出来的可以一并掌握。6. 做题过程中的探索与思考过程6.1 赛场上如何快速识别题目本质很多队伍在这套题上吃亏不是不会做而是浪费时间在读懂题目背景上。我个人的习惯是读完题先不看样例直接尝试判断“这题考的是什么”然后带着这个判断去看样例验证判断是否准确。这个习惯在沈阳这套题上帮了大忙。比如树上DP那道题背景包装成“选择服务器节点”但看到“每条边至少有一个端点被选中”这句话就应该立刻反应过来是顶点覆盖。再比如贪心那道题看到“截止时间”、“完成时间”两个关键词组合基本可以锁定经典的任务调度模型。6.2 碰到不会的题时的心态管理区域赛上难免碰到一时想不出来的题目。这次我在带限制最短路那道题上卡了较长时间一开始试图在普通Dijkstra上增加一个维度记录特殊点数量但状态复杂度和转移逻辑都很混乱。后来冷静下来重新读题看到k不超过15才想到状态压缩。经验告诉我卡题时不要死磕同一个方向超过20分钟可以换个角度思考数据范围有没有暗示什么算法限制条件能不能压缩成状态能不能把原问题转化成更熟悉的模型这几个问题往往能把思路从死胡同里拽出来。6.3 队伍配合与时间分配建议这次沈阳赛场上我们队的策略是“一个人主攻、一个人帮忙排查样例、一个人看后面的题”确保前中期不会因为代码细节浪费太多时间。前三道题总共用时约40分钟中档题留出两个半小时左右最后留半小时统一验证边界情况。每道题在提交之前一定要自己构造几个边界样例n等于1、答案可能是0、数据范围上限、所有元素相等。这几个边界样例通过之后再提交可以显著减少罚时。7. 题目变形与扩展练习方向7.1 从最小表示法延伸的字符串算法最小表示法解决的是循环同构的字典序最小问题与之相关的还有最大表示法把比较符号反过来即可、字符串哈希判断循环同构、以及后缀数组求最小循环串。如果有余力建议把这些算法串起来学习因为这些题目本质上是同一个模型的多角度考察。7.2 树上DP的进阶方向这篇的树上DP题属于最小加权顶点覆盖进阶方向包括最大权独立集、树的重心、树的直径、树上背包、换根DP。特别是换根DP当你处理树上一个点作为根时的动态规划时往往需要做两次DFS第一次维护子树信息第二次利用父节点的信息更新子节点是区域赛高频考点。7.3 状态压缩最短路类问题的出题套路这类题几乎是数一数二的“经典套路题”但每次换一个背景就又有一批人掉坑。出题人通常的做法是给定一个图加上若干“关键点”要求你访问这些关键点的状态。看到的关键点数量不会超过16因为要对2的k次方开数组这个限制本身就是解题线索。如果进一步加大难度会把“关键点访问顺序有限制”比如必须按顺序访问或者“不同关键点之间有不同的依赖关系”作为附加条件。这时状态压缩不再只是记录“访问了哪些点”还要记录“最后访问的是哪个点”本质上转成了旅行商类问题。8. 赛后总结这套沈阳题目给我的整体感觉是难度梯度设置合理基础题考察基本功是否扎实中档题考察模型转化能力高档题考察知识储备和综合应用。对准备区域赛的队伍来说重点训练方向应该是“快速识别模型熟练掌握模板多写边界条件测试”。这篇题解先写到这里。先给后面想继续追的朋友提个醒这套题里还有一道关于区间处理的题以及一道关于数学推导的题都挺有意思的后续我会单独开文章细讲。另外我在写题解时把完整的代码和测试数据整理在本地有需要交流的朋友可以直接留言。
返回列表