十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

BFS算法实战:从马的遍历理解广度优先搜索与最短路径

BFS算法实战:从马的遍历理解广度优先搜索与最短路径 1. 项目概述从“马的遍历”理解广度优先搜索的实战应用如果你刚开始接触算法竞赛或者数据结构看到“马的遍历”这个题目可能会觉得有点抽象。但说白了这就是一个用国际象棋里的“马”骑士在一个棋盘上跳来跳去问你它最少需要多少步能跳到某个格子的经典问题。洛谷Luogu作为国内知名的在线评测平台收录了这道题P1443它几乎成了每个学习BFS广度优先搜索算法的新手必经的“洗礼”。我当年也是从这道题开始真正理解了BFS那种“层层递进、稳扎稳打”的搜索策略它和DFS深度优先搜索那种“一条路走到黑”的风格完全不同。这道题的核心价值在于它是一个二维网格上的单源最短路径问题的完美教学案例。棋盘就是网格马的走法日字形定义了移动规则BFS天然保证了第一次到达某个格子时的步数就是最短步数。通过解决它你不仅能掌握BFS的模板写法更能深刻理解队列Queue在其中的核心作用以及如何处理状态表示、边界判断和步数记录。这对于后续解决更复杂的迷宫问题、连通块问题甚至是图论中的最短路径问题都打下了坚实的基础。无论你是用C、Java还是Python这道题背后的思想都是相通的。2. 问题核心与BFS思想深度拆解2.1 问题场景化当棋盘变成一个导航地图让我们先把问题场景化。想象你是一个游戏开发者设计了一个棋盘战场。玩家操控一个骑士单位它的移动规则很特别每次可以走“日”字形即横向移动两格同时纵向移动一格或者横向移动一格同时纵向移动两格一共有8个可能的移动方向。现在你需要编写一个AI快速计算出骑士从起始位置到达地图上任意一个位置的最短步数并显示出来。如果某个位置根本到达不了就标记为不可达。这就是“马的遍历”要解决的核心需求。输入会给你棋盘的大小n行m列、骑士的起始坐标x, y。输出则是一个n*m的矩阵每个格子上的数字代表从起点到该格子的最少步数无法到达则输出-1。洛谷上的原题数据范围一般不大n, m 400这正好允许我们使用最经典的BFS算法在时间限制内通过。2.2 BFS为什么是“最短路径”的天然解法这里需要深入理解BFS和DFS的本质区别。DFS像是一个冒险家选择一个方向就深入探索直到碰壁再返回尝试其他岔路。它可能会很早就“碰到”目标点但无法保证这条路径是最短的因为它探索的顺序是深度优先。而BFS更像是一滴墨水在清水中扩散或者像声波的传播。它从起点开始首先访问所有距离起点为1步的点然后访问所有距离为2步的点以此类推。这个特性是由队列的“先进先出”FIFO特性保证的。当我们从队列中取出一个节点进行扩展时我们总是先处理完当前“层”的所有节点才会进入下一层。因此当一个节点第一次被访问到时它所经历的步数必然是起点到它的最短步数。这是BFS解决无权图或等权图如此题中每走一步代价相同最短路径问题的理论基石。对于“马的遍历”棋盘上的每个格子就是一个节点马的8种走法定义了节点之间的边。由于每一步的代价相同步数1BFS就是求解此问题最高效且正确的算法。相比之下如果用DFS你需要记录所有可能的路径并比较长度时间复杂度会指数级爆炸。2.3 状态定义与关键数据结构设计在代码实现前我们必须明确如何表示“状态”。在这个问题中一个完整的状态由两个要素唯一确定当前骑士所在的行坐标通常用r或x表示。当前骑士所在的列坐标通常用c或y表示。因此我们可以用一个二元组(r, c)来表示一个状态。在C中常用pairint, int在Java中可以用一个自定义的Node类或直接使用数组在Python中则常用元组(r, c)。接下来是核心数据结构队列 (Queue)用于存储待扩展的状态。它保证了我们按“层”序进行搜索。距离数组 (dist数组)一个二维数组dist[n][m]用于记录起点到每个格子的最短步数。初始化时所有值设为-1表示未访问/不可达起点距离设为0。这个数组同时充当了访问标记visited数组的作用如果dist[r][c] ! -1说明该格子已被访问过无需再次入队。这避免了重复访问和死循环。方向数组 (dirs数组)用一个数组预先定义马可以走的8个方向偏移量。例如// C 示例 int dx[8] {-2, -1, 1, 2, 2, 1, -1, -2}; int dy[8] {1, 2, 2, 1, -1, -2, -2, -1};这样在遍历时通过当前坐标(r, c)加上(dx[i], dy[i])就能得到下一个可能的位置(nr, nc)。3. 完整代码实现与逐行解析下面我将以C为例给出一个清晰、健壮且带有详细注释的ACAccepted代码实现。其他语言的思路完全一致。#include iostream #include queue #include cstring // 用于memset using namespace std; // 定义方向数组马的8种走法 (日字形) const int dx[8] {-2, -1, 1, 2, 2, 1, -1, -2}; const int dy[8] {1, 2, 2, 1, -1, -2, -2, -1}; int main() { int n, m, startX, startY; cin n m startX startY; // 注意题目中输入的坐标是1-based从1开始而我们的数组是0-based从0开始。 // 这是一个常见的坑点处理方式有两种 // 1. 将输入坐标减1转换为0-based如下所示。 // 2. 声明数组时大小设为[n1][m1]并忽略0行0列。 // 这里采用第一种更符合编程习惯。 startX--; // 转换为0-based行索引 startY--; // 转换为0-based列索引 // 步骤1初始化距离数组-1表示未访问/不可达 int dist[410][410]; // 根据数据范围适当开大一点 memset(dist, -1, sizeof(dist)); // 快速初始化为-1 // 步骤2创建队列并将起点状态入队 queuepairint, int q; q.push({startX, startY}); dist[startX][startY] 0; // 起点到自己的距离为0 // 步骤3开始BFS while (!q.empty()) { // 取出队首的当前状态 auto [x, y] q.front(); q.pop(); // 遍历8个方向 for (int i 0; i 8; i) { int nx x dx[i]; int ny y dy[i]; // 关键判断新坐标是否合法且未被访问过 // 1. nx, ny 必须在棋盘范围内 [0, n-1] 和 [0, m-1] // 2. dist[nx][ny] 必须等于-1未访问 if (nx 0 nx n ny 0 ny m dist[nx][ny] -1) { // 找到一个新的可达格子 dist[nx][ny] dist[x][y] 1; // 其距离为父节点距离1 q.push({nx, ny}); // 将这个新状态加入队列等待后续扩展 } } } // 步骤4输出结果 for (int i 0; i n; i) { for (int j 0; j m; j) { // 使用左对齐宽5格输出符合题目格式要求 printf(%-5d, dist[i][j]); } printf(\n); // 每行输出完换行 } return 0; }逐行核心解析与避坑指南坐标转换第14-15行这是第一个易错点。洛谷的题目输入通常是1-based即左上角为(1,1)而我们在数组中存储使用0-based即左上角为(0,0)。如果不进行转换会导致数组越界或答案错误。务必在读取输入后立即进行-1操作。另一种做法是声明dist[n1][m1]并从下标1开始使用但个人认为统一使用0-based更清晰不易混淆。距离数组初始化第18-19行使用memset(dist, -1, sizeof(dist))将整个数组初始化为-1。-1是一个很好的“未访问”标记因为它不可能是有效的步数步数从0开始。sizeof(dist)能正确计算出整个二维数组的字节大小。BFS循环第25-41行这是算法的核心。while (!q.empty())只要队列不为空就说明还有待探索的节点。auto [x, y] q.front();C17的结构化绑定方便地取出队首坐标。等价于int x q.front().first; int y q.front().second;。方向遍历对于当前点(x, y)尝试所有8种走法计算下一个点(nx, ny)。合法性判断第34行这是第二个关键点必须按顺序判断nx 0 nx n行坐标不越界。ny 0 ny m列坐标不越界。dist[nx][ny] -1该点未被访问过。这个判断必须在坐标合法之后否则可能访问到dist数组外的非法内存导致运行时错误RE。状态更新与入队第36-37行一旦(nx, ny)合法且未访问它的最短距离就是父节点距离加1。然后立即将其入队。这个顺序不能颠倒必须先更新距离再入队否则在极端情况下如起点可能导致逻辑错误。输出格式第46-53行题目要求每个数字占5格、左对齐。使用C语言的printf的%-5d格式控制可以轻松实现。用cout实现则需要配合setw和left稍显繁琐。注意每输出一行后要换行。4. BFS算法模板的通用化提炼通过“马的遍历”我们可以提炼出一个解决二维网格最短路径问题的通用BFS模板。这个模板稍加修改就能解决洛谷上大量的迷宫、连通块问题如P1162、P1141等。// 通用BFS模板框架伪代码 int dist[N][M]; // 距离数组兼作访问标记 int dirs[K][2] {...}; // 移动方向K是方向数如4方向或8方向 void bfs(int startX, int startY) { // 1. 初始化 memset(dist, -1, sizeof(dist)); queuepairint, int q; // 2. 起点处理 dist[startX][startY] 0; // 根据题意起点距离可能是0或其他初始值 q.push({startX, startY}); // 3. BFS主循环 while (!q.empty()) { auto [x, y] q.front(); q.pop(); // 4. 遍历所有可能移动方向 for (int i 0; i K; i) { int nx x dirs[i][0]; int ny y dirs[i][1]; // 5. 合法性判断是否在网格内是否可访问不是墙是否未访问过 if (nx 0 nx N ny 0 ny M map[nx][ny] 可通行标记 dist[nx][ny] -1) { // 6. 更新新状态的距离 dist[nx][ny] dist[x][y] 1; // 或加上本次移动的代价 // 7. 可选如果找到终点可以提前结束 (if (nx targetX ny targetY) return;) // 8. 新状态入队 q.push({nx, ny}); } } } }模板使用要点dist数组的多功能它记录了最短距离其初始值-1也充当了visited数组的角色避免了额外开一个bool数组。dirs方向数组根据具体问题定义。四方向是{(1,0),(-1,0),(0,1),(0,-1)}八方向则包含对角线。合法性判断这是模板中最需要根据题目定制的地方。除了边界和访问标记还可能包括地形判断如是否是水域、墙壁、特殊条件如需要钥匙开门等。提前终止如果是单目标最短路可以在步骤6更新距离后立即判断是否到达终点如果是则直接返回dist[nx][ny]可以节省时间。5. 常见错误与调试技巧实录即便理解了算法在实现时依然会踩坑。下面是我在刷题和教学过程中总结的常见问题。5.1 坐标系统混乱问题表现样例能过但提交后出现“数组越界”、“答案错误”或“运行时错误”。根因分析这是最常见的问题。输入坐标、数组索引、循环边界使用了不同的坐标系。解决方案统一思想在脑海中明确数组下标永远从0开始。这是编程的通用约定。输入转换读入题目给出的1-based坐标后第一时间执行x--; y--;转换为0-based。边界检查在BFS中判断nx, ny时使用nx 0 nx n这里的n是棋盘的行数也是数组第一维的大小。 n意味着最大有效下标是n-1。输出对应输出时我们遍历dist[0..n-1][0..m-1]这正好对应棋盘的n行m列。5.2 队列操作与状态更新顺序错误问题表现程序逻辑看似正确但结果不对或者在某些情况下陷入死循环。根因分析错误1先入队再更新距离。这可能导致同一个节点被重复入队。例如节点A扩展出节点BB被放入队列但距离未标记。在下一轮节点C也可能扩展出B由于B的距离还是-1它又会被放入队列造成重复。错误2忘记弹出队首元素q.pop()导致无限循环处理同一个节点。解决方案严格遵守“先更新状态再入队”的铁律。模板中的顺序dist[nx][ny]...; q.push(...);必须坚持。同时在while循环开头一定要记得q.pop()。5.3 方向数组定义错误或遗漏问题表现马走“日”字但程序走成了“田”字或别的走法结果自然错误。根因分析手动写8个方向时容易写错或写漏。马的走法是“两格一格”的组合共有8种(±2, ±1)和(±1, ±2)。调试技巧将方向数组打印出来或者单独写一个小程序从(0,0)出发用你的方向数组计算8个点看看是不是正确的“日”字形位置。一个快速检查法从(0,0)出发走一步后到达的点其横纵坐标的绝对值之和应为3因为 |2||1|3 或 |1||2|3。5.4 输出格式不符合要求问题表现答案数字都对但提交后显示“格式错误”。根因分析洛谷是严格对比输出的。题目要求“左对齐宽5格”如果你的输出是右对齐、宽度不足或多了空格都会判错。解决方案使用printf进行格式化输出printf(“%-5d”, dist[i][j]);是最稳妥的方式。-表示左对齐5表示宽度为5。使用cout需要包含iomanip并写成cout left setw(5) dist[i][j];。注意setw需要每次输出前设置。检查行末空格/空行通常每行最后一个数字后面不要有空格但题目P1443的格式要求比较宽松主要关注对齐和宽度即可。最保险的方法是完全按照题目给出的样例输出格式来模仿。5.5 性能与空间问题问题表现棋盘较大如400*400时程序运行超时或内存超限。根因分析时间BFS每个节点只入队、出队一次时间复杂度是 O(nm)对于400400160,000个点完全在承受范围内。如果超时检查是否有死循环或无效的重复判断。空间主要开销是dist数组和队列。dist[410][410]约占用 4104104 bytes ≈ 0.67 MB。队列在最坏情况下几乎全图入队可能存储 O(nm) 个元素每个元素是一个pairint,int约8字节160,0008 ≈ 1.28 MB。总内存消耗很小。如果开得过大如dist[1000][1000]则可能达到4MB但通常也符合限制。优化建议对于此题无需过度优化。确保数组大小适当比最大数据范围稍大即可如开410避免使用vector等动态容器时不必要的扩容开销用原生数组或提前reserve。6. 从“马的遍历”到更广阔的BFS应用场景掌握了“马的遍历”这道经典题你手中的BFS就从一个具体的解法变成了一把可以打开许多问题大门的钥匙。它的变体和应用场景极其丰富。1. 多源BFSMulti-source BFS想象一下棋盘上不止一匹马而是有多匹在不同的起始位置。你需要计算每个格子到任意一匹马的最短距离。朴素的做法是对每匹马都做一次BFS然后取最小值但这样复杂度是 O(K * n * m)。更高效的做法是初始化时将所有的马的位置同时放入队列并且距离都记为0。这样BFS会从多个源头同时开始“扩散”每个格子第一次被访问到时其距离就是离它最近的那匹马的距离。洛谷的“P1332 血色先锋队”就是一个典型的多源BFS问题。2. 带权BFS与双端队列BFS0-1 BFS在“马的遍历”中每一步的代价都是1。如果移动代价不同呢比如有些方向走一步代价是0有些是1例如直走免费转弯收费。这时普通BFS就不适用了因为队列的FIFO性质无法保证“当前队列中距离最小的点先出队”。你需要使用双端队列deque对于代价为0的移动将新状态从队头插入对于代价为1的移动从队尾插入。这样能保证队列中的状态始终按距离单调不减从而求出最短路径。这可以看作是Dijkstra算法在边权仅为0或1时的特化高效实现。3. 状态空间搜索BFS不仅能搜地图还能搜“状态”。例如经典的“八数码”问题一个3x3棋盘上的滑块拼图每个状态是整个棋盘的排列。你可以把每一种排列看作一个节点一次合法的滑动看作一条边。BFS可以用来寻找从初始排列到目标排列的最少滑动步数。这时状态表示如将3x3矩阵转化为字符串、状态判重如使用unordered_set就成了新的挑战。4. 连通块问题给一张地图1代表陆地0代表海洋求陆地连通块的个数。这就是一个经典的连通块问题可以用BFS或DFS解决。思路是遍历每个格子如果它是未被访问过的陆地就从它开始进行一次BFS或DFS标记所有能到达的陆地同时连通块计数加1。BFS在搜索过程中使用队列适合寻找“一圈一圈”扩张的连通区域。洛谷的“P1162 填涂颜色”和“P1141 01迷宫”都涉及了连通块的思想。实操心得当你遇到一个新的搜索问题时先问自己几个问题1) 问题的“状态”是什么一个坐标、一个排列、一个组合2) 状态之间如何“转移”即边如何定义3) 转移的“代价”是否相同4) 目标是什么单点最短路径、多点最近距离、连通性判断回答清楚这些问题就能判断是否能用BFS以及需要用哪种变体。从“马的遍历”这个二维坐标状态的等权图最短路出发逐步扩展到更复杂的状态表示和权重处理是学习搜索算法的一条清晰路径。
返回列表