队列 + 方向数组 + 「入队即标记」—— 求最少步数的标准武器
| 0 - 30 min | BFS 是什么,和 DFS 的分工(第 1 节) |
| 30 - 55 min | 队列 queue 用法速成(第 2 节) |
| 55 - 100 min | BFS 通用模板 + 「入队即标记」(第 3 节) |
| 100 - 130 min | 状态抽象:节点不一定是格子(第 4 节) |
| 130 - 250 min | 做 4 道题,每题限时 30 分钟 |
| 250 - 280 min | 对照本文题解,把没做出来的补掉 |
| 280 - 310 min | 自测 + 存速查卡 |
昨天你学会了方向数组(nx = x + dx[d])和网格 DFS 框架。今天这两个东西一个字都不用改,
只换一件事:把「递归深入」换成「用队列一层一层地扩散」。
为什么要换?因为 DFS 是「一条道走到黑」,它能告诉你「能不能走到」,但很难告诉你「最少几步能走到」—— 它第一次到达终点时走的路,未必是最短的。而 BFS 天生就是按距离从近到远的顺序扩散的,第一次碰到某个点,一定就是最短路。
往平静的池塘里扔一块石头。波纹是怎么扩散的?
先是一圈小的,半径 1;然后半径 2;然后半径 3……同一圈上的水面,是「同时」被波及的。
从起点开始,先把距离为 1 的所有点全部访问完,再访问距离为 2 的,再访问距离为 3 的……
「BFS」的全称是 Breadth-First Search,中文叫「广度优先搜索」——「广度优先」说的就是这个「先把一圈铺满,再走下一圈」。
因为扩散顺序是按距离严格从小到大排的。所以当一个点第一次被访问到时,它必然是被「最近的那一圈」够到的。 我们把它记下来的距离,就是最短距离——不需要再比较、不需要再更新,记一次就完事。
在网格里走一格、按一次电梯按钮、走一步马——这些都是「一步」,代价一样,所以 BFS 适用。
但如果题目变成「平地走一格 1 秒、上坡走一格 3 秒」,各条边的代价不一样了, BFS 就不再保证最短路,那时候要换算法(最短路:Dijkstra / SPFA,以后会学)。
| 对比项 | DFS(深度优先) | BFS(广度优先) |
|---|---|---|
| 走法 | 一条路走到黑,走不通再回头 | 一层一层往外摊 |
| 实现工具 | 递归(函数调用栈) | 队列 queue |
| 擅长什么 | 枚举所有方案、判断是否存在、连通块、回溯 | 求最少步数 / 最少操作次数 |
| 空间 | 和「最长路径深度」有关 | 和「最宽的一层」有关 |
| 昨天的题 | P1219 八皇后、P1605 迷宫路径数、P1596 水塘、P1101 单词方阵 | — |
| 今天的题 | — | P1443 马的最少步数、P1135 最少按键、P1746 最短距离、P1162 填充 |
题目问「有多少种」「能不能」「有哪些」→ 用 DFS。
题目问「最少几步」「最短距离」「最少操作几次」→ 用 BFS。
最后一道 P1162 是个例外:它只是「把区域填满」,用 DFS 或 BFS 都能做,我们用 BFS 顺手练一下。
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)。
队列里要存 (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 下编译通过的。
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(昨天) | 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。)
dis 数组还能兼职当 vis
因为我们一开始把 dis 全部初始化成 −1(表示「还没到过」),
之后凡是走到过的点都会变成 ≥ 0 的数。
于是 dis[nx][ny] != -1 就等价于「这个点已经到过了,跳过」——
一个数组同时干了两件事。而且最后题目要输出「到不了的点」,我们直接输出 −1 就行,
连额外的特判都省了(P1443 就是这么做的)。
这是今天最重要的思维跃迁,也是 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 的骨架。
// 二维:状态是 (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,每次都不同,直接算出来比查表更自然。
所以方向数组不是必需的,它只是「一组固定的偏移量」的一种优雅写法。 当你发现偏移量是算出来的、每个点都不一样时,就老老实实写算式。
下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译运行过,输出是真实结果。
题意:在 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 月之后官方去掉了场宽要求, 明确说明「以空格或者合理的场宽分割每个整数都将判作正确」。所以直接空格分隔就行(下面的代码就是这么写的)。
dx[8] = {-2,-2,-1,-1,1,1,2,2},
dy[8] = {-1,1,-2,2,-2,2,-1,1}。检查方法:每个方向都是「一个坐标动 2、另一个动 1」。memset(dis, -1, sizeof(dis))。−1 同时表示「没到过」和「到不了」两个意思。push({x, y}),dis[x][y] = 0(马自己所在的位置需要 0 步)。dis != -1 就跳过 → 否则算距离、入队。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。
#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 秒,非常宽裕)。
dis[x][y] = 0:起点会被当成「没到过」,然后又被邻居重新入队,甚至输出 −1。push 之前/同时设置 dis(见第 3 节)。if(j > 1) cout << " "; 控制,别每行末尾留一个空格。题意:一栋 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 判重挡掉,否则会死循环。
这题是状态抽象的最佳教材:结点不是坐标,而是一个楼层编号。
dis[205]。cur + k[cur] 和 cur - k[cur]。nx < 1 || nx > n 就跳过(没有 0 层,也没有 N+1 层)。dis != -1 就跳过。dis[b]:到不了就还是 −1,直接输出即可,不用特判。题目问的是「最少按几次」,这正是 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(一步都不用按,正确)。
dis[a] = 0 已经处理好了,但如果忘了给起点设初值就会输出 −1。dis != -1 挡掉,否则死循环。nx <= 0 和 nx >= n:楼层是 1~N,应该是 nx < 1 || nx > n。[205]:N 最大 200,下标要到 200,开 205 留余量。k 当成「方向数组」硬塞进 dx/dy:这里每个状态的步长都不同,直接算更清楚。题意:给一个 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 节的模板套上去:
char g[1005][1005],同时把 dis 全部初始化成 −1。x₁ y₁ x₂ y₂(前面是行号,后面是列号)。dis[x1][y1] = 0。g == '1'(店铺)跳过,遇到 dis != -1(走过)跳过。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,符合预期。
int 数组约 4 MB,局部变量放在栈上会崩,一定要写在所有函数外面(全局)。cin >> g[i][j] 读字符是安全的;但如果先用 cin >> n 再用 getline,会先读到那个残留的换行符。'0'/'1' 当成整数 0/1 比较:地图存的是字符,判断必须写 g[nx][ny] == '1'(带单引号)。dis 初值是 0,输出 0,正确。题意:给一个由 0 和 1 组成的 n×n 方阵,其中有一个由 1 构成的闭合圈。 把闭合圈内部的所有 0 都改成 2,其余不变,然后输出整个方阵。
题目给的判断标准很关键:如果从某个 0 出发,只走上下左右、只经过 0,却永远到不了方阵的边界,那它就在圈内。
对于 100% 的数据,1 ≤ n ≤ 30。方阵内只有一个闭合圈,圈内至少有一个 0。
n 只有 30,规模非常小,DFS 和 BFS 都能做。但这里用 BFS 有一个天然的优势:多个起点可以一次性全部入队(见下面的思路)。
直接找「哪些 0 在圈内」很难——你得先知道圈在哪、圈是什么形状。
但反过来想就简单多了:一个 0 只要能从边界走到,它就是圈外的。 而「从边界能走到」正好是一个 BFS 能回答的问题:
1 照抄;0 且被标记 → 输出 0;0 且没被标记 → 输出 2。普通的 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,符合预期。
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; | 起点被当成没到过,答案错乱甚至输出 −1 | push 起点的同时就标记距离 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 |
答:问「最少步数 / 最短距离 / 最少操作次数」,而且每一步代价相同 → BFS。
问「有多少种方案 / 能不能做到 / 有几个连通块」→ DFS(这也是为什么昨天四道题全是 DFS)。
★ 注意前提:BFS 求最短路只在边权为 1(每步代价一样)时成立。
答:因为一个点可能有多个邻居。若出队时才标记,那么在这些邻居被处理时,它还是「未标记」状态, 于是会被每个邻居各 push 一次,同一个点重复入队。
重复入队会让队列长度暴涨(16 万格的棋盘上可能变成几百万次操作),结果是 TLE 甚至 MLE。 入队时立刻标记,等价于「这个点我已经预约了,别人别再排队」。
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 的来历。
答:在 i 层按上按下都还停在 i 层,也就是产生了一条「自己指向自己」的边。
这时候靠 if(dis[nx] != -1) continue; 挡住就行——因为 i 层本身在入队时就已经被标记过了,
「走到自己」会被判为「已经到过」直接跳过,不会死循环。
如果没有这个判重,就会无限循环。这也说明「dis 兼职 vis」这个设计有多重要。
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 求最短路不成立
BFS 进阶 + 第三周复盘
今天的 BFS 只处理了「一个起点、一种状态」。明天要把它撑开:
· 多源 BFS:把多个起点一次性全部入队,得到的就是「到最近的起点的距离」——P1332 血色先锋队、
P2895 Meteor Shower 都靠这一招(今天 P1162 已经偷偷用过一次了)。
· 带时间维度的状态:P2895 里陨石会按时间砸下来,同一格「早到」和「晚到」的处境完全不同,
这就需要把「时间」也纳入状态,或者在 BFS 时把时间一起算进去。
· 连通块 + 记忆化:P1141 01迷宫有 10⁵ 次询问,如果每次询问都 BFS 一遍必然超时,
标准做法是「先给每个连通块染个色、记下大小,之后每次询问 O(1) 回答」。
今天请务必把 BFS 模板默写到不用思考的程度——明天的所有题目都是它的变形。