算法基础篇:(十九)吃透 BFS!从原理到实战,解锁宽度优先搜索的核心玩法

目录

前言

[一、BFS 到底是什么?------ 从 "一层一层找答案" 说起](#一、BFS 到底是什么?—— 从 “一层一层找答案” 说起)

[1.1 BFS 的核心思想](#1.1 BFS 的核心思想)

[1.2 BFS 的基本框架](#1.2 BFS 的基本框架)

[二、BFS 经典例题实战 ------ 从基础到进阶](#二、BFS 经典例题实战 —— 从基础到进阶)

[2.1 基础款:马的遍历(洛谷 P1443)](#2.1 基础款:马的遍历(洛谷 P1443))

题目描述

核心分析

完整代码

关键细节

[2.2 进阶款:kotori 和迷宫(牛客网)](#2.2 进阶款:kotori 和迷宫(牛客网))

题目描述

核心分析

完整代码

关键细节

[2.3 优化款:Catch That Cow(洛谷 USACO07OPEN)](#2.3 优化款:Catch That Cow(洛谷 USACO07OPEN))

题目描述

核心分析

完整代码

关键细节

[2.4 高阶款:八数码难题(洛谷 P1379)](#2.4 高阶款:八数码难题(洛谷 P1379))

题目描述

核心分析

完整代码

关键细节

[三、BFS 的扩展玩法 ------ 多源 BFS、01 BFS](#三、BFS 的扩展玩法 —— 多源 BFS、01 BFS)

[3.1 多源 BFS:矩阵距离(牛客网)](#3.1 多源 BFS:矩阵距离(牛客网))

核心思想

典型场景

核心代码片段

[3.2 01 BFS:小明的游戏(洛谷 P4554)](#3.2 01 BFS:小明的游戏(洛谷 P4554))

核心思想

典型场景

核心代码片段

[四、BFS 的避坑指南 ------ 这些错误 90% 的人都会犯](#四、BFS 的避坑指南 —— 这些错误 90% 的人都会犯)

总结


前言

在算法的世界里,搜索类算法是解决 "找路径、求最优" 问题的核心武器,而宽度优先搜索(BFS)更是其中的 "明星选手"------ 它凭借 "逐层扩展、最短路径" 的特性,成为处理边权为 1 的最短路问题的首选。今天,我们就从零开始,把 BFS 的底层逻辑、实现技巧、经典例题掰开揉碎讲清楚,让你不仅会写代码,更能理解 "为什么要这么写"。下面就让我们正式开始吧!


一、BFS 到底是什么?------ 从 "一层一层找答案" 说起

1.1 BFS 的核心思想

BFS(Breadth-First Search),直译是 "宽度优先搜索",也常被称作 "广度优先搜索"。如果说深度优先搜索(DFS)是 "一条路走到黑,走不通再回头",那 BFS 就是 "稳扎稳打,一层一层往外扩"。

想象一个场景:你站在迷宫的起点,想要找到出口,且要求走最少的步数。DFS 的做法是随便选一个方向冲到底,碰壁了就退回来换方向;而 BFS 的做法是,先把起点周围一步能到的位置都走一遍,再把这些位置周围两步能到的位置走一遍...... 直到找到出口。这种 "逐层探索" 的方式,天然保证了 "第一次遇到目标时,走的步数就是最少的"------ 这也是 BFS 最核心的优势:在边权为 1 的图 / 网格中,能直接求出起点到目标点的最短路径

1.2 BFS 的基本框架

BFS 的实现离不开两个关键工具:队列 (用来存储待扩展的节点)和标记数组(用来记录节点是否被访问过,避免重复搜索)。

我们先给出 BFS 的通用模板(以网格类问题为例),后续所有例题都会基于这个模板变形:

cpp 复制代码
#include <iostream>
#include <queue>
#include <cstring>
using namespace std;

// 定义方向数组(上下左右)
int dx[] = {0, 0, 1, -1};
int dy[] = {1, -1, 0, 0};
// 标记数组:记录是否访问过,也可记录最短距离
int dist[100][100];
// 网格大小
int n, m;

void bfs(int start_x, int start_y) {
    // 初始化:距离数组置为-1(表示未访问)
    memset(dist, -1, sizeof dist);
    // 队列存储坐标对
    queue<pair<int, int>> q;
    // 起点入队,距离置为0
    q.push({start_x, start_y});
    dist[start_x][start_y] = 0;

    // 队列不为空时循环
    while (!q.empty()) {
        // 取出队头节点
        auto t = q.front();
        q.pop();
        int x = t.first, y = t.second;

        // 扩展四个方向
        for (int i = 0; i < 4; i++) {
            int nx = x + dx[i];
            int ny = y + dy[i];
            // 检查边界 + 未访问
            if (nx >= 1 && nx <= n && ny >= 1 && ny <= m && dist[nx][ny] == -1) {
                // 更新距离:当前距离+1
                dist[nx][ny] = dist[x][y] + 1;
                // 新节点入队
                q.push({nx, ny});
                // 找到目标可提前退出(可选)
                if (nx == 目标x && ny == 目标y) return;
            }
        }
    }
}

这个模板看似简单,但藏着几个关键细节:

  1. 队列的作用 :保证 "先进先出",符合 BFS "逐层扩展" 的逻辑;
  2. 距离数组 dist:既标记是否访问,又记录最短距离,一举两得;
  3. 方向数组:把 "上下左右" 的移动转化为循环,简化代码;
  4. 提前退出:如果目标明确,找到后可直接 return,减少不必要的计算。

二、BFS 经典例题实战 ------ 从基础到进阶

光说不练假把式,接下来我们结合 4 道经典例题,一步步拆解 BFS 的应用技巧,从 "马的遍历" 到 "八数码难题",难度由浅入深,带你吃透 BFS 的精髓。

2.1 基础款:马的遍历(洛谷 P1443)

题目链接: https://www.luogu.com.cn/problem/P1443

题目描述

给定一个 n×m 的棋盘,马初始在 (x,y) 位置,求马到达棋盘上任意点的最少步数(不能到达则输出 - 1)。

核心分析

这是 BFS 的入门题,核心考点是**"多方向扩展"**------ 马走 "日",有 8 个移动方向,只需把方向数组改一下,其余逻辑和通用模板一致。

完整代码

cpp 复制代码
#include <iostream>
#include <queue>
#include <cstring>
using namespace std;

typedef pair<int, int> PII;
const int N = 410;

int n, m, x, y;
int dist[N][N];
// 马的8个移动方向(走日)
int dx[] = {1, 2, 2, 1, -1, -2, -2, -1};
int dy[] = {2, 1, -1, -2, -2, -1, 1, 2};

void bfs() {
    memset(dist, -1, sizeof dist);
    queue<PII> q;
    q.push({x, y});
    dist[x][y] = 0;

    while (q.size()) {
        auto t = q.front();
        q.pop();
        int i = t.first, j = t.second;

        for (int k = 0; k < 8; k++) {
            int nx = i + dx[k], ny = j + dy[k];
            // 边界检查 + 未访问
            if (nx >= 1 && nx <= n && ny >= 1 && ny <= m && dist[nx][ny] == -1) {
                dist[nx][ny] = dist[i][j] + 1;
                q.push({nx, ny});
            }
        }
    }
}

int main() {
    cin >> n >> m >> x >> y;
    bfs();
    // 输出结果矩阵
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            cout << dist[i][j] << " ";
        }
        cout << endl;
    }
    return 0;
}

关键细节

  • 马的方向数组是本题的唯一难点,记住 "走日" 的 8 个组合即可;
  • 距离数组初始化为 - 1,起点置 0,扩展时每一步距离 + 1,天然满足 "最短步数";
  • 无需提前退出,因为要计算所有点的距离,需遍历完整个队列。

2.2 进阶款:kotori 和迷宫(牛客网)

题目链接: https://ac.nowcoder.com/acm/problem/50041

题目描述

n×m 的迷宫,最外层是岩浆(无法走),有 k 个出口('e'),起点是 'k',道路是 '.',墙壁是 '*'。求能到达的出口数量,以及最近出口的步数(无法到达输出 - 1)。

核心分析

这道题在基础 BFS 上增加了 "多目标判断":需要遍历所有出口,统计可达数量和最短距离。核心思路是:

  1. 先用 BFS 求出起点到所有点的最短距离;
  2. 遍历整个迷宫,筛选出标记为 'e' 且距离不为 - 1 的位置,统计数量和最小值。

完整代码

cpp 复制代码
#include <iostream>
#include <queue>
#include <cstring>
#include <climits>
using namespace std;

typedef pair<int, int> PII;
const int N = 35;

int n, m, start_x, start_y;
char a[N][N];
int dist[N][N];

int dx[] = {0, 0, 1, -1};
int dy[] = {1, -1, 0, 0};

void bfs() {
    memset(dist, -1, sizeof dist);
    queue<PII> q;
    q.push({start_x, start_y});
    dist[start_x][start_y] = 0;

    while (q.size()) {
        auto t = q.front();
        q.pop();
        int i = t.first, j = t.second;

        for (int k = 0; k < 4; k++) {
            int nx = i + dx[k], ny = j + dy[k];
            // 边界 + 不是墙壁 + 未访问
            if (nx >= 1 && nx <= n && ny >= 1 && ny <= m && a[nx][ny] != '*' && dist[nx][ny] == -1) {
                dist[nx][ny] = dist[i][j] + 1;
                q.push({nx, ny});
            }
        }
    }
}

int main() {
    cin >> n >> m;
    // 读入迷宫,定位起点
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            cin >> a[i][j];
            if (a[i][j] == 'k') {
                start_x = i;
                start_y = j;
            }
        }
    }

    bfs();

    // 统计出口数量和最短距离
    int cnt = 0;
    int min_step = INT_MAX;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (a[i][j] == 'e' && dist[i][j] != -1) {
                cnt++;
                min_step = min(min_step, dist[i][j]);
            }
        }
    }

    if (cnt == 0) {
        cout << -1 << endl;
    } else {
        cout << cnt << " " << min_step << endl;
    }

    return 0;
}

关键细节

  • 迷宫类问题的核心是**"合法路径判断":本题中需排除墙壁('*')和岩浆(题目说最外层是岩浆,只需限制nx/ny**在 1~n/1~m 即可);
  • 最短距离初始化用 INT_MAX(需包含<climits>),避免初始值干扰最小值计算;
  • 统计阶段要同时满足 "是出口" 和 "可达(距离≠-1)" 两个条件。

2.3 优化款:Catch That Cow(洛谷 USACO07OPEN)

题目链接: https://www.luogu.com.cn/problem/P1588

题目描述

FJ 和牛在一维直线上,初始位置 x 和 y(牛不动)。FJ 的移动方式:前进一步(x+1)、后退一步(x-1)、瞬移(x×2),求追上牛的最少步数。

核心分析

这是一维 BFS 的经典题,看似简单,但不剪枝会超时!因为 x 可能达到 1e5,盲目扩展会导致队列过大。核心优化点(剪枝策略):

  1. 后退到负数:剪掉(退到负数再回来,步数一定更多);
  2. 前进超过 y:剪掉(已经在牛右边,再前进只会离得更远);
  3. 瞬移超过 2*y:剪掉(若 y 是偶数,瞬移到超过 y 不如先减到 y/2 再瞬移;若 y 是奇数,超过 y+1 也无意义)。

完整代码

cpp 复制代码
#include <iostream>
#include <queue>
#include <cstring>
using namespace std;

const int N = 1e5 + 10;
int dist[N];
int x, y;

void bfs() {
    memset(dist, -1, sizeof dist);
    queue<int> q;
    q.push(x);
    dist[x] = 0;

    while (q.size()) {
        int t = q.front();
        q.pop();

        // 找到目标,提前退出
        if (t == y) return;

        // 扩展三种移动方式
        // 1. 前进一步
        if (t + 1 <= N - 10 && dist[t + 1] == -1) {
            dist[t + 1] = dist[t] + 1;
            q.push(t + 1);
        }
        // 2. 后退一步(不能为负)
        if (t - 1 >= 0 && dist[t - 1] == -1) {
            dist[t - 1] = dist[t] + 1;
            q.push(t - 1);
        }
        // 3. 瞬移(不超过2*y,避免无效扩展)
        if (t * 2 <= 2 * y && dist[t * 2] == -1) {
            dist[t * 2] = dist[t] + 1;
            q.push(t * 2);
        }
    }
}

int main() {
    int T;
    cin >> T;
    while (T--) {
        cin >> x >> y;
        bfs();
        cout << dist[y] << endl;
    }
    return 0;
}

关键细节

  • 一维 BFS 的队列存储的是 "位置值",而非坐标对,简化了数据结构;
  • 剪枝是本题的核心:瞬移的**剪枝条件(t2 <= 2y)**能大幅减少队列大小,避免超时;
  • 多组数据时,每次 BFS 前要重置 dist 数组(memset),否则会继承上一组的结果。

2.4 高阶款:八数码难题(洛谷 P1379)

题目链接: https://www.luogu.com.cn/problem/P1379

题目描述

3×3 棋盘上有 8 个棋子(1~8)和一个空格(0),空格周围的棋子可移动到空格。给定初始状态,求到目标状态(123804765)的最少步数。

核心分析

这是 BFS 的经典难题,核心难点是**"状态表示"** 和**"状态转换"**:

  1. 状态表示:把 3×3 棋盘转化为字符串(如初始状态 283104765 对应字符串 "283104765"),方便存储和哈希;
  2. 状态转换:找到空格的位置(pos),计算其在 3×3 棋盘中的二维坐标(x=pos/3,y=pos%3),然后交换空格与上下左右棋子的位置,生成新状态;
  3. 距离记录:用 **unordered_map<string, int>**存储每个状态的最短距离,避免重复访问。

完整代码

cpp 复制代码
#include <iostream>
#include <queue>
#include <unordered_map>
using namespace std;

string aim = "123804765";
unordered_map<string, int> dist;

// 方向数组(上下左右)
int dx[] = {0, 0, 1, -1};
int dy[] = {1, -1, 0, 0};

void bfs(string start) {
    queue<string> q;
    q.push(start);
    dist[start] = 0;

    while (q.size()) {
        string t = q.front();
        q.pop();

        // 找到目标,提前退出
        if (t == aim) return;

        // 找到空格的位置
        int pos = t.find('0');
        int x = pos / 3, y = pos % 3;

        // 扩展四个方向
        for (int i = 0; i < 4; i++) {
            int nx = x + dx[i];
            int ny = y + dy[i];
            // 边界检查
            if (nx >= 0 && nx < 3 && ny >= 0 && ny < 3) {
                // 计算新状态的空格位置
                int new_pos = nx * 3 + ny;
                // 交换空格与相邻棋子
                string next = t;
                swap(next[pos], next[new_pos]);
                // 未访问过则更新距离
                if (!dist.count(next)) {
                    dist[next] = dist[t] + 1;
                    q.push(next);
                }
            }
        }
    }
}

int main() {
    string s;
    cin >> s;
    bfs(s);
    cout << dist[aim] << endl;
    return 0;
}

关键细节

  • 状态转换的核心是 "一维下标与二维坐标的转换":pos = x*3 + yx = pos/3y = pos%3(这个技巧在网格类问题中高频使用);
  • unordered_mapcount 方法:判断状态是否已访问,避免重复入队;
  • 字符串的 find方法:快速定位空格('0')的位置,简化代码。

三、BFS 的扩展玩法 ------ 多源 BFS、01 BFS

除了基础 BFS,还有两种高频变种:多源 BFS01 BFS,它们是解决复杂问题的 "杀手锏"。

3.1 多源 BFS:矩阵距离(牛客网)

题目链接: https://ac.nowcoder.com/acm/problem/51024

核心思想

当问题有多个起点时,把所有起点一次性加入队列,视为 "超级源点",然后按普通 BFS 扩展 ------ 本质是 "把多个单源 BFS 合并为一次遍历",大幅降低时间复杂度。

典型场景

求矩阵中每个 0 到最近的 1 的曼哈顿距离(曼哈顿距离:|x1-x2| + |y1-y2|)。

核心代码片段

cpp 复制代码
void bfs() {
    memset(dist, -1, sizeof dist);
    queue<PII> q;
    // 所有1的位置入队(多源起点)
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (a[i][j] == '1') {
                q.push({i, j});
                dist[i][j] = 0;
            }
        }
    }

    // 普通BFS扩展
    while (q.size()) {
        auto t = q.front();
        q.pop();
        int x = t.first, y = t.second;
        for (int i = 0; i < 4; i++) {
            int nx = x + dx[i], ny = y + dy[i];
            if (nx >= 1 && nx <= n && ny >= 1 && ny <= m && dist[nx][ny] == -1) {
                dist[nx][ny] = dist[x][y] + 1;
                q.push({nx, ny});
            }
        }
    }
}

3.2 01 BFS:小明的游戏(洛谷 P4554)

题目链接: https://www.luogu.com.cn/problem/P4554

核心思想

当边权只有 0 和 1 时,用**双端队列(deque)**替代普通队列:

  • 边权为 0:新节点加入队头(优先扩展,保证距离最短);
  • 边权为 1:新节点加入队尾(常规扩展)。这种方式能在 O (nm) 时间内求出最短路径,比 Dijkstra 算法更高效。

典型场景

棋盘移动,走相同类型格子费用 0,不同类型费用 1,求最小费用。

核心代码片段

cpp 复制代码
int bfs() {
    memset(dist, -1, sizeof dist);
    deque<PII> q;
    q.push_back({x1, y1});
    dist[x1][y1] = 0;

    while (q.size()) {
        auto t = q.front();
        q.pop_front();
        int a = t.first, b = t.second;

        if (a == x2 && b == y2) return dist[a][b];

        for (int i = 0; i < 4; i++) {
            int nx = a + dx[i], ny = b + dy[i];
            if (nx >= 1 && nx <= n && ny >= 1 && ny <= m && dist[nx][ny] == -1) {
                if (g[a][b] == g[nx][ny]) {
                    // 边权0,加入队头
                    dist[nx][ny] = dist[a][b];
                    q.push_front({nx, ny});
                } else {
                    // 边权1,加入队尾
                    dist[nx][ny] = dist[a][b] + 1;
                    q.push_back({nx, ny});
                }
            }
        }
    }
    return -1;
}

四、BFS 的避坑指南 ------ 这些错误 90% 的人都会犯

  1. 忘记标记访问状态:导致节点重复入队,队列无限膨胀,超时或内存溢出;
  2. 边界判断错误:比如把 "nx <= n" 写成 "nx < n",漏掉最后一行 / 列,导致结果错误;
  3. 多组数据未重置数组:dist、st 等数组继承上一组结果,逻辑混乱;
  4. 方向数组写错:比如把马的方向数组写漏,导致扩展不完整;
  5. 状态表示不当:如八数码用二维数组存储状态,无法哈希,导致重复访问。

总结

BFS 看似简单,但想要用得 "炉火纯青",需要吃透底层逻辑,更要多练、多总结。希望这篇文章能帮你打通 BFS 的任督二脉,下次遇到搜索问题,能第一时间想到 "用 BFS 是不是更优?"------ 这才是真正掌握了这一算法的精髓。

最后,留一个小问题:八数码问题中,如果目标状态不固定,如何优化状态存储?欢迎在评论区交流~

相关推荐
测试仪器廖生1359025638516 小时前
罗德与施瓦茨 FSP13频谱分析仪FSP30
网络·人工智能·算法
happymaker062616 小时前
LeetCodeHot100——560.和为K的子数组
算法
dtq042416 小时前
C语言刷题数组5,6(求平均值,求最大值)
c语言·数据结构·算法
郭梧悠16 小时前
Hash算法入门Hash冲突解决方案
算法·哈希算法
洛水水17 小时前
【力扣100题】81.寻找两个正序数组的中位数
数据结构·算法·leetcode
happymaker062617 小时前
LeetCodeHot100——155.最小栈
算法
洛水水17 小时前
【力扣100题】85.每日温度
算法·leetcode·职场和发展
Coder-magician18 小时前
《代码随想录》刷题打卡day15:二叉树part05
数据结构·c++·算法
Kurisu_红莉栖18 小时前
力扣56合并区间
算法·leetcode
Irissgwe18 小时前
算法的时间复杂度和空间复杂度
数据结构·c++·算法·c·时间复杂度·空间复杂度