尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

蓝桥杯国赛C++算法深度解析:DFS、哈希与BFS实战复盘

蓝桥杯国赛C++算法深度解析:DFS、哈希与BFS实战复盘 1. 项目概述一次对经典赛题的深度复盘最近整理资料翻到了2016年蓝桥杯软件类B组C国赛的几道真题。作为国内覆盖面最广的计算机类学科竞赛之一蓝桥杯的国赛题目一直以其综合性、灵活性和对算法思维的高要求著称。2016年的这套题即便放到今天来看依然充满了值得玩味和学习的点。它不像一些纯算法竞赛那样追求极致的技巧和冷门知识而是更侧重于考察选手在有限时间内对问题建模、算法选择、代码实现以及边界情况处理的综合能力。这对于我们日常的编程思维训练和解决实际工程问题有着非常直接的借鉴意义。这次我挑选了其中几道具有代表性的题目准备进行一次深度的复盘和解析。我的目标不仅仅是给出答案更重要的是拆解每道题背后的思考过程为什么这么建模为什么选这个算法编码时有哪些坑如何优化我希望通过这次分享无论是正在备赛的同学还是希望提升自己C算法能力的开发者都能从中获得一些实实在在的启发和可以“抄作业”的解题框架。2. 核心解题思路与策略总览面对蓝桥杯国赛级别的题目拿到手后直接埋头编码是大忌。一套高效的解题策略往往能事半功倍。根据我的经验可以遵循以下四个步骤第一步问题抽象与模型建立。这是最关键的一步。你需要完全理解题意剥离掉问题描述中可能存在的“故事背景”将其转化为一个清晰的数学模型或计算机模型。例如一个关于路径规划的问题最终可能抽象为图论中的最短路径问题一个关于资源分配的问题可能对应动态规划中的背包模型。这一步做对了方向就对了。第二步算法设计与复杂度评估。根据建立的模型快速在脑海中检索可能的算法。蓝桥杯的题目通常对时间和空间复杂度有隐含要求虽然不像ACM那样明确给出限制但测试数据规模会体现。你需要估算最坏情况下的数据规模并评估候选算法是否能在规定时间内通常是1秒左右完成。一个常见的技巧是对于C在1秒内O(n)算法通常能处理10^7级别数据O(n log n)能处理10^6级别O(n^2)则只能处理10^4级别。这个经验法则在快速筛选算法时非常有用。第三步细节规划与边界确认。在动笔写代码前在草稿纸上规划好核心的数据结构用数组、向量、集合还是映射、关键变量的含义、以及核心算法的伪代码。同时必须花时间思考所有可能的边界情况输入为空怎么办数据溢出怎么办图是否可能不连通数组下标是否可能越界提前想好这些能避免调试阶段的大量返工。第四步编码实现与测试验证。最后才是将思路转化为C代码。编码时应力求清晰、模块化。完成编码后务必用题目给的样例、自己设计的小规模数据包括边界数据进行测试。如果时间允许还可以尝试对拍用暴力算法生成小数据对比结果来确保正确性。注意蓝桥杯的评测系统是OI赛制即“提交后一次性评测”没有实时反馈。这意味着你无法通过多次提交来试探数据特点一次编码的正确性至关重要。因此前三步的思考时间至少应占到总解题时间的一半以上。3. 精选题目深度解析与实现下面我将选取三道2016年国赛B组C的题目按照上述思路进行拆解。为了还原真实的解题思考过程我会先给出题目描述简化版然后逐步展开分析。3.1 题目一方格填数DFS与全排列的应用题目描述简化在一个2行5列的方格矩阵中填入0~9这10个数字每个数字用一次。要求相邻的格子上下左右数字之差不为1。求一共有多少种合法的填数方案。3.1.1 思路拆解与模型建立初看此题是一个典型的“约束满足问题”。我们有10个位置2*510个不同的数字0-9以及一个相邻数字差不为1的约束条件。最直接的暴力方法是生成0-9的所有全排列10! 3,628,800然后依次检查每个排列填入方格后是否满足约束。这个计算量对于计算机来说是完全可以接受的百万级别。因此模型建立为生成所有排列 - 映射到矩阵 - 检查约束。但这里有一个优化点我们是在一个2x5的固定网格里填数相邻关系是固定的。与其生成排列后再映射检查不如在生成排列即深度优先搜索DFS的过程中每填一个数就检查它与已填的、相邻位置上的数是否冲突。这样可以提前剪枝大幅减少搜索量。这就是“回溯法”的核心思想。3.1.2 算法实现与关键代码我们用一个一维数组grid[10]来表示方格下标0-4为第一行5-9为第二行。用一个布尔数组used[10]标记数字0-9的使用情况。#include iostream #include cstring using namespace std; int grid[10]; // 存储填写的数字 bool used[10]; // 标记数字是否已使用 int ans 0; // 方案总数 // 判断将数字num填入位置pos是否合法 bool check(int pos, int num) { // 检查左侧邻居如果不是第一列 if (pos % 5 ! 0) { // 不是每行的第一个 int leftPos pos - 1; if (grid[leftPos] ! -1 abs(grid[leftPos] - num) 1) { return false; } } // 检查上方邻居如果不是第一行 if (pos 5) { int upPos pos - 5; if (grid[upPos] ! -1 abs(grid[upPos] - num) 1) { return false; } } // 注意我们按顺序从左到右、从上到下填所以只需检查左和上。 // 右和下位置的格子还没填无需检查。 return true; } void dfs(int pos) { if (pos 10) { // 所有位置填满 ans; return; } for (int num 0; num 9; num) { if (!used[num] check(pos, num)) { used[num] true; grid[pos] num; dfs(pos 1); // 回溯 used[num] false; grid[pos] -1; } } } int main() { memset(grid, -1, sizeof(grid)); // 初始化为-1表示未填 dfs(0); cout ans endl; return 0; }3.1.3 注意事项与优化心得检查函数的编写这是回溯法的核心。关键在于确定检查的范围。因为我们采用DFS按特定顺序通常是行优先填充当我们填充第pos个位置时其右侧和下侧的格子都还是空的只有左侧和上方的格子可能已经填了数。因此check函数只需检查这两个方向的邻居即可。这能避免重复判断也符合回溯“向前看”的逻辑。初始化与回溯grid数组初始化为-1或任何非0-9的值用于在check中判断邻居位置是否已填。在DFS递归返回后必须记得将used[num]和grid[pos]恢复原状这是回溯的“撤销操作”至关重要。对称性剪枝进阶本题中由于数字0-9完全对称且网格也是对称的理论上存在大量重复方案。例如把整个网格的数字都加1模10处理或者进行旋转、镜像可能得到新的合法解但这些在题目中算作不同方案。如果题目要求本质不同的方案去重则需要更复杂的群论知识来剪枝但本题无需考虑。运行效率上述代码在我的机器上运行时间远小于1秒。它遍历了所有可能性但通过check进行了有效剪枝。你可以尝试输出递归次数会发现它远小于10!。3.2 题目二四平方和哈希映射与空间换时间题目描述简化每个正整数都可以表示为至多4个正整数的平方和。给定一个正整数N (N5,000,000)要求找到字典序最小的一组四个非负整数a, b, c, d满足 a^2 b^2 c^2 d^2 N。字典序最小指先比较aa相同比较b以此类推。3.2.1 思路拆解与模型建立这是一个“多元方程整数解”问题且要求字典序最小解。最无脑的暴力是四重循环枚举a, b, c, d复杂度O(n^2)对于N5e6每个变量的上限大约是sqrt(N)≈2236四重循环是(2236^4)≈2.5e13完全不可行。我们需要优化。一个经典的优化策略是“折半枚举”或称为“中途相遇法”。 基本思想将四个平方和分成两组。第一组枚举a和b计算sum1 a*a b*b。第二组我们需要找到c和d使得c*c d*d N - sum1。如果我们能快速知道对于某个值remain N - sum1是否存在c和d并且能知道字典序最小的c和d是什么问题就解决了。这就自然引出了哈希表unordered_map的使用。步骤1预处理。双重循环枚举c和d0 c d因为题目要求非负整数且为了字典序和去重让c不大于d计算sum2 c*c d*d。用哈希表map存储key为sum2value为对应的c因为cd存c就能通过计算得到d且c是字典序更靠前的部分。如果同一个sum2被多次计算我们只保留c最小的那次以保证最终解的字典序最小。步骤2求解。双重循环枚举a和b同样0 a b计算sum1计算remain N - sum1。查询哈希表中是否存在remain。如果存在则取出对应的c并计算出d sqrt(remain - c*c)。此时(a, b, c, d)就是一个候选解。由于我们按a,b递增顺序枚举并且哈希表中存储的是c最小的组合因此找到的第一个有效解就是全局字典序最小的解。3.2.2 算法实现与关键代码#include iostream #include unordered_map #include cmath using namespace std; int main() { int N; cin N; unordered_mapint, int cache; // key: c^2d^2, value: c // 预处理枚举c和d for (int c 0; c * c N; c) { // 内层循环d可以从c开始保证cd并且能减少枚举量 for (int d c; c * c d * d N; d) { int sum2 c * c d * d; // 如果这个sum2第一次出现或者当前c比已存储的c更小则更新 if (cache.find(sum2) cache.end() || cache[sum2] c) { cache[sum2] c; } } } // 枚举a和b寻找解 for (int a 0; a * a N; a) { for (int b a; a * a b * b N; b) { int sum1 a * a b * b; int remain N - sum1; if (cache.find(remain) ! cache.end()) { int c cache[remain]; int d (int)sqrt(remain - c * c); // 注意转为整型 // 验证一下 d*d 是否确实等于 remain - c*c防止浮点数误差 if (c * c d * d remain) { cout a b c d endl; return 0; // 找到第一个解即返回保证字典序最小 } } } } // 理论上必能找到解 return 0; }3.2.3 注意事项与避坑指南字典序的处理这是本题的易错点。字典序最小要求a b c d吗题目只说是四个非负整数并未要求非递减。但为了找到字典序最小的解我们在枚举时让a b和c d是合理的因为如果a b交换它们得到的解和更小不符合字典序最小。同理在哈希表中对于同一个sum2我们存储c最小的那个组合也是为了配合外层a, b的枚举顺序确保整体字典序最小。哈希表的价值选择为什么只存c因为知道了sum2和c就可以唯一确定dd sqrt(sum2 - c*c)且dc。存c比存一个pair(c,d)更节省空间也便于比较更新只比较c的大小。浮点数与整数转换计算d时使用了sqrt函数其结果是一个浮点数。将其转换为整数后必须验证c*c d*d remain。因为浮点数运算可能存在极小的误差直接转换可能导致d的值差1从而使得等式不成立。这是一个非常重要的防御性编程习惯。复杂度分析预处理的双重循环循环次数约为(sqrt(N))^2 / 2 N/2量级即约250万次。查询部分也是类似的数量级。总复杂度约为O(N)对于N5e6完全可行。这完美体现了“空间换时间”的思想。3.3 题目三棋子换位最小步数问题与BFS题目描述简化在一个2x4的棋盘上摆放着4个白棋和4个黑棋初始状态为WWWWBBBB第一行是4个白棋W第二行是4个黑棋B。棋子可以移动到相邻的空位上下左右或者跳过相邻的一个棋子到空位上像跳棋一样。目标是让所有白棋和黑棋互换位置即变成BBBBWWWW。求最少的移动步数。3.3.1 思路拆解与模型建立这是一个典型的“状态空间搜索”问题求的是初始状态到目标状态的最短路径最少步数。这类问题只要状态空间不是特别大广度优先搜索BFS是标准解法。状态表示棋盘有8个位置每个位置可以是白棋(W)、黑棋(B)或空位(O)。我们可以用一个长度为8的字符串来表示一个状态例如初始状态“WWWWBBBB”这里假设没有空位等等题目描述似乎没有明确提到有空位这是一个巨大的陷阱重新审题“棋子可以移动到相邻的空位”。这说明棋盘上必须至少有一个空位否则棋子无法移动。但题目给出的初始状态WWWWBBBB是满的。这里我怀疑题目原文可能有一个隐含的空位或者初始状态是WWWWOBBBB之类的。为了进行有意义的分析我们假设棋盘是2x48格其中7个是棋子1个是空位。这是一个合理的常见设定。我们假设初始时空位在左上角状态为“OWWWBBBB”O代表空。目标状态为空位可能在任意位置但黑白棋子互换例如“BBBBWWWO”。关键点状态数量。每个格子有3种可能(W, B, O)但棋子总数固定4W4B1O9不对格子只有8个。这说明我的假设可能有问题。更常见的类似题目是“八数码”的变种棋盘有8个格子7个棋子3白3黑1空。鉴于原题描述可能不完整我们调整为一个经典模型来分析假设是2x3棋盘有2白2黑1空共5个棋子符合移动条件。但这与“4白4黑”矛盾。为了不陷入对缺失条件的纠结我们将问题抽象为一个通用模型在一个MxN的网格上有若干棋子和一个空位棋子可以移动到相邻空位或隔子跳向空位。给定初始和终态求最少移动步数。3.3.2 算法实现与关键代码基于通用模型我们以更经典的“跳棋”式移动为例移动规则是一个棋子可以移动到相邻空位或者跳过相邻的一个棋子无论颜色落到空位上。BFS需要解决以下几个问题状态表示使用字符串如“WBO OWB”包含空格表示空位。状态转移给定一个状态找到空位索引然后生成所有可能的下一步状态。相邻移动检查空位上下左右四个方向如果有棋子则可以将该棋子移动到空位生成新状态。跳跃移动检查空位隔一个格子的位置即两个格子之外。如果路径是“棋子-棋子-空位”且中间那个棋子是任意颜色则第一个棋子可以跳过中间棋子落到空位。这需要检查方向上的连续两个格子。判重使用unordered_setstring来记录已访问过的状态避免重复搜索。终止条件当前状态等于目标状态。#include iostream #include queue #include unordered_set #include string #include vector using namespace std; // 假设棋盘是1x8的字符串简化为一维下标0-7。 // 实际2x4的话需要处理二维坐标与一维下标的转换。 // 这里以1维8格初始状态“OWWWBBBB”目标“BBBBWWWO”为例。 string start OWWWBBBB; string target BBBBWWWO; int empty_pos; // 方向数组左右上下在一维数组中需要根据棋盘布局定义邻居关系 // 对于1x8只有左右有效。对于2x4需要定义二维邻居。 // 此处以2x4为例定义方向0:左, 1:右, 2:上, 3:下 int dirs[4][2] {{0, -1}, {0, 1}, {-1, 0}, {1, 0}}; // 将一维下标转换为二维坐标 (row, col) pairint, int idxToCoord(int idx) { return {idx / 4, idx % 4}; // 2行4列 } int coordToIdx(int r, int c) { return r * 4 c; } // 获取从状态s出发可以到达的所有下一个状态 vectorstring getNextStates(const string s) { vectorstring nextStates; int emptyIdx s.find(O); auto [er, ec] idxToCoord(emptyIdx); // 1. 相邻移动 for (auto d : dirs) { int nr er d[0]; int nc ec d[1]; if (nr 0 nr 2 nc 0 nc 4) { int neighborIdx coordToIdx(nr, nc); string next s; swap(next[emptyIdx], next[neighborIdx]); // 棋子和空位交换 nextStates.push_back(next); } } // 2. 跳跃移动跳过一颗棋子 for (auto d : dirs) { int jumpr er d[0]; int jumpc ec d[1]; int landr er 2 * d[0]; int landc ec 2 * d[1]; // 检查跳过的位置是否有棋子且落点是否在棋盘内且为空实际上落点就是当前空位这个逻辑需要调整 // 正确的跳跃逻辑棋子从 (landr, landc) 跳过 (jumpR, jumpC) 落到 (er, ec)。 // 所以需要检查 (landr, landc) 是否有棋子(jumpR, jumpC) 是否有棋子。 if (landr 0 landr 2 landc 0 landc 4) { int jumpIdx coordToIdx(jumpr, jumpc); int landIdx coordToIdx(landr, landc); if (jumpIdx 0 jumpIdx 8 landIdx 0 landIdx 8) { if (s[jumpIdx] ! O s[landIdx] ! O) { // 跳过的位置和起跳位置都不是空位 string next s; // 将 landIdx 的棋子移动到 emptyIdx swap(next[emptyIdx], next[landIdx]); nextStates.push_back(next); } } } } return nextStates; } int bfs() { if (start target) return 0; queuepairstring, int q; // 状态步数 unordered_setstring visited; q.push({start, 0}); visited.insert(start); while (!q.empty()) { auto [curState, steps] q.front(); q.pop(); vectorstring nexts getNextStates(curState); for (string ns : nexts) { if (ns target) { return steps 1; } if (visited.find(ns) visited.end()) { visited.insert(ns); q.push({ns, steps 1}); } } } return -1; // 无解 } int main() { int ans bfs(); if (ans ! -1) { cout Minimum steps: ans endl; } else { cout No solution found! endl; } return 0; }3.3.3 注意事项与排查技巧状态表示的选择字符串非常直观且便于使用哈希表判重。如果状态更复杂可以考虑压缩为一个整数状态压缩但字符串对于本题规模足够。移动规则的准确实现这是本题最易错的地方。必须清晰地区分“移动到相邻空位”和“跳过相邻棋子到空位”这两种操作。在代码中我分别用两个循环实现。特别注意跳跃的逻辑跳跃是“起跳点”的棋子跳过“中间点”的棋子落到“空位点”。在代码中我检查了“落点”是否有棋子应有“中间点”是否有棋子应有然后进行交换。这个逻辑需要根据题目描述仔细推敲最好画图验证。BFS的判重至关重要状态空间可能很大但很多状态是重复的。不使用判重队列可能会无限膨胀导致程序内存溢出或超时。unordered_set的查找和插入平均是O(1)效率很高。二维与一维坐标转换对于网格类问题在代码中统一使用一维索引操作字符串是方便的但在计算邻居时需要用到二维坐标。编写清晰的转换函数能减少错误。关于原题条件的说明由于我手头没有2016年国赛题目的完整准确描述上述实现是基于常见“棋子换位”或“跳棋”问题的通用BFS解法。如果原题规则或棋盘布局不同调整start,target, 棋盘行列数以及getNextStates函数中的规则即可。核心的BFS框架是完全通用的。4. 国赛备赛与实战经验总结通过对以上三道题目的拆解我们可以提炼出一些应对蓝桥杯国赛乃至类似算法竞赛的通用经验。4.1 时间分配与答题策略国赛通常时长4小时题量在6-10道左右。合理的策略是前1小时快速通读所有题目按理解难度和估计编码复杂度进行分类。优先解决描述清晰、思路明显的“签到题”或“套路题”如简单的模拟、排序、日期处理确保基础分到手。像“方格填数”这种DFS回溯题如果熟悉也应尽快解决。中间2小时主攻中等难度、需要一定算法设计的题目如“四平方和”。这类题目通常需要你灵活运用基础算法二分、哈希、简单DP、BFS/DFS。此时需要沉下心来分析在草稿纸上完成思路设计再编码。最后1小时挑战难题并检查。对于“棋子换位”这类状态搜索题如果之前有准备可以尝试否则应优先回头检查已做题目的正确性特别是边界条件和输入输出格式。确保每道已做题都能通过样例和自测数据。4.2 常见失分点与避坑指南整数溢出这是C选手的老大难问题。当涉及乘法特别是平方、累加时务必警惕。int的范围大约是±21亿对于a*a当a46340时就会溢出。对于题目中的N5e6sqrt(N)≈2236其平方在int范围内但若数据更大则需使用long long。一个安全习惯是当不确定时对参与大规模计算的变量直接使用long long。浮点数精度如“四平方和”中求sqrt后再转整数必须验证。比较浮点数是否相等时应使用fabs(a-b) 1e-6而非ab。多组输入数据蓝桥杯题目有时会说明“包含多组测试数据”但有时不说明。一个稳健的做法是使用while(cin N)或while(scanf(“%d”, N) ! EOF)来读取输入直到文件结束。这能避免因误判输入格式而导致的答案错误。输出格式严格遵循题目要求注意空格、换行、大小写。特别是最后一行是否需要有换行通常评测系统对此要求严格。递归深度与栈溢出DFS回溯时如果递归深度过深如超过1万层可能会导致栈溢出。在C中可以通过编译选项-Wl,--stack,size来扩大栈空间或者尝试将递归改为迭代非递归。4.3 工具与调试技巧本地环境使用你熟悉的IDE如VS Code、CLion、Dev-C。配置好基本的代码模板包含常用的头文件和快速输入输出ios::sync_with_stdio(false); cin.tie(0);。调试对于DFS/BFS可以输出关键节点的状态或递归深度帮助理解程序流程。对于复杂逻辑使用assert断言来检查中间结果是否符合预期。对拍对于不确定的题目可以写一个保证正确但效率低的暴力算法如枚举所有可能用其生成小规模随机数据与你的优化算法对比结果。这是确保正确性的终极武器。利用返回值蓝桥杯的填空题通常需要直接输出答案。你可以在本地运行程序得到答案后直接提交输出。对于编程题确保你的main函数返回0。4.4 从解题到提升如何利用真题做完题目不是终点。更高阶的学习方法是一题多解尝试用不同的方法解决同一道题。例如“四平方和”除了哈希法能否用二分查找复杂度如何举一反三识别题目类型。例如“方格填数”是约束回溯“四平方和”是哈希优化枚举“棋子换位”是BFS状态搜索。建立自己的“算法-问题”映射库。总结模板将BFS框架、DFS回溯框架、二分查找、快速幂等常用算法写成自己最熟悉的模板代码并理解其每一个细节。模拟赛场定期进行4小时的限时训练使用往届真题营造真实比赛压力锻炼心态和时间管理能力。国赛的题目往往在基础算法之上增加一层巧妙的变形或组合。它考察的不仅是知识储备更是临场的问题分析、转化和解决能力。希望这次对2016年部分题目的深度复盘能为你提供一份清晰的解题地图和实用的备战指南。记住扎实的基础 清晰的思路 细致的编码 稳定的心态是通往高分的不二法门。
返回列表