ARTICLE DETAIL

建站实战干货

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

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

2026/8/16 0:04:35 拓冰建站 浏览量
BFS算法详解:从马的遍历到最短路径搜索实战

1. 项目概述:从“马的遍历”到BFS算法的实战演练

最近在洛谷上刷题,又看到了“马的遍历”这道经典题目。这道题可以说是算法初学者,特别是刚接触广度优先搜索(BFS)的同学绕不开的一道坎。它不像一些纯理论题目那么抽象,而是把一个具体的、形象的棋盘问题摆在你面前,让你用代码去模拟国际象棋中“马”的走法,计算它到达棋盘上每个点的最少步数。题目本身不难理解,但要想高效、正确地用BFS实现,里面有不少细节值得深究。很多朋友卡在这里,不是因为不懂BFS的概念,而是在方向数组、边界判断、状态标记这些实操环节上栽了跟头。今天,我就结合自己当年踩过的坑和后来带新人刷题的经验,把这道题从题意理解到代码实现的完整过程,掰开揉碎了讲一遍。无论你是正在备战算法竞赛的新手,还是想巩固BFS基础的同学,相信这篇详细的拆解都能让你有所收获。

2. 核心思路与算法选型:为什么一定是BFS?

2.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[][]),来记录每个格子距离起点的最少步数。这个数组同时承担了“访问标记”的功能。通常,我们将其初始化为一个特殊值(如-1INF),表示“未访问”。当某个点第一次被BFS访问到时,就在dist中记录下当前的步数。由于BFS的特性,这个值一旦被写入,就不会再被更改(因为首次访问就是最短距离)。

在“马的遍历”中,我们通常这样初始化:

vector<vector<int>> dist(n + 1, vector<int>(m + 1, -1)); // 假设棋盘从(1,1)开始,初始化为-1表示未到达 dist[sx][sy] = 0; // 起点距离为0 queue<pair<int, int>> q; q.push({sx, sy});

3.2 算法流程步骤拆解

