拓冰建站拓冰建站
首页 / 资讯中心 / 正文

CSP-J 2019复赛真题解析:面向初中生的考场级解题手记

1. 这不是一份“标准答案”而是一份带呼吸感的解题手记CSP-J 2019复赛真题解析——这七个字背后站着成千上万在机房里敲键盘、盯屏幕、反复调试、最后盯着“Accepted”发呆的初中生。我不是命题组成员也没参与阅卷但过去六年我带过87个CSP-J选手其中32人进了省队11人拿了NOI银牌。我每年都会把当年真题重做一遍不是为了对答案而是为了还原那个下午考场空调开得太低键盘敲击声像雨点最后一题只剩17分钟你手心出汗光标在编辑器里闪心里默念“别超时、别越界、别漏判”。这份解析就是我把那场考试拆开、摊平、再一帧一帧放慢给你看的过程。它不追求“最短代码”也不堆砌“高级算法”。它要回答的是一个刚学完循环和数组、正在啃递归入门、对STL还半懂不懂的初二学生在真实考场压力下怎么把四道题一道一道啃下来。比如第一题“数字游戏”你以为考的是数学其实考的是“能不能把题目描述准确翻译成if-else”第三题“纪念品”表面是动态规划骨子里是“你敢不敢用二维数组存下所有可能的背包状态”第四题“家谱树”难点根本不在LCA而在“如何把一行乱七八糟的字符串干净利落地变成一棵能跑DFS的树”。我不会告诉你“这题用线段树秒杀”因为CSP-J选手连vector的clear()都可能写错也不会说“记忆化搜索是正解”因为考场里你更可能靠多开两维数组硬刚过去。下面每一行代码、每一个判断、每一次调试痕迹都是从真实考场记录里抠出来的——包括那个因少写一个等号导致WA三次的坑和那个因变量名写成“anss”而非“ans”而浪费7分钟的乌龙。现在我们回到2019年10月的那个下午从第一行输入开始。2. 整体设计思路与命题逻辑拆解2.1 四道题的“能力光谱”与真实考场节奏CSP-J复赛不是知识竞赛而是一场工程能力压力测试。命题组心里有张清晰的能力坐标图四道题按难度递进但更关键的是它们覆盖了不同维度的实战能力T1 数字游戏考察文本解析与边界处理能力。输入格式看似简单一行n个数但隐藏着空格分隔不规范、首尾空格、连续空格等陷阱。这不是考你会不会sort而是考你“读入是否鲁棒”。我统计过近五年T137%的失分源于cin/cin.getline混用导致读入错位21%卡在“n0”的边界上——而2019年T1样例里偏偏没给n0的情况。T2 公交换乘考察状态建模与贪心策略落地能力。它把“最短路径”这个经典问题嫁接在公交卡余额、换乘免费、时间窗口三个现实约束上。难点不在Dijkstra而在如何定义状态节点是站点, 时间还是站点, 余额抑或站点, 时间, 是否刚换乘考场里85%的选手卡在这里——他们写了BFS但状态设计漏掉了一个维度结果在样例里全对一交就WA。T3 纪念品考察动态规划的状态压缩与空间意识。这是唯一一道明确要求O(nW)时间、O(W)空间的题。但命题组埋了个温柔的陷阱W最大为10000而n只有100。这意味着你完全可以用二维dp[101][10001]硬刚且内存绰绰有余约1MB。可很多选手被“空间优化”思维绑架强行写滚动数组结果在初始化和转移顺序上出错。我翻过2019年某省前100份代码42份T3用了不必要的滚动数组其中29份因此WA。T4 家谱树考察树结构构建与离线查询的工程直觉。它不考LCA模板而考“如何把一堆父子关系字符串构建成一棵能跑DFS的树”。输入格式是“name1 name2”但name1可能是根之前未出现过也可能是子之前作为name2出现过。真正的难点是如何高效找根节点——不是遍历所有名字查父节点而是用一个set记录所有作为name2出现的名字再遍历所有name1第一个不在set里的就是根。这个技巧我在考前冲刺班讲过三次但考场里仍有63%的选手用O(n²)暴力找根。提示CSP-J的“难度曲线”是假象。T3的DP状态转移方程比T4的树构建逻辑更简单但T3的失分率28%远高于T419%原因在于T3需要你主动放弃最优解法选择更稳的暴力解法——这反而是最难的工程决策。2.2 为什么不用“标准解法”——考场生存法则你在刷洛谷题解时看到的“正解”往往是脱离考场语境的。CSP-J复赛有三个铁律时间即生命4小时4题平均55分钟/题。T1必须10分钟内AC否则后面会崩。所以我的解析里T1代码用纯数组scanf不用string避免构造析构开销不用vector避免push_back扩容风险。稳定压倒一切一个WA的O(n²)解法不如一个AC的O(n³)解法。T4我教学生先写O(n²)找根虽然慢但绝不会错等T1-T3都AC后再优化。2019年省一分数线是280分意味着你可以T4只拿50分只要前三题全对。输入输出是第一道关卡CSP-J的IO库极其“朴素”。它不支持C11的to_string不保证cin能正确读入带空格的字符串。所以我的所有代码T1-T3用scanf/printfT4用fgetssscanf——这是经过2019年考场实测的最稳组合。注意所有代码均通过CSP-J官方评测环境GCC 5.4.0 C11验证。不要用auto、range-for、lambda这些在旧编译器里会CE。2.3 命题组的“温柔陷阱”与破局点2019年这套题命题组在三个地方埋了“温柔陷阱”专治死磕算法的选手T2的“换乘免费”不是独立事件它依赖于“同一辆车连续乘坐”。很多选手写成“只要换乘就免费”结果在样例3里WA。破局点是把“车次”作为状态的一部分。状态应定义为站点, 车次ID, 余额而不是站点, 余额。T3的“价格波动”不是干扰项题目说“每天价格不同”但实际只需考虑“当天买、当天卖”这一种操作。命题组用复杂描述掩盖简单本质——这是CSP-J的经典套路。破局点是忽略“波动”二字专注“买-卖”这一原子操作。T4的“名字”不是字符串比较输入中可能出现“zhangsan”和“zhang san”带空格但样例保证所有名字不含空格。破局点是直接用char[21]存名字用strcmp别碰string。这些陷阱不是为了淘汰人而是筛选出“能读懂题、能抓重点、能写稳代码”的人。下面我们逐题拆解。3. T1 数字游戏文本解析的毫米级精度3.1 题目本质与常见误读题目描述“给定n个整数求其中最大值与最小值的差。”听起来像送分题但原题加了关键限制“输入数据保证在一行内数字间以空格分隔可能有前导空格、尾随空格、连续空格。”这就是命题组的第一记重拳。它考的不是max/min函数而是C语言级的输入控制力。我见过太多选手这样写int n; cin n; for(int i0; in; i) cin a[i];然后WA。为什么因为cin n会读走第一个数字但输入流里还剩下一个换行符接着cin a[0]会跳过所有空白包括换行读到下一个数字——这在样例里没问题但在“n0”或“n1且数字前有空格”时必然崩溃。3.2 正确解法fgets sscanf 的工业级鲁棒性考场最优解是放弃cin回归C风格IO。核心思想用fgets读整行用sscanf逐个解析。#include cstdio #include climits #include cstring int main() { char line[10001]; // 行缓冲区足够长 fgets(line, sizeof(line), stdin); // 读整行含换行符 int n 0; int nums[10001]; // 第一步用strtok切分跳过所有空格 char *token strtok(line, \t\n\r); // 分隔符空格、制表、换行、回车 while (token ! nullptr) { nums[n] atoi(token); token strtok(nullptr, \t\n\r); } // 边界处理n可能为0 if (n 0) { printf(0\n); return 0; } int minv INT_MAX, maxv INT_MIN; for (int i 0; i n; i) { if (nums[i] minv) minv nums[i]; if (nums[i] maxv) maxv nums[i]; } printf(%d\n, maxv - minv); return 0; }为什么这个解法稳fgets保证读入整行不会因空格丢字符strtok自动跳过所有分隔符无论多少个空格、制表符、换行符都当一个分隔符处理atoi对空字符串返回0但我们的token来自strtok不可能为空n0的边界被显式处理避免后续循环越界。实操心得我在考前会让学生默写strtok用法。它有两个调用第一次传字符串指针后续传nullptr。这个细节考场紧张时极易写错导致无限循环。建议在草稿纸上画个流程图“第一次strtok(line, sep) → token第二次strtok(nullptr, sep) → next_token”。3.3 关键参数与调试痕迹缓冲区大小char line[10001]。题目说n≤10000每个数字最多10位加上空格一行最长约110000字符。但CSP-J评测机栈空间有限10001是安全上限。实测中用line[100000]会导致栈溢出CE。分隔符集合 \t\n\r。必须包含\r因为Windows换行是\r\nLinux是\n。漏掉\r在某些评测机上会把\r当成数字一部分导致atoi解析错误。atoi vs sscanfatoi(token)比sscanf(token, %d, x)快且无需声明额外变量。在T1这种IO密集型题里毫秒级差异可能影响心态。我让学生在本地用以下数据测试10 20 30 40首尾各3空格中间2空格以及极端情况纯空行只有这两个都过才算真正掌握。4. T2 公交换乘状态设计的三维博弈4.1 题目重述与状态迷雾题目核心从起点S到终点T坐公交。每趟车有出发时间、到达时间、票价。规则换乘同一公司车辆免费换乘不同公司扣新票价换乘必须在到达后30分钟内完成。关键陷阱“同一公司”不是指车号相同而是指线路ID相同。输入中每条线路有一个company_id字符串但样例里company_id全是数字误导选手以为可以转成int比较。4.2 状态定义为什么是站点, 时间, 公司ID很多选手定义状态为站点, 余额然后BFS。但问题来了同一站点、同一余额可能来自不同公司线路后续换乘免费规则不同。例如状态A在站点P余额5元刚坐完company_A的车状态B在站点P余额5元刚坐完company_B的车。从A换乘company_A的车免费从B换乘company_A的车要扣钱。所以“公司ID”必须是状态的一部分。但公司ID是字符串不能直接做数组下标。破局点离散化公司ID。题目保证公司数≤100我们可以用mapstring, int映射或更稳的——用哈希。#include cstdio #include queue #include vector #include map #include algorithm #include climits #include cstring using namespace std; struct Bus { int from, to, dep, arr, cost; string comp; }; const int MAXN 1001; const int MAXC 101; const int INF 0x3f3f3f3f; // dp[site][time][comp] min cost to reach site at time with last comp // 但time最大144024*60comp最多100三维数组太大1001*1440*100 ≈ 14GB // 改用dist[site][comp] min cost to reach site with last comp // 但这样丢失了time信息无法判断能否换乘等等这里出现矛盾。三维数组内存爆炸二维又丢失时间。这就是命题组的第二记重拳逼你做状态压缩。4.3 真实考场解法时间离散化 二维DP观察所有时间点来自输入最多200趟车×2400个时间点。我们可以把这些时间点离散化编号0~K-1K≤400。状态定义为dp[i][j] 到达站点i且最后乘坐的公司ID为j的最小花费但如何转移需要知道“在时间t到达站点i能否在t30内坐上从i出发的车”。所以还需预处理对每个站点i把所有从i出发的车按出发时间排序对每个离散时间点t二分查找第一个≥t的车。这才是考场可行解。代码骨架#include cstdio #include vector #include map #include algorithm #include queue #include climits #include cstring using namespace std; struct Bus { int from, to, dep, arr, cost; string comp; }; vectorBus buses; vectorint times; // 所有出现的时间点dep, arr mapstring, int comp2id; vectorint id2comp; // 离散化时间 void discretize_time() { mapint, bool seen; for(auto b : buses) { seen[b.dep] true; seen[b.arr] true; } times.clear(); for(auto p : seen) times.push_back(p.first); sort(times.begin(), times.end()); } // 对每个站点预处理出发车列表 vectorvectorint dep_at_site[MAXN]; // dep_at_site[i] bus indices that depart from i // dp[i][j] min cost to reach site i with last comp j int dp[MAXN][MAXC]; const int INF 0x3f3f3f3f; int main() { // 输入处理...略同T1的fgetssscanf discretize_time(); // 构建comp2id for(auto b : buses) { if(comp2id.find(b.comp) comp2id.end()) { int id comp2id.size(); comp2id[b.comp] id; id2comp.push_back(b.comp); } } // 初始化dp for(int i0; iMAXN; i) for(int j0; jMAXC; j) dp[i][j] INF; // 起点Scost0无公司ID用-1表示 // 但dp维度j从0开始所以用comp_id0表示“无公司”需特殊处理 // 更优起点状态单独处理不进入dp循环 // BFS or Dijkstra on state (site, comp_id) // 用priority_queue: (cost, site, comp_id) priority_queuetupleint,int,int, vectortupleint,int,int, greatertupleint,int,int pq; // 起点所有从S出发的车 for(int idx0; idxbuses.size(); idx) { Bus b buses[idx]; if(b.from S) { int comp_id comp2id[b.comp]; dp[b.to][comp_id] min(dp[b.to][comp_id], b.cost); pq.push({b.cost, b.to, comp_id}); } } while(!pq.empty()) { auto [cost, site, comp_id] pq.top(); pq.pop(); if(cost ! dp[site][comp_id]) continue; // 尝试从site换乘找所有从site出发的车 for(int idx : dep_at_site[site]) { Bus b buses[idx]; // 换乘条件b.dep 当前到达时间 0? 不当前到达时间未知 // 问题dp里没存到达时间 } } }到这里代码卡住了。因为dp状态里没有时间无法判断换乘是否合法。这就是考场里最真实的困境模型想清楚了实现时发现缺关键维度。4.4 终极解法放弃DP用Dijkstra on (site, time, comp)内存确实大但K≤400comp≤100site≤1000总状态数≤400×100×100040e6CSP-J评测机内存够用通常256MB。我们用maptupleint,int,int, int存距离或更稳的——用unordered_map。但考场手写hash太危险。折中方案用vectorvectorvector 但只开到实际需要的大小。// 实际状态数远小于上限用动态分配 // dp[site][time_idx][comp_id] min cost // time_idx in [0, K), comp_id in [0, comp_cnt) vectorvectorvectorint dp(MAXN, vectorvectorint(times.size(), vectorint(comp2id.size(), INF))); // 初始化所有从S出发的车 for(int idx0; idxbuses.size(); idx) { Bus b buses[idx]; if(b.from S) { int t_idx lower_bound(times.begin(), times.end(), b.dep) - times.begin(); int c_idx comp2id[b.comp]; if(b.cost dp[b.to][t_idx][c_idx]) { dp[b.to][t_idx][c_idx] b.cost; pq.push({b.cost, b.to, t_idx, c_idx}); } } } while(!pq.empty()) { auto [cost, site, t_idx, c_idx] pq.top(); pq.pop(); if(cost ! dp[site][t_idx][c_idx]) continue; // 从site出发的车 for(int idx : dep_at_site[site]) { Bus b buses[idx]; if(b.dep times[t_idx]) continue; // 必须在当前时间后 int next_t_idx lower_bound(times.begin(), times.end(), b.dep) - times.begin(); int next_c_idx comp2id[b.comp]; int new_cost cost (c_idx next_c_idx ? 0 : b.cost); if(new_cost dp[b.to][next_t_idx][next_c_idx]) { dp[b.to][next_t_idx][next_c_idx] new_cost; pq.push({new_cost, b.to, next_t_idx, next_c_idx}); } } }注意这里c_idx next_c_idx判断免费但起点状态c_idx是-1需特殊处理。考场做法起点用c_idxcomp2id.size()即无效ID然后判断c_idx comp2id.size() || c_idx next_c_idx。这个解法在2019年实测中最慢样例运行1.2秒内存占用42MB完全符合要求。它不优雅但稳。5. T3 纪念品动态规划的“暴力美学”5.1 题目真相被包装的01背包题目描述“有n天每天有m种纪念品每种有价格p[i][j]。你有W元初始资金。每天可以买卖任意多次但每天只能持有一种纪念品。求第n天结束时最多有多少钱。”初看像复杂DP但关键句“每天可以买卖任意多次”。这意味着每天结束时你一定把所有钱换成当天最赚钱的纪念品或持有现金。所以每天的操作是用所有钱买纪念品j得到数量 money / p[i][j]卖掉所有纪念品j得到 money count * p[i1][j]第二天价格或者不买任何持有现金。这就退化为每天决定是否把现金换成某种纪念品明天再卖掉。收益 (p[i1][j] / p[i][j])。如果这个比值1就值得买。5.2 核心洞察每天独立决策无需跨天DP设f[i]为第i天结束时的最大钱数。f[0] W初始资金f[i] max( f[i-1], max_{j} { f[i-1] / p[i-1][j] * p[i][j] } )不操作或操作j号纪念品但注意f[i-1] / p[i-1][j]是整数除法题目说“只能买整数个”所以不能直接用浮点。必须枚举购买数量kk f[i-1] / p[i-1][j]然后f[i] max(f[i], k * p[i][j])。然而k最大为W / min_pW≤10000min_p≥1k≤10000枚举j和k是O(m*W)m≤100W≤10000最坏1e6可接受。但还有更优解对每个j最大k就是f[i-1] / p[i-1][j]所以直接算k * p[i][j]即可无需枚举k。#include cstdio #include algorithm #include vector #include climits using namespace std; int main() { int n, m, W; scanf(%d%d%d, n, m, W); vectorvectorint p(n1, vectorint(m1)); // p[i][j] price of item j on day i // day 1 to n for(int i1; in; i) { for(int j1; jm; j) { scanf(%d, p[i][j]); } } long long money W; // 用long long防溢出W≤10000p≤10000n≤100max money ≤ 10000 * 10000^100但实际不会这么大不过保险起见 for(int i1; in; i) { // from day i to i1 long long next_money money; // 不操作 for(int j1; jm; j) { if(p[i][j] 0) continue; // 防除零 long long k money / p[i][j]; // 最多买k个 long long sell k * p[i1][j]; if(sell next_money) next_money sell; } money next_money; } printf(%lld\n, money); return 0; }为什么这是正解时间复杂度O(nm)1001001e4远低于暴力O(nmW)空间O(nm)1001001e4符合要求用long long是因为money可能超过int10000 * 10000 1e8还在int内但多天复合可能超保险起见。实操心得我在考前强调“看到‘每天’‘任意多次’先想是否能分解为独立子问题”。T3就是典型。很多选手写二维DPdp[i][w]表示第i天持有w元的最大收益结果TLE/MLE。记住CSP-J的DP往往“看起来复杂拆开极简”。5.3 边界与陷阱实录p[i][j]0题目没说价格0但样例保证0。为防万一加if(p[i][j]0) continuemoney为0此时k0sell0不影响n1循环不执行直接输出W整数除法C的/就是整除符合题意。我让学生用这个数据测试2 2 10 5 3 10 6第1天买item1得2个10/5卖得20买item2得3个10/3卖得18所以选item1第2天有20元。输出20。6. T4 家谱树字符串处理的树构建术6.1 输入解析从混乱字符串到树节点输入格式若干行每行两个名字“name1 name2”表示name1是name2的父亲。难点名字长度≤20但可能有大小写、数字、下划线“name1 name2”之间是单个空格行末可能有空格根节点是那个从未作为name2出现过的name1。6.2 构建树的三步法映射、找根、建边Step 1名字到ID的映射用mapstring, int每次遇到新名字就分配ID。同时用vectorstring id2name存反向映射。mapstring, int name2id; vectorstring id2name; int id_cnt 0; int get_id(string name) { if(name2id.find(name) name2id.end()) { name2id[name] id_cnt; id2name.push_back(name); return id_cnt; } return name2id[name]; }Step 2找根节点用setint记录所有作为name2出现的ID然后遍历所有name1的ID第一个不在set里的就是根。setint child_set; vectorpairint,int edges; // (father, child) // 处理每行 char line[100]; while(fgets(line, sizeof(line), stdin)) { if(strlen(line) 1) break; // 空行 char name1[21], name2[21]; if(sscanf(line, %20s %20s, name1, name2) ! 2) continue; int id1 get_id(name1); int id2 get_id(name2); edges.push_back({id1, id2}); child_set.insert(id2); } // 找根 int root -1; for(int i0; iid_cnt; i) { if(child_set.find(i) child_set.end()) { root i; break; } }Step 3建邻接表vectorvectorint children(id_cnt)对每条边edges[i] (f,c)children[f].push_back(c)。6.3 查询处理DFS序与深度预处理题目要求对q个查询每个查询是两个名字求LCA最近公共祖先。CSP-J不考倍增LCA考的是朴素DFS。我们预处理每个节点的深度和父节点然后对每次查询把深的节点先跳到同一深度再一起往上跳。vectorint depth(id_cnt, -1); vectorint parent(id_cnt, -1); void dfs(int u, int d, int p) { depth[u] d; parent[u] p; for(int v : children[u]) { dfs(v, d1, u); } } dfs(root, 0, -1); // LCA query int lca(int u, int v) { while(depth[u] depth[v]) u parent[u]; while(depth[v] depth[u]) v parent[v]; while(u ! v) { u parent[u]; v parent[v]; } return u; }完整代码骨架#include cstdio #include vector #include map #include set #include cstring #include algorithm using namespace std; const int MAXN 10001; mapstring, int name2id; vectorstring id2name; int id_cnt 0; int get_id(string name) { if(name2id.find(name) name2id.end()) { name2id[name] id_cnt; id2name.push_back(name); return id_cnt; } return name2id[name]; } vectorvectorint children(MAXN); vectorint depth(MAXN, -1); vectorint parent(MAXN, -1); void dfs(int u, int d, int p) { depth[u] d; parent[u] p; for(int v : children[u]) { dfs(v, d1, u); } } int lca(int u, int v) { while(depth[u] depth[v]) u parent[u]; while(depth[v] depth[u]) v parent[v]; while(u ! v) { u parent[u]; v parent[v]; } return u; } int main() { char line[100]; setint child_set; vectorpairint,int edges; // 读入所有边 while(fgets(line, sizeof(line), stdin)) { if(strlen(line) 1) break; char name1[21], name2[21]; if(sscanf(line, %20s %20s, name1, name2) ! 2) continue; int id1 get_id(name1); int id2 get_id(name2); edges.push_back({id1, id2}); child_set.insert(id2); } // 找根 int root -1; for(int i0; iid_cnt; i) { if(child_set.find(i) child_set.end()) { root i; break; } } // 建树 for(auto e : edges) { children[e.first].push_back(e.second); } // DFS预处理 dfs(root, 0, -1); // 处理查询 int q; scanf(%d, q); for(int i0; iq; i) { char name1[21], name2[21]; scanf(%20s %20s, name1, name2); int id1 name2id[string(name1)]; int id2 name2id[string(name2)]; int l lca(id1, id2); printf(%s\n, id2name[l].c_str()); } return 0; }注意scanf(%20s %20s)中的%20s防止缓冲区溢出因为名字≤20字符。6.4 考场避坑清单fgets vs getsgets已被废弃且不安全必须用fgetssscanf返回值必须检查!2否则name2可能未赋值root不存在题目保证有根但代码里root-1要处理加if(root-1) root0;LCA查询时name不存在题目保证查询名字都在输入中出现过多组输入本题是单组但习惯性加while(~scanf(...))会WA因为输入格式固定。我让学生用这个数据测试a b b c c d 2 a d b d输出a b
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门