ACM 一个月学习计划 · DAY 20

Day 20 · BFS 入门:一层一层地扩散

队列 + 方向数组 + 「入队即标记」—— 求最少步数的标准武器

今日目标:会写队列实现的 BFS,知道什么时候必须用 BFS 而不是 DFS
预计用时:约 3.5 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1443 / P1135 / P1746 / P1162
← Day 19 · DFS 回溯:网格搜索与冲突检测

⏱今日时间安排

0 - 30 minBFS 是什么,和 DFS 的分工(第 1 节)
30 - 55 min队列 queue 用法速成(第 2 节)
55 - 100 minBFS 通用模板 + 「入队即标记」(第 3 节)
100 - 130 min状态抽象:节点不一定是格子(第 4 节)
130 - 250 min做 4 道题,每题限时 30 分钟
250 - 280 min对照本文题解,把没做出来的补掉
280 - 310 min自测 + 存速查卡
💡 和昨天内容的衔接

昨天你学会了方向数组(nx = x + dx[d])和网格 DFS 框架。今天这两个东西一个字都不用改, 只换一件事:把「递归深入」换成「用队列一层一层地扩散」。

为什么要换?因为 DFS 是「一条道走到黑」,它能告诉你「能不能走到」,但很难告诉你「最少几步能走到」—— 它第一次到达终点时走的路,未必是最短的。而 BFS 天生就是按距离从近到远的顺序扩散的,第一次碰到某个点,一定就是最短路。

1BFS 是什么:往水里扔一块石头

先看一个画面

往平静的池塘里扔一块石头。波纹是怎么扩散的?

先是一圈小的,半径 1;然后半径 2;然后半径 3……同一圈上的水面,是「同时」被波及的。

🎯 BFS 的全部思想就这一句话

从起点开始,先把距离为 1 的所有点全部访问完,再访问距离为 2 的,再访问距离为 3 的……

「BFS」的全称是 Breadth-First Search,中文叫「广度优先搜索」——「广度优先」说的就是这个「先把一圈铺满,再走下一圈」。

为什么它能求最少步数

因为扩散顺序是按距离严格从小到大排的。所以当一个点第一次被访问到时,它必然是被「最近的那一圈」够到的。 我们把它记下来的距离,就是最短距离——不需要再比较、不需要再更新,记一次就完事。

⚠️ 前提:每一步的代价必须相同(边权为 1)

在网格里走一格、按一次电梯按钮、走一步马——这些都是「一步」,代价一样,所以 BFS 适用。

但如果题目变成「平地走一格 1 秒、上坡走一格 3 秒」,各条边的代价不一样了, BFS 就不再保证最短路,那时候要换算法(最短路:Dijkstra / SPFA,以后会学)。

DFS 和 BFS 的分工表

对比项DFS(深度优先)BFS(广度优先)
走法一条路走到黑,走不通再回头一层一层往外摊
实现工具递归(函数调用栈)队列 queue
擅长什么枚举所有方案、判断是否存在、连通块、回溯求最少步数 / 最少操作次数
空间和「最长路径深度」有关和「最宽的一层」有关
昨天的题P1219 八皇后、P1605 迷宫路径数、P1596 水塘、P1101 单词方阵—
今天的题—P1443 马的最少步数、P1135 最少按键、P1746 最短距离、P1162 填充
✅ 一句话判断该用哪个

题目问「有多少种」「能不能」「有哪些」→ 用 DFS。
题目问「最少几步」「最短距离」「最少操作几次」→ 用 BFS。
最后一道 P1162 是个例外:它只是「把区域填满」,用 DFS 或 BFS 都能做,我们用 BFS 顺手练一下。

2队列 queue:BFS 的发动机

队列是什么

排队买奶茶:先进先出。先来的人先买到,后来的人排在队尾。

队列这种「先进先出」的顺序,正好就是 BFS 需要的顺序:先把距离小的点排进去,它们自然就先被处理; 处理它们时新产生的点(距离 +1)排在队尾,等前面的都处理完才轮到它们。于是整个处理顺序就是按距离递增的。

四个操作,两分钟学会

想干什么写法说明
建一个存整数的队列queue<int> q;需要 #include <queue>
建一个存坐标的队列queue<pair<int,int>> q;存 (x, y) 一对数
把 x 放进队尾q.push(x);入队
看队头是谁q.front()只是「看一眼」,不删掉
把队头删掉q.pop();出队。注意它不返回任何值
队列空了吗q.empty()空返回 true
🚫 新手最容易踩的一个坑

q.pop() 不返回被删掉的元素!它就是个「删除」动作。

所以取出队头的标准姿势永远是两句:

int cur = q.front();   // ① 先看一眼队头是什么
q.pop();               // ② 再把它删掉

写成 int cur = q.pop(); 会直接编译错误(void 值不能赋给 int)。

