蓝桥杯国赛C++ B组经典真题深度解析:贪心、状压DP与拓扑排序实战 📅 发布时间:2026/8/28 5:18:03 👁 浏览次数: 1. 从一道“国赛”真题说起为什么我们还在讨论2017年的题如果你是一名正在备战蓝桥杯尤其是瞄准C B组国赛的选手可能会觉得奇怪都202X年了为什么还要去看2017年的老题市面上不是有最新的真题和题解吗这个问题问得好也是我写这篇长文的核心出发点。我接触过不少学生他们刷题的策略往往是“追新”认为最新的题目才代表最新的趋势。这个想法对了一半。对于考察具体语法、新特性或者紧跟时事热点的比赛追新是必要的。但蓝桥杯尤其是其国赛阶段的题目有一个非常鲜明的特点它考察的不是“新”而是“深”和“巧”。国赛题目的设计往往围绕一些经典的算法思想、精巧的数学模型和严格的工程实现展开。这些核心的“套路”和“思维模型”在多年的赛事中形成了稳定的传承。2017年B组国赛的几道题目恰恰是这种“经典套路”的集中体现。它们不像一些年份的题目那样有特别“偏门”的知识点而是把常见的动态规划、搜索、数论、贪心等基础算法包装在了需要深刻理解和灵活运用的场景里。把这些题目吃透其价值远大于盲目刷十套模拟题。你会清晰地看到国赛级别的难题是如何把多个基础知识点串联、变形并设置那些容易让人“想当然”的陷阱的。所以这篇题解的目的不仅仅是告诉你答案是什么更重要的是拆解“为什么这么想”、“当时可能踩什么坑”以及“这类题以后怎么破”。我们假设你已经有了一定的C和算法基础目标是冲击省一甚至国奖。那么请跟着我的思路我们挑几道最具代表性的2017年国赛题来一次深度的“复盘式”学习。2. 真题深度复盘三道典型题目的思维链路拆解由于原题描述较长我在这里不直接粘贴原题而是概括其核心模型并附上原题的关键数据约束确保我们的讨论是精准的。我们会重点分析三道题一道涉及数位处理与贪心策略一道是经典的状态压缩动态规划变种还有一道是搜索优化与可行性剪枝的典范。2.1 例题一“平方十位数”的贪心构造法问题简述找出一个10位数它是某个整数的平方并且这个10位数包含0~9这10个数字且每个数字恰好出现一次即一个0~9的全排列。很多同学的第一反应是暴力枚举。平方根大约是10^5量级似乎可行但仔细一想10位数平方根的范围大约是3万多到10万之间因为sqrt(10^10) 10^5但10位数最小是10^9其平方根约为31623。在这个范围内枚举并检查平方后的数是否为0-9的一个排列计算量在可接受范围内但这并不是最优解也缺乏思维美感。国赛题往往有更巧妙的入口。我们换个角度什么样的平方数会是0-9的一个排列一个重要的突破口是平方数的末尾数字。我们知道一个数平方后的末位数字只取决于原数的末位数字0~9可能的末位是0,1,4,5,6,9。而一个0-9的排列末位可以是任意数字。但如果我们考虑最后两位情况就更有趣了。一个数平方后的最后两位其实只取决于原数的最后两位。我们可以枚举00-99计算其平方的最后两位会发现很多重复的模式。但这个方法还是有点繁琐。更高级的贪心策略是从高位开始构造。我们知道这个平方数是10位数那么它的平方根大约是5位数因为10000^210^8是9位数100000^210^10是11位数。我们可以尝试从最高位开始逐步确定平方根的每一位。但这里有一个更经典的思路利用枚举平方根的范围并结合数学性质快速剪枝。实际上最优雅的方法是直接枚举平方根。范围是 sqrt(1023456789) ≈ 31992 到 sqrt(9876543210) ≈ 99380。在这个大约6.7万个数的范围内进行枚举对于现代计算机来说瞬间完成。关键在于如何高效判断一个10位数是否是0-9的一个排列。核心实现与技巧#include iostream #include cmath #include cstring using namespace std; bool isPandigital(long long n) { if (n 1000000000) return false; // 不足10位 int cnt[10] {0}; while (n) { cnt[n % 10]; n / 10; } for (int i 0; i 10; i) { if (cnt[i] ! 1) return false; } return true; } int main() { // 估算上下界最小的10位pandigital是1023456789最大是9876543210 long long lower sqrt(1023456789); long long upper sqrt(9876543210); // 因为平方根可能是小数所以向下取整和向上取整要小心 // 实际上我们直接遍历整数区间即可 for (long long i lower; i upper; i) { long long square i * i; if (square 1000000000) continue; // 确保是10位数 if (isPandigital(square)) { cout square endl; // 根据题目要求可能需要输出最大的或最小的这里输出找到的第一个通常只有一个 break; } } return 0; }避坑点数据类型10位数最大约为9.87e9其平方根约为99380在int范围内。但是平方数i*i可能超过int范围最大值约21亿必须使用long long来存储平方结果。边界判断isPandigital函数中首先要判断数字是否恰好是10位因为像102345678这样的9位数即使各位数字不同也不符合要求。枚举范围优化实际上因为要求数字是0-9的排列平方数必然能被9整除因为0-9的和是45能被9整除。那么其平方根也必须能被3整除。因此枚举步长可以设为3能减少2/3的枚举量。这是一个非常重要的数论剪枝。结果唯一性这类题目通常只有一个答案。在竞赛中如果你通过程序找到了一个数一定要用手工或再验算一次确保它确实是一个完全平方数且是0-9的排列。这道题给我们的启示是面对一个看似需要复杂构造的问题有时最直接、范围清晰的暴力枚举就是最佳策略关键在于利用数学性质如整除特性进行剪枝以及注意数据类型的溢出问题。2.2 例题二“瓷砖样式”的状态压缩DP问题简述有一个2行n列的网格要用1x2的瓷砖可以横铺或竖铺铺满并且规定不能有连续的、完全相同的花色图案即铺好后任意2x2的子网格中不能出现四块瓷砖花色完全一样的情况。求铺满的方案数。这题是状态压缩动态规划的经典变种。一看到“2行n列”、“铺砖”老手立刻会联想到“轮廓线DP”或者“按列递推”。但这里增加了“禁止2x2同色”的约束使得状态定义和转移变得复杂。核心思路拆解状态定义既然只有两行我们可以按列进行递推。定义dp[i][state]表示处理到第i列且第i列的填充状态为state时前i列的合法方案数。这里的state需要编码当前列两行格子的“归属”情况。因为瓷砖是1x2的一个格子可能属于一块竖铺瓷砖的下半部分由上一列延伸而来也可能属于一块横铺瓷砖的右半部分与本列同行左侧格子相连或者是新的一块竖铺瓷砖的上半部分将延续到下一列。状态编码挑战传统的铺砖DP状态通常表示当前轮廓线上每个格子是否被覆盖。对于2行我们可以用0/1表示该格子是否已被之前的瓷砖覆盖即是否是“空位”。但本题中我们还需要记录瓷砖的颜色信息因为约束是关于花色的。这意味着状态需要同时编码“是否被覆盖”和“如果被覆盖是什么颜色”。假设有k种颜色原题会给定状态空间会急剧膨胀。简化与转化一个关键的洞察是题目禁止的是“2x2格子花色完全相同”。在只有两行的情况下这个约束等价于不能出现连续两列其同一行的两格瓷砖颜色相同且都是横铺或者同一列的两格瓷砖颜色相同且都是竖铺仔细推敲约束更具体对于任意相邻两列如果它们共同组成一个2x2的区域那么这个区域内的四块瓷砖不能同色。这影响到状态转移时新放置的瓷砖颜色不能与相邻已放置的瓷砖颜色在形成2x2区域时构成全同色。实现策略由于颜色和铺法耦合一个可行的办法是使用DFS搜索来枚举每一列的铺砖方式并结合记忆化搜索Memoization。状态可以定义为(col, row1_status, row2_status, color1, color2...)但这样参数太多。更聪明的方法是将“颜色”也纳入状态编码但只针对当前轮廓线。例如我们可以用三进制数表示状态0表示空位1表示颜色A2表示颜色B。然后进行逐格DFS填充在填充时检查新放入的瓷砖是否会导致与左方、上方的瓷砖形成禁止的2x2同色块。记忆化搜索框架#include iostream #include cstring #include map using namespace std; int n, k; // n列k种颜色 maplong long, long long memo; // 记忆化缓存 // 状态压缩用三进制数表示当前处理位置(i,j)时上一行和当前行已处理部分的状态 // 这里简化起见我们用一个DFS函数参数为当前列索引c当前行索引r以及表示前两行最近格子颜色的状态码。 // 更通用的写法是使用轮廓线DP状态表示当前轮廓线上m个格子的颜色/覆盖情况。 // 由于篇幅和复杂度这里不展开完整代码但给出思路伪代码 long long dfs(int col, int row, int state) { if (col n row 0) { // 所有格子处理完毕 return 1; } if (memo.count(key)) return memo[key]; long long res 0; int current_cell_status get_status(state, pos); // 获取当前位置的状态是否已铺/颜色 if (current_cell_status ! EMPTY) { // 当前位置已被之前的瓷砖覆盖直接跳到下一个位置 res dfs(next_col, next_row, new_state); } else { // 尝试竖铺如果下方还有空间 if (row 0) { // 在第一行可以竖铺 for (int color 0; color k; color) { if (check_color_constraint(state, color, col, row, VERTICAL)) { int new_state update_state(state, col, row, color, VERTICAL); res dfs(col, row1, new_state); // 竖铺占据当前格和下一行同列格 } } } // 尝试横铺如果右方还有空间 if (col n - 1) { for (int color 0; color k; color) { if (check_color_constraint(state, color, col, row, HORIZONTAL)) { int new_state update_state(state, col, row, color, HORIZONTAL); res dfs(col 1, row, new_state); // 横铺占据当前格和同行下一列格 } } } } memo[key] res; return res; }避坑点状态设计是核心如何用一个整数高效地表示当前轮廓线的颜色和覆盖情况是本题最大难点。可能需要使用基于k进制的压缩方式。约束检查的复杂性check_color_constraint函数需要仔细实现。它需要检查如果竖铺新瓷砖的颜色不能与上方如果存在瓷砖颜色相同因为会形成2x1的同色列进而可能与左侧的瓷砖构成2x2同色块。如果横铺新瓷砖的颜色不能与左方瓷砖颜色相同。同时还要检查是否会在2x2区域内造成四同色。这需要根据state还原出相邻格子的颜色。记忆化键值DFS的参数(col, row, state)需要被唯一地编码成一个键值如long long用于记忆化存储。确保状态编码是唯一的。初始化与最终状态初始状态是所有格子为空。最终状态是所有格子都被覆盖并且轮廓线状态可以回归到一个特定的“完成”状态。这道题是状态压缩DP的硬骨头它考验的是将复杂约束转化为可管理状态的能力。在竞赛中如果时间紧张可以考虑先实现一个不加颜色约束的铺砖方案数经典的铺砖问题然后再加入颜色检查和约束这样分步调试会更清晰。2.3 例题三“发现环”的拓扑排序与并查集思维问题简述给定一个n个节点、n条边的连通无向图保证图中有且仅有一个环。要求找出这个环上的所有节点。n个点n条边的连通图就是一棵树加了一条边因此必然存在唯一一个环。这是一个非常经典的结构。解题方法有很多各有利弊。方法一DFS/BFS 父节点记录这是最直观的方法。从任意节点开始进行深度优先搜索DFS同时记录每个节点的访问状态未访问、访问中、已访问和父节点。当遍历时遇到一个已经处于“访问中”状态的邻居节点并且这个邻居不是当前节点的父节点那么就说明找到了环。然后从当前节点和这个邻居节点开始分别沿着父节点指针回溯直到相遇这条路径上的所有节点就是环。#include iostream #include vector #include algorithm using namespace std; vectorint graph[100005]; int vis[100005]; // 0未访问, 1访问中, 2已访问 int parent[100005]; vectorint cycle; int cycle_start, cycle_end; bool dfs(int u, int p) { vis[u] 1; // 标记为访问中 parent[u] p; for (int v : graph[u]) { if (v p) continue; // 忽略父节点 if (vis[v] 0) { if (dfs(v, u)) return true; } else if (vis[v] 1) { // 找到环 cycle_end u; cycle_start v; return true; } } vis[u] 2; // 标记为已访问 return false; } int main() { int n; cin n; for (int i 0; i n; i) { int a, b; cin a b; graph[a].push_back(b); graph[b].push_back(a); } // 初始化 fill(vis, vis n 1, 0); cycle_start cycle_end -1; // 图是连通的从1开始DFS即可 dfs(1, -1); // 重构环 cycle.push_back(cycle_start); for (int v cycle_end; v ! cycle_start; v parent[v]) { cycle.push_back(v); } // 环的顺序可能需要调整题目可能要求按节点编号排序 sort(cycle.begin(), cycle.end()); for (int node : cycle) { cout node ; } cout endl; return 0; }避坑点无向图处理在DFS遍历无向图时必须跳过父节点否则会把父子边误判为环。状态管理使用三种访问状态0,1,2是检测有向图环的经典方法拓扑排序对于无向图同样有效能清晰地区分“回溯时遇到的已访问节点”和“当前路径上遇到的节点”即环。环的存储与输出找到环的起点和终点后通过父节点数组回溯得到环上节点。注意回溯得到的节点顺序可能是反的或者不包含起点需要根据实际情况调整。题目往往要求按节点编号升序输出所以最后需要排序。递归深度n最大可能达到10^5递归DFS可能导致栈溢出。在竞赛环境中可以设置编译栈大小或者改用迭代DFS栈模拟。方法二拓扑排序剥洋葱法因为图中只有一个环所有不在环上的节点都是“叶子”或“链”的一部分。我们可以不断删除度为1的节点类似于拓扑排序最后剩下的节点就是环。计算每个节点的度。将所有度为1的节点加入队列。从队列中取出节点将其从图中“删除”将其邻居的度减1如果邻居的度变为1则加入队列。重复过程直到队列为空。此时所有度仍大于等于2的节点就是环上的节点。 这种方法实现简单不易出错且是线性的时间复杂度。#include iostream #include vector #include queue #include algorithm using namespace std; int main() { int n; cin n; vectorvectorint graph(n 1); vectorint degree(n 1, 0); for (int i 0; i n; i) { int a, b; cin a b; graph[a].push_back(b); graph[b].push_back(a); degree[a]; degree[b]; } queueint q; for (int i 1; i n; i) { if (degree[i] 1) { q.push(i); } } while (!q.empty()) { int u q.front(); q.pop(); for (int v : graph[u]) { degree[v]--; if (degree[v] 1) { q.push(v); } } } vectorint cycle; for (int i 1; i n; i) { if (degree[i] 1) { // 或者 degree[i] 2 因为环上节点在删完后度应该为2 cycle.push_back(i); } } sort(cycle.begin(), cycle.end()); for (int node : cycle) { cout node ; } cout endl; return 0; }拓扑排序法的优势思路非常直观代码简洁不需要处理复杂的递归和父节点回溯也不容易在环的存储上出错。在竞赛中我通常更推荐这种方法因为它更稳健尤其是对于心态紧张的赛场环境。这道题告诉我们对于这种特殊性质的图n点n边连通有比通用找环算法更简单高效的特殊解法。识别题目条件的特殊性并选择最匹配的算法是国赛水平的重要体现。3. 国赛编程的共性技巧与赛场策略通过对以上三道题的分析我们可以提炼出一些应对蓝桥杯国赛编程题尤其是C B组的通用技巧和策略。3.1 审题与建模抓住“不变量”和“特殊性”国赛题目的描述有时会比较冗长但核心约束往往只有一两句。例如“平方十位数”中的“0-9各出现一次”“瓷砖样式”中的“禁止2x2同色”“发现环”中的“n点n边连通”。这些就是题目的“题眼”。列出所有条件在动手前把题目中的每一个条件数据范围、约束、特殊要求写在草稿纸上。比如“10位数”、“完全平方”、“0-9各一次”三个条件缺一不可。寻找简化与转化“发现环”的n点n边条件直接提示了拓扑排序的解法。“平方十位数”的整除9性质提供了剪枝依据。思考题目条件是否对应某个经典的数学模型或算法。确定核心算法是模拟、枚举、贪心、动态规划、搜索、图论还是数论快速归类能帮你缩小思考范围。3.2 实现与调试稳健性高于奇技淫巧在时间有限的赛场代码的稳健性和可调试性比追求极致的优化更重要。数据类型这是C选手最常见的坑。看到10^5以上的数据范围或者涉及乘法、累加第一时间考虑int是否会溢出果断使用long long。对于大规模数组注意全局区和栈区的内存限制必要时使用vector。输入输出蓝桥杯通常使用标准输入输出。对于大量数据10^5级别考虑使用scanf/printf或关闭流同步的cin/cout(ios::sync_with_stdio(false); cin.tie(0);) 来提升效率。但要注意一旦关闭流同步就不要再混用cin/cout和scanf/printf。模块化与测试对于复杂的题目如状态压缩DP不要试图一口气写完整个程序。先写核心的DFS函数和状态转移用小的、已知的测试用例验证。例如“瓷砖样式”可以先假设只有一种颜色即不考虑颜色约束验证铺砖方案数是否正确这是一个经典问题答案可能是斐波那契数列。然后再加入颜色判断逻辑。调试输出在关键步骤如状态转移、循环边界添加条件编译的调试输出在本地验证后可以快速关闭。#define DEBUG #ifdef DEBUG #define LOG(...) printf(__VA_ARGS__) #else #define LOG(...) #endif // 使用时 LOG(Processing col%d, state%d\n, col, state);3.3 时间与空间管理估算与取舍蓝桥杯国赛的题目难度梯度明显时间分配至关重要。时间复杂度估算对于n 20可能是O(2^n)的指数枚举或状压DPn 1000可能是O(n^2)的DP或双重循环n 10^5必须是O(n log n)或O(n)的算法。在编码前心里要对算法的复杂度有数。空间复杂度估算dp[10000][1024]这样的数组约1000010244字节 ≈ 40MB在256MB内存限制下是安全的。但如果开到dp[100000][1024]就危险了。考虑使用滚动数组优化或者用map/unordered_map存储稀疏状态。取舍策略如果一道题思考20分钟仍没有清晰的、可实现的思路先标记去做下一道。确保把所有会做的、能拿分的题目都做完并检查无误。最后再回头啃难题。有时难题的部分分比如暴力枚举小数据也可以通过简单代码获得。4. 从解题到出题逆向思维提升算法设计能力想要在国赛中游刃有余一个高阶的训练方法是尝试“出题人思维”。看完一道题的解答后不妨问自己几个问题这道题的核心考点是什么是考察对特定算法如拓扑排序的理解还是考察将实际问题转化为模型如状态压缩的能力题目的数据范围是如何设计的为什么n最大是1000而不是100000这个范围暗示了应该使用什么复杂度级别的算法如果把范围改大现在的解法还成立吗常见的错误解法有哪些比如“发现环”这题如果不用三种状态标记只用两种已访问/未访问会在哪里出错如果“瓷砖样式”题中忘记检查2x2约束会多算出多少非法方案这道题可以如何变形或加强例如“平方十位数”如果改为“平方十二位数且是0-9和两个重复数字的排列”该如何求解“发现环”如果图不连通且有多个环要求找出所有环又该怎么做通过这种逆向思考你能更深刻地理解题目设计的精妙之处也能在遇到新题时更快地抓住本质。蓝桥杯国赛的题目尤其是B组其美感往往在于用看似简单的条件组合出需要深入思考才能破解的谜题。2017年的这几道题就很好地体现了这一点它们不需要你知道多么冷僻的算法但需要你对基础算法有透彻的理解和灵活的运用能力。最后我个人的建议是在备考后期不要只是泛泛地刷题。像这样挑选几个经典年份的国赛真题进行深度的、慢节奏的复盘分析把每一道题吃透理解其背后的思维链条和陷阱设置比你快速刷完几十套模拟题的效果要好得多。编程竞赛归根结底是思维能力的竞赛而思维的深度正是在这种一次次“为什么是这样”的追问中积累起来的。