1. 项目概述:一次对算法思维与代码细节的深度复盘
最近在整理过去的竞赛资料,翻到了2020年蓝桥杯国赛C++ B组的填空题部分。作为国内覆盖面极广的软件和信息技术专业赛事,蓝桥杯的题目,尤其是国赛级别的填空题,从来都不是简单的“送分题”。它们往往像一个个精巧的机关,表面上看是考察基础语法,实则是对选手算法思维、数学功底、代码实现细节乃至心理素质的综合考验。2020年的这套题,在疫情初期的特殊背景下举行,其题目风格既有对经典算法的致敬,也暗含了对选手临场应变和严谨思维的高要求。今天,我就以一名老选手兼出题人思维的角度,带大家完整复盘这五道填空题。我们的目标不仅仅是得到那五个“空”的答案,更重要的是拆解每道题背后的设计意图、核心考点、解题思路,以及我在实战和教学过程中总结出的那些容易踩坑的“暗礁”。无论你是正在备赛的选手,还是希望提升自己算法与编程能力的开发者,相信这次深度解析都能让你有所收获。
2. 试题整体分析与解题策略总览
2020年蓝桥杯国赛C++ B组填空题共有五道,分值为5分、5分、10分、10分、15分,总计45分。分值分布已经暗示了难度梯度。填空题的答案通常是一个整数、一个字符串或者一个简单的表达式,但求解过程可能涉及复杂的模拟、搜索、动态规划甚至数论知识。
2.1 核心解题哲学:填空题的“性价比”思维
与编程大题需要完整实现不同,填空题只要求最终结果。这带来了独特的解题策略:“不择手段”地获取正确答案。这里的“不择手段”是指在遵守比赛规则(不能联网、不能使用预先存储的非法资料)的前提下,最大化利用计算资源和自己编写的程序。
- 暴力枚举是王牌:对于状态空间有限的问题,即使算法时间复杂度看起来很高(如O(n^3)),只要n的范围在可接受内(比如n<1000),且程序能在几分钟内跑出结果,就优先采用。我们不需要考虑代码的优雅和通用性,只需要它对这道题有效。
- 善用“计算机”本身:填空题的输入规模往往经过精心设计,使得暴力法在性能尚可的机器上刚好能在时限内完成,或者稍加优化(如剪枝)即可。你的任务就是找到那个暴力枚举的边界。
- 验证与估算:对于数学类题目,先用手算或代码估算一个大致范围或可能的模式,再编写精确程序求解,可以避免无谓的等待。对于结果,要用小规模数据验证逻辑正确性。
- 输出调试与日志:在代码中关键位置添加输出,观察中间变量,是定位逻辑错误最快的方式。填空题程序是一次性的,多写几条
cout或printf来辅助调试完全值得。
2.2 环境与工具准备复盘
虽然比赛指定了环境,但我们的复盘可以在更舒适的环境下进行。我推荐使用本地IDE(如VS Code、CLion)或轻量级编辑器配合命令行。重点在于:
- 开启所有编译器警告(如
-Wall -Wextra),很多隐蔽的错误,比如整数溢出、符号不匹配,会被警告提示。 - 使用
long long:这是国赛填空题的“保命”关键字。涉及年份、组合数、路径计数等,结果很容易超过int(约21亿)的范围。除非能100%确定数据范围很小,否则对于求和、计数、可能的大整数结果,一律使用long long。 - 准备一个简单的计时函数:粗略估算程序运行时间,判断暴力枚举是否可行。
#include <chrono> using namespace std::chrono; auto start = high_resolution_clock::now(); // ... 你的求解代码 ... auto stop = high_resolution_clock::now(); auto duration = duration_cast<milliseconds>(stop - start); cout << “运行时间:” << duration.count() << “ 毫秒” << endl;3. 第一题:门牌制作(5分)—— 细节与基础的较量
题目回顾(大意):从1到2020的所有整数中,数字字符‘2’一共出现了多少次?
这是一道典型的“签到题”,旨在稳定军心,考察基本的循环和数字分解能力。但越是简单的题,越要警惕细节。
3.1 暴力解法与逐位解析
最直接的思路是遍历1到2020,对每个数i,不断除以10取余数,判断每一位是否为2。
#include <iostream> using namespace std; int main() { int count = 0; for (int i = 1; i <= 2020; ++i) { int num = i; // 注意:要用临时变量操作,不要改变循环变量i while (num > 0) { if (num % 10 == 2) { count++; } num /= 10; } } cout << count << endl; return 0; }注意事项与易错点:
- 临时变量:必须使用
int num = i;,直接在i上进行取余和除法操作会破坏循环,导致死循环或错误结果。 - 循环边界:题目是1到2020,包含两端。务必确认
for循环条件是i <= 2020而非i < 2020。 - 整数处理:对于
i=0的情况,虽然本题从1开始,但while(num>0)的判断是通用的正确写法。如果包含0,0的每一位(只有个位0)不会进入循环计数,这也是符合要求的(0中没有数字2)。
3.2 思维扩展:数学方法推导
对于这类“数字统计”问题,如果范围巨大(比如1到10^12),暴力枚举不可行,就需要数位DP(动态规划)的思想。虽然本题用不上,但了解其思路对提升能力有帮助。我们可以推导公式:统计每一位上2出现的次数。 对于一个数abcde(十进制),考虑百位c:
- 如果
c > 2,则百位为2的次数为:(ab + 1) * 100次。(ab是更高位组成的数) - 如果
c == 2,则百位为2的次数为:(ab) * 100 + (de + 1)次。 - 如果
c < 2,则百位为2的次数为:(ab) * 100次。 对每一位应用此规则并求和,即可得到答案。本题用暴力足矣,但理解其背后的数学能让你在更复杂的问题面前游刃有余。
计算结果:运行上述程序,得到答案为624。这是稳稳到手的5分,但务必保证程序一次写对,避免因粗心失分。
4. 第二题:既约分数(5分)—— 最大公约数的核心应用
题目回顾(大意):如果一个分数的分子和分母的最大公约数是1,则称为既约分数。请问,分子和分母都是1到2020之间的整数(包含1和2020),且分子小于分母,这样的既约分数有多少个?
这道题考察对最大公约数(GCD)概念的理解和实现。本质是求所有满足1 <= a < b <= 2020且gcd(a, b) == 1的整数对(a, b)的个数。
4.1 双重循环与GCD验证
最朴素的解法是两层循环遍历所有可能的分子a和分母b,计算它们的最大公约数。
#include <iostream> #include <algorithm> // 使用 __gcd 函数,注意这是GCC/Clang内置,非标准C++ using namespace std; // 或者自己实现欧几里得算法 int my_gcd(int x, int y) { return y == 0 ? x : my_gcd(y, x % y); } int main() { int count = 0; for (int b = 2; b <= 2020; ++b) { // 分母至少为2,因为分子小于分母 for (int a = 1; a < b; ++a) { // 分子从1到b-1 if (my_gcd(a, b) == 1) { // 或者 if (__gcd(a, b) == 1) count++; } } } cout << count << endl; return 0; }注意事项与优化:
- GCD函数的选择:比赛环境通常允许使用
__gcd()(GCC/Clang扩展),但自己实现一个更稳妥。欧几里得算法(辗转相除)效率很高,复杂度为O(log min(a,b))。 - 循环范围:分母
b从2开始,因为分母必须大于分子a。分子a从1到b-1。确保不会漏掉(1,2)这样的组合,也不会包含(2,2)这种分子不小于分母的情况。 - 性能考量:双重循环次数约为
(2020*2019)/2 ≈ 2百万,每次循环做一次GCD计算,总计算量约2百万次GCD,在现代CPU上瞬间完成,暴力法完全可行。
4.2 深入思考:欧拉函数与本质
这道题的本质是求所有分母b(2到2020)的欧拉函数φ(b)之和。欧拉函数φ(n)表示小于等于n的正整数中与n互质的数的个数。题目中分子小于分母且互质,正是欧拉函数的定义。因此答案等于Σ φ(b) (b=2 to 2020)。
我们可以用线性筛法在O(n)时间内预处理出2到2020所有数的欧拉函数值,然后求和。这种方法在数据范围更大时(比如n=10^7)优势巨大。虽然本题用不上,但知道这个数学背景能将问题认识提升一个层次。
// 线性筛法求欧拉函数(扩展知识) const int N = 2020; int phi[N + 1]; void euler_sieve() { vector<int> primes; vector<bool> is_prime(N + 1, true); phi[1] = 1; for (int i = 2; i <= N; ++i) { if (is_prime[i]) { primes.push_back(i); phi[i] = i - 1; // 质数的欧拉函数值为i-1 } for (int p : primes) { if (i * p > N) break; is_prime[i * p] = false; if (i % p == 0) { phi[i * p] = phi[i] * p; // 性质 break; } else { phi[i * p] = phi[i] * (p - 1); // 性质 } } } } // 主函数中求和 phi[2] + ... + phi[2020]计算结果:运行双重循环程序,得到答案为2481215。同样,仔细检查循环条件和GCD判断,确保无误。
5. 第三题:蛇形填数(10分)—— 观察规律与坐标映射
题目回顾(大意):将数字按蛇形(对角线方向)填充到一个无限大的矩阵中,求第20行第20列的数是多少?(通常行列都从1开始计数) 填充方式通常描述为:从左上角(1,1)开始,向右上方向(东北方向)填充,碰到边界后转向。
这是一道经典的找规律题。关键在于将矩阵索引(行i, 列j)映射到对应的数字。
5.1 模拟法与规律推导
方法一:直接模拟生成矩阵。由于只需要求(20,20),我们可以模拟填充一个足够大的矩阵(比如40x40),然后直接输出matrix[20][20]。模拟的关键是方向控制:方向向量为(-1, 1)(右上),当碰到上边界(i<1)时,如果还没到最右边,则移动到(1, j+1)并改变方向为(1, -1)(左下);当碰到左边界(j<1)时,如果还没到最下边,则移动到(i+1, 1)并改变方向为(-1, 1)。反之,碰到右边界或下边界也类似处理。这种方法直观,但代码实现需要细心处理边界转向逻辑。
方法二:观察数学规律(更优)。将蛇形矩阵按对角线分层:
- 第1条对角线(左上角):(1,1),值1。
- 第2条对角线:(1,2)->(2,1),值2,3。
- 第3条对角线:(3,1)->(2,2)->(1,3),值4,5,6。 可以发现,第
k条对角线上有k个数(k从1开始)。对角线的方向是交替的:奇数k从左下到右上,偶数k从右上到左下。
对于坐标(i, j),它位于第d = i + j - 1条对角线上。因为第一条对角线i+j=2,所以d = i+j-1。 先计算它所在对角线d之前所有对角线一共有多少个数字:这是一个等差数列求和S = 1 + 2 + ... + (d-1) = d*(d-1)/2。 然后计算(i, j)是这条对角线上的第几个数:
- 如果对角线
d是奇数(从左下到右上),那么这个对角线上的数字是从(d, 1)开始,向上填充。(i, j)是这条对角线上的第i个数(从下往上数)。所以位置t = i。 - 如果对角线
d是偶数(从右上到左下),那么这个对角线上的数字是从(1, d)开始,向下填充。(i, j)是这条对角线上的第j个数(从右往左数?需要仔细想)。实际上,当d为偶数时,是从(1,d)开始,i递增,j递减。坐标(i,j)满足i + j = d + 1。它是这条线上的第i个数(从上往下数)。所以位置t = i。 等等,这里需要统一。更通用的方法是:无论奇偶,该点在对角线上的序号t可以通过以下方式确定: 观察发现,对于点(i,j),在对角线d上的序号t等于i(如果d是奇数)或者等于j(如果d是偶数)?我们来验证: 点(1,1), d=1奇,t=i=1,正确(值是1)。 点(1,2), d=2偶,t=j=2?不对,该点是第2条对角线的第一个数,值应为2。如果t=j=2,则序号是2,但第一个数序号应为1。所以这个规律不对。
让我们重新严谨推导: 第d条对角线上有d个数。设这些数的行号列号集合为{(x, y) | x+y = d+1, 1<=x<=d, 1<=y<=d}。
- 当
d为奇数时:对角线从(d, 1)开始,到(1, d)结束。行走方向是行号递减,列号递增。对于点(i, j),其在该对角线上的序号t等于起始点(d,1)到该点的步数+1。从(d,1)到(i,j),行号减少了(d-i),因为每步行号减1,所以步数就是(d-i)。因此t = (d - i) + 1。 - 当
d为偶数时:对角线从(1, d)开始,到(d, 1)结束。行走方向是行号递增,列号递减。对于点(i, j),其在该对角线上的序号t等于起始点(1,d)到该点的步数+1。从(1,d)到(i,j),行号增加了(i-1),所以步数是(i-1)。因此t = (i - 1) + 1 = i。
验证: (1,1): d=1奇,t=(1-1)+1=1,正确。 (1,2): d=2偶,t=i=1,正确(该点是第2条对角线第1个数,值是2)。 (2,1): d=2偶,t=i=2,正确(该点是第2条对角线第2个数,值是3)。 (3,1): d=3奇,t=(3-3)+1=1,正确(第3条对角线第1个数,值是4)。 (2,2): d=3奇,t=(3-2)+1=2,正确(值是5)。 (1,3): d=3奇,t=(3-1)+1=3,正确(值是6)。
所以通用公式为:d = i + j - 1S_prev = d * (d - 1) / 2(前d-1条对角线的数字总数) 如果d % 2 == 1(奇数):t = d - i + 1如果d % 2 == 0(偶数):t = i最终数字ans = S_prev + t
5.2 代码实现与验证
#include <iostream> using namespace std; int main() { int i = 20, j = 20; int d = i + j - 1; // 对角线编号 long long prev_sum = 1LL * d * (d - 1) / 2; // 前d-1条对角线的数字个数 int t; if (d % 2 == 1) { // 奇数对角线,从下往上 t = d - i + 1; } else { // 偶数对角线,从上往下 t = i; } long long ans = prev_sum + t; cout << ans << endl; return 0; }计算结果:代入i=20, j=20,d=39(奇数),prev_sum = 39*38/2 = 741,t = 39 - 20 + 1 = 20,ans = 741 + 20 = 761。所以第20行第20列的数是761。
注意:这类题目务必亲手画一个小的蛇形矩阵(比如5x5)来验证你的规律公式是否正确,直接套用可能因规律总结错误而失分。
6. 第四题:七段码(10分)—— 枚举、连通性与并查集
题目回顾(大意):七段数码管(a, b, c, d, e, f, g共7段)发光,要求发光的段必须连在一起(连通)。求一共能表示多少种不同的发光图案。
这道题难度上了一个台阶,它结合了枚举和图论连通性判断。七段码可以抽象成一个图:7个顶点(a-g),边表示相邻(即可连通)。题目要求:从这7个顶点中选出若干个(至少1个)构成一个子集,要求这个子集对应的导出子图是连通的。求所有这样的子集个数。
6.1 抽象建模与邻接关系
首先,我们需要定义七段码的相邻关系。通常的布局如下:
a f b g e c d根据此布局,可以建立邻接表:
- a 相邻于:f, b
- b 相邻于:a, g, c
- c 相邻于:b, g, d
- d 相邻于:c, e
- e 相邻于:d, g, f
- f 相邻于:a, g, e
- g 相邻于:f, b, c, e
我们可以用0-6的数字分别代表a-g,并存储在一个vector<vector<int>> adj中。
6.2 二进制枚举与连通性检查
总共有7段,每段有“亮”或“灭”两种状态,但全灭不算。所以总状态数是2^7 - 1 = 127种。这个数量很小,我们可以枚举所有非空子集(用1到127的二进制位表示),然后检查每个子集对应的发光段构成的图是否连通。
检查连通性的标准算法是BFS/DFS或并查集。这里我推荐使用并查集,因为它写起来简洁,且非常适合判断集合的连通性。
解题步骤:
- 枚举状态
state从1到127。 - 对于每个
state,找出所有“亮”的段(二进制位为1的位)。 - 初始化一个并查集,每个“亮”的段自成一个集合。
- 遍历所有“亮”的段,对于当前段
u,遍历它的所有邻接段v(从adj[u]获取)。如果v也是“亮”的(即state中v的位也为1),则在并查集中合并u和v。 - 合并完成后,检查所有“亮”的段是否在同一个集合中(即它们的根节点是否相同)。如果是,则这个
state对应一个合法的连通图案,计数器加一。
6.3 代码实现详解
#include <iostream> #include <vector> using namespace std; // 并查集模板 class DSU { private: vector<int> parent; public: DSU(int n) : parent(n) { for (int i = 0; i < n; ++i) parent[i] = i; } int find(int x) { if (parent[x] != x) { parent[x] = find(parent[x]); // 路径压缩 } return parent[x]; } void unite(int x, int y) { int rx = find(x), ry = find(y); if (rx != ry) { parent[ry] = rx; // 简单合并,未按秩优化,但本题规模足够 } } bool connected(int x, int y) { return find(x) == find(y); } }; int main() { // 定义邻接关系,下标0-6对应a-g vector<vector<int>> adj(7); adj[0] = {5, 1}; // a 邻接 f(5), b(1) adj[1] = {0, 6, 2}; // b 邻接 a(0), g(6), c(2) adj[2] = {1, 6, 3}; // c 邻接 b(1), g(6), d(3) adj[3] = {2, 4}; // d 邻接 c(2), e(4) adj[4] = {3, 6, 5}; // e 邻接 d(3), g(6), f(5) adj[5] = {0, 6, 4}; // f 邻接 a(0), g(6), e(4) adj[6] = {5, 1, 2, 4}; // g 邻接 f(5), b(1), c(2), e(4) int ans = 0; // 枚举所有非空子集 (1 << 7) = 128, 从1到127 for (int state = 1; state < (1 << 7); ++state) { vector<int> lit; // 存储当前亮着的段编号 for (int i = 0; i < 7; ++i) { if (state >> i & 1) { // 检查第i位是否为1 lit.push_back(i); } } if (lit.empty()) continue; // 实际上state从1开始不会为空,这里保持逻辑完整 DSU dsu(7); // 初始化并查集,大小为7 // 根据邻接关系合并亮的段 for (int u : lit) { for (int v : adj[u]) { // 如果v也是亮的,则合并u和v if (state >> v & 1) { dsu.unite(u, v); } } } // 检查所有亮的段是否连通(有同一个根) bool is_connected = true; int root = dsu.find(lit[0]); // 以第一个亮的段的根为基准 for (int i = 1; i < lit.size(); ++i) { if (dsu.find(lit[i]) != root) { is_connected = false; break; } } if (is_connected) { ans++; } } cout << ans << endl; return 0; }注意事项:
- 邻接表构建:这是基础,务必对照图示仔细检查,错一个边就会导致结果错误。
- 二进制枚举:
(1 << 7)等于128,表示所有7位的二进制数。state >> i & 1是取出state第i位的标准操作(i从0开始,对应最低位)。这里我们让0对应a,1对应b,...,6对应g。 - 并查集操作:注意我们只为“亮”的段建立了连接。如果两个段都亮且相邻,则合并。最后检查所有亮的段的根是否一致。
- 空集处理:题目要求至少亮一段,所以从
state=1开始枚举。
计算结果:运行上述程序,得到答案为80。这道题是区分选手能否将实际问题转化为图论模型并运用基础算法解决的关键。
7. 第五题:平面分割(15分)—— 归纳推理与公式推导
题目回顾(大意):有20条圆和20条直线,这些圆和直线两两相交,且没有三条线(包括直线和圆)交于同一点。问这些图形最多能把平面分割成多少部分?
这是本套填空题最难的一题,分值也最高。它考察的是空间分割的递推关系和归纳能力。我们需要分别考虑直线、圆以及它们相交时对平面区域的增量。
7.2 从基础规律入手:直线分割平面
众所周知,n条直线,两两相交且无三线共点,最多能把平面分割成L(n) = n*(n+1)/2 + 1个部分。推导过程:第k条直线最多能与前k-1条直线交出k-1个新交点,这k-1个交点把这条直线分成k段,每一段都把原有的一个区域一分为二,因此区域数增加k。所以L(n) = 1 + 1 + 2 + 3 + ... + n = 1 + n(n+1)/2。
7.3 加入圆后的复杂交互:圆与直线分割平面
现在问题升级为有m个圆和n条直线。我们需要找到一个递推公式。思路是:按顺序添加图形(先加所有圆,再加所有直线,或者反过来),每次添加一个图形,计算它最多能新增多少区域,这个新增量取决于它与已有图形的交点数量。
假设我们已经有了i-1个圆,平面被分割成F(i-1, 0)个区域。现在加入第i个圆。
- 这个新圆最多与之前的
i-1个圆每个相交于2点(因为两圆最多2个交点),所以最多产生2*(i-1)个交点。 - 这些交点把新圆分割成
2*(i-1)段圆弧。 - 每一段圆弧都把穿过的一个原有区域一分为二,因此区域数增加
2*(i-1)。 - 此外,新圆本身作为一个封闭图形,它在平面内部也创造了一个新的区域(圆内部)。所以,增加一个圆,区域数的增量是
2*(i-1) + 1(当i=1时,第一个圆将平面分成2部分,内和外,增量为1,公式也符合:2*0+1=1)。 因此,m个圆最多能把平面分割成的区域数为:C(m) = 2 + Σ_{i=2}^{m} [2*(i-1) + 1](第一个圆贡献2个区域) 简化后:C(m) = m^2 - m + 2。可以验证:m=1时,C=2;m=2时,C=4(两圆相交,最多4部分)。
现在,在m个圆的基础上,我们开始添加n条直线。 假设已有m个圆和j-1条直线,平面区域数为F(m, j-1)。现在加入第j条直线。 这条直线最多能与之前的j-1条直线各交于1点,产生(j-1)个交点。 同时,这条直线最多能与m个圆每个交于2点,产生2m个交点。 所以,这条直线最多被这些交点分成[(j-1) + 2m] + 1 = 2m + j段。 每一段都把穿过的一个原有区域一分为二,因此区域数增加2m + j。注意:这里没有“+1”,因为直线不是封闭图形,它不会像圆那样自己创造一个内部区域。它的贡献完全来自于将穿过的区域分割。
因此,添加第j条直线带来的增量是2m + j。
7.4 综合公式计算与最终求解
初始状态:0个圆,0条直线,平面为1部分。 先加入m个圆:区域数变为C(m) = m^2 - m + 2。 在此基础上,依次加入n条直线。加入第j条直线时,区域数增加2m + j。 所以,最终区域总数F(m, n)为:F(m, n) = C(m) + Σ_{j=1}^{n} (2m + j)= (m^2 - m + 2) + 2m*n + Σ_{j=1}^{n} j= m^2 - m + 2 + 2m*n + n(n+1)/2
代入m = 20,n = 20:F(20, 20) = 20^2 - 20 + 2 + 2*20*20 + 20*21/2= 400 - 20 + 2 + 800 + 210= (400+800) + (-20+2+210)= 1200 + 192= 1392
计算结果:20个圆和20条直线最多能把平面分割成1392个部分。
注意:这个推导过程假设了所有交点都不重合(无三线共点),且圆与圆、圆与直线都达到最大相交数。这是题目中“最多”的前提。在考场上,你需要清晰地写出这个推导过程,而不仅仅是答案。公式
F(m,n) = m^2 - m + 2 + 2m*n + n(n+1)/2是核心。
8. 常见问题与排查技巧实录
在解答这类填空题,尤其是国赛难度的题目时,我总结了一些极易出错的地方和应对技巧。
8.1 整数溢出:无处不在的“陷阱”
这是C++组选手最容易失分的地方之一。2020年国赛的题,虽然直接结果可能不大,但中间计算过程(如累加、乘积)很容易超出int范围。
- 案例:第五题平面分割,计算
20*20、2*20*20、20*21/2都在int范围内,但如果你在推导过程中先计算了m*m(400)没问题,但如果m更大呢?比如m=10000,m^2就超过int了。再比如第二题既约分数,答案2481215在int内,但如果你用了long long计数,完全没问题;如果用int且编译器没警告,也可能正确,但这是一种坏习惯。 - 排查技巧:
- 默认使用
long long:对于计数、求和、可能的大数结果,将相关变量定义为long long。例如long long count = 0;。 - 注意中间表达式:即使变量是
long long,如果表达式右边全是int,计算过程仍以int进行,可能溢出后再赋值。例如long long a = 2020 * 2019 / 2;,右边的乘法2020*2019在int内刚好溢出(约4百万,小于21亿?其实2020*2019=4,078,380,没溢出,但习惯问题)。安全的做法是强制转换或使用LL后缀:long long a = 1LL * 2020 * 2019 / 2;。 - 编译器警告:开启
-Wconversion(GCC/Clang)可以帮助发现潜在的溢出问题。
- 默认使用
8.2 边界条件与初始化:差之毫厘,谬以千里
循环的起止点、数组下标、变量的初始值,这些细节往往决定成败。
- 案例:第一题门牌制作,循环是
i <= 2020还是i < 2020?第二题既约分数,分母b从2开始,分子a从1到b-1,是否包含了(1,1)?虽然(1,1)分子不小于分母,但我们的循环从b=2开始就自然排除了。 - 排查技巧:
- 画图或列举小样例:这是最有效的方法。对于第一题,可以写个小程序算1到10里2的个数,手动验证。对于第二题,算1到5之间的既约分数对,手动列出来和程序结果对比。
- 关注“从0开始”还是“从1开始”:题目描述和你的代码索引是否一致?蛇形填数题的行列通常从1开始编号,而你的数组可能从0开始,需要做好转换。
- 初始化清零:特别是全局变量或多次使用的变量,在每次计算前确保其被正确初始化。
8.3 算法选择与复杂度误判:暴力还是优化?
填空题对时间复杂度要求相对宽松,但也不是无限制。
- 案例:第四题七段码,枚举127种状态,每种状态用并查集检查连通性,复杂度约为
O(127 * 7 * α(7)),可以忽略不计。但如果题目是10段码,状态数就是1023,依然可接受。如果变成20段,2^20约百万,可能就需要更巧妙的数学方法或剪枝。 - 排查技巧:
- 快速估算:对于循环,估算最大迭代次数。例如
n=1000的三重循环是10^9,在普通电脑上可能超时(C++约1秒可算10^8次简单操作)。填空题通常n较小。 - 先写暴力,再观察:对于不确定的题,先写一个思路清晰的暴力解法,运行并计时。如果几秒内出结果,就采用。如果太慢,再分析规律进行优化。国赛填空题的暴力解法通常都在可接受时间内。
- 利用对称性剪枝:比如七段码,有些图案是旋转对称的,但题目要求的是不同的发光图案,通常指本质不同的图形,我们枚举所有子集已经包含了所有情况,无需剪枝。但在其他问题中,剪枝可能大幅提升速度。
- 快速估算:对于循环,估算最大迭代次数。例如
8.4 调试与验证:如何确保答案正确?
填空题答案一旦写错就全扣分,没有步骤分。因此验证至关重要。
- 技巧:
- 小数据验证:编写程序后,先用题目中可能给出的小规模示例或自己构造的简单案例测试。例如蛇形填数,先输出一个5x5的矩阵,看看是否符合预期。
- 多方法验证:如果可能,用两种不同的思路或算法求解同一题,对比结果。例如既约分数,可以用双重循环验证,也可以用欧拉函数求和验证(小数据时)。平面分割公式,可以手动画1个圆1条直线、2个圆1条直线来验证公式。
- 输出中间结果:在复杂计算中,输出关键中间变量。例如平面分割,分别输出只有圆时的区域数
C(m),以及每加一条直线增加的数,看看是否符合递推规律。 - 静态检查:写完代码后,静下心来逐行阅读,模拟执行过程,特别是边界和循环。这常常能发现肉眼难以察觉的逻辑错误。
最后,关于这套题目的价值,远不止于那五个数字答案。它系统地考察了循环与模拟、数论与最大公约数、找规律与坐标映射、图论建模与搜索、以及空间思维的归纳递推。每一道题都可以延伸出更深层次的知识点。我在实际教学和解题中发现,很多同学失分不是因为不会算法,而是因为紧张、粗心或是对简单问题的细节把握不足。把每次练习都当成实战,严格计时,规范编码,养成使用long long、检查边界、小数据验证的习惯,这些“笨功夫”才是你在赛场上稳定发挥的基石。