存坐标怎么办:pair

队列里要存 (x, y) 两个数,用 pair<int,int> 就能把两个整数打包成一个整体:

queue<pair<int,int>> q;      // 注意 >> 中间的老编译器要写空格,C++11 起可以直接写

q.push({x, y});              // 入队一个坐标(花括号打包)

pair<int,int> cur = q.front();
q.pop();
int cx = cur.first;          // 取出第一个数(行)
int cy = cur.second;         // 取出第二个数(列)
💡 关于 auto [x, y] = q.front();

你在别的地方可能见过这种写法(叫「结构化绑定」),确实好看。但它是 C++17 才有的语法, 用 -std=c++14 编译会报错。

很多 OJ 的编译器版本比较老,本文统一用 .first / .second, 兼容性最好,本文所有代码都是在 g++ -std=c++14 下编译通过的。

3BFS 通用模板(今天的核心,背下来)

queue<pair<int,int>> q;
q.push({sx, sy});
dis[sx][sy] = 0;                      // ★ 起点:入队的同时就把距离标记好

while(!q.empty()){
    pair<int,int> cur = q.front();    // ① 看一眼队头
    q.pop();                          // ② 删掉它
    int x = cur.first, y = cur.second;

    for(int d = 0; d < 4; d++){                    // ③ 昨天学的方向数组
        int nx = x + dx[d], ny = y + dy[d];
        if(nx < 1 || nx > n || ny < 1 || ny > m) continue;   // ④ 越界
        if(不能走) continue;                                  // ⑤ 墙 / 障碍
        if(dis[nx][ny] != -1) continue;                       // ⑥ 已经到过,别再走

        dis[nx][ny] = dis[x][y] + 1;                          // ⑦ 距离 = 上一层 + 1
        q.push({nx, ny});                                     // ⑧ 入队(⑦⑧两句必须挨着)
    }
}

和昨天的 DFS 框架对照着看

步骤DFS(昨天)BFS(今天)
标记初始点vis[sx][sy] = true;dis[sx][sy] = 0; 并且 push
枚举方向一模一样一模一样
越界 / 障碍判断一模一样一模一样
推进方式dfs(nx, ny); 直接递归进去dis[nx][ny] = dis[x][y]+1; q.push(...); 放进队列排队
撤销路径类题目要 vis = false永不撤销(最短距离一旦定下就不会变)

★ 最重要的一条规矩:入队时就标记

🚨 「入队时标记」和「出队时标记」,差别是天壤之别

看下面这段错误代码——它把标记放在了出队的时候:

// ❌ 错误写法
while(!q.empty()){
    auto cur = q.front(); q.pop();
    int x = cur.first, y = cur.second;
    vis[x][y] = true;              // ← 标记在出队时才做
    for(int d = 0; d < 4; d++){
        int nx = x + dx[d], ny = y + dy[d];
        if(越界 || 不能走) continue;
        if(vis[nx][ny]) continue;
        dis[nx][ny] = dis[x][y] + 1;
        q.push({nx, ny});            // ← 同一个点可能被 push 好几次!
    }
}

问题出在哪?假设 A 和 B 都和起点相邻,B 又和 C 相邻。处理 A 的时候把 C 也放进队列了, 但此时 C 还没被标记(它还在队里等着),于是处理 B 的时候又把 C 再放一次。

结果:同一个点被重复入队,队列迅速膨胀,轻则超时、重则内存爆掉(MLE)。 在 400×400 的棋盘上这种情况会被放大成千上万倍。

正确做法:dis[nx][ny] = dis[x][y] + 1; 和 q.push({nx, ny}); 这两句必须紧挨着, 一旦入队就立刻算「已经到过」。(起点也一样,push 之前就设好 dis[sx][sy] = 0。)

💡 为什么 BFS 里的 dis 数组还能兼职当 vis

因为我们一开始把 dis 全部初始化成 −1(表示「还没到过」), 之后凡是走到过的点都会变成 ≥ 0 的数。

于是 dis[nx][ny] != -1 就等价于「这个点已经到过了,跳过」—— 一个数组同时干了两件事。而且最后题目要输出「到不了的点」,我们直接输出 −1 就行, 连额外的特判都省了(P1443 就是这么做的)。

4状态抽象:BFS 的节点不一定是格子

这是今天最重要的思维跃迁,也是 P1135 最想教你的东西。

换个视角看问题

前面我们一直说「点」就是棋盘上的格子。但 BFS 本质上是在一张图上跑: 图里有若干个状态(结点),状态之间如果「一步能到」就有一条边。

至于这个「状态」是什么形状,BFS 一点都不关心:

