拓冰建站拓冰建站
首页 / 资讯中心 / 正文

BFS算法详解:从马的遍历到最短路径搜索实战

1. 项目概述从“马的遍历”到BFS算法的实战演练最近在洛谷上刷题又看到了“马的遍历”这道经典题目。这道题可以说是算法初学者特别是刚接触广度优先搜索BFS的同学绕不开的一道坎。它不像一些纯理论题目那么抽象而是把一个具体的、形象的棋盘问题摆在你面前让你用代码去模拟国际象棋中“马”的走法计算它到达棋盘上每个点的最少步数。题目本身不难理解但要想高效、正确地用BFS实现里面有不少细节值得深究。很多朋友卡在这里不是因为不懂BFS的概念而是在方向数组、边界判断、状态标记这些实操环节上栽了跟头。今天我就结合自己当年踩过的坑和后来带新人刷题的经验把这道题从题意理解到代码实现的完整过程掰开揉碎了讲一遍。无论你是正在备战算法竞赛的新手还是想巩固BFS基础的同学相信这篇详细的拆解都能让你有所收获。2. 核心思路与算法选型为什么一定是BFS2.1 问题本质与算法匹配度分析我们先抛开代码回归问题本身。题目要求我们计算马从起点(sx, sy)出发到达棋盘上每一个点的“最少步数”。注意这个“最少步数”是关键。它意味着我们需要找到从起点到任意目标点的最短路径长度。这立刻让我们联想到两类常见的路径搜索算法深度优先搜索DFS和广度优先搜索BFS。为什么这道题几乎毫无争议地选择BFS呢这得从两种算法的核心特性说起。DFS的策略是“一条路走到黑”它会沿着一个分支尽可能深地搜索直到尽头再回溯。这种策略在寻找“是否存在一条路径”或者遍历所有可能路径如排列组合时很有效。但是对于寻找“最短路径”DFS有一个致命缺陷它首次到达某个点时所走的路径并不一定是最短的。DFS可能会绕很远的路才到达某个点而更短的路径可能存在于其他尚未探索的分支中。虽然可以通过记录全局最优解并不断剪枝来让DFS也能找到最短路径但实现复杂效率通常也不如BFS直观。BFS的策略则是“层层推进”。它从起点开始首先访问所有距离起点为1步的点然后再访问所有距离为2步的点以此类推。这个特性完美契合了“最少步数”的要求。当BFS第一次访问到某个格子时它所经历的层数或者说步数就是从起点到该格子的最短距离。因为BFS是按距离由近及远进行探索的不可能有更短的路径被遗漏。这种“首次访问即最优”的特性使得BFS成为解决无权图或等权图如此题中每走一步代价相同最短路径问题的天然选择。所以面对“马的遍历”我们几乎可以条件反射般地确定使用BFS。这不仅是经验更是由问题目标最少步数和算法本质层序扩展共同决定的。2.2 状态定义与搜索空间建模确定了算法接下来就要把棋盘问题抽象成BFS能处理的形式。BFS通常在“图”上进行我们需要明确什么是“点”状态什么是“边”状态转移。状态点在本题中一个状态就是马在棋盘上的一个特定位置可以用坐标(x, y)唯一表示。整个棋盘上所有可能的(x, y)坐标构成了我们的搜索空间状态图。状态转移边马从一个位置移动到另一个位置的规则就是状态之间的转移关系。具体来说就是国际象棋中马的走法“日”字形。从一个点(x, y)出发马可以跳到8个可能的后续位置。我们需要用一个数组来清晰地定义这8个方向。这是实现BFS循环的核心之一。// 马可以走的8个方向顺序不重要但需要完整 int dx[8] {-2, -1, 1, 2, 2, 1, -1, -2}; int dy[8] {1, 2, 2, 1, -1, -2, -2, -1};这里dx[i]和dy[i]成对定义了第i个方向的横纵坐标偏移量。例如(dx[0], dy[0]) (-2, 1)表示马可以向左走两格再向上走一格或先上后左结果坐标一致。有了状态和转移的定义整个问题就变成了在一个以坐标点为顶点以马的走法为边的图上从起点开始进行BFS记录每个顶点第一次被访问时的“层数”步数。注意这里隐含了一个重要条件——棋盘是有限的坐标有边界。因此在尝试向某个方向移动后必须立即检查新坐标(nx, ny)是否在棋盘范围内例如1 nx n, 1 ny m。这是BFS实现中常见的“合法性判断”。3. BFS算法框架与关键组件实现理解了思路我们来搭建BFS的完整框架。一个标准的BFS实现通常包含以下几个核心组件我会结合“马的遍历”逐一讲解。3.1 数据结构队列与距离记录BFS之所以能“层层推进”核心数据结构是队列Queue。队列“先进先出”的特性恰好保证了先被发现的点距离起点更近的点先被扩展。队列的作用存储待扩展的状态坐标。初始时将起点入队。然后循环执行从队头取出一个状态尝试向所有可能的方向扩展将合法且未访问过的新状态放入队尾。如此反复直到队列为空意味着所有可达点都已访问。距离记录我们需要一个与棋盘大小相同的二维数组如dist[][]来记录每个格子距离起点的最少步数。这个数组同时承担了“访问标记”的功能。通常我们将其初始化为一个特殊值如-1或INF表示“未访问”。当某个点第一次被BFS访问到时就在dist中记录下当前的步数。由于BFS的特性这个值一旦被写入就不会再被更改因为首次访问就是最短距离。在“马的遍历”中我们通常这样初始化vectorvectorint dist(n 1, vectorint(m 1, -1)); // 假设棋盘从(1,1)开始初始化为-1表示未到达 dist[sx][sy] 0; // 起点距离为0 queuepairint, int q; q.push({sx, sy});3.2 算法流程步骤拆解让我们把BFS的过程一步步拆解初始化创建距离数组dist并填充为-1。创建队列q。将起点(sx, sy)的dist值设为0并将其入队。循环条件当队列q不为空时继续循环。队列空则BFS结束。取出队首在循环体内取出当前队首元素(x, y)。这个点是我们本轮要扩展的中心点。扩展搜索遍历马的8个走法方向。对于每一个方向i a. 计算下一个点的坐标nx x dx[i],ny y dy[i]。 b.合法性检查判断(nx, ny)是否在棋盘范围内1 nx n 1 ny m。如果越界则跳过此方向。 c.访问判断检查dist[nx][ny]是否等于-1。如果等于-1说明这个点尚未被访问过。处理新状态如果(nx, ny)合法且未被访问则 a. 计算其距离dist[nx][ny] dist[x][y] 1。这表示从(x, y)走一步到达。 b. 将新状态(nx, ny)入队等待后续扩展。循环结束当前点(x, y)的所有方向扩展完毕回到步骤2处理队列中的下一个点。这个过程就像在水池中投入一颗石子涟漪BFS的层一圈圈荡开直到覆盖整个水池棋盘的可达区域。3.3 方向数组与边界处理的编码细节方向数组dx[], dy[]的编写看似简单但务必仔细核对。一个常见的错误是漏写某个方向或者写错正负号。我建议在写完后心里默念或画个图对照一下(-2,1), (-1,2), (1,2), (2,1), (2,-1), (1,-2), (-1,-2), (-2,-1)正好围成一个“日”字的8个端点。边界处理是另一个易错点。在计算nx, ny后必须立即进行判断。判断条件要与题目输入的棋盘范围保持一致。如果题目说棋盘是n行 m列且坐标从1开始那么判断条件就是if(nx 1 || nx n || ny 1 || ny m) continue; // 越界跳过如果坐标从0开始则相应调整。务必在尝试访问dist[nx][ny]之前进行越界判断否则会导致数组下标越界程序运行时可能崩溃。4. 完整代码实现与逐行解析理论讲透了我们来看一份完整的C实现代码。我会加上详细注释并解释一些关键选择。#include iostream #include queue #include vector #include iomanip // 用于输出格式化 using namespace std; int main() { int n, m, sx, sy; cin n m sx sy; // 1. 初始化距离数组-1表示未访问 vectorvectorint dist(n 1, vectorint(m 1, -1)); // 2. 定义马的8个移动方向 int dx[8] {-2, -1, 1, 2, 2, 1, -1, -2}; int dy[8] {1, 2, 2, 1, -1, -2, -2, -1}; // 3. BFS初始化 queuepairint, int q; dist[sx][sy] 0; // 起点距离为0 q.push({sx, sy}); // 4. BFS主循环 while (!q.empty()) { auto [x, y] q.front(); // C17结构化绑定取出队首坐标 q.pop(); // 遍历8个方向 for (int i 0; i 8; i) { int nx x dx[i]; int ny y dy[i]; // 关键先判断是否在棋盘内 if (nx 1 || nx n || ny 1 || ny m) { continue; // 越界跳过这个方向 } // 关键再判断该点是否已被访问过 if (dist[nx][ny] -1) { // 首次访问记录最短步数 dist[nx][ny] dist[x][y] 1; // 将新点加入队列以便从它继续扩展 q.push({nx, ny}); } } } // 5. 输出结果 for (int i 1; i n; i) { for (int j 1; j m; j) { // 使用setw(5)进行格式化对齐使输出美观 cout setw(5) left dist[i][j]; } cout endl; } return 0; }代码关键点解析数据结构选择使用vectorvectorint创建二维动态数组比原生二维数组更灵活且便于初始化为-1。使用queuepairint,int存储坐标对。C17结构化绑定auto [x, y] q.front();这行代码是C17的特性可以方便地将pair解包到两个变量中。如果你的编译环境不支持可以用传统的int x q.front().first; int y q.front().second;代替。访问判断的逻辑顺序必须先判断(nx, ny)是否越界再判断是否访问过。如果顺序颠倒当(nx, ny)越界时直接访问dist[nx][ny]会导致非法内存访问这是非常严重的错误。距离更新dist[nx][ny] dist[x][y] 1;这行代码是BFS的核心逻辑它保证了每个点记录的是从起点出发的最短步数。输出格式化题目通常要求输出对齐。setw(5)设置输出宽度为5left设置左对齐。这样即使数字位数不同输出结果也会整齐美观。这是一个很好的编程习惯能避免因格式问题导致的答案错误。5. 常见问题、调试技巧与性能优化即使理解了算法实际编码时还是会遇到各种问题。下面是我总结的几个常见坑点和解决思路。5.1 典型错误与排查清单问题现象可能原因排查与解决方法输出全部或大部分是-11. BFS循环根本没启动或提前结束。2. 起点坐标设置错误。3. 方向数组dx/dy写错导致所有移动都越界或被判断为已访问。1. 检查起点dist[sx][sy]是否初始化为0并入队。2. 打印起点坐标确认。3. 在方向遍历循环内打印nx, ny的值观察计算是否正确。检查边界条件n, m是否与题意一致。结果部分正确部分错误1. 方向数组不完整少于8个。2. 边界判断条件写反如nx 1。3. 访问标记逻辑有误可能重复访问导致距离值被错误覆盖。1. 核对dx, dy数组确保是8个方向。2. 仔细检查 if(nx1程序运行超时棋盘过大如500x500但算法逻辑错误导致死循环或复杂度爆炸。标准的BFS时间复杂度是 O(N*M)对于500x500的棋盘是2.5e5个点完全在承受范围内。超时大概率是逻辑错误导致队列无法清空或陷入无效循环。检查访问标记确保每个点入队一次。输出格式错误没有按要求左对齐或宽度不足导致“格式错误”。严格按照题目要求使用setw()和left进行格式化输出。可以先将结果存入字符串或直接调整输出流。5.2 调试与验证技巧对于BFS这类搜索算法小数据手工模拟是最有效的调试方法。画图法在纸上画一个5x5的棋盘手动模拟BFS过程。从起点开始一步步画出马每一步可以到达的位置并标上步数。然后对比你程序输出的dist数组看是否一致。这能帮你迅速定位是哪个方向的移动出了问题或者是哪一步的更新逻辑不对。打印中间状态在代码的关键位置插入打印语句。例如在每次从队列取出点(x, y)时打印它的坐标和步数在尝试向(nx, ny)移动时打印计算出的新坐标和判断结果。通过观察这些中间日志你可以清晰地看到BFS的扩展过程是否符合预期。使用简单测试用例不要一上来就用复杂的用例。先用一个2x2或3x3的棋盘起点在角落计算一下。结果应该很容易心算验证。5.3 算法扩展与性能考量本题的棋盘规模通常不会导致性能问题。但了解其性能边界和优化思路是有益的。时间复杂度O(N * M)。最坏情况下BFS需要访问棋盘上的每一个格子每个格子入队、出队一次每次扩展8个方向常数。因此是格子数量的线性复杂度。空间复杂度O(N * M)。主要用于存储dist距离数组和队列q。在最坏情况下队列中可能同时存储接近一层的所有节点但总量级仍与棋盘大小成线性关系。优化点对于本题算法本身已是最优。在一些变种问题中如非常大的棋盘但马步有特殊限制可以考虑使用双向BFS从起点和终点同时开始搜索相遇时停止来减少搜索空间。但就标准“马的遍历”而言上述单源BFS实现已经足够高效和简洁。6. 举一反三BFS的应用场景与变式思考通过“马的遍历”彻底掌握BFS后你会发现它是一把解决一大类问题的万能钥匙。核心思想都是“按层扩展首次访问即最短”。迷宫最短路径这是最直接的变式。把棋盘换成迷宫网格把马的8种走法换成上下左右4种走法障碍物对应的格子不可访问相当于永远为-1。解题框架一模一样。连通块问题洛谷P1451求网格中细胞的数量或面积。这时BFS或DFS的作用是“遍历一个连通区域”。从一个未访问的点开始BFS将所有能连通的点标记为已访问这就找到了一个连通块。计数器加1然后继续寻找下一个未访问的点。层序遍历树在树数据结构中BFS就是层序遍历。队列中存放树节点扩展方式是从父节点到子节点。状态搜索问题有些问题不能直接映射为坐标但可以将一种“状态”作为一个点。例如“八数码”问题每一种棋盘排列就是一个状态一次合法的滑动就是状态之间的边。这时需要用BFS在“状态图”中搜索从初始状态到目标状态的最短路径。状态通常用字符串或哈希来表示并用unordered_set来记录已访问状态防止重复搜索。最后一点个人心得BFS的代码模板性很强。一旦掌握很多题目都是套用框架主要精力花在“状态定义”和“状态转移”的建模上。多练习几道题比如洛谷上的“迷宫”、“马的遍历”、“填涂颜色”等你就能形成肌肉记忆。遇到新题先问自己“状态是什么怎么转移BFS第一次到达目标状态是不是就保证了最短”想清楚这三个问题代码就能很快写出来了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门