尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

蓝桥杯国赛C/C++ B组真题深度解析:从质数筛法到动态规划优化

蓝桥杯国赛C/C++ B组真题深度解析:从质数筛法到动态规划优化 1. 项目概述一次国赛真题的深度复盘之旅看到这个标题相信很多正在备战蓝桥杯尤其是目标国赛的C/C选手都会心头一紧。“国赛C/CB组”、“未完待续”这几个关键词组合在一起立刻勾勒出一幅充满挑战与求知欲的图景。这不仅仅是一份普通的题解更像是一位同行在激烈竞赛后带着尚未平复的心绪迫不及待地开始对顶级赛事真题进行的一次系统性拆解与复盘。对于所有志在攀登算法竞赛高峰的开发者而言国赛真题是检验实力、洞察趋势最宝贵的试金石。然而真题资源往往稀缺官方通常只提供题目而详细的思路、踩坑记录和优化心法才是真正帮助后来者实现突破的关键。今天我们就以这个“未完待续”的题解为契机假设自己就是那位参赛归来的选手对2021年第十二届蓝桥杯国赛C/C B组的真题进行一次全面的、深度的、带有强烈个人实战色彩的解析。我们的目标不是简单地给出答案而是还原解题时的完整思考链条从第一眼看到题目时的直觉到多种思路的碰撞与取舍再到编码实现中那些魔鬼般的细节最后是对于更高维度优化的探讨。我会分享我在模拟解题过程中“踩过的坑”、“灵光一现的优化”以及“事后看来可以做得更好的地方”希望这份超过5000字的详实记录能成为你备赛路上的一块坚实垫脚石。2. 整体赛题分析与解题策略总览第十二届国赛的题目整体上延续了蓝桥杯“重思维、考基础、有区分度”的特点。B组的题目相较于A组在数学模型和算法深度上要求稍低但对编程技巧、代码效率和边界情况的考察依然严苛。拿到一套题首先得有全局观。通常前几题是签到或简单题用于稳定心态和争取时间中间部分考察经典算法如动态规划、搜索、图论的应用与变形最后的压轴题则往往需要比较深刻的洞察力或复杂的数据结构/算法组合。我的策略通常是“三轮递进法”第一轮快速通读用10-15分钟浏览所有题目对每道题的题意、数据范围和可能考点做出初步判断。标记出一眼就有思路的“签到题”和需要仔细琢磨的“硬骨头”。第二轮稳扎稳打从最简单的题目开始入手确保这些分数稳稳拿到。在实现简单题的同时大脑后台会持续思考难题的关键点。第三轮攻坚克难集中精力解决剩下的中等和难题。此时要合理分配时间对于有思路但实现复杂的题先写出基础版本保证部分分再尝试优化对于完全没思路的果断跳过检查前面题目的正确性。对于“未完待续”的题解我们不妨假设作者就是按照比赛节奏先解决了部分题目并进行分享。我们接下来的解析也将遵循一种合理的解题顺序兼顾难度和思维连贯性。2.1 环境准备与心态调整在深入每一道题之前有两个非技术因素至关重要环境和心态。环境国赛通常使用指定的IDE如Dev-C但日常练习我强烈建议使用自己最熟悉的工具例如Visual Studio Code GCC/Clang配合简单的输入输出重定向进行测试。准备好一个本地测试脚本能快速编译、运行并对比样例输出可以节省大量时间。# 一个简单的测试脚本示例 (test.sh) g -stdc11 -O2 -o sol solution.cpp ./sol input.txt my_output.txt diff -w my_output.txt expected_output.txt心态国赛时长4小时压力巨大。遇到卡顿比如调试半小时找不到bug时最容易慌乱。我的经验是设置时间盒。比如给一道题分配最多1小时包括思考、编码和调试。如果超时仍未解决保存当前代码切换到另一道题或回头检查。往往在思考其他问题后回头再看会有新的灵感。此外一定要仔细阅读数据范围这直接决定了算法的时间复杂度上限是选择暴力还是优化算法的根本依据。3. 真题逐题深度解析与实现由于是“未完待续”我们假设从部分已解题开始并补充完整后续题目的解析。以下解析包含题目大意、核心思路、代码实现以及至关重要的注意事项。3.1 试题A纯质数假设题题目大意计算1到N之间其本身是质数并且其每一位十进制数也都是质数即每位只能是2,3,5,7的数字个数。N可能很大例如10^7。核心思路双重判断首先这个数必须是质数。其次分解其每一位数字检查是否都在集合{2,3,5,7}中。算法选择判断单个质数可以用试除法时间复杂度O(√n)。判断每一位通过不断取模和整除10来分解数字。优化点对于大范围N对每个数都进行O(√n)的质数判断会超时。需要使用埃拉托斯特尼筛法预先筛选出所有范围内的质数然后在这些质数中检查数位条件。数位检查可以在筛法过程中或筛完后进行。一个关键的剪枝如果一个数的任何一位包含0,1,4,6,8,9它肯定不是纯质数无需进行质数判断。这个判断成本极低O(位数)可以提前过滤掉大量数字。代码实现与注释#include iostream #include vector #include cmath using namespace std; bool isDigitPrime(int x) { // 检查每一位是否为2,3,5,7 while (x) { int d x % 10; if (d ! 2 d ! 3 d ! 5 d ! 7) { return false; } x / 10; } return true; } int main() { int N; cin N; vectorbool isPrime(N 1, true); isPrime[0] isPrime[1] false; // 埃氏筛法 for (int i 2; i * i N; i) { if (isPrime[i]) { for (int j i * i; j N; j i) { isPrime[j] false; } } } int ans 0; // 遍历所有数先检查数位再判断质数对于非质数数位检查也很快 for (int i 2; i N; i) { if (isDigitPrime(i) isPrime[i]) { ans; } } cout ans endl; return 0; }注意事项与踩坑点注意埃氏筛法的内层循环起始点应该是j i * i而不是j i i。从i*i开始标记是因为更小的i的倍数已经被之前的质数标记过了。这是写筛法时非常容易出错的地方。 另一个坑点1不是质数在初始化筛法数组和遍历计数时一定要从2开始。数位检查函数中如果输入是0循环会直接跳过返回true但0不在我们考虑范围内且筛法中isPrime[0]已被设为false所以不会影响结果但逻辑上要清晰。3.2 试题B完全日期假设题题目大意定义日期“完全”为将年月日连成一个8位数如20210101这个数的所有位数之和是一个完全平方数。给定起止日期统计期间有多少个“完全日期”。核心思路日期处理这是核心。需要能正确遍历给定范围内的每一天并处理闰年、月份天数变化。自己实现日期递增函数或使用C的chrono库但竞赛环境可能有限制。数位和计算将8位整数分解求和。完全平方数判断计算出的数位和sum判断是否存在整数t使得t*t sum。可以预处理一个布尔数组标记1到100因为8位数最大和是8*972以内的完全平方数。代码实现与注释#include iostream using namespace std; // 预处理完全平方数表 bool isPerfectSquare[100] {false}; // 下标即数字值为是否为完全平方数 // 判断闰年 bool isLeapYear(int y) { return (y % 4 0 y % 100 ! 0) || (y % 400 0); } // 获取某年某月的天数 int daysOfMonth(int y, int m) { if (m 2) { return isLeapYear(y) ? 29 : 28; } if (m 4 || m 6 || m 9 || m 11) { return 30; } return 31; } // 计算数位和 int digitSum(int num) { int sum 0; while (num) { sum num % 10; num / 10; } return sum; } int main() { // 初始化平方数表 for (int i 1; i * i 100; i) { isPerfectSquare[i * i] true; } int y1, m1, d1, y2, m2, d2; // 假设输入格式为 y1 m1 d1 y2 m2 d2 // 这里为了演示直接赋值一个范围 y1 2001, m1 1, d1 1; y2 2021, m2 12, d2 31; int ans 0; int y y1, m m1, d d1; // 循环遍历每一天直到超过结束日期 while (!(y y2 || (y y2 m m2) || (y y2 m m2 d d2))) { int dateNum y * 10000 m * 100 d; // 组成8位数 int sum digitSum(dateNum); if (isPerfectSquare[sum]) { ans; } // 日期递增 d; if (d daysOfMonth(y, m)) { d 1; m; if (m 12) { m 1; y; } } } cout ans endl; return 0; }注意事项与踩坑点日期遍历的边界条件是极易出错的地方。循环条件while (!(y y2 ...))确保了在日期严格大于终止日期时停止。也可以写成while (y y2 || (y y2 m m2) || (y y2 m m2 d d2))但要注意d d2。闰年判断规则必须记牢能被4整除但不能被100整除或者能被400整除。2月的天数处理依赖于这个函数。性能直接遍历每一天在日期跨度大时比如百年也是可行的因为总天数大约在3万左右计算量很小。重点在于日期递增逻辑的正确性。3.3 试题C最小权值动态规划典型题题目大意对一棵有N个节点的二叉树定义其权值为所有节点的“权值”之和。每个节点的“权值”定义为以其为根的子树中所有节点到它的距离之和。现在给定N求所有可能结构的二叉树的最小权值。核心思路解析 这道题是动态规划的经典应用需要一定的抽象和建模能力。理解题意所谓“所有可能结构的二叉树”是指所有不同形态的二叉树考虑左右子树形态。我们需要找出所有形态中权值最小的那个。问题转化假设我们定义dp[i]为有i个节点时所能得到的最小权值。考虑如何从子问题推导。状态转移对于一棵有i个节点的树我们可以将根节点拿出来剩下的i-1个节点分配给左子树和右子树。设左子树有j个节点则右子树有i-1-j个节点j从0到i-1。根节点本身的贡献左子树所有j个节点到根的距离为1右子树所有i-1-j个节点到根的距离也为1。所以根节点带来的权值增加为j (i-1-j) i-1。左右子树的贡献左子树本身的权值是dp[j]但注意左子树中每个节点到根的距离等于它到左子树根的距离再加1。因此左子树的所有节点对总权值的贡献除了自身的dp[j]还要加上j * 1因为每个节点到新根的距离都增加了1。右子树同理。转移方程dp[i] min_{j0}^{i-1} { (i-1) dp[j] j dp[i-1-j] (i-1-j) }简化后dp[i] min_{j0}^{i-1} { dp[j] dp[i-1-j] i - 1 }这里i-1是根节点的直接贡献dp[j] j是左子树的总贡献自身权值距离增量dp[i-1-j] (i-1-j)是右子树的总贡献。 进一步观察dp[j] j可以看作是一个新的状态f[j]。但直接按上式计算即可。初始化dp[0] 0空树权值为0。dp[1] 0只有一个节点距离和为0。代码实现与注释#include iostream #include vector #include climits using namespace std; int main() { int N; cin N; vectorlong long dp(N 1, LLONG_MAX); // 权值可能很大用long long dp[0] 0; dp[1] 0; // 初始化 for (int i 2; i N; i) { for (int j 0; j i; j) { // j为左子树节点数 int left j; int right i - 1 - j; // 计算当前分配方案下的权值 long long cur dp[left] dp[right] i - 1; // 注意dp[left]已经包含了左子树内部的距离和 // 加上 (i-1) 是根节点带来的贡献所有子节点到根距离为1。 // 为什么不是加上 left right因为 left right i-1。 // 更严谨的推导总权值 根贡献(i-1) 左子树贡献(dp[left] left) 右子树贡献(dp[right] right) // dp[left] dp[right] (i-1) left right dp[left] dp[right] 2*(i-1)这里需要仔细核对。 // 让我们重新推导这是最容易出错的地方 } } cout dp[N] endl; return 0; }停下来这里发现了问题。上面的推导和注释出现了矛盾。这说明在压力下动态规划的状态定义和转移方程极易搞混。我们必须静下心来重新严谨推导。重新推导动态规划状态 定义dp[i]为有 i 个节点的二叉树其最小权值是多少。注意这个权值定义是树中所有节点到其子树根节点的距离之和。但题目定义是每个节点的权值是其子树中所有节点到它的距离之和然后对所有节点求和。对于整棵树而言如果我们选定了树根那么总权值就是根节点的权值 左子树的总权值 右子树的总权值。然而左子树的总权值在左子树自己的坐标系下是dp[left]但放在整棵树下左子树每个节点到整棵树根的距离等于它到左子树根的距离再加1。所以左子树对总权值的贡献是dp[left] left因为 left 个节点每个距离1。右子树同理。因此正确的转移方程应该是dp[i] min_{j0}^{i-1} { (i-1) (dp[j] j) (dp[i-1-j] (i-1-j)) }化简dp[i] min_{j0}^{i-1} { dp[j] dp[i-1-j] i - 1 j (i-1-j) } min_{j0}^{i-1} { dp[j] dp[i-1-j] 2*(i-1) } min_{j0}^{i-1} { dp[j] dp[i-1-j] } 2*(i-1)修正后的代码#include iostream #include vector #include climits using namespace std; int main() { int N; cin N; vectorlong long dp(N 1, LLONG_MAX); dp[0] 0; // 空树 dp[1] 0; // 只有一个节点 for (int i 2; i N; i) { long long minVal LLONG_MAX; for (int j 0; j i; j) { // j 左子树节点数 int left j; int right i - 1 - j; // 左右子树节点数必须合法非负 if (left 0 right 0) { long long cur dp[left] dp[right]; if (cur minVal) { minVal cur; } } } dp[i] minVal 2LL * (i - 1); // 加上根节点带来的固定增量 } cout dp[N] endl; return 0; }注意事项与踩坑点这是本题最核心的陷阱。动态规划的状态转移方程必须经过严格验证最好用小的例子如N2,3手动计算看是否符合程序输出。我第一版的错误推导就是一个活生生的教训。 数据范围N可能较大比如2000dp值增长很快必须使用long long。 时间复杂度O(N^2)对于N2000是可行的400万次操作。3.4 试题D大写字符串处理基础题题目大意给定一个只包含大小写字母的字符串将其中的小写字母转换成大写字母。核心思路这题是绝对的签到题考察基本的字符处理。两种方法使用Ctoupper函数。利用ASCII码小写字母a到z对应97-122大写字母A到Z对应65-90。小写转大写只需c - a A或c - 32。代码实现与注释#include iostream #include string #include cctype using namespace std; int main() { string s; cin s; // 或 getline(cin, s) 如果包含空格 for (char c : s) { // 使用引用直接修改原字符串 c toupper(c); // 方法一库函数 // 方法二if (c a c z) c c - a A; } cout s endl; return 0; }注意事项与踩坑点虽然简单但要注意输入字符串是否可能包含空格。如果题目说明是“一行字符串”则可能需要使用getline(cin, s)。仔细看题 使用范围循环for (char c : s)时记得加引用否则修改的是副本。3.5 试题E123前缀和与数学规律题题目大意有一个无限长的序列1, 1,2, 1,2,3, 1,2,3,4, ...。即先放1再放1,2再放1,2,3以此类推。多次询问每次询问区间[L, R]内所有数的和。核心思路解析问题规模L和R可以非常大比如10^12不可能直接模拟生成序列。寻找规律序列是分块的。第i块包含数字1到i。第1块长度1数字和1。第2块长度2数字和123。第3块长度3数字和1236。第i块长度i数字和i*(i1)/2。定位与求和给定一个位置pos需要知道它在第几块以及在该块内的第几个位置。如何找到pos所在的块号k满足条件12... (k-1) pos 12...k。即k*(k-1)/2 pos k*(k1)/2。可以通过解不等式或二分查找得到k。知道块号k后该块起始位置的前缀和是S(k-1) sum_{i1}^{k-1} (i*(i1)/2)。这个公式可以简化sum i*(i1)/2 1/2 * (sum i^2 sum i) 1/2 * (n(n1)(2n1)/6 n(n1)/2) n(n1)(n2)/6。所以前m块的总数字和不是位置和是F(m) m*(m1)*(m2)/6。对于位置pos假设它在第k块中的偏移量为offset (offset pos - k*(k-1)/2)。那么从第1块到pos位置的总和可以分两部分计算前k-1块的总和F(k-1)。第k块中前offset个数的和12...offset offset*(offset1)/2。因此区间[L,R]的和等于sumToPos(R) - sumToPos(L-1)。代码实现与注释#include iostream #include cmath using namespace std; using ll long long; // 计算前x块的总数字和 ll sumOfBlocks(ll x) { return x * (x 1) * (x 2) / 6; } // 计算从序列开始到位置pos的总和 ll sumToPos(ll pos) { if (pos 0) return 0; // 二分查找pos所在的块号k ll l 1, r 2e6; // 估算一个上界因为k*(k1)/2 pos, k约等于sqrt(2*pos) while (l r) { ll mid (l r) / 2; if (mid * (mid 1) / 2 pos) { r mid; } else { l mid 1; } } ll k l; // pos所在的块号 // 前k-1块的总和 ll res sumOfBlocks(k - 1); // 在第k块中的偏移量从1开始 ll offset pos - (k - 1) * k / 2; // 加上第k块内前offset个数的和 res offset * (offset 1) / 2; return res; } int main() { int T; cin T; while (T--) { ll L, R; cin L R; cout sumToPos(R) - sumToPos(L - 1) endl; } return 0; }注意事项与踩坑点二分查找的边界块号k的上界需要合理估计。因为k*(k1)/2 ≈ pos所以k ≈ sqrt(2*pos)。对于pos最大为10^12sqrt(2e12) ≈ 1.4e6所以上界设为2e6是安全的。数据溢出计算过程中涉及多个大数相乘如k*(k1)*(k2)即使k2e6结果也远超32位int范围。必须全程使用long long。公式推导的正确性sumOfBlocks的公式m*(m1)*(m2)/6需要自己动手推导验证死记硬背容易出错。可以写个小程序验证前几项。位置与偏移量的计算(k-1)*k/2是前k-1块的总长度也是第k块开始的位置。offset的计算要小心确保是从1开始计数。4. 常见问题与调试技巧实录在竞赛或练习中除了算法思路调试能力同样决定成败。以下是我在解决这类题目时积累的一些常见问题排查技巧。4.1 答案错误Wrong Answer的排查流程重读题目确保完全理解题意特别是输入输出格式、数据范围、边界条件如LRN0。这是最常见的问题源。测试样例自己构造一些小的、边界性的测试用例。对于日期题测试闰年2月29日、跨年、同一天等。对于DP题测试N0,1,2,3。中间输出调试在代码关键位置如循环内、状态转移后打印中间变量与手算结果对比。例如在动态规划题中打印出dp数组的前几项。对拍写一个暴力但正确的程序通常只适用于小数据范围用随机生成的数据同时运行你的优化程序和暴力程序比较输出。这是找出逻辑错误的大杀器。关注溢出对于涉及大量加法、乘法的题目如“123”int溢出是隐形杀手。养成习惯看到10^5以上的数据范围直接使用long long。4.2 时间超限Time Limit Exceeded的优化思路复杂度分析首先分析你的算法理论时间复杂度。O(N^2)对于N10^5肯定超时。国赛B组O(NlogN)或O(N)通常是安全的。减少常数检查循环内部是否有不必要的函数调用如pow,sqrt、是否能用前缀和/差分避免重复计算、是否能用数组代替vector以提升访问速度在C中差异不大但在极端情况下有影响。I/O优化当输入数据量巨大时如10^6个数使用cin/cout可能成为瓶颈。可以ios::sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr);或者使用scanf/printf。避免递归过深DFS等递归算法在数据量大时可能栈溢出或超时考虑迭代写法或显式栈。4.3 内存超限Memory Limit Exceeded的检查点检查数据结构大小你申请的数组大小是否与题目要求匹配int dp[1000000]大约占用4MBlong long则翻倍。估算一下总内存消耗。不必要的缓存是否存储了所有中间结果有时可以滚动数组只保留最近几层状态。递归开销深度递归不仅慢而且每个函数调用都会占用栈空间。4.4 蓝桥杯国赛特有的注意事项结果填空题有些题目是填空题只要求提交最终结果。对于这类题可以写程序暴力计算但一定要确保答案唯一且正确。计算出来后最好用不同的思路或程序验证。编程题仔细阅读输入输出描述。蓝桥杯有时要求输出特定格式比如“Case #1: ”前缀或者结果对某个数取模。环境差异本地环境如Mac的Clang和比赛环境通常Windows的GCC可能有细微差别比如rand()函数、to_string的支持度。避免使用非标准特性。长整型使用国赛题目经常涉及大数long long是你的好朋友。定义别名using ll long long;是个好习惯。5. 备赛策略与资源推荐国赛的备战是一个系统工程不能只靠刷题。5.1 系统性知识梳理你需要一个清晰的知识图谱基础语法与STL熟练使用C11/14掌握vector,map,set,queue,stack,string等容器及其方法。基础算法排序、二分查找、前缀和、差分、双指针。搜索DFS、BFS、回溯、剪枝。动态规划线性DP、背包DP、区间DP、树形DP、状态压缩DP。掌握经典模型和状态设计方法。图论最短路Dijkstra, Floyd, SPFA、最小生成树Kruskal, Prim、拓扑排序、并查集。数学质数筛法、快速幂、最大公约数/最小公倍数、简单组合数学。数据结构单调栈、单调队列、并查集、树状数组、线段树提高组。5.2 有效的练习方法专题突破针对自己的弱点进行集中训练。例如花一周时间专门练习动态规划从简单题到难题。模拟赛定期进行4小时的全程模拟使用历年真题或高质量模拟题。严格计时模拟真实比赛环境包括不能上网查资料。复盘总结每做完一套题或一次模拟赛无论结果如何必须复盘。写出详细的题解记录自己的思考过程、错误原因、优化方法。本篇“未完待续”的题解就是极好的复盘形式。构建代码模板将常用的、易错的算法如快速幂、Dijkstra、并查集写成简洁、正确的模板并熟记于心。5.3 资源推荐官方题库蓝桥杯官网的练习系统是最直接的资源。在线判题平台洛谷、AcWing、Codeforces、LeetCode侧重算法思维都有丰富的题库和社区讨论。书籍《算法竞赛入门经典》刘汝佳、《算法竞赛进阶指南》李煜东是经典教材。社区与博客多看看其他优秀选手的博客和题解学习不同的思路和编码技巧。国赛的挑战性正在于它综合考察了你的知识广度、思维深度、编码速度和心理素质。这份针对2021年国赛B组的“未完待续”式深度解析希望能帮你捋清一类题目的解题脉络更重要的是传递一种“复盘”和“深究”的态度。每一道错题、每一个卡住的点都是进步的阶梯。当你能够独立完成这样一篇详尽的题解时你的实力必然已经更上一层楼了。
返回列表