题目「点」是什么「一步」是什么距离数组
P1443 马的遍历棋盘上的一格 (x, y)马跳一次(8 种)dis[405][405] 二维
P1746 离开中山路地图上的一格 (x, y)上下左右走一格dis[1005][1005] 二维
P1162 填涂颜色方阵里的一格 (x, y)上下左右走一格只要 bool 标记
P1135 奇怪的电梯一个楼层编号 f(就一个整数!)按一次上 / 下一次按钮dis[205] 一维
🎯 一句话总结

「状态」就是「局面」的抽象。棋盘局面是两个坐标,电梯局面是一个楼层号, 以后还会遇到「坐标 + 剩余钥匙数」「坐标 + 时间」这样的状态。

一旦你把它抽象成「结点 + 边走一步」,剩下的代码就是同一个 BFS 模板—— 换的是状态的表示方式,不换的是 BFS 的骨架。

一维 BFS 和二维 BFS 的写法对照

// 二维:状态是 (x, y),队列存 pair,邻居靠方向数组算
queue<pair<int,int>> q;
q.push({sx, sy});  dis[sx][sy] = 0;
...
for(int d = 0; d < 4; d++){
    int nx = x + dx[d], ny = y + dy[d];
    ...
}

// 一维:状态就是一个楼层号,队列存 int,邻居靠「加 / 减 K」算
queue<int> q;
q.push(a);  dis[a] = 0;
...
int nxt[2] = {cur + k[cur], cur - k[cur]};   // ★ 「方向」变成了「加/减 K」
for(int t = 0; t < 2; t++){
    int nx = nxt[t];
    ...
}
💡 看出来了吗——「方向」也可以不是数组

在网格里,四个方向太规整了,所以我们用数组存偏移量。 在电梯里,两个「方向」是 +K 和 −K,每次都不同,直接算出来比查表更自然。

所以方向数组不是必需的,它只是「一组固定的偏移量」的一种优雅写法。 当你发现偏移量是算出来的、每个点都不一样时,就老老实实写算式。

5四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译运行过,输出是真实结果。

P1443 马的遍历 普及- · BFS 求最短步数模板题

题意:在 n×m 的棋盘上,点 (x, y) 有一匹中国象棋的马。求这匹马走到棋盘上每一个格子最少需要几步。 走不到的格子输出 -1。输出一个 n 行 m 列的矩阵。

⚠️ 数据范围 / 关键约束

对于全部测试点,1 ≤ x ≤ n ≤ 400,1 ≤ y ≤ m ≤ 400。也就是说棋盘最大 400×400 = 16 万个格子。

16 万这个规模说明:必须一次 BFS 就求出「到所有格子的距离」, 而不能对每个格子单独搜一遍(那样就是 16 万次 BFS,必超时)。BFS 一次扩散恰好覆盖所有能到达的格子, 复杂度是 O(n·m),非常合适。

输出格式:本题原版要求每个数字占 5 个字符左对齐;2022 年 8 月之后官方去掉了场宽要求, 明确说明「以空格或者合理的场宽分割每个整数都将判作正确」。所以直接空格分隔就行(下面的代码就是这么写的)。

思路

  1. 写出马的方向数组:马走「日」字,八种走法:dx[8] = {-2,-2,-1,-1,1,1,2,2}, dy[8] = {-1,1,-2,2,-2,2,-1,1}。检查方法:每个方向都是「一个坐标动 2、另一个动 1」。
  2. dis 全部初始化成 −1:用 memset(dis, -1, sizeof(dis))。−1 同时表示「没到过」和「到不了」两个意思。
  3. 起点入队:push({x, y}),dis[x][y] = 0(马自己所在的位置需要 0 步)。
  4. 标准 BFS:出队 → 八个方向 → 越界就跳过 → dis != -1 就跳过 → 否则算距离、入队。
  5. 输出矩阵:扫描 n×m 输出 dis[i][j],到不了的自然还是 −1。
样例输入
3 3 1 1
样例输出
0 3 2
3 -1 1
2 1 4

马在 (1,1),到 (1,2) 要 3 步,到 (2,2) 到不了(3×3 的正中间是马的死穴),所以是 −1。

0 起点32 3−1 到不了1 214

完整代码

#include <iostream>
#include <queue>
#include <cstring>
using namespace std;

int n, m, x, y;
int dis[405][405];          // dis[i][j] = 马到 (i,j) 的最少步数,-1 表示到不了

// ★ 马走「日」字的 8 个方向(一个坐标动 2,另一个动 1)
int dx[8] = {-2, -2, -1, -1,  1, 1,  2, 2};
int dy[8] = {-1,  1, -2,  2, -2, 2, -1, 1};

