文章目录
广度优先遍历,又称作 BFS ,通常可以用于等权值边最短路问题或最小操作数问题。
BFS 使用一个队列(Queue)来维护当前待访问的节点,每次从队头取出一个节点,将其所有未访问的邻接节点加入队列,从而实现按层遍历。
本质上是层层扩散的算法,而不是像DFS先搜索到底,所以使用 BFS 搜索最先到达就是最短路径(最小操作数)。
基本概念
- 核心定义:
- BFS是一种用于遍历或搜索树或图等数据结构的算法。
- 广度优先: 尽可能搜索当前层次的所有节点,然后才进入下一层级的节点
- 问题解决:
- **遍历图或树:**系统地访问图中的所有节点一次
- 寻找最短路径: 最重要的独特的应用之一
- 在所有边权重(或视为)相同的图中,BFS找到的路径一定是**边数最少(即步数最少)**的路径
- 连通性问题: 判断两个节点是否连通(属于同一个连通分量)。计算连通分量的数量
- 层级分析: 找出距离某个起始节点特定步数(层数)内的所有节点。
- 状态空间搜索(如迷宫、拼图问题): 从一个初始状态开始,每次进行所有可能的合法操作到达新的状态,寻找到达目标状态的最少操作步骤路径。
- 核心流程:
- 从某个起始节点开始, 先访问该起始节点
- 然后访问该起始节点所有直接相邻的节点(第一邻居)
- 接着再访问这些邻居节点尚未被访问过的直接相邻的节点(第二邻居)
- 不断逐层向外扩张,直到访问完所有可达节点(无解)或者找到目标节点(最优解)
工作原理
- 核心数据结构: 队列(Queue)
-
BFS 的实现高度依赖 队列(Queue) 这种数据结构。队列遵循 FIFO(First-In-First-Out,先进先出) 原则。
-
作用: 队列用来存储当前层或待探索的节点。算法会取出队列最先加入的节点(即当前层的节点)进行处理,并将其尚未访问的邻居加入到队列的末尾(即下一层或更远层的节点)。这种顺序保证了广度/层次优先。
-
对比DFS: 深度优先搜索(DFS)通常使用栈(LIFO - Last-In-First-Out),它会沿着一条路径深入探索到底再回溯,不像BFS那样分层扫描。
-
- 算法步骤:
- 设
start
为起始节点 - 初始化:
- 创建一个空队列
q
- 创建一个集合 visited(或者可以是布尔数组,Set,Map等)来标记哪些节点已被访问
- 可选: 创建一个数据结构(如数组或Map) distance记录起点到每个节点的最短距离/步数。
- 可选: 创建一个数据结构(如数组或Map)parent / previous记录路径(某个节点是从哪个邻居节点访问来的)
- 将
start
放入队列q
- 标记
start
为 visited (vis[start] = true;
) - 如果需要记录距离/步数:
dist[start] = 0;
- 创建一个空队列
- 循环: 直到队列为空 或者 到达目标位置/终点
- 出队(Dequeue): 从队列
q
的前端取出一个节点current
- 处理当前节点: 处理
current
,例如:输出值、检查是否目标值… - 访问邻居: 遍历
current
的所有未被访问过的邻居节点neighbor
- 标记
neighbor
为visitedvis[neighbor] = true;
- 将
neighbor
加入队列q
的末尾(Enqueue) - 如果记录距离:
dist[neighbor] = dist[]current + 1;
- 如果记录路径:
parent[neighbor] = current;
- 标记
- 结束:
- 当队列为空时,说明所有起点可达的节点都已经被访问过
- 如果到达目标值/终点, 可以提前终止循环(break)
- 出队(Dequeue): 从队列
- 设
- 分层理解: 想象队列处理的过程
- 在每一轮循环的开始,队列里存放的都是当前层的所有节点
- 当我们把这些节点(假设有 k 个)逐个从队列头部取出时,我们就处理完了当前层
- 在处理每个current节点时,我们会将它所有未访问的邻居加入队列未尾。这些加入的节点就属于下一层。
- 当我们将当前层的 k 个节点都处理完并出队后,队列里剩下的节点恰好就是下一层的所有节点(因为新加入的都放末尾了)。
- 下一轮循环开始,这些节点又成为当前层,如此往复,层次分明。记录distance实际上就是记录了节点所在的层数(距离起点的步数)
复杂度分析
时间复杂度
- 邻接表(Adjacency List):
O(V+E)
。其中V是顶点数(Vertex),E是边数(Edge)。因为每个节点访问一次(入队出队一次O(V)
),每条边也被检查一次(O(E)
)。这是最常见的情况 - 邻接矩阵 (Adjacency Matrix):
O(V^2^)
。因为访问每个节点时,需要扫描一行(长度为 V)来检查邻居关系
空间复杂度 O(V)
主要消耗在:
- 存储访问标记的
vis
数组/集合:O(V)
- 队列 q :在最坏情况下(如一个完全二叉树最后两层),队列可能存储接近
O(V)
个节点(最后一层节点数) - 距离数组和父节点数组(如果需要):也各是
O(V)
简单应用
例题:走迷宫
问题描述
题目描述
给定一个 N x M 的网格迷宫 G,其中每个格子要么是道路(用1表示),要么是障碍物(用0 表示)。
你从迷宫的入口位置 (x1, y1) 出发,目标是走到出口位置 (x2, y2) 。请你计算从入口走到出口,最少需要经过多少个格子(包含起点和终点)。只能向上下左右四个方向走,并且不能走出边界或进入障碍物。
若无法到达出口,请输出 一1。
输入格式
第一行包含两个正整数 N, M,表示迷宫的大小。
接下来 N 行,每行 M 个整数 Gi,j,若 Gi,j = 1表示该格子是道路,Gi,j = 0表示该格子是障碍物。
最后一行包含四个整数x1, y1, x2, y2,表示入口位置和出口位置。
(1 ≤ N, M ≤ 100, 1 ≤ x1, x2 ≤ N, l ≤ y1, y2 ≤ M)
输出格式
输出一个整数,表示从入口走到出口最少经过的格子数。若无法到达出口,输出-1。
样例输入
5 5
1 0 1 1 0
1 1 0 1 1
0 1 0 1 1
1 1 1 1 1
1 0 0 0 1
1 1 5 5
样例输出
8
问题分析
经典简单的迷宫最短路问题。
算法设计
BFS广度优先遍历,确保到达终点是最短路径(经过格子数最少)
详细实现
- 初始化数据结构:
- 队列 queue 存储待访问的节点
- 二维数组 dist 记录起点走到每个格子所需步数,初始化为
-1
表示未访问 - 方向数组
dir_x
,dir_y
表示上下左右移动
- 起点入队:
- 将起点
(x1, y1)
入队 - 设置
dist[x1][y1] = 1
,表示起点出发已经走了一个格子(包含自身,已走格子数和路径长度并不等同)
- 将起点
- BFS循环:
- 每次从队首取出一个点(current)
(x, y)
- 向四个方向扩展,计算相邻点 (neighbor)
(nx, ny)
,如果:- 边界约束:
1 <= nx && nx <= n
并且1 <= ny && ny <= m
--> 没有越界 - 障碍物/道路:
mp[nx][ny] == 1
--> 是道路 - 访问标记:
dist[nx][ny] == -1
--> 未访问 - 那么就入队,并且记录格子数
dist[nx][ny] = dist[x][y] + 1
- 边界约束:
- 每次从队首取出一个点(current)
- 结束条件:
- 队列为空,即达不到终点,输出 -1
- 终点可达,输出格子数
#include <iostream>
#include <queue>
#include <cstring> // memset头文件
using namespace std;
const int N = 110;
int n, m; // 迷宫的大小,n 行 m 列
int x1, x2, y1, y2; // 起点和终点坐标
int mp[N][N]; // 迷宫数组,存储道路和障碍物坐标
int dist[N][N]; // 标记数组,记录未访问状态(-1)和到达当前位置经过的格子数
// 方向数组 下、右、上、左
const int dir_x[4] = {1, 0, -1, 0}; // 横向方向数组
const int dir_y[4] = {0, 1, 0, -1}; // 纵向方向数组
void bfs() {
// 初始化标记数组
memset(dist, -1, sizeof(dist)); // -1 表示未访问
queue<pair<int, int>> q; // 使用 pair 存储坐标
q.emplace(x1, y1); // 使用emplace代替push(后续讲解)
dist[x1][y1] = 0; // 起点步数为 0
while (!q.empty()) {
// pair<int, int> t = q.front(); // 队首坐标存入 t
// int x = t.first, y = t.second; // (x, y) 是当前坐标
auto [x, y] = q.front(); // c++ 17 结构化绑定
q.pop(); // 取出队首
// 检查是否到达终点
if (x == x2 && y == y2) { // 到达终点
cout << dist[x][y] << endl;
return;
}
for (int i = 0; i < 4; i++) { // 遍历所有方向(也就是模仿现实中走迷宫四个方向都试一试)
int nx = x + dir_x[i], ny = y + dir_y[i];
if (nx < 1 || nx > n || y < 1 || y > m) continue; // 是否超出迷宫边界 -- 边界约束
if (mp[nx][ny] == 0 || dist[nx][ny] != -1) continue; // 是否能走:障碍物或者已访问
// 可以走这一步
dist[nx][ny] = dist[x][y] + 1; // 上一步的步数基础上加一
q.emplace(nx, ny); // 新坐标入队
}
}
// 没有走到终点
cout << -1 << endl;
}
int main() {
cin >> n >> m;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
cin >> mp[i][j];
}
}
cin >> x1 >> y1 >> x2 >> y2;
bfs();
return 0;
}
知识补充
queue入队方法比较:emplace和push
C++ 中,queue
的 push()
和 emplace()
方法都用于向队列尾部添加元素,但它们的用法和效率有区别。
push()
方法
- 用法:接受一个已存在的对象(复制或移动)
- 适用场景:
- 已有现成的对象需要放入队列
- 需要显式复制或移动对象
queue<MyClass> q;
MyClass obj(10); // 已存在的对象
q.push(obj); // 复制构造(可能开销大)
q.push(move(obj)); // 移动构造(高效)
q.push(MyClass(20)); // 构造临时对象 + 移动
emplace()
方法
- 用法:直接在队列内存中构造对象(无需创建临时对象)
- 适用场景:
- 优先使用:直接传递构造参数,避免临时对象
- 对象构造开销大时(减少一次复制/移动)
queue<MyClass> q;
q.emplace(10); // 直接在队列中构造 MyClass(10)
q.emplace(20, "abc"); // 多参数构造:MyClass(20, "abc")
选择原则
场景 | 推荐方法 | 原因 |
---|---|---|
已有对象需放入队列 | push() | 直接利用现有对象 |
需要移动语义(如 move ) | push() | 显式控制移动操作 |
直接传递构造参数 | emplace() | 避免创建临时对象,性能更高(尤其大对象) |
构造参数复杂或多参数 | emplace() | 语法更简洁,避免额外构造步骤 |
效率对比
// 方式1:push + 临时对象(低效)
q.push(MyClass(10)); // 步骤: 构造临时对象 + 移动构造 + 销毁临时对象
// 方式2:emplace(高效)
q.emplace(10); // 步骤: 直接在队列内存中构造
emplace()
通常比push()
+ 临时对象 少一次移动/复制操作,对大型对象或不可移动对象更高效。
最佳实践:默认使用
emplace()
,仅在已有对象或需显式移动时用push()
。
memset函数
memset
是 C/C++ 标准库中的内存操作函数,用于将一块内存区域的内容设置为指定的值。它定义在头文件 <cstring>
(C++)或 <string.h>
(C)中。
函数原型
void* memset(void* dest, int ch, size_t count);
- dest: 指向目标内存起始地址的指针
- ch: 要设置的填充值(以
int
形式传入,实际会转换为unsigned char
) - count: 要设置的字节数
- 返回值: 返回目标内存的指针
dest
基本用法
将连续的内存块设置为指定值:
#include <cstring>
char buffer[100];
// 将buffer全部置为0
memset(buffer, 0, sizeof(buffer));
// 将前50字节置为'A' (ASCII 65)
memset(buffer, 'A', 50);
关键特性
-
按字节操作
- 每次操作设置 1 字节的内存
int arr[10]; memset(arr, 0, 10 * sizeof(int)); // 正确:整个数组置0 memset(arr, 1, 10 * sizeof(int)); // 危险!每个int变为0x01010101 (16843009)
-
常见用途
- 内存清零:
memset(ptr, 0, size)
- 内存初始化:
memset(struct_ptr, 0, sizeof(MyStruct))
- 填充字符:
memset(str, '-', 20)
(创建分隔线)
- 内存清零:
-
效率
- 比循环赋值更高效(编译器常优化为单条 CPU 指令)
替代方案(C++推荐)
场景 | 推荐方案 | 示例 |
---|---|---|
数组清零 | 统一初始化 | int arr[100] = {0}; |
对象初始化 | 构造函数 | MyClass obj{}; |
内存填充 | fill | fill(arr, arr+100, 0); |
结构体清零 | 值初始化 | MyStruct s = {}; |
练习题(后续会发布解题报告并补上链接)
最小操作数
题目描述
给定两个整数 n 和 k。
现你有以下操作可以执行!
- 令 n = n + 1
- 令 n = n - 1
- 令 n = n × 2
问要使得 n = k,至少要执行多少次操作。
输入描述
输入进一行,包含两个整数 n, k。(1 ≤ n, k ≤ 105)
输出描述
输出一个整数表示答案。
输入输出样例:
- 输入
3 19
- 输出
5
最多金币数量
问题描述
小蓝为了寻宝来到了一个迷宫,这个迷宫是一个n x m 的矩阵,矩阵元素由0和1构成,小蓝现在需要从起点走到终点,他只能上下左右移动且走值为 1 的格子。当他走到终点时,他可以获得矩阵中值为 0的格子的数量的金币。现在小蓝可以使用一个魔法,该魔法只能使用一次,魔法的功能为在他开始出发前,可以将任意个值为 1的格子变成 0,现在问你小蓝在走到终点后能获得的最大金币为多少?
数据保证在一开始时,有一条起点通往终点的路线,你无法将起点与终点变成 0。
输入格式
第一行输入二个整数 n, m,代表矩阵大小。
第二行输入四个整数 x1, y1, x2, y2,代表起点与终点坐标。
接下来 n 行每行输入一个长度为 m 的01
串,代表迷宫的构造情况。
输出格式
输出小蓝能获得的最大金币数量。
输入输出样例:
- 输入
4 4
1 1 4 4
1100
1111
1001
1111
- 输出
9
八数码
问题描述
给定一个 3 × 3 的网格, 其由 1 ~ 8 和一个x
构成
例如:
1 2 3
4 x 6
7 5 8
我们可以将x
与其上、下、左、右四个方向之一的数字互换位置(如果存在)我们目的是通过交换,使得网格变成如下格式(也可以称作标准格式)
1 2 3
4 5 6
7 8 x
例如示例的交换过程为:
1 2 3 | 1 2 3 | 1 2 3
4 x 6 | 4 5 6 | 4 5 6
7 5 8 | 7 x 8 | 7 8 x
现在给定你一个初始网格,请你求出得到标准格式的最少交换次数,如果不存在可行解,则输出-1
输入格式
输入一行,表示初始网格。
例如:
用1 2 3 4 x 6 7 5 8
表示:
1 2 3
4 x 6
7 5 8
输出格式
输出一个整数,表示最少交换次数,如果不存在可行解,则输出-1
。
输入输出样例:
- 输入
1 3 x 2 5 8 4 7 6
- 输出
16