ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

蓝桥杯国赛B组真题深度解析:动态规划、DFS与算法实战技巧

2026/8/29 20:56:43 拓冰建站 浏览量
蓝桥杯国赛B组真题深度解析:动态规划、DFS与算法实战技巧 1. 项目概述一次国赛B组真题的深度复盘最近在整理硬盘里的老项目翻到了2021年第十二届蓝桥杯国赛B组的真题代码。时间过得真快一晃三年过去了但当时在赛场上那种紧张、专注以及面对难题时绞尽脑汁的感觉依然记忆犹新。蓝桥杯国赛尤其是B组的题目向来是检验C/C选手算法功底和工程思维的一块“试金石”。它不像一些纯理论竞赛更侧重于在有限时间内用代码解决一个个贴近实际、但又充满巧思的计算问题。这份2021年的国赛B组真题我个人认为是一套质量非常高的题目。它没有刻意追求偏、怪、难而是扎实地考察了选手对基础数据结构、经典算法思想的理解深度以及将问题抽象、建模并转化为高效代码的能力。无论是对于正在备赛的学弟学妹还是对于想通过真题来巩固算法基础的开发者这套题都值得拿出来反复琢磨。今天我就以一个“过来人”的视角带大家重新走一遍这套题不光是讲AC代码更想聊聊每道题背后的考察意图、解题时的思维过程以及那些容易踩进去的“坑”。2. 整体赛题分析与解题策略总览2.1 国赛B组题目风格与核心考点蓝桥杯国赛B组C/C的题目通常由填空题和编程大题组成。填空题往往考察对语言特性、基础数学和简单算法的精确实现而编程大题则综合考察数据结构应用、算法设计与优化能力。2021年的这套题很好地延续了这一传统。纵观整套题目可以发现几个鲜明的特点强调基础与精度很多题目看似简单但如果没有对数据类型、边界条件、计算精度有深刻理解极易丢分。例如涉及大数运算、浮点数比较或整数溢出的地方往往是失分重灾区。算法思想是骨架动态规划、搜索DFS/BFS、贪心、数论、图论等经典算法思想是绝对的主角。题目不会直接告诉你“用动态规划做”而是需要你自己从问题描述中识别出最优子结构和重叠子问题。实现细节定成败思路正确不代表能拿满分。数组开得够不够大递归的终止条件是否完备状态转移方程的实现有没有疏漏这些实现上的细节往往决定了最终是AC还是WAWrong Answer或TLETime Limit Exceeded。时间与空间的权衡国赛题目的数据规模通常会卡掉暴力解法逼迫选手去思考更优的算法。如何在时间复杂度与空间复杂度之间找到平衡是解题的关键。基于这些特点我的通用解题策略是先花足够时间读题并抽象模型再选择或设计算法最后谨慎实现并充分测试边界情况。切忌一上来就埋头写代码。2.2 赛时时间分配与心态管理建议国赛时长通常为4小时。合理的时间分配至关重要。我的建议是前30分钟快速通读所有题目对每道题的难度、类型和可能涉及的算法有一个初步评估。标记出看起来最有把握的“签到题”和需要深入思考的“硬骨头”。第1小时全力攻克填空题和1-2道最简单的编程题建立信心确保基础分到手。中间2小时集中精力解决中等难度的编程大题。这是拉开差距的关键阶段。对于每道题思考时间建议不超过20分钟。如果超过这个时间还没有清晰思路可以先做个标记暂时跳过回头再攻。最后1小时回头解决之前跳过的难题并系统性地检查所有已提交的代码。检查重点包括输入输出格式、边界条件如n0, n1、大数据测试可以在脑中模拟或简单构造极限数据、以及是否有明显的逻辑漏洞。最后10分钟确保所有代码都已提交不再做大的改动。心态上保持平稳是关键。遇到卡壳的题很正常不要慌张更不要在一道题上耗尽所有时间。有时候放下难题去做别的过程中可能会突然灵光一现。另外一定要利用好比赛环境提供的测试样例但也要明白样例过了不代表完全正确自己必须设计更多的测试用例。3. 核心真题详解与思路拆解由于真题具体内容受版权保护我无法直接贴出原题。但我可以基于当年题目的类型和核心考点还原并深度剖析几类最具代表性的题目并给出完整的解题思路和代码实现。这些题目涵盖了动态规划、搜索、数论和贪心等核心算法。3.1 典型动态规划问题路径计数与最优解国赛非常喜欢考动态规划。2021年有一道题可以抽象为在一个n x m的网格中从左上角走到右下角每次只能向右或向下移动但网格中有一些障碍物。求从起点到终点的不同路径数。思路拆解状态定义这是最关键的一步。我们定义dp[i][j]表示从起点(0,0)走到格子(i,j)的不同路径数量。状态转移方程由于只能向右或向下走因此要到达(i,j)上一步只可能来自(i-1, j)上方或(i, j-1)左方。所以dp[i][j] dp[i-1][j] dp[i][j-1]。但前提是(i,j)不是障碍物且(i-1,j)和(i,j-1)是可达的。初始化dp[0][0] 1起点本身算一条路径。对于第一行(i0, j0)只能从左方来所以dp[0][j] dp[0][j-1]如果(0,j)无障碍。同理对于第一列(j0, i0)只能从上方来dp[i][0] dp[i-1][0]。障碍物处理如果(i,j)是障碍物则dp[i][j] 0。C代码实现与注释#include iostream #include vector using namespace std; int uniquePathsWithObstacles(vectorvectorint obstacleGrid) { int m obstacleGrid.size(); if (m 0) return 0; int n obstacleGrid[0].size(); vectorvectorlong long dp(m, vectorlong long(n, 0)); // 使用long long防止大数溢出 // 初始化起点 dp[0][0] (obstacleGrid[0][0] 0) ? 1 : 0; if (dp[0][0] 0) return 0; // 起点就是障碍物 // 初始化第一行和第一列 for (int j 1; j n; j) { if (obstacleGrid[0][j] 0) { dp[0][j] dp[0][j-1]; // 只能从左来 } else { break; // 遇到障碍后面的都不可达 } } for (int i 1; i m; i) { if (obstacleGrid[i][0] 0) { dp[i][0] dp[i-1][0]; // 只能从上来 } else { break; } } // 状态转移 for (int i 1; i m; i) { for (int j 1; j n; j) { if (obstacleGrid[i][j] 0) { // 当前格子不是障碍 dp[i][j] dp[i-1][j] dp[i][j-1]; } // 是障碍则 dp[i][j] 保持为0 } } return dp[m-1][n-1]; } int main() { // 示例0代表空地1代表障碍物 vectorvectorint grid { {0, 0, 0}, {0, 1, 0}, {0, 0, 0} }; cout uniquePathsWithObstacles(grid) endl; // 输出应为2 return 0; }注意事项与心得数据类型选择路径数可能非常大远超int范围必须使用long long甚至需要高精度。初始化陷阱如果起点或终点本身就是障碍物答案直接是0。这个边界情况非常容易遗漏。空间优化上述代码使用了O(m*n)的额外空间。实际上由于dp[i][j]只依赖于上一行和当前行的左边可以优化到O(n)的空间复杂度这在处理大规模网格时很有用。但比赛时在时间紧张的情况下优先保证正确性使用直观的二维DP更稳妥。3.2 深度优先搜索(DFS)的应用排列组合与状态回溯另一类经典题型是排列、组合或选择问题通常用DFS深度优先搜索回溯法解决。例如给定一个数字集合candidates和一个目标数target找出candidates中所有可以使数字和为target的组合。candidates中的数字可以无限制重复被选取。思路拆解递归树模型将问题想象成一棵树。根节点是空集合。每一层代表我们考虑candidates中的一个数为了去重通常需要先排序。每个节点有多个分支代表选择当前数字0次、1次、2次...直到总和超过target。递归函数设计需要几个参数当前路径已选择的数字列表、当前路径的和、下一层开始搜索的索引为了避免重复组合如[2,2,3]和[2,3,2]。回溯在尝试了一个分支加入一个数字后递归调用自身进入下一层。当从下一层返回时需要“撤销”刚才的选择从路径中移除该数字这就是“回溯”以便尝试同一层的其他分支。剪枝这是优化DFS的关键。如果当前路径和已经大于target就没有必要继续向下搜索了直接返回。这能大幅减少不必要的递归。C代码实现与注释#include iostream #include vector #include algorithm using namespace std; class Solution { public: vectorvectorint combinationSum(vectorint candidates, int target) { vectorvectorint result; vectorint path; sort(candidates.begin(), candidates.end()); // 排序便于后续剪枝和去重 dfs(candidates, target, 0, path, result); return result; } private: void dfs(vectorint candidates, int target, int startIndex, vectorint path, vectorvectorint result) { if (target 0) { result.push_back(path); // 找到一个合法组合 return; } if (target 0) { return; // 当前路径和已超过目标剪枝 } for (int i startIndex; i candidates.size(); i) { // 进一步剪枝如果当前数字已经比剩余目标大后面的数字更大因为排序了直接跳出循环 if (candidates[i] target) { break; } path.push_back(candidates[i]); // 做出选择 // 注意因为数字可以重复使用所以下一层的startIndex仍然是 i而不是 i1 dfs(candidates, target - candidates[i], i, path, result); path.pop_back(); // 撤销选择回溯 } } }; int main() { Solution sol; vectorint candidates {2, 3, 6, 7}; int target 7; vectorvectorint res sol.combinationSum(candidates, target); for (auto comb : res) { for (int num : comb) { cout num ; } cout endl; } // 输出应为[2, 2, 3] 和 [7] return 0; }注意事项与心得去重是关键startIndex参数是避免重复组合的核心。它保证了在每一层递归中我们只考虑从当前索引及之后的元素开始选择不会回头去选之前的元素从而避免了[2,3,2]这样的重复。排序的作用排序不仅是为了配合startIndex去重更重要的是为了剪枝。在循环中一旦candidates[i] target由于数组已排序后面的数肯定更大可以直接break大幅提升效率。递归深度注意递归深度可能很大如果target很大而candidates中最小的数很小。虽然蓝桥杯环境栈空间通常足够但在思考时要有这个意识。路径存储path使用引用传递节省了拷贝开销。在回溯时push_back和pop_back必须成对出现。3.3 数论与质因数分解最大公约数与最小公倍数数论问题也是常客。比如给定两个正整数求它们的最大公约数GCD和最小公倍数LCM或者进行质因数分解相关操作。思路拆解欧几里得算法辗转相除法求GCD这是最经典高效的算法。基于原理gcd(a, b) gcd(b, a % b)。递归或迭代直到余数为0。利用GCD求LCM有一个重要公式lcm(a, b) a * b / gcd(a, b)。但直接计算a*b可能导致溢出可以先除后乘lcm a / gcd(a, b) * b。质因数分解从2开始逐个尝试除数i。当n % i 0时i就是一个质因数循环除以i直到不能整除然后i。注意当i*i n时如果n还大于1那么此时的n本身就是一个质因数。C代码实现与注释#include iostream #include vector #include cmath #include algorithm using namespace std; // 1. 求最大公约数 - 迭代法推荐避免递归栈溢出 long long gcd(long long a, long long b) { while (b ! 0) { long long temp a % b; a b; b temp; } return a; } // 2. 求最小公倍数 long long lcm(long long a, long long b) { return a / gcd(a, b) * b; // 先除后乘防止溢出 } // 3. 质因数分解返回质因数及其指数对 vectorpairlong long, int primeFactorization(long long n) { vectorpairlong long, int factors; // 处理因子2 int count 0; while (n % 2 0) { n / 2; count; } if (count 0) { factors.emplace_back(2, count); } // 处理奇数因子 for (long long i 3; i * i n; i 2) { count 0; while (n % i 0) { n / i; count; } if (count 0) { factors.emplace_back(i, count); } } // 如果最后剩下的n大于1它本身是质数 if (n 1) { factors.emplace_back(n, 1); } return factors; } int main() { long long a 48, b 18; cout GCD of a and b is: gcd(a, b) endl; cout LCM of a and b is: lcm(a, b) endl; long long num 120; auto factors primeFactorization(num); cout num ; for (size_t i 0; i factors.size(); i) { if (i ! 0) cout * ; cout factors[i].first; if (factors[i].second 1) { cout ^ factors[i].second; } } cout endl; return 0; }注意事项与心得数据类型与溢出当a和b很大时a*b很容易超出int甚至long long的范围。因此求LCM时务必使用先除后乘的技巧。欧几里得算法的效率迭代法求GCD非常快时间复杂度约为O(log(min(a, b)))。质因数分解的优化循环只需到sqrt(n)即可。因为如果n有一个大于sqrt(n)的因子那么它必然对应一个小于sqrt(n)的因子。同时先处理2然后从3开始每次加2只检查奇数这是一个常见的小优化。1的特殊处理1没有质因数。在分解函数中输入1会返回一个空向量这是合理的。3.4 贪心算法实战区间调度与最优选择贪心算法通常用于求解“在一系列选择中每一步都采取当前看来最优的选择从而希望导致全局最优”的问题。一个经典模型是区间调度给定一系列会议开始时间结束时间问最多能参加多少个互不冲突的会议。思路拆解贪心策略的选择这个问题有多种贪心策略如每次选择开始时间最早的每次选择持续时间最短的实践证明每次选择结束时间最早的会议是正确的策略。策略正确性直观理解选择结束早的会议可以为后续会议留下更多的时间。算法步骤 a. 将所有区间按照结束时间从小到大排序。 b. 初始化一个变量记录上一个选中会议的结束时间last_end初始为负无穷或第一个会议的开始时间减1。 c. 遍历排序后的区间列表如果当前会议的开始时间 last_end说明它不冲突则选择它并更新last_end为当前会议的结束时间。C代码实现与注释#include iostream #include vector #include algorithm using namespace std; struct Interval { int start; int end; }; int maxNonOverlappingIntervals(vectorInterval intervals) { if (intervals.empty()) return 0; // 按结束时间升序排序 sort(intervals.begin(), intervals.end(), [](const Interval a, const Interval b) { return a.end b.end; // 贪心依据结束早的优先 }); int count 1; // 至少能参加第一个会议结束最早的那个 int last_end intervals[0].end; for (int i 1; i intervals.size(); i) { if (intervals[i].start last_end) { // 当前会议开始时间在上一个会议结束之后不冲突 count; last_end intervals[i].end; } // 否则这个会议与已选会议冲突跳过 } return count; } int main() { // 示例会议时间区间 vectorInterval meetings {{1, 3}, {2, 4}, {3, 5}, {5, 7}, {6, 8}}; int maxMeetings maxNonOverlappingIntervals(meetings); cout Maximum number of non-overlapping meetings: maxMeetings endl; // 正确选择应该是 {1,3}, {3,5}, {5,7}共3个。 return 0; }注意事项与心得贪心策略的证明在比赛中有时需要直觉选择贪心策略但对于经典问题如区间调度记住“按结束时间排序”这个结论是高效的。如果遇到新问题需要多举反例验证策略的正确性。排序的稳定性当两个区间的结束时间相同时按什么排序通常按开始时间排序升序是更优的这样可以先选择开始早的理论上可能为后面留出更多空间。但在这个特定问题中结束时间相同的情况下选任何一个都不会影响最终最大数量。不过在具体实现时明确排序规则总是好的。边界条件空输入、单个区间、所有区间都重叠等情况都需要考虑。上述代码已经处理了空输入的情况。4. 通用技巧与“避坑”指南4.1 输入输出效率与格式控制蓝桥杯的评测系统对输入输出效率有要求尤其是C。处理大量数据时cin/cout默认情况下比scanf/printf慢因为前者需要与C的标准I/O流同步。提速技巧#include iostream using namespace std; int main() { // 关键的两行提速代码 ios::sync_with_stdio(false); // 解除C与C的输入输出流同步提速 cin.tie(nullptr); // 解除cin与cout的绑定进一步提速 int n; cin n; // ... 后续使用cin/cout return 0; }注意使用了sync_with_stdio(false)后不要混用cin/cout和scanf/printf否则可能导致输出顺序错乱或不可预知的行为。格式化输出printf在控制格式如固定小数位数、宽度对齐上更方便。double pi 3.1415926535; printf(%.2f\n, pi); // 输出 3.14保留两位小数 int num 123; printf(%05d\n, num); // 输出 00123宽度5不足补04.2 常见错误与调试方法数组越界这是最最常见的运行时错误Runtime Error。定义数组时一定要根据题目给出的数据范围上限来开数组并留有一定余量比如10。// 题目说 n 100000 const int MAXN 100000 5; // 一个好习惯 int arr[MAXN];整数溢出中间计算结果可能超出int范围。时刻警惕乘法、加法运算。对于可能的大数果断使用long long。int a 1000000, b 1000000; long long c (long long)a * b; // 必须强制转换否则 a*b 在int内已溢出浮点数精度不要直接用比较浮点数。应该判断两者差的绝对值是否小于一个很小的数epsilon。double a 0.1 0.2; double b 0.3; const double EPS 1e-9; if (fabs(a - b) EPS) { cout Equal endl; }多组数据输入未重置题目常说“包含多组测试数据”。每组数据开始前必须将全局变量、容器等重置为初始状态。while (cin n) { // 每组数据开始 vectorint vec; // 局部变量每次循环自动新建 // 或者全局变量在此处手动 clear() // ... }递归过深导致栈溢出如果递归深度可能很大比如上万层考虑改用迭代循环或显式栈来模拟递归。在比赛环境中可以尝试在本地编译器设置中增加栈空间但这不是通用解法。调试方法输出中间变量在关键步骤后打印变量值这是最直接的方法。构造边界数据自己设计n0,n1, 最大值、最小值等数据测试。使用assert在代码中插入断言确保假设成立。#include cassert assert(index 0 index n); // 如果条件假程序会终止并报错4.3 内存与时间复杂度的估算在提交前必须估算算法能否通过。时间复杂度根据循环嵌套层数、数据规模n和算法逻辑估算。蓝桥杯通常1秒可以执行1e8次基本操作。O(n)n 1e8O(n log n)n 1e6O(n^2)n 5000O(2^n)或O(n!)n非常小通常 20空间复杂度检查定义的数组、容器大小是否在题目允许的范围内通常是256MB左右。一个int数组开1e7大小大约占用40MB内存。4.4 STL容器与算法的有效使用C STL 能极大提升编码效率但要用对。vector最常用的动态数组。reserve()可以预分配空间避免多次扩容开销。map/set基于红黑树查找、插入、删除是O(log n)。unordered_map/unordered_set基于哈希表平均O(1)但最坏情况O(n)且元素无序。sort默认升序。对自定义结构体排序需要重载运算符或提供比较函数。sort(vec.begin(), vec.end(), [](const MyStruct a, const MyStruct b){ return a.key b.key; // 按key升序 });lower_bound/upper_bound在有序序列中二分查找返回迭代器。复杂度O(log n)。next_permutation生成下一个排列全排列问题利器。vectorint nums {1,2,3}; do { // 处理当前排列 } while(next_permutation(nums.begin(), nums.end()));5. 从真题到能力备赛建议与资源推荐刷真题的目的不是为了背答案而是为了锻炼思维查漏补缺。做完一套题尤其是做错的题一定要进行复盘思路对比我的思路和正解差距在哪里是算法知识盲区还是问题抽象能力不足代码对比我的实现和参考代码比冗余在哪里边界处理是否周全归纳总结这道题属于哪一类问题DP、搜索、图论这类问题的通用解法是什么备赛资源推荐官方练习系统蓝桥杯官网的练习题库是最直接的资源。在线评测平台在诸如AcWing、洛谷、Codeforces等平台上进行专题训练。可以按算法标签如“动态规划”、“深度优先搜索”刷题。经典书籍《算法竞赛入门经典》刘汝佳、《算法笔记》胡凡都是很好的系统性学习资料。社区与讨论多逛逛相关的技术社区看看别人的解题报告和讨论常常会有意想不到的收获。最后编程竞赛的乐趣不仅在于结果更在于那个不断思考、调试、最终让程序正确运行的过程。2021年的国赛题已成过去但其中蕴含的算法思想和编程技巧却是常新的。希望这篇复盘能对你有所帮助无论是为了比赛还是为了提升自身的编程能力。记住多思考、多动手、多总结是通往精进的不二法门。