int main(){
    cin >> n >> m >> x >> y;

    memset(dis, -1, sizeof(dis));       // 先全部设成 -1 = 还没到过(也等于到不了)
    queue<pair<int,int>> q;
    q.push({x, y});
    dis[x][y] = 0;                      // ★ 起点:入队的同时就把距离标记好

    while(!q.empty()){
        pair<int,int> cur = q.front();  // ① 看一眼队头
        q.pop();                        // ② 再删掉它
        int cx = cur.first, cy = cur.second;
        for(int d = 0; d < 8; d++){                   // ★ 马有 8 种走法
            int nx = cx + dx[d], ny = cy + dy[d];
            if(nx < 1 || nx > n || ny < 1 || ny > m) continue;   // 跳出棋盘了
            if(dis[nx][ny] != -1) continue;                      // ★ 入队时判重
            dis[nx][ny] = dis[cx][cy] + 1;                       // 扩散一层,步数 +1
            q.push({nx, ny});                                    // 入队(和上一句必须挨着)
        }
    }

    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= m; j++){
            if(j > 1) cout << " ";      // 数字之间用空格分隔
            cout << dis[i][j];          // 到不了的自然就是 -1
        }
        cout << "\n";
    }
    return 0;
}
✅ 实测结果

官方样例 3 3 1 1 → 实际输出

0 3 2
3 -1 1
2 1 4

数值与样例完全一致(样例原文中每个数字占 5 个字符的场宽,官方已说明空格分隔同样判对)。

最大数据 400 400 1 1 → 第一行是 0 3 2 3 2 3 4 5 …, 第二行以 3 4 1 2 3 4 … 开头,最后一行以 … 201 200 201 结尾, 用时约 50 ms(总共访问 16 万个格子,时限 1 秒,非常宽裕)。

易错点

  • 方向数组写错:马是 (2,1) 型而不是 (1,1) 型斜线。漏一个方向会导致大片格子算成 −1。
  • dis 没有初始化成 −1:全局数组默认是 0,那样「没到过的点」也会被当成距离 0,全盘皆错。
  • 忘记 dis[x][y] = 0:起点会被当成「没到过」,然后又被邻居重新入队,甚至输出 −1。
  • 标记时机:一定要在 push 之前/同时设置 dis(见第 3 节)。
  • 行末多打空格:用 if(j > 1) cout << " "; 控制,别每行末尾留一个空格。
  • 数组开 405×405:n、m 最大 400,下标要到 400,开 405 留余量。
P1135 奇怪的电梯 普及/提高- · 一维状态 BFS

题意:一栋 N 层的楼,第 i 层上写着一个数字 Ki。电梯只有「上」「下」两个有用按钮, 且上下都必须走 Ki 层(在 1 层按「上」就到 1+K1 层,按「下」如果超出 1~N 就失灵)。 问从 A 层到 B 层,最少要按几次按钮;到不了输出 −1。

⚠️ 数据范围 / 关键约束

对于 100% 的数据,1 ≤ N ≤ 200,1 ≤ A, B ≤ N,0 ≤ Ki ≤ N。本题共 16 个测试点。

注意 Ki 可以是 0!这时在 i 层按上按下都还在 i 层——所以「走到的楼层」有可能等于「当前楼层」, 这类「原地不动」的边必须靠 dis 判重挡掉,否则会死循环。

思路

这题是状态抽象的最佳教材:结点不是坐标,而是一个楼层编号。

  1. 状态 = 楼层:一共有 N 个状态(1 到 N 层),距离数组只需要一维 dis[205]。
  2. 从状态 cur 出发能到哪:两个「方向」——cur + k[cur] 和 cur - k[cur]。
  3. 越界判断:nx < 1 || nx > n 就跳过(没有 0 层,也没有 N+1 层)。
  4. 标准 BFS:入队时设 dis,dis != -1 就跳过。
  5. 答案就是 dis[b]:到不了就还是 −1,直接输出即可,不用特判。
🎯 想一想:为什么用 BFS 而不是 DFS

题目问的是「最少按几次」,这正是 BFS 的主场。 如果用 DFS 把所有能走的路线都搜一遍再取最小,虽然 N ≤ 200 也许能过,但写法复杂、还容易写错。

换成 BFS:第 1 层扩散出的都是「按 1 次能到的楼层」,第 2 层是「按 2 次的」…… 第一次遇到 B 层时,按的次数就已经是最少的了。

样例输入
5 1 5
3 3 1 2 5
样例输出
3

路线:1 层上到 4 层(K₁=3),4 层下到 2 层(K₄=2),2 层上到 5 层(K₂=3),一共按 3 次。

完整代码

#include <iostream>
#include <queue>
using namespace std;

int n, a, b;
int k[205];          // 第 i 层楼上的数字
int dis[205];        // 到第 i 层最少按几次,-1 表示到不了

