YAOTU INSIGHTS

蓝桥杯BFS全攻略:从最短路径到多源搜索与状态压缩实战

蓝桥杯BFS全攻略:从最短路径到多源搜索与状态压缩实战
说实话每年蓝桥杯备赛群里问得最多的算法不是啥高深的DP也不是玄学图论而是BFS广度优先搜索。这玩意儿看着代码短、思路直白但真上了赛场各种变形题一出不少选手就懵在原地了——第一次碰迷宫还行第二次碰到状态压缩BFS就不知道队列里该装啥了。这篇就站在备赛视角把BFS从模板到实战拆透了讲你跟着过一遍拿捏蓝桥杯里的搜索题会稳得多。很多同学学BFS是一看就懂、一写就废根本原因在于只背了模板没搞清楚BFS解决问题的底层逻辑。1. 蓝桥杯为什么偏爱BFS1.1 蓝桥杯搜索题的分布规律参赛多了就发现蓝桥杯的题目风格有个明显倾向不太刻意考偏难怪的算法更看重你写出来的程序是否稳妥可靠。而BFS恰好是“稳妥”的代表——本质上就是按层推进的穷举保证在无权图上拿到最短路径。它不像DFS那样一条道走到黑也不需要像DFS那样天天担心递归栈溢出。我翻过历年的真题和模拟题搜索类题目在省赛里的出现频率一直很稳定。简单粗暴从题目描述辨认看到“最少步数”“最短路径”“能不能到达”“最小操作次数”这类字眼第一反应就应该是BFS。少数情况DFS也能解但求最短步数时BFS的广搜天然优势就是首轮命中即最优省掉额外的比较逻辑。另外蓝桥杯的判题策略有个特性部分测试点数据量不大暴力搜索能过但设计精巧的搜索能全过。BFS就是那种“题面听着唬人、代码写着顺手、改改就能拿分”的万能选手。1.2 迷宫话题从LeetCode到蓝桥杯的差距很多人在普通刷题平台练过“迷宫最短路径”觉得不就是queue加visited数组么但蓝桥杯的迷宫题往往藏了两个额外难点多起点、多终点不是只从一个点出发而是有多个入口有些甚至要求多个起点同时向外扩散。障碍物和状态切换有些迷宫里有传送门、门锁钥匙、可变地形路径选择不再只看坐标还要看当前持有的状态。从做题经验看蓝桥杯的搜索题更贴近“现实中的导航问题”不能只会裸BFS还得掌握多源BFS和状态BFS这两件套。这两件套在省赛和国赛里的频率都不低懂了这个比赛时就不会被困在“明明会BFS但题读不懂”的尴尬里。2. BFS核心原理与模板打磨2.1 队列、标记与层数BFS的三件套BFS的核心思想用一句话概括从起点出发先把离起点距离为1的所有点走完再走距离为2的所有点一层层往外扩。就像在水面上扔下一块石头波纹一圈圈往外扩散每一圈就是一层。实现这个思想需要三样东西队列用来存储当前层的节点以及下一层将要访问的节点。标记数组记录哪些点已经被访问过防止重复入队避免死循环。步数记录可以跟着队列节点一起存也可以单独用距离数组记录。这里有个新手常踩的坑认为BFS必须要用递归。实际上BFS几乎都是用循环加队列实现的根本不需要递归也就不存在递归层数过深的问题。对于标记数组我用的是最习惯的方式二维数组vis[x][y]表示坐标是否访问过。如果网格较大可以换成visited单维数组做索引映射。关于用布尔值还是整型存步数我的建议是直接用整型数组初始化 -1一步到位省得后面还要再开一个距离数组。2.2 手写队列还是STL deque有经验的选手都知道BFS的代码差异最大的地方就是队列怎么实现。我见过不少用queueint存节点编号的也见过用queuepairint,int存坐标的甚至见过用结构体把坐标、步数、状态全塞进去的。就蓝桥杯而言我个人的实践体会是简单坐标BFS直接queuepairint,int代码好写、思路清楚。带有状态或额外信息的定义结构体成员放坐标、步数、钥匙状态等比用pair层层嵌套好读一万倍。追求极限性能手写数组模拟队列用头尾指针控制出队入队。这在数据量极大时有效但比赛里99%的情况用STL队列完全够用不要为了炫技浪费时间。队列选择这个事还牵扯到一个关键点层数的计数方式。如果用结构体存步数每入队一个新节点步数 当前节点步数 1。如果用分层遍历每轮循环处理一层就需要在队列里记录当前层的长度。两招都行但结构体存步数是最不易写错的推荐新手优先其实队列选择这个事没有绝对标准核心是你自己写得顺、不容易出bug。我之前见过一个同学为了“性能最优”手写队列结果头尾指针更新条件写错了调了四十分钟心态直接崩了。蓝桥杯比拼的是在规定时间内拿分稳定压倒一切。2.3 一个通用可复用的BFS模板这里给出一份我常用的BFS模板基于坐标型问题。你完全可以把它当作蓝桥杯搜索题的“通用底盘”遇到具体题改改状态定义和转移逻辑就行。struct Node { int x, y, step; // 坐标和当前步数 // 如果有额外状态比如钥匙、方向、血量继续加字段 }; int bfs(vectorvectorint grid, pairint,int start, pairint,int target) { int n grid.size(), m grid[0].size(); vectorvectorint vis(n, vectorint(m, -1)); // -1表示未访问 queueNode q; q.push({start.first, start.second, 0}); vis[start.first][start.second] 0; // 标记并记录步数 // 四个方向上、下、左、右 int dx[4] {-1, 1, 0, 0}; int dy[4] {0, 0, -1, 1}; while (!q.empty()) { Node cur q.front(); q.pop(); if (cur.x target.first cur.y target.second) { return cur.step; } for (int i 0; i 4; i) { int nx cur.x dx[i]; int ny cur.y dy[i]; // 边界检查和可通行判断 if (nx 0 || nx n || ny 0 || ny m) continue; if (vis[nx][ny] ! -1) continue; // 已访问过 if (grid[nx][ny] 1) continue; // 1代表障碍物 vis[nx][ny] cur.step 1; q.push({nx, ny, cur.step 1}); } } return -1; // 无路可走 }注意几个细节越界检查一定要放在最前面然后才是访问标记、障碍判断顺序反了可能导致数组越界或者访问到非法位置。标记数组用 -1 初始化有个好处它既是访问标记也是距离记录省一个数组。queue里的元素是值拷贝的结构体别太大不然出入队有开销但蓝桥杯的数据量一般不用在意这个。这个模板的扩展性很好。比如你在迷宫题里遇到“只能走两次的格子”把结构体加上int used转移的时候判断used的取值再比如传送门坐标判断出来后直接改nx, ny就行。3. 必备变形多源BFS与状态BFS3.1 多源BFS同时点燃多个火种先说多源BFS。这种题典型特征就是从多个起点同时开始搜索或者要求计算“每个格子到最近一个源点的距离”。比如看一个经典描述的题目派生给定一个矩形区域里面有若干个人为设置的起点和一个目标位置一个人到达某个位置的最短用时取决于多个源同时扩散到该处的最短时间。遇到这种题很多人第一反应是“对每个源依次BFS然后取最小值”理论上也没错但时间复杂度直接乘了源点数一旦数据范围上来就容易被卡。正确做法非常简单初始化的时候把所有起点全部放进队列然后正常BFS。因为BFS是按层扩散的多起点入队后从整体上看就等价于每个起点同时向外扩了一层当前格子被哪个源最先到达步数就是全局最短。queueNode q; for (auto start : starts) { q.push({start.x, start.y, 0}); vis[start.x][start.y] 0; // 多个起点统一标记 } // 后面就是常规BFS这就好比在一个池塘里同时扔好几块石头水波交汇处就是多源搜索的结果。这个“同时”的意义在于所有源点在层数推进上保持一致第一次到达某个格子的步数必然是最小值。蓝桥杯里多源BFS经常出现在“火势蔓延”类的题目中人和火同时移动人能不能在火到达前到终点。这种题常用处理思路是先用多源BFS算出火到达每个格子的时间得到一张时间表。再用人做一次BFS要求人在某个格子时人当前步数小于火的到达时间。这种双BFS或者多BFS的题型就是典型的多源BFS应用会了就是送分题不会就是卡死题。双BFS的题目中有时候火源不是一个而是好几个位置同时起火这时多源BFS的价值就很大。如果每次人都要“预判”火的程度可以先跑火再做人的顺序不然在BFS过程里交叉判断容易乱。3.2 状态BFS队列里装的不是坐标另一种高频变形是状态BFS。这种题目的共同点单纯坐标无法区分当前情况必须把“状态”也加入判重条件。就拿一类典型题来说一个迷宫里有多把钥匙对应多个门只有拿到钥匙才能打开对应的门求从起点到终点的最短路径。这时两个不同坐标还不够钥匙的持有情况不同路线的可走性就完全不同。处理思路是在判重数组里加一维vis[x][y][keyState]keyState用一个整数二进制位表示当前持有哪些钥匙。每走一步根据状态判断能否过门以及是否需要更新钥匙状态。struct Node { int x, y, step; int keys; // 二进制状态比如 bit0 表示第0把钥匙 };这种BFS的核心就是把“状态”纳入广度优先的层数含义里——你从起点拿到钥匙和没拿到钥匙到达同一个格子是两种完全不同的状态必须分别入队。这也是为什么状态BFS的队列元素往往比坐标BFS复杂但正因为如此它才能解决比普通迷宫更复杂的问题。蓝桥杯偶尔还会把状态BFS和“传送门”“钥匙”等结合如果你只会在二维网格上四方向走遇到这类题真会有点措手不及。好在状态BFS的模板只比普通BFS多了一个维度判重数组从二维变三维结构体里多一个状态字段理解了之后完全能举一反三。我见过一个备赛的同学自己琢磨状态BFS时总是纠结“同一个格子访问多次会不会死循环”。实际上只要状态变了就允许再访问因为新状态意味着新的路径可能性。判重数组的作用不是防止物理折返而是防止“同一位置同一状态”被重复扩散避免队列爆炸。3.3 步数记录的另一个技巧分层BFS结构体存步数最直观还有一种写法也有不少拥趸就是分层BFS。不把步数存进结构体而是每轮记录当前队列的长度处理完一整层步数加1while (!q.empty()) { int layerSize q.size(); while (layerSize--) { Node cur q.front(); q.pop(); // 扩展逻辑 } step; }这种写法在求“最少步数”时和存步数法一样但在某些题目里更省空间队列元素里少了step字段结构物体积更小。不过从可读性和出错率看我更推荐初学者用结构体存步数因为分层BFS一旦层内逻辑写岔了步数就不对了。4. 实战晋级三维BFS与迷宫最短路径4.1 从二维到三维多一层坐标的搜索空间蓝桥杯的省赛和国赛偶尔会出现三维搜索题“在一个三维迷宫里从起点找出口”。很多人看到三维就紧张其实思路完全相同——从二维四个方向变成三维六个方向而已。我印象比较深的一道模拟题给一个长方体空间0代表空1代表障碍给定起点和终点求最短步数。无非就是扩展方向数组int dz[6] {-1, 1, 0, 0, 0, 0}; int dx[6] {0, 0, -1, 1, 0, 0}; int dy[6] {0, 0, 0, 0, -1, 1};判重数组也变成三维vis[x][y][z]主循环结构完全不用动。三维BFS最需要注意的是输入数据的行、列、层顺序。三维数组通常声明成[层][行][列]此时遍历顺序应该是最外层是层中间是行最内是列。很多选手在细节上栽跟头就是把行列坐标搞反了BFS逻辑是对的数据读进来就乱了一直报错却找不出原因。从经验上看三维BFS在蓝桥杯中出现的频率远不如二维但一旦出现区分度非常高会者不难难者不会。建议备赛时找一题三维迷宫的经典题练练手把方向数组改成三维模组花半小时就能掌握性价比很高。4.2 从BFS到最短路如何判断是否该用Dijkstra蓝桥杯搜索题还有一个常见混淆点BFS和Dijkstra到底用哪个一句话说清如果所有边的代价相同BFS就是Dijkstra的特例。网格迷宫大多数是每走一步代价为1直接用BFS求最短步数就行。但如果题目说不同地形消耗不同体力值比如平地花费1、沼泽花费3此时BFS就失效了需要换成Dijkstra或优先队列BFS。看过一些备赛同学在这上面踩坑他们的BFS模板背得很熟遇到“步数最少但代价不同”的题还硬套BFS结果逻辑推来推去就是不对。这里有个判断技巧——看状态转移时步数增加的幅度是否恒定恒定每次 1普通BFS队列足够。不同转移代价不同必须用优先队列堆优化处理等价于Dijkstra。这也是为什么BFS模板里我习惯把结构体定义为step字段——如果代价从固定1变成了可变的2或3只需要把push的步数改成代价加权队列换成优先队列核心BFS框架依然是可用的修复成本很小。4.3 一个完整的迷宫案例拆解为了把上面的知识点串起来这里用一道典型的模拟赛题练练手题目类似这样给定一个n*m的地图.表示空地#表示墙壁S表示起点E表示终点从S出发每一步可以走上、下、左、右四个方向求到达E的最短步数。第一步数据处理读入地图存储为字符数组。记录起终点坐标。pairint,int startIdx, endIdx; vectorstring mp(n); for (int i 0; i n; i) { cin mp[i]; for (int j 0; j m; j) { if (mp[i][j] S) startIdx {i, j}; if (mp[i][j] E) endIdx {i, j}; } }第二步BFS主体按照模板走额外判断一下当前格子字符是不是#。这里有个小优化进入队列前就判断障碍不要出队列才判断这样能减少无效入队稍微提升性能。第三步路径回溯如果题目不只要步数还要输出路径可以在入队时记录每个节点的父节点。等信息收集完后从终点反推回到起点即可还原路径。// 增加一个parent数组 vectorvectorpairint,int parent(n, vectorpairint,int(m, {-1, -1})); // 入队时记录 parent[nx][ny] {cur.x, cur.y}; // 最终从终点回推 vectorpairint,int path; pairint,int cur endIdx; while (cur ! startIdx) { path.push_back(cur); cur parent[cur.first][cur.second]; } path.push_back(startIdx); reverse(path.begin(), path.end());这套“BFS求最短步数 parent回溯路径”的组合拳在蓝桥杯中很常用。碰到输出路径的题很多同学只记住BFS求步数忘了存父节点临时想就来不及了。你要是提前把这个板子准备好考场上一分钟就能搞定。我在准备这类题目时会强制自己每次写BFS都顺手加好parent数组哪怕最终不需要回溯。这样练习多了真遇到回溯路径的题肌肉记忆自然就有了不需要现想。整体上迷宫类的BFS题目变化核心就三个起点和终点的数量和类型、状态维度以及地形转换规则。把握好这三块不管题目包装得再花哨底层都是同一套BFS扩散。5. 蓝桥杯BFS高频题型与实战策略5.1 判重数组的大小设计与内存优化一个经常被忽视但特别重要的部分是判重数组的尺寸。蓝桥杯一些题数据范围看似不大但二叉树、排列、状态压缩的场景里如果你用粗暴的“所有状态全开数组”内存直接爆掉。举个典型例子涉及八数码或排列的BFS如果用mappairstring,int,bool做判重时间开销很大如果直接开一个大数组空间又不够。这时候两个优化思路状态编码把坐标或字符串映射成一个整数用一维数组判重。哈希判重用unordered_map或unordered_set存状态适合状态空间极大且稀疏的场景。蓝桥杯判题内存限制一般是256MB或512MB二维数组随便开但三维四维就要精打细算。比如一个100x100的地图再加一个10种状态的维度int数组就有100*100*10*4字节400KB还OK但如果加了更多维度就得考虑用bool或bitset了。写BFS前先估算一下状态总数别让判重数组成为MLE的罪魁祸首。我见过不止一个选手BFS完全正确但就因为判重数组多开了一个无用维度导致内存超限非常可惜。5.2 搜索顺序与字典序最小路径很多同学以为BFS就是无脑四个方向都尝试其实搜索顺序的选择也有讲究。有部分题目要求输出“字典序最小”的路径比如在多个最短路径中要求按字母序输出最小的那一个。处理这类题目有个小技巧四个方向的遍历顺序按照字典序排列。比如方向用字符表示D下、L左、R右、U上那么字典序最小的顺序就是D-L-R-U你在写方向数组时按这个顺序遍历BFS第一次到达终点时对应的一定是字典序最小的路径。不过要注意这个方法只在所有路径步数相同的条件下成立。因为BFS按层扩展同一层内顺序决定先入队谁的扩展先处理到的自然优先。只要层数相同字典序规则就有效。备赛时为了应对这种要求我习惯了在写方向数组时要明确考虑题目是否有字典序限制不然后续返工改起来麻烦。一次赛前模拟我偷懒没注意结果输出顺序不对改了老半天从此记住了这个小点。5.3 剪枝优化BFS也需要的几点策略别以为BFS不需要剪枝实际上稍复杂一点的搜索题不做剪枝很可能超时。几个常用的优化方向可行性剪枝提前算好起点到终点的大致距离如果当前步数 曼哈顿距离已经大于当前最优解直接放弃扩展。最优解剪枝如果题目要求找最小步数而我们已经有一个可行解那么在BFS中一旦某节点步数超过已知最优解直接跳过。边界压缩对于大网格如果所有障碍物都集中在某几个区域可以提前分析出哪些区域根本不可能访问减少不必要的坐标访问。这几点里可行性剪枝通常最有效尤其是障碍物多、可通行区域有限的迷宫用了剪枝后运行时间能缩短数倍。蓝桥杯的测试数据虽不至于极限但在1秒时限下剪枝是保险的好习惯。5.4 运行效率实测STL队列和数据规模的关系来点实际数据感受一下。假设一个 1000x1000 的网格起点和终点分别在角落所有格子畅通无阻那么BFS会访问约100万个格子。每个格子入栈一次STL queue的push/pop都是常数时间在C下大约几十毫秒到一百毫秒量级完全能过。但如果你做多源BFS源点有1000个每个源点跑一遍复杂度就会变成1000*100万10亿次操作TLE没跑了。这时候多源BFS一次入队所有源点还是100万格子但总操作量就能控制在千万量级。这也是为什么总强调多源BFS优于多次BFS的原因——不仅写法上更优雅性能上限完全不同。平时练习的时候我建议大家养成分析复杂度的习惯。BFS时间复杂度基本等于状态总数乘以每个状态的扩展数。普通二维网格是O(n*m*4)状态BFS则是O(状态数 * 维度大小 * 扩展方向数)。心里有这笔账考场选算法就不会糊里糊涂地超时。6. 备战蓝桥杯BFS的刷题路线和常见问题6.1 从基础到进阶的推荐刷题计划很多同学备赛时候的通病是——一上来就刷难题看题解时“哦原来如此”自己写的时候大脑一片空白。BFS这种基础算法建议按阶段推进阶段一模板题把类似“迷宫最短路径”这种最原始的BFS题做熟。目标不是AC就完事而是闭着眼睛能写出模板包括方向数组、判重、入队出队全流程。阶段二变形题重点做多源BFS和状态BFS。比如“火势蔓延”类型的题、带钥匙的迷宫题。这一步是为了让你理解“状态”这个概念学会从坐标思维升级到状态思维。阶段三综合题做需要BFS和其他算法结合的题目比如BFS二分答案、BFS记忆化搜索。这类题在蓝桥杯国赛里出现概率较高备赛时间充裕的话建议早早接触。阶段四真题模拟历年真题的搜索题拿出来按比赛时间完整模拟。不仅要写出来还要关注自己是否能在30分钟内搞定一道中等难度的搜索题。毕竟考场上时间分配太重要了。6.2 易错点大汇总全是心酸教训把这几届备赛过程里大家常犯的错误攒一攒给你排排雷忘记标记起点。BFS的起点一定要在入队时立刻标记否则扩展一圈后起点会被再次入队轻则性能下降重则死循环。方向数组和坐标对应错误。比如把“上”写成(x-1, y)结果方向数组写成了(x1, y)整个搜索直接错位跑出来一直找不到终点。边界条件写错。nx 0 || nx n尤其容易把写成当访问到边界时直接崩溃。多源BFS只入队不标记。多个起点入队后如果没在入队时设置步数某些起点会重复被访问到。状态BFS判重维度不全。只判断坐标不判断状态导致大量无效搜索或者漏掉可行解。输出的换行大小写问题。蓝桥杯很多题对输出格式要求严格比如要求“最少步数是5”你多打了空格或少打了冒号都会判错。这是心酸教训比赛前一定要逐行检查输入的边界、判重的维度、输出的格式。赛场上没时间让你反复调试这些低级的标的错误。6.3 考场上的debug顺序如果考试时BFS代码跑出来不对我一般建议按这个顺序排查先查输入解析坐标是不是读对了行列先后顺序有没有搞反。再查方向数组四个方向的坐标增量是否和题目匹配。查判重时机有没有在入队后立刻标记还是拖到了出队才标记。最后查终点判断是出队时判断还是入队时判断。两种写法都可以但逻辑要闭环。这里有个容易忽视的问题入队时判断终点和出队时判断终点在某些情况下结果不同。比如多终点时如果终点状态不同必须先判断能不能合法入队再决定是否返回。建议统一使用出队时判断终点的写法兼容性更强。有一次我写带钥匙的BFS终点判断放在了入队之前导致需要钥匙的终点状态被误判成不可达憋了半小时才排查出来。这种细节问题最磨人但也最锻炼人。6.4 蓝桥杯BFS题型的走向预判从近几届的趋势看BFS的考察方向越来越偏向“状态压缩多源”的组合题型单纯的裸BFS送分题在减少。但这并不意味你可以放弃经典BFS——相反经典BFS是所有变形题的基础只有基础扎实了才能真正掌握多源、多状态甚至A*这些进阶内容。另外有个趋势值得注意部分题目开始要求你打印路径而不仅仅是步数。这意味着你在练BFS时不能只记板子还要理解搜索树的结构知道怎么用父指针回溯。这在前面已经重点写过了建议反复练习。从维度上看三维BFS、带时间维度的BFS比如行动有冷却时间这些方向很可能出现在后续考题里。备赛时如果时间充足可以刻意去练几道这类题目扩充状态定义的能力。掌握一个搜索题的关键其实不在算法本身而是你把“状态”定义得是否清楚。我个人练了这么久BFS最大的体会是不要嫌弃模板题简单也不要畏惧变形题难。BFS的板子真的就那么几行但每道题对“状态”的定义千差万别。你刷的变形题越多对状态的感知越敏锐考场上碰到任何搜索题都能快速找到切入口。最后分享一个我自己的复盘方法每次做完一道BFS题我都会问自己三个问题——这道题的状态是什么判重依据是什么步数的增长规则是什么如果这三个问题都能在三秒钟内回答清楚那这道题你是真会了。靠堆题量记住答案没用把问题拆到这三个维度才算真正掌握了BFS的解题思维。