广度优先遍历

广度优先遍历,又称作 BFS ,通常可以用于等权值边最短路问题或最小操作数问题。
BFS 使用一个队列(Queue)来维护当前待访问的节点,每次从队头取出一个节点,将其所有未访问的邻接节点加入队列,从而实现按层遍历。

BFS图示
本质上是层层扩散的算法,而不是像DFS先搜索到底,所以使用 BFS 搜索最先到达就是最短路径(最小操作数)。

基本概念

  1. 核心定义:
    • BFS是一种用于遍历或搜索等数据结构的算法。
    • 广度优先: 尽可能搜索当前层次的所有节点,然后才进入下一层级的节点
  2. 问题解决:
    • **遍历图或树:**系统地访问图中的所有节点一次
    • 寻找最短路径: 最重要的独特的应用之一
      • 在所有边权重(或视为)相同的图中,BFS找到的路径一定是**边数最少(即步数最少)**的路径
    • 连通性问题: 判断两个节点是否连通(属于同一个连通分量)。计算连通分量的数量
    • 层级分析: 找出距离某个起始节点特定步数(层数)内的所有节点。
    • 状态空间搜索(如迷宫、拼图问题): 从一个初始状态开始,每次进行所有可能的合法操作到达新的状态,寻找到达目标状态的最少操作步骤路径。
  3. 核心流程:
    • 从某个起始节点开始, 先访问该起始节点
    • 然后访问该起始节点所有直接相邻的节点(第一邻居)
    • 接着再访问这些邻居节点尚未被访问过的直接相邻的节点(第二邻居)
    • 不断逐层向外扩张,直到访问完所有可达节点(无解)或者找到目标节点(最优解)

工作原理

  1. 核心数据结构: 队列(Queue)
    • BFS 的实现高度依赖 ​​队列(Queue)​​ 这种数据结构。队列遵循 ​​FIFO(First-In-First-Out,先进先出)​​ 原则。

    • 作用: 队列用来存储​​当前层​或待探索的节点。算法会取出队列​​最先加入​​的节点(即当前层的节点)进行处理,并将其​​尚未访问的邻居​​加入到队列的​​末尾​​(即下一层或更远层的节点)。这种顺序保证了广度/层次优先

    • ​​对比DFS: 深度优先搜索(DFS)通常使用栈(LIFO - Last-In-First-Out),它会沿着一条路径深入探索到底再回溯,不像BFS那样分层扫描。

  2. 算法步骤:
    • 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 为visited vis[neighbor] = true;
        • neighbor 加入队列 q末尾(Enqueue)
        • 如果记录距离: dist[neighbor] = dist[]current + 1;
        • 如果记录路径: parent[neighbor] = current;
      • 结束:
        • 当队列为空时,说明所有起点可达的节点都已经被访问过
        • 如果到达目标值/终点, 可以提前终止循环(break)
  3. 分层理解: 想象队列处理的过程
    • 在每一轮循环的开始,队列里存放的都是当前层的所有节点
    • 当我们把这些节点(假设有 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广度优先遍历,确保到达终点是最短路径(经过格子数最少)

详细实现

  1. 初始化数据结构:
    • 队列 queue 存储待访问的节点
    • 二维数组 dist 记录起点走到每个格子所需步数,初始化为 -1 表示未访问
    • 方向数组 dir_xdir_y 表示上下左右移动
  2. 起点入队:
    • 将起点 (x1, y1) 入队
    • 设置 dist[x1][y1] = 1 ,表示起点出发已经走了一个格子(包含自身,已走格子数和路径长度并不等同)
  3. 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
  4. 结束条件:
    • 队列为空,即达不到终点,输出 -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++ 中,queuepush()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()直接利用现有对象
需要移动语义(如 movepush()显式控制移动操作
直接传递构造参数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. 按字节操作

    • 每次操作设置 1 字节的内存
    int arr[10];
    memset(arr, 0, 10 * sizeof(int));  // 正确:整个数组置0
    memset(arr, 1, 10 * sizeof(int));  // 危险!每个int变为0x01010101 (16843009)
    
  2. 常见用途

    • 内存清零:memset(ptr, 0, size)
    • 内存初始化:memset(struct_ptr, 0, sizeof(MyStruct))
    • 填充字符:memset(str, '-', 20)(创建分隔线)
  3. 效率

    • 比循环赋值更高效(编译器常优化为单条 CPU 指令)

替代方案(C++推荐)
场景推荐方案示例
数组清零统一初始化int arr[100] = {0};
对象初始化构造函数MyClass obj{};
内存填充fillfill(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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值