int main(){
    cin >> n >> a >> b;
    for(int i = 1; i <= n; i++){
        cin >> k[i];
        dis[i] = -1;                 // 先全部设成「到不了」
    }

    queue<int> q;                    // ★ 状态就是「楼层」这一个数,所以队列存 int
    q.push(a);
    dis[a] = 0;                      // 起点:入队时同时标记

    while(!q.empty()){
        int cur = q.front();
        q.pop();
        int nxt[2] = {cur + k[cur], cur - k[cur]};   // 「方向」= 上楼 / 下楼
        for(int t = 0; t < 2; t++){
            int nx = nxt[t];
            if(nx < 1 || nx > n) continue;           // 没有 0 层,也没有 N+1 层
            if(dis[nx] != -1) continue;              // 走过了就不再走(K=0 时靠这句防死循环)
            dis[nx] = dis[cur] + 1;
            q.push(nx);
        }
    }

    cout << dis[b] << endl;          // 到不了自然就是 -1,不用特判
    return 0;
}
✅ 实测结果

官方样例 5 1 5 / 3 3 1 2 5 → 输出 3 与样例完全一致。

补充测试:

· 4 1 4,K 全是 0 → 输出 −1(哪一层都动不了,正确)。
· 5 3 3(起点就是终点)→ 输出 0(一步都不用按,正确)。

易错点

  • A 等于 B 时答案应为 0。代码里 dis[a] = 0 已经处理好了,但如果忘了给起点设初值就会输出 −1。
  • Ki = 0 的楼层:会生成「走到自己」的边,一定要靠 dis != -1 挡掉,否则死循环。
  • 越界判断写成 nx <= 0 和 nx >= n:楼层是 1~N,应该是 nx < 1 || nx > n。
  • 数组开 [205]:N 最大 200,下标要到 200,开 205 留余量。
  • 不要把 k 当成「方向数组」硬塞进 dx/dy:这里每个状态的步长都不同,直接算更清楚。
P1746 离开中山路 普及- · 网格 BFS 最短距离

题意:给一个 n×n 的地图,0 表示马路(可以走),1 表示店铺(不能穿过)。 给出起点 (x₁, y₁) 和车站 (x₂, y₂),只能上下左右沿着马路走。求走到目的地的最短距离(相邻两格距离为 1)。

⚠️ 数据范围 / 关键约束

对于 20% 的数据,1 ≤ n ≤ 100;对于 100% 的数据,1 ≤ n ≤ 1000。

地图最大 1000×1000 = 100 万个格子,所以要注意两件事: ① 复杂度必须是 O(n²) 级别(BFS 正好); ② 距离数组一定要开在全局——int dis[1005][1005] 大约占 4 MB, 如果写在 main 里面(栈上),会直接爆栈崩溃。

地图每行的数字之间没有空格,所以要么用 cin >> g[i][j] 逐字符读,要么整行读成字符串。

思路

这就是「网格 + 边权为 1 + 求最短路」的标准场景,直接用第 3 节的模板套上去:

  1. 读地图:逐字符读进 char g[1005][1005],同时把 dis 全部初始化成 −1。
  2. 读起点终点:注意顺序是 x₁ y₁ x₂ y₂(前面是行号,后面是列号)。
  3. 起点入队,dis[x1][y1] = 0。
  4. 四方向 BFS:遇到 g == '1'(店铺)跳过,遇到 dis != -1(走过)跳过。
  5. 输出 dis[x2][y2]:走不到就是 −1。
样例输入
3
001
101
100
1 1 3 3
样例输出
4

路径:(1,1) → (1,2) → (2,2) → (3,2) → (3,3),共 4 步。

完整代码

#include <iostream>
#include <queue>
using namespace std;

int n;
char g[1005][1005];      // '0' 马路,'1' 店铺
int dis[1005][1005];     // ★ 4MB,必须开在全局(写在 main 里会爆栈)

int dx[4] = {0, 0, 1, -1};   // 昨天的方向数组,原封不动
int dy[4] = {1, -1, 0, 0};

int main(){
    cin >> n;
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= n; j++){
            cin >> g[i][j];       // 逐字符读入,可以跳过换行
            dis[i][j] = -1;       // 顺手初始化成「没到过」
        }

    int x1, y1, x2, y2;
    cin >> x1 >> y1 >> x2 >> y2;   // 起点、终点

    queue<pair<int,int>> q;
    q.push({x1, y1});
    dis[x1][y1] = 0;                  // 起点入队 + 标记

    while(!q.empty()){
        pair<int,int> cur = q.front();
        q.pop();
        int cx = cur.first, cy = cur.second;
        for(int d = 0; d < 4; d++){
            int nx = cx + dx[d], ny = cy + dy[d];
            if(nx < 1 || nx > n || ny < 1 || ny > n) continue;   // 越界
            if(g[nx][ny] == '1') continue;                       // 店铺,不能走
            if(dis[nx][ny] != -1) continue;                      // 已经到过
            dis[nx][ny] = dis[cx][cy] + 1;
            q.push({nx, ny});
        }
    }

    cout << dis[x2][y2] << endl;       // 到不了就是 -1
    return 0;
}
✅ 实测结果

官方样例 → 输出 4 与样例完全一致。

