ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

网格图DFS/BFS实战模板:算法竞赛与面试的搜索利器

网格图DFS/BFS实战模板:算法竞赛与面试的搜索利器 1. 项目概述网格图搜索算法的实战价值在算法竞赛和日常开发中网格图Grid Graph是一种极其常见的数据结构。它由一个个排列整齐的单元格构成就像一张棋盘或者一张像素图。无论是解决迷宫寻路、岛屿数量统计、图像连通区域分析还是游戏中的地图探索网格图都是绕不开的模型。而深度优先搜索DFS和广度优先搜索BFS则是处理这类问题的两把核心“钥匙”。我参加过多次蓝桥杯国赛也辅导过不少学弟学妹发现很多人在面对网格图题目时代码写得冗长、易错调试起来非常痛苦。问题的根源往往不是不懂DFS/BFS的原理而是缺少一套经过实战检验、清晰可靠且易于定制的代码模板。一套好的模板能让你在紧张的比赛或面试中快速搭建起解题框架把精力集中在问题本身的逻辑上而不是反复调试基础的搜索边界。今天要分享的就是我根据多年参赛和教学经验为第十二届蓝桥杯国赛级别题目打磨的一套网格图DFS/BFS个人模板。这不是教科书上干巴巴的伪代码而是包含了状态记录、方向处理、剪枝优化和易错点注释的“即插即用”实战代码块。掌握它你就能从容应对大多数基于网格的搜索问题。2. 核心思路与模板设计哲学2.1 为什么需要网格图专用模板通用图的DFS/BFS实现需要处理邻接表或邻接矩阵节点关系是显式存储的。但网格图有其特殊性每个单元格节点的位置由行列坐标(r, c)唯一确定其邻居节点通常为上下左右四方向或包含对角的八方向可以通过坐标加减直接计算得出无需预先存储边。这种结构上的规律性使得我们可以将访问标记、条件判断等逻辑高度模板化。模板的核心目标有三个正确性、简洁性和可扩展性。正确性确保基础逻辑无误简洁性保证代码清晰易于在压力下编写可扩展性则允许我们根据具体问题如是否需要记录路径、是否有多重状态快速增删模块。2.2 模板的四个核心组成部分一套完整的网格搜索模板通常由以下四个部分有机组成方向数组这是驱动搜索的“方向盘”定义了搜索的移动规则。四方向和八方向是最常见的。访问标记数组这是避免重复访问、防止死循环的“备忘录”。通常用一个与网格同尺寸的二维布尔数组visited实现。边界判断函数这是一个辅助函数用于判断下一个坐标(nr, nc)是否在网格有效范围内并且是否满足访问条件如不是障碍物、未被访问过。它保证了搜索不会“越界”。搜索主体函数即DFS的递归函数或BFS的队列循环。这是模板的骨架包含了节点处理、邻居遍历等核心流程。设计时我倾向于将方向数组和边界判断尽可能抽象出来使得搜索主体函数逻辑聚焦于问题本身的业务逻辑比如“当前格子是否为陆地”、“累计路径和是否超过K”等。3. 模板代码深度解析与实操要点下面我将分别给出DFS和BFS的模板并逐行解析其设计意图和关键细节。假设网格用vectorvectorint grid或char g[N][N]表示行数为m列数为n。3.1 DFS深度优先搜索模板深度优先搜索像是一个执着探险家选择一条路会一直走到黑递归到底直到无路可走再回溯尝试其他分支。它适合求解连通性、可达性问题以及需要遍历所有可能路径的场景通常需配合回溯。// 方向数组上、右、下、左 (顺时针或逆时针顺序保持一致即可) int dirs[4][2] {{-1, 0}, {0, 1}, {1, 0}, {0, -1}}; // 访问标记数组 vectorvectorbool visited(m, vectorbool(n, false)); // 边界与条件判断函数 (inline 提升效率) inline bool inArea(int r, int c) { return r 0 r m c 0 c n; } void dfs(int r, int c) { // 1. 标记当前节点已访问 visited[r][c] true; // 2. **业务逻辑处理**这里是模板的定制点 // 例如计数、记录路径、修改网格值等 // process(grid[r][c]); // 3. 遍历四个方向 for (auto d : dirs) { int nr r d[0]; int nc c d[1]; // 核心判断必须在区域内、满足题目特定条件、且未被访问 if (inArea(nr, nc) !visited[nr][nc] isValid(grid[nr][nc])) { dfs(nr, nc); // 递归深入 } } // 4. 回溯场景如果需要尝试所有路径可能需要在此处撤销访问标记 // visited[r][c] false; }关键解析与实操要点方向数组的设计dirs数组的顺序本身不影响正确性但保持一个固定的习惯比如顺时针有助于调试和思维的一致性。对于八方向米字型数组扩展为8个{dr, dc}对即可。visited数组的初始化务必在开始搜索前根据网格尺寸正确初始化。这是新手常犯的错误忘记初始化或尺寸弄错会导致运行时错误。inArea函数将其定义为内联(inline)函数或直接写宏因为它在递归中被频繁调用微小优化在数据量大时可能有收益。递归前的条件判断判断!visited[nr][nc]和isValid(grid[nr][nc])必须在递归调用dfs(nr, nc)之前进行。这是“剪枝”思想提前避免无效的递归调用能显著提升效率防止栈溢出。isValid函数需要根据题目实现例如判断是否为陆地(grid[nr][nc] 1)、是否为可通过的路径(grid[nr][nc] ! #)等。递归与回溯模板中注释了回溯操作。在典型的“寻找一条路径”或“连通分量标记”问题中我们不需要回溯访问过的格子不再访问。但在“遍历所有可能路径”如迷宫所有走法或“尝试所有组合”的问题中必须在递归返回后撤销当前状态包括visited标记和可能的路径记录这就是回溯法DFS是其自然实现方式。注意递归深度的限制。蓝桥杯等竞赛环境通常有默认栈大小限制如8MB。对于极大的网格如1000x1000上的深度搜索递归DFS可能导致栈溢出。此时应考虑使用**迭代DFS显式栈**或改用BFS。迭代DFS模板稍复杂但原理相同用stackpairint, int代替递归调用栈。3.2 BFS广度优先搜索模板广度优先搜索像水波扩散从起点一层层向外探索总是先访问离起点最近的节点。它天然适合求解最短路径、最少步数等问题因为第一次访问到某个节点时走过的步数一定是最少的。// 方向数组同上 int dirs[4][2] {{-1, 0}, {0, 1}, {1, 0}, {0, -1}}; // 访问标记数组有时可直接用grid修改或额外记录步数 vectorvectorbool visited(m, vectorbool(n, false)); // BFS 函数通常返回最短步数或是否可达 int bfs(int start_r, int start_c) { // 使用队列 queuepairint, int q; // 起点入队并标记 q.push({start_r, start_c}); visited[start_r][start_c] true; int steps 0; // 记录层数步数 while (!q.empty()) { int size q.size(); // 关键记录当前层的节点数 for (int i 0; i size; i) { auto [r, c] q.front(); q.pop(); // **业务逻辑处理**判断是否到达终点 // if (isTarget(r, c)) return steps; // 遍历邻居 for (auto d : dirs) { int nr r d[0]; int nc c d[1]; if (inArea(nr, nc) !visited[nr][nc] isValid(grid[nr][nc])) { visited[nr][nc] true; q.push({nr, nc}); } } } steps; // 当前层所有节点处理完毕步数加一 } return -1; // 未找到目标 }关键解析与实操要点队列的使用BFS的核心数据结构是队列FIFO确保“先进先出”从而实现层次遍历。C中queue是常用选择。层序遍历与步数记录这是BFS模板的精华所在。while循环的每一轮对应“一步”或“一层”。通过int size q.size()获取当前层的节点数量然后用一个for循环处理完这一整层所有节点后步数steps才增加。这种方式能精确计算从起点到任意节点的最短距离。如果不需要步数只是遍历可以省略这个size循环。入队时标记必须在节点入队的同时将其标记为已访问visited[nr][nc] true。如果等到出队时才标记可能会导致同一个节点被多次重复加入队列造成时间和空间的浪费甚至在网格较大时导致队列爆炸、内存超限。这是一个非常经典的易错点多源BFS模板可以轻松扩展为多源BFS。初始化时将所有起点如多个火源、多个感染源都放入队列并标记然后正常进行BFS。这样BFS第一次访问到某个节点时就是离它最近的那个起点到达它的最短距离。这在解决“地图中离最近起点的距离”一类问题时非常高效。4. 经典题型实战与模板应用理论说得再多不如真刀真枪练一遍。下面我们看两个蓝桥杯真题级别的例子看看如何用这套模板快速解题。4.1 实战一岛屿数量问题LeetCode 200. Number of Islands问题描述给你一个由1陆地和0水组成的二维网格计算网格中岛屿的数量。岛屿由水平或垂直方向上相邻的陆地连接形成。分析这是最经典的连通分量问题。遍历网格遇到一个未被访问的1就以其为起点进行一次DFS或BFS把所有相连的1都标记为已访问。这一整片区域就是一个岛屿计数加一。继续扫描寻找下一个未被访问的1。DFS解法代码示例class Solution { private: int dirs[4][2] {{-1,0}, {1,0}, {0,-1}, {0,1}}; void dfs(vectorvectorchar grid, int r, int c) { int m grid.size(), n grid[0].size(); // 隐含的边界和条件判断grid[r][c] ! 1 时不会进入dfs grid[r][c] 0; // 访问标记直接修改原数组将陆地变为水 for (auto d : dirs) { int nr r d[0], nc c d[1]; if (nr 0 nr m nc 0 nc n grid[nr][nc] 1) { dfs(grid, nr, nc); } } } public: int numIslands(vectorvectorchar grid) { int m grid.size(), n grid[0].size(); int count 0; for (int i 0; i m; i) { for (int j 0; j n; j) { if (grid[i][j] 1) { // 发现新岛屿 count; dfs(grid, i, j); // 淹没整个岛屿 } } } return count; } };模板应用点这里没有使用独立的visited数组而是通过将访问过的陆地1修改为0来充当访问标记节省了空间。dfs函数的结构与我们的模板完全一致。4.2 实战二迷宫最短路径问题问题描述给定一个N x M的网格迷宫.表示通路#表示墙壁S是起点E是终点。每次可以向上、下、左、右移动一格。求从起点到终点的最短步数。如果无法到达返回-1。分析求最短步数是BFS的典型应用场景。我们使用BFS模板从S开始层序遍历遇到E时返回当前步数。BFS解法代码示例#include bits/stdc.h using namespace std; const int N 105; char g[N][N]; bool vis[N][N]; int dirs[4][2] {{-1,0}, {1,0}, {0,-1}, {0,1}}; int n, m; struct Node { int x, y, step; }; int bfs(int sx, int sy) { queueNode q; q.push({sx, sy, 0}); vis[sx][sy] true; while (!q.empty()) { Node cur q.front(); q.pop(); // 业务逻辑判断终点 if (g[cur.x][cur.y] E) { return cur.step; } for (auto d : dirs) { int nx cur.x d[0]; int ny cur.y d[1]; // 边界、障碍物、访问判断 if (nx 0 nx n ny 0 ny m g[nx][ny] ! # !vis[nx][ny]) { vis[nx][ny] true; q.push({nx, ny, cur.step 1}); // 步数1 } } } return -1; // 队列为空仍未找到终点 } int main() { cin n m; int sx, sy; for (int i 0; i n; i) { for (int j 0; j m; j) { cin g[i][j]; if (g[i][j] S) sx i, sy j; } } memset(vis, 0, sizeof(vis)); int ans bfs(sx, sy); cout ans endl; return 0; }模板应用点这里在节点结构Node中增加了step字段记录到达该节点的步数。在将邻居节点入队时步数为当前节点步数加一。当从队列中取出节点时如果它是终点其step就是最短步数。这种在入队时计算并存储步数的方式比之前模板中“每层steps”的方式在某些情况下更直观尤其适用于单一起点求单一路径的问题。两种方式本质等价可根据喜好选择。5. 性能优化与高级技巧当网格规模变大如1000x1000或者搜索状态变复杂时基础的模板可能需要优化才能通过。5.1 双向BFSBidirectional BFS在已知起点和终点的情况下双向BFS可以大幅减少搜索空间。从起点和终点同时开始BFS当两个搜索的“前沿”相遇时路径找到。理论上搜索深度减半时间复杂度和空间复杂度都有显著改善。实现要点使用两个队列和两个访问标记数组或一个数组用不同值标记来源。每次迭代选择当前节点数较少的那一端进行扩展保持平衡。判断相遇的条件从一端扩展出的节点在另一端的访问标记中已经被访问过。5.2 使用位运算压缩状态如果网格每个格子只有少数几种状态比如0/1或者访问标记需要携带额外小信息可以使用位运算压缩到一个整数中用int或long long代替二维数组能提升缓存友好性和速度。例如一个n*m的0/1网格可以用一个长度为n*m的bitset或整数数组的每一位来表示。5.3 剪枝策略这是提升DFS效率的关键尤其是在解决搜索和回溯问题时。可行性剪枝在递归深入前判断当前状态是否已经不可能达到目标。例如在路径和中如果当前和已经超过目标值直接返回。最优性剪枝记录当前找到的最优解如最短路径长度在搜索过程中如果当前路径长度已经超过记录的最优解则停止搜索。记忆化搜索Memoization对于会重复到达的相同状态相同的坐标相同的附加状态如剩余步数、携带钥匙情况将计算结果缓存起来。下次遇到相同状态时直接返回结果避免重复计算。这其实是动态规划的思想与搜索的结合。6. 常见“坑点”与调试心得即便有了模板实际编码时还是会踩坑。下面是我总结的几个高频问题数组越界这是最最常见的运行时错误。务必在访问grid[nr][nc]之前先检查nr和nc是否在[0, m)和[0, n)范围内。inArea函数就是为此而生。死循环忘记设置或错误设置visited标记。在DFS中必须在递归调用前标记在BFS中必须在入队时标记。确保每个节点只被处理一次。步数计算错误BFS特有错误地将steps放在内层for循环里导致每个节点都增加步数。记住steps的增加应该发生在一整层节点都被处理完毕之后。递归栈溢出网格太大DFS递归太深。解决方法是a) 确认问题是否必须用DFS能否用BFSb) 改用迭代DFS显式栈c) 在允许的情况下进行剪枝减少递归深度。多源BFS初始化错误进行多源BFS时只将一个起点入队或者入队后忘记标记所有起点。务必将所有起点都放入队列并标记。状态混淆在复杂搜索中如带钥匙的迷宫状态不仅仅是坐标(r, c)还可能包含持有的钥匙集合。此时visited需要升维例如visited[r][c][keyState]。忘记维度会导致错误地将不同状态下的同一坐标视为已访问。调试建议小数据测试先用一个很小的、你能手动推导的网格比如3x3测试打印出每一步的visited数组和队列/递归栈状态。可视化对于路径类问题尝试在搜索过程中记录前驱节点最后反向还原出路径并打印在网格上非常直观。边界测试测试空网格(m0或n0)、单行单列网格、全为障碍物、起点即终点等情况。这套模板是我在多次实战中打磨出来的它不能保证你解决所有难题但能为你提供一个坚实可靠的起点。在竞赛或面试中看到网格图问题先冷静地套上这个模板框架然后专注于填充题目特有的业务逻辑判断你的解题速度和代码正确率都会大大提高。最后记住模板是死的人是活的深刻理解其原理才能灵活调整应对万变。
返回列表