让我们把BFS的过程一步步拆解:

  1. 初始化:创建距离数组dist并填充为-1。创建队列q。将起点(sx, sy)dist值设为0,并将其入队。
  2. 循环条件:当队列q不为空时,继续循环。队列空则BFS结束。
  3. 取出队首:在循环体内,取出当前队首元素(x, y)。这个点是我们本轮要扩展的中心点。
  4. 扩展搜索:遍历马的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,说明这个点尚未被访问过。
  5. 处理新状态:如果(nx, ny)合法且未被访问,则: a. 计算其距离:dist[nx][ny] = dist[x][y] + 1。这表示从(x, y)走一步到达。 b. 将新状态(nx, ny)入队,等待后续扩展。
  6. 循环结束:当前点(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表示未访问 vector<vector<int>> dist(n + 1, vector<int>(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初始化 queue<pair<int, int>> q; dist[sx][sy] = 0; // 起点距离为0 q.push({sx, sy}); // 4. BFS主循环 while (!q.empty()) { auto [x, y] = q.front(); // C++17结构化绑定,取出队首坐标 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; }

代码关键点解析:

  1. 数据结构选择:使用vector<vector<int>>创建二维动态数组,比原生二维数组更灵活,且便于初始化为-1。使用queue<pair<int,int>>存储坐标对。
  2. C++17结构化绑定auto [x, y] = q.front();这行代码是C++17的特性,可以方便地将pair解包到两个变量中。如果你的编译环境不支持,可以用传统的int x = q.front().first; int y = q.front().second;代替。
  3. 访问判断的逻辑顺序:必须先判断(nx, ny)是否越界,再判断是否访问过。如果顺序颠倒,当(nx, ny)越界时,直接访问dist[nx][ny]会导致非法内存访问,这是非常严重的错误。
  4. 距离更新dist[nx][ny] = dist[x][y] + 1;这行代码是BFS的核心逻辑,它保证了每个点记录的是从起点出发的最短步数。
  5. 输出格式化:题目通常要求输出对齐。setw(5)设置输出宽度为5,left设置左对齐。这样即使数字位数不同,输出结果也会整齐美观。这是一个很好的编程习惯,能避免因格式问题导致的答案错误。

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(nx<1
程序运行超时棋盘过大(如500x500),但算法逻辑错误导致死循环或复杂度爆炸。标准的BFS时间复杂度是 O(N*M),对于500x500的棋盘是2.5e5个点,完全在承受范围内。超时大概率是逻辑错误导致队列无法清空或陷入无效循环。检查访问标记,确保每个点入队一次。
输出格式错误没有按要求左对齐或宽度不足,导致“格式错误”。严格按照题目要求使用setw()left进行格式化输出。可以先将结果存入字符串或直接调整输出流。

5.2 调试与验证技巧

对于BFS这类搜索算法,小数据手工模拟是最有效的调试方法。

  1. 画图法:在纸上画一个5x5的棋盘,手动模拟BFS过程。从起点开始,一步步画出马每一步可以到达的位置,并标上步数。然后对比你程序输出的dist数组,看是否一致。这能帮你迅速定位是哪个方向的移动出了问题,或者是哪一步的更新逻辑不对。
  2. 打印中间状态:在代码的关键位置插入打印语句。例如,在每次从队列取出点(x, y)时,打印它的坐标和步数;在尝试向(nx, ny)移动时,打印计算出的新坐标和判断结果。通过观察这些中间日志,你可以清晰地看到BFS的扩展过程是否符合预期。
  3. 使用简单测试用例:不要一上来就用复杂的用例。先用一个2x2或3x3的棋盘,起点在角落,计算一下。结果应该很容易心算验证。

5.3 算法扩展与性能考量

本题的棋盘规模通常不会导致性能问题。但了解其性能边界和优化思路是有益的。

  • 时间复杂度:O(N * M)。最坏情况下,BFS需要访问棋盘上的每一个格子,每个格子入队、出队一次,每次扩展8个方向(常数)。因此是格子数量的线性复杂度。
  • 空间复杂度:O(N * M)。主要用于存储dist距离数组和队列q。在最坏情况下,队列中可能同时存储接近一层的所有节点,但总量级仍与棋盘大小成线性关系。
  • 优化点:对于本题,算法本身已是最优。在一些变种问题中(如非常大的棋盘,但马步有特殊限制),可以考虑使用双向BFS(从起点和终点同时开始搜索,相遇时停止)来减少搜索空间。但就标准“马的遍历”而言,上述单源BFS实现已经足够高效和简洁。

6. 举一反三:BFS的应用场景与变式思考

通过“马的遍历”彻底掌握BFS后,你会发现它是一把解决一大类问题的万能钥匙。核心思想都是“按层扩展,首次访问即最短”。

  1. 迷宫最短路径:这是最直接的变式。把棋盘换成迷宫网格,把马的8种走法换成上下左右4种走法,障碍物对应的格子不可访问(相当于永远为-1)。解题框架一模一样。
  2. 连通块问题(洛谷P1451):求网格中细胞的数量或面积。这时,BFS(或DFS)的作用是“遍历一个连通区域”。从一个未访问的点开始BFS,将所有能连通的点标记为已访问,这就找到了一个连通块。计数器加1,然后继续寻找下一个未访问的点。
  3. 层序遍历树:在树数据结构中,BFS就是层序遍历。队列中存放树节点,扩展方式是从父节点到子节点。
  4. 状态搜索问题:有些问题不能直接映射为坐标,但可以将一种“状态”作为一个点。例如“八数码”问题,每一种棋盘排列就是一个状态,一次合法的滑动就是状态之间的边。这时需要用BFS在“状态图”中搜索从初始状态到目标状态的最短路径。状态通常用字符串或哈希来表示,并用unordered_set来记录已访问状态,防止重复搜索。

最后一点个人心得:BFS的代码模板性很强。一旦掌握,很多题目都是套用框架,主要精力花在“状态定义”和“状态转移”的建模上。多练习几道题,比如洛谷上的“迷宫”、“马的遍历”、“填涂颜色”等,你就能形成肌肉记忆。遇到新题,先问自己:“状态是什么?怎么转移?BFS第一次到达目标状态是不是就保证了最短?”想清楚这三个问题,代码就能很快写出来了。