补充测试:

· 1000×1000 全马路,从 (1,1) 到 (1000,1000) → 输出 1998(只往右往下走,999+999 步,正确),用时约 115 ms。
· 中间被一排店铺完全隔断的地图 → 输出 −1,符合预期。

易错点

  • 数组开在 main 里面:1000×1000 的 int 数组约 4 MB,局部变量放在栈上会崩,一定要写在所有函数外面(全局)。
  • 地图读入方式混乱:cin >> g[i][j] 读字符是安全的;但如果先用 cin >> n 再用 getline,会先读到那个残留的换行符。
  • 把 '0'/'1' 当成整数 0/1 比较:地图存的是字符,判断必须写 g[nx][ny] == '1'(带单引号)。
  • 终点本身就是店铺:本题数据不保证不会,稳妥起见可以特判;不过按题意车站一定在可到达的位置。
  • 起点等于终点:dis 初值是 0,输出 0,正确。
P1162 填涂颜色 普及- · 多源 BFS 填充

题意:给一个由 0 和 1 组成的 n×n 方阵,其中有一个由 1 构成的闭合圈。 把闭合圈内部的所有 0 都改成 2,其余不变,然后输出整个方阵。

题目给的判断标准很关键:如果从某个 0 出发,只走上下左右、只经过 0,却永远到不了方阵的边界,那它就在圈内。

⚠️ 数据范围 / 关键约束

对于 100% 的数据,1 ≤ n ≤ 30。方阵内只有一个闭合圈,圈内至少有一个 0。

n 只有 30,规模非常小,DFS 和 BFS 都能做。但这里用 BFS 有一个天然的优势:多个起点可以一次性全部入队(见下面的思路)。

思路:反过来想,标记「圈外」

直接找「哪些 0 在圈内」很难——你得先知道圈在哪、圈是什么形状。

但反过来想就简单多了:一个 0 只要能从边界走到,它就是圈外的。 而「从边界能走到」正好是一个 BFS 能回答的问题:

  1. 把所有边界上的 0 一次性全部入队(第 1 行、第 n 行、第 1 列、第 n 列上的 0),并标记它们「属于圈外」。
  2. 从这些起点一起做 BFS,沿着上下左右扩散,凡是能走到的 0 都标记成「圈外」。
  3. BFS 结束后,所有没被标记的 0 就是走不到边界的——它们必然在圈内。
  4. 输出:1 照抄;0 且被标记 → 输出 0;0 且没被标记 → 输出 2。
🎯 多源 BFS:多个起点一起入队

普通的 BFS 只有一个起点。这里我们把边界上所有合法的 0 在循环开始前全部 push 进同一个队列, 然后照常跑 BFS——这叫做多源 BFS。

它的效果是:每个格子记录的是「到最近的那个起点的距离」。 本题我们连距离都不用存,只要一个 bool outside 标记「能不能从某条边界走到」就够。

多源 BFS 是 Day 21 的主角之一(P1332 血色先锋队就靠它),今天先用 P1162 尝个鲜。

样例输入
6
0 0 0 0 0 0
0 0 1 1 1 1
0 1 1 0 0 1
1 1 0 0 0 1
1 0 0 0 0 1
1 1 1 1 1 1
样例输出
0 0 0 0 0 0
0 0 1 1 1 1
0 1 1 2 2 1
1 1 2 2 2 1
1 2 2 2 2 1
1 1 1 1 1 1

注意输入的最后两行:第 5 行左边的 1 0 0 0 0 1, 这几个 0 其实是被圈住的(左边被第 5 行第 1 列的 1 挡住、下边被第 6 行挡住),所以它们都被涂成 2。

完整代码

#include <iostream>
#include <queue>
using namespace std;

int n;
int g[35][35];
bool outside[35][35];      // 这格的 0 能不能从边界走到(true = 圈外)

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

int main(){
    cin >> n;
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= n; j++)
            cin >> g[i][j];

    // 第一步:把所有边界上的 0 一次性入队(多源 BFS 的「多源」就在这里)
    queue<pair<int,int>> q;
    for(int i = 1; i <= n; i++){
        if(g[i][1] == 0 && !outside[i][1]){ outside[i][1] = true; q.push({i, 1}); }
        if(g[i][n] == 0 && !outside[i][n]){ outside[i][n] = true; q.push({i, n}); }
    }
    for(int j = 1; j <= n; j++){
        if(g[1][j] == 0 && !outside[1][j]){ outside[1][j] = true; q.push({1, j}); }
        if(g[n][j] == 0 && !outside[n][j]){ outside[n][j] = true; q.push({n, j}); }
    }

    // 第二步:从边界一路扩散,能走到的 0 全是「圈外」
    while(!q.empty()){
        pair<int,int> cur = q.front();
        q.pop();
        int cx = cur.first, cy = cur.second;
        for(int d = 0; d < 4; d++){
            int nx = cx + dx[d], ny = cy + dy[d];
            if(nx < 1 || nx > n || ny < 1 || ny > n) continue;   // 越界
            if(g[nx][ny] == 1 || outside[nx][ny]) continue;      // 是墙,或已经标记过
            outside[nx][ny] = true;
            q.push({nx, ny});
        }
    }

    // 第三步:输出。是 1 就照抄;是圈外的 0 也照抄;剩下的 0 全涂成 2
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= n; j++){
            if(j > 1) cout << " ";
            if(g[i][j] == 1) cout << 1;
            else cout << (outside[i][j] ? 0 : 2);
        }
        cout << "\n";
    }
    return 0;
}
💡 输出那三行是在干什么

把三种格子对应清楚就不容易写错:

格子原本是能不能从边界走到输出
1(墙)—1
0能(圈外)0
0不能(圈内)2
✅ 实测结果

官方样例 → 实际输出

0 0 0 0 0 0
0 0 1 1 1 1
0 1 1 2 2 1
1 1 2 2 2 1
1 2 2 2 2 1
1 1 1 1 1 1

与样例逐字符完全一致(6 行,含数字间的空格)。

补充测试:30×30 外圈全是 1、里面全是 0 → 输出 30 行,第 1 行全是 1,第 2 行是 1 2 2 … 2 1, 即内圈 28×28 全部涂成 2,符合预期。

易错点

  • 只从 4 个角出发:圈外的 0 可能只和边上中间的某个 0 连通,角落出发会漏掉一部分,导致把圈外的 0 误涂成 2。要把四条边上的所有 0 都入队。
  • 忘记排除墙:扩散时 g[nx][ny] == 1 必须跳过,否则 1 会被当成可通行。
  • 输入带空格:本题输入的数字之间有空格,所以用 cin >> g[i][j] 读整数即可(注意别和 P1746 那种无空格字符地图搞混)。
  • 输出格式:数字间用空格分隔,行末不要留多余空格。
  • 把 outside 反过来理解:它标记的是「圈外」,不是「圈内」,写输出时别弄反。

✗今日常见错误汇总

错误写法后果正确写法
在「出队时」才打访问标记同一个点被反复入队,队列爆炸 → TLE / MLE入队前就设好 dis[nx][ny] = dis[x][y] + 1; 再 push
int cur = q.pop();编译错误(pop 不返回值)int cur = q.front(); q.pop();
忘记 dis[sx][sy] = 0;起点被当成没到过,答案错乱甚至输出 −1push 起点的同时就标记距离 0
dis 没初始化成 −1全局数组默认 0,「没到过」和「距离 0」混淆memset(dis, -1, sizeof(dis));
400×400 / 1000×1000 的 dis 写在 main 里栈空间不够 → 程序直接崩大数组一律写在所有函数外面(全局)
在 BFS 里加 vis = false 撤销点被重复访问,死循环或超时BFS 永不撤销(和 DFS 路径计数不同)
各边代价不同还用 BFS 求最短路答案不是最短只有边权为 1 才能用 BFS;否则要用最短路算法
P1162 只从 4 个角开始 BFS边上中间连通的圈外 0 被误涂成 2四条边上所有 0 一起入队(多源 BFS)
把字符地图当整数比较:g[nx][ny] == 1永远不成立,墙形同虚设字符地图写 == '1',整数地图才写 == 1

✓自测:你真的会了吗

第 1 题:什么题必须用 BFS,什么题用 DFS?

答:问「最少步数 / 最短距离 / 最少操作次数」,而且每一步代价相同 → BFS。
问「有多少种方案 / 能不能做到 / 有几个连通块」→ DFS(这也是为什么昨天四道题全是 DFS)。

★ 注意前提:BFS 求最短路只在边权为 1(每步代价一样)时成立。

第 2 题:为什么必须在「入队时」标记,不能「出队时」标记?

答:因为一个点可能有多个邻居。若出队时才标记,那么在这些邻居被处理时,它还是「未标记」状态, 于是会被每个邻居各 push 一次,同一个点重复入队。

重复入队会让队列长度暴涨(16 万格的棋盘上可能变成几百万次操作),结果是 TLE 甚至 MLE。 入队时立刻标记,等价于「这个点我已经预约了,别人别再排队」。

第 3 题:手算 BFS 距离

3×3 棋盘,马在 (1,1),马走「日」字。请问 (2,3) 的距离是几?(2,2) 呢?

答:(2,3) 是 1 步——(1,1) + (1,2) = (2,3),正好是马的一个走法。
(2,2) 是 −1(到不了)——从 (1,1) 能到 (2,3)、(3,2),从 (2,3) 又能到 (1,1)、(3,1)、(4,2)… 全都出界或回到原点。 3×3 棋盘的正中心是马的死角。这正是官方样例里那个 −1 的来历。

第 4 题:P1135 里,如果 Ki = 0 会怎样?

答:在 i 层按上按下都还停在 i 层,也就是产生了一条「自己指向自己」的边。

这时候靠 if(dis[nx] != -1) continue; 挡住就行——因为 i 层本身在入队时就已经被标记过了, 「走到自己」会被判为「已经到过」直接跳过,不会死循环。

如果没有这个判重,就会无限循环。这也说明「dis 兼职 vis」这个设计有多重要。

第 5 题(手写代码):BFS 求一张 01 网格里,从 (1,1) 到每个格子的最少步数(1 是墙)
int n, m;
int g[1005][1005], dis[1005][1005];
int dx[4] = {0, 0, 1, -1}, dy[4] = {1, -1, 0, 0};

void bfs(int sx, int sy){
    queue<pair<int,int>> q;
    memset(dis, -1, sizeof(dis));
    q.push({sx, sy});
    dis[sx][sy] = 0;                    // ★ 入队即标记
    while(!q.empty()){
        pair<int,int> cur = q.front(); q.pop();
        int x = cur.first, y = cur.second;
        for(int d = 0; d < 4; d++){
            int nx = x + dx[d], ny = y + dy[d];
            if(nx < 1 || nx > n || ny < 1 || ny > m) continue;
            if(g[nx][ny] == 1) continue;
            if(dis[nx][ny] != -1) continue;
            dis[nx][ny] = dis[x][y] + 1;
            q.push({nx, ny});
        }
    }
}

★ 请默写三遍,直到不用看就能写出来——这是后面所有 BFS 题(Day 21 的多源 BFS、状态 BFS)的骨架。

▤速查卡(截图存手机)

【队列】
  #include <queue>
  queue<int> q;                 // 存一个数(如楼层)
  queue<pair<int,int>> q;       // 存坐标
  q.push(x);                    // 入队
  int cur = q.front(); q.pop();  // ★ 取出队头必须两句
  q.empty()                     // 判空
  cur.first / cur.second        // pair 取两个分量(C++14 不能用结构化绑定)

【BFS 网格模板(求最少步数)】
  memset(dis, -1, sizeof(dis));
  queue<pair<int,int>> q;
  q.push({sx, sy});
  dis[sx][sy] = 0;                      // ★ 入队即标记
  while(!q.empty()){
      pair<int,int> cur = q.front(); q.pop();
      int x = cur.first, y = cur.second;
      for(int d = 0; d < 4; d++){
          int nx = x + dx[d], ny = y + dy[d];
          if(nx < 1 || nx > n || ny < 1 || ny > m) continue;
          if(不能走) continue;
          if(dis[nx][ny] != -1) continue;   // 到过了
          dis[nx][ny] = dis[x][y] + 1;
          q.push({nx, ny});                 // 这两句必须挨着
      }
  }
  // dis 里剩下的 -1 就是「到不了」

【一维状态 BFS(P1135 型)】
  queue<int> q;  q.push(a);  dis[a] = 0;
  while(!q.empty()){
      int cur = q.front(); q.pop();
      int nxt[2] = {cur + k[cur], cur - k[cur]};   // 方向是算出来的
      for(int t = 0; t < 2; t++){
          int nx = nxt[t];
          if(nx < 1 || nx > n) continue;
          if(dis[nx] != -1) continue;
          dis[nx] = dis[cur] + 1;
          q.push(nx);
      }
  }

【多源 BFS(P1162 型)】
  // 把所有起点在循环前一次性 push 进去,其余照常
  for(每个边界上的 0) { mark = true; q.push(...); }
  // 跑完 BFS,没被标记的 0 就是「圈内」

【铁律】
  1. 入队时就标记,绝不出队才标记
  2. BFS 不撤销标记(DFS 路径计数才撤销)
  3. 大数组(> 10^5)一律开全局
  4. 边权不为 1 时,BFS 求最短路不成立

→明天预告 · Day 21

BFS 进阶 + 第三周复盘

今天的 BFS 只处理了「一个起点、一种状态」。明天要把它撑开:

· 多源 BFS:把多个起点一次性全部入队,得到的就是「到最近的起点的距离」——P1332 血色先锋队、 P2895 Meteor Shower 都靠这一招(今天 P1162 已经偷偷用过一次了)。
· 带时间维度的状态:P2895 里陨石会按时间砸下来,同一格「早到」和「晚到」的处境完全不同, 这就需要把「时间」也纳入状态,或者在 BFS 时把时间一起算进去。
· 连通块 + 记忆化:P1141 01迷宫有 10⁵ 次询问,如果每次询问都 BFS 一遍必然超时, 标准做法是「先给每个连通块染个色、记下大小,之后每次询问 O(1) 回答」。

今天请务必把 BFS 模板默写到不用思考的程度——明天的所有题目都是它的变形。