0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 B3625,日期见页头。两边不一致时信原站。
题目描述
机器猫被困在一个矩形迷宫里。
迷宫可以视为一个 n × m 矩阵,每个位置要么是空地,要么是墙。
机器猫只能从一个空地走到其上、下、左、右的空地。
机器猫初始时位于 (1, 1) 的位置,问能否走到 (n, m) 位置。
输入格式
第一行,两个正整数 n, m。
接下来 n 行,输入这个迷宫。每行输入一个长为 m 的字符串,# 表示墙,. 表示空地。
输出格式
仅一行,一个字符串。如果机器猫能走到 (n, m),则输出 Yes;否则输出 No。
说明/提示
样例解释:路线如下 (1,1) → (2,1) → (3,1) → (3,2) → (3,3) → (2,3) → (2,4) → (2,5) → (3,5)。
数据规模与约定:对于 100% 的数据,保证 1 ≤ n, m ≤ 100,
且 (1,1) 和 (n,m) 均为空地。
输入输出样例
输入
3 5 .##.# .#... ...#.
输出
Yes
⚠ 注意最后那半句「且 (1,1) 和 (n,m) 均为空地」—— 第 ⑤ 步会把它称一遍,
它是命门,而且触发条件是三层的。
1★ 正解:本章那句话在这道题上是白送的
网格是图的一个特例。(第 30 章从头到尾都在说这一句)
「站在一个位置上 → 看它的邻居 → 没去过就去」——
这道题的「邻居」就是上下左右四格,别的和本章那份 fast.cpp 一个字都不差。
// ★ B3625 正解:把网格当图跑一次 BFS(或 DFS,都行 —— 这道题只问「通不通」,不问最短)。//// 本章那句话在这道题上是白送的:**网格是图的一个特例**,// 「邻居」就是上下左右四格,剩下的和 fast.cpp 一模一样。//// 从 (0,0) 出发泛洪,最后看 (n-1, m-1) 有没有被标记。//// ⚠ 三处最容易写错的地方(本页各配了一个错误版本):// ① **先判界,再看格子** —— 反过来写就是越界读(b3625Bound.cpp);// ② **入队就标记** —— 忘了标记会死循环 / 爆栈(b3625NoVis.cpp);// ③ 四个方向,别顺手写成八个(b3625Eight.cpp)。//// ⚠ 而题面那句「保证 (1,1) 和 (n,m) 均为空地」是**命门**:// 这一版从 (0,0) 直接入队,压根没检查它是不是墙。解析页第 ⑤ 步把这句话称了一遍。//// 复杂度 O(nm)。顶格 n = m = 100 ⇒ 10⁴ 格。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0; vector<string> g(n); for (int i = 0; i < n; i++) cin >> g[i];
const int dx[4] = {-1, 1, 0, 0}, dy[4] = {0, 0, -1, 1}; vector<vector<char>> vis(n, vector<char>(m, 0)); queue<pair<int, int>> q; q.push({0, 0}); vis[0][0] = 1; // ★ 入队就标记 while (!q.empty()) { auto [x, y] = q.front(); q.pop(); for (int d = 0; d < 4; d++) { int nx = x + dx[d], ny = y + dy[d]; if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue; // ① 先判界 if (g[nx][ny] == '#' || vis[nx][ny]) continue; // 再看格子 vis[nx][ny] = 1; q.push({nx, ny}); } } printf("%s\n", vis[n - 1][m - 1] ? "Yes" : "No"); return 0;}点「运行 ▶」看结果
这道题只问「通不通」,不问「几步」⇒ BFS 和 DFS 都行,随便挑一个。
2⚠ 三个错法,两个被官方样例一测就死
// ✗ B3625 错法一:**忘了标记走过的格子**。//// 泛洪的三句话是「看邻居 → 没去过就去 → 去了就标记」。少了最后一句,// A 把 B 推进队列、B 又把 A 推回来,**队列永远空不了**。//// ⚠ 它的现形方式不是「答案错」,是**卡死或者内存爆掉** ——// 而这正是对拍最不擅长的那一类([第 20 章 P5019](/sol/p5019/) 那条)。// 所以这一版加了一道保险:入队超过 10⁷ 次就打 `LOOP` 退出,// ★ 否则它会把 check:viz 整个挂住([第 4 章 P1731](/sol/p1731/) 踩过一次)。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; if (!(cin >> n >> m)) return 0; vector<string> g(n); for (int i = 0; i < n; i++) cin >> g[i];
const int dx[4] = {-1, 1, 0, 0}, dy[4] = {0, 0, -1, 1}; vector<vector<char>> seen(n, vector<char>(m, 0)); // 只用来判答案,不当守卫 queue<pair<int, int>> q; q.push({0, 0}); seen[0][0] = 1; long long pushes = 1; while (!q.empty()) { auto [x, y] = q.front(); q.pop(); for (int d = 0; d < 4; d++) { int nx = x + dx[d], ny = y + dy[d]; if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue; if (g[nx][ny] == '#') continue; /* ★ 少了这一句:if (seen[nx][ny]) continue; */ seen[nx][ny] = 1; q.push({nx, ny}); if (++pushes > 10000000) { printf("LOOP\n"); return 0; } } } printf("%s\n", seen[n - 1][m - 1] ? "Yes" : "No"); return 0;}点「运行 ▶」看结果
泛洪的三句话是「看邻居 → 没去过就去 → 去了就标记」。少了最后一句, A 把 B 推进队列、B 又把 A 推回来 —— 队列永远空不了。
所以这一版加了一道保险:入队超过 10⁷ 次就打 LOOP 退出。
★ 否则它会把 check:viz 整个挂住 —— 第 4 章 P1731 真踩过这一脚。
⇒ 演示「会死循环 / 会爆栈」的错误写法时,得先给它一个出口,否则挂住的是你的闸门。
// ✗ B3625 错法二:**先看格子,再判界** —— 顺序反了就是越界读。//// if (g[nx][ny] == '#' || vis[nx][ny]) continue; // ← 这一句先跑了// if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;//// `g` 是 `vector<string>`,`nx = -1` 时 `g[-1]` 是**未定义行为** ——// 它可能崩、可能读到垃圾、也可能什么事都没有。//// ⚠⚠ 而「什么事都没有」才是最坏的情况:本机跑一百遍全对,交上去 RE 在第 3 个点。// ⇒ 这一版用 `.at()` 把 UB 变成**确定的异常**,好让它在页面上真的现形一次// ([第 45 章](/ch/45-estimate/)那条:演示错误写法时,要让那个错**可复现**)。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; if (!(cin >> n >> m)) return 0; vector<string> g(n); for (int i = 0; i < n; i++) cin >> g[i];
const int dx[4] = {-1, 1, 0, 0}, dy[4] = {0, 0, -1, 1}; vector<vector<char>> vis(n, vector<char>(m, 0)); queue<pair<int, int>> q; q.push({0, 0}); vis[0][0] = 1; try { while (!q.empty()) { auto [x, y] = q.front(); q.pop(); for (int d = 0; d < 4; d++) { int nx = x + dx[d], ny = y + dy[d]; if (g.at(nx).at(ny) == '#' || vis.at(nx).at(ny)) continue; // ★ 先看格子 if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue; // 才判界 vis[nx][ny] = 1; q.push({nx, ny}); } } } catch (const std::out_of_range&) { printf("越界了(真机上这里是未定义行为,可能崩、可能读到垃圾、也可能装作没事)\n"); return 0; } printf("%s\n", vis[n - 1][m - 1] ? "Yes" : "No"); return 0;}点「运行 ▶」看结果
if (g[nx][ny] == '#' || vis[nx][ny]) continue; // ← 这一句先跑了
if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
nx = -1 时 g[-1] 是未定义行为 —— 可能崩、可能读到垃圾、也可能什么事都没有。
⚠⚠ 「什么事都没有」才是最坏的情况:本机跑一百遍全对,交上去 RE 在第 3 个点。
⇒ 这一版用 .at() 把 UB 换成确定的异常,好让那个错在页面上真的现形一次
(第 45 章那条:演示错误写法,要让那个错可复现)。
3★★ 第三个错法:顺手写成了八个方向 —— 而它的抓获率是一条单峰曲线
// ✗ B3625 错法三:**顺手写成了八个方向**(连斜着也能走)。//// 题面写的是「只能从一个空地走到其上、下、左、右的空地」—— 四个方向。// 八连通把图变宽了,于是它**只会多说 Yes,不会多说 No** ⇒ 答案**恒 ≥ 正解**//(把 Yes 当作「更大」)。//// ⚠⚠ 这个错法在同一张题单里是有来处的:[P1443 马的遍历](/sol/p1443/) 那道题,// 「邻居」正是八个(日字)方向 —— **上一道题的方向数组,就是这一道题的 bug**。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; if (!(cin >> n >> m)) return 0; vector<string> g(n); for (int i = 0; i < n; i++) cin >> g[i];
const int dx[8] = {-1, 1, 0, 0, -1, -1, 1, 1}; const int dy[8] = {0, 0, -1, 1, -1, 1, -1, 1}; vector<vector<char>> vis(n, vector<char>(m, 0)); queue<pair<int, int>> q; q.push({0, 0}); vis[0][0] = 1; while (!q.empty()) { auto [x, y] = q.front(); q.pop(); for (int d = 0; d < 8; d++) { // ★ 8 而不是 4 int nx = x + dx[d], ny = y + dy[d]; if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue; if (g[nx][ny] == '#' || vis[nx][ny]) continue; vis[nx][ny] = 1; q.push({nx, ny}); } } printf("%s\n", vis[n - 1][m - 1] ? "Yes" : "No"); return 0;}点「运行 ▶」看结果
八连通把图变宽了 ⇒ 它只会多说 Yes,不会多说 No。官方样例放过了它。
| 墙的百分比 | 0% | 10% | 25% | 40% | 55% | 70% | 90% |
|---|---|---|---|---|---|---|---|
正解说 Yes 的轮数 |
300 | 291 | 201 | 73 | 20 | 4 | 0 |
| 八连通被抓 | ★ 0 | 9 | 79 | ★ 128 | 89 | 22 | 2 |
两头那两个 0 都能一句话说清:
墙太少 ⇒ 四连通自己就通了(两版都 Yes);墙太多 ⇒ 斜着也过不去(两版都 No)。
只有中间那一段,「斜着能过、直着不能过」才成立。
⇒ ★★ 这是本书第三次量到「抓获率沿一个旋钮是单峰的」 (第 13 章 P1596 沿水的密度、第 20 章 P1048 沿时间上限)—— ⚠ 而顺手写的生成器最容易一头扎在「墙很少」那一端(那儿是精确的 0)。
4★★ 真的把网格建成一张图 —— 答案一样,而代价能算出来
本章正文有一份 gridToGraph.cpp,把「网格是图的一个特例」从一句话变成了可验证的事。
这一版在一道真题上把它做完:每个空地格 (i, j) 编号成 i × m + j,相邻空地之间连边,
然后 for (int v : g[u]) —— 一个字都不知道「上下左右」是什么。
| 顶格 100 × 100 | |
|---|---|
| 格子数 | 10 000 |
| 全空地时建图要存的有向边 | 39 600 |
| ★ 直接在网格上走要存的边 | ★ 0(方向数组就是那张图) |
| 两版答案 | ★ 300 轮 0 次不一致 |
⇒ 网格的邻接关系是能算出来的,所以它根本不用存 —— 这正是上一轮 P1113 那条「选存法里包括不存」在网格上的样子。 ★ 而建图那一版仍然值钱:它证明了这道题和一般图上的搜索是同一件事。
5★★★ 题面那句「保证 (1,1) 和 (n,m) 均为空地」—— 触发条件是三层的
上面那份正解从 (0,0) 直接入队,压根没检查它是不是墙。造一档违反题面的数据看看:
| 300 轮,起点 / 终点也可能是墙 | |
|---|---|
| ① 真的「脏」(起点或终点是墙)的轮数 | 158 |
| ② 其中起点是墙 | 84(终点是墙 96,有重叠) |
| ③ 不检查起点的那版真的错 | ★ 26 |
| ★ 「只有终点是墙」的那些轮它错过吗 | ★ 0 次 |
★ 「起点是墙、而它仍说 Yes」的轮数 |
★ 26 —— 正好等于被抓数 |
- 158 → 84:终点是墙根本坑不到它 —— 它只对起点免检,
对别的格子都老实判了
g[nx][ny] == '#',所以从不把墙格标记成走过 ⇒ 终点是墙时它照样答No,恰好对(实测 0 次错,可证); - 84 → 26:起点是墙还不够,还得从这堵墙里泛洪出去、真能摸到终点 ——
只有这时候它才会把
No说成Yes。
⇒ ★★ 本书量过好多次「满足触发条件 ↔ 真被抓」,比值从一个不差到差 150 倍都有; 这一页是目前分层最清楚的一次:三层,而且中间那一层是精确的 0、最后一层一个不差。 ⇒ 抓获率对不上时别急着加轮数,去把中间那几层数出来。
★ 而按第 12 章那套三分法:题面那句保证对这个写法是命门
(不写那句检查,26/300 轮直接错)—— 一行 if 就能让它彻底变成噪声。
6★★ 递归能不能用:一道算术题,答案是「能,余量 17.4 倍」
上一轮 P5318 量出来一条能直接用的公式: 能递归多少层 = 栈上限 ÷ 每层字节数,而每层多少字节由你在那个函数里写了什么决定。
在同一台机器上把这个 dfs 的门槛二分出来
(A 机 · WSL2 · i5-13500H · ulimit -s = 8192 KB · -O2 -std=c++17 · 2026-08-30):
| 门槛(层) | ⇒ 每层约 | 这道题顶格要几层 | 结论 | |
|---|---|---|---|---|
本页这个 dfs |
174 000 ~ 174 500 | 48 字节 | 100 × 100 = 10 000 |
★ 安全,余量 17.4 倍 |
| ⚠ 同题单下一道 P1141 | 同一个量级 | 同 | 1000 × 1000 = 10⁶ |
★★ 必炸 |
P5318 那份「精简递归」量出来是 174 000 ~ 174 500 层 / 48 字节,
这一页这个网格 dfs 量出来一模一样。
⇒ ★★ 那条公式不是玄学:只要函数体里没有 std::string 那种要现摆一个临时对象的东西,
每层就都是那几十个字节。(P5318 里带 out += to_string(u) 的那版是 341 字节 / 2.4 万层,差 7 倍。)
⇒ 所以「这道题能不能写递归」是一道三十秒的算术题: 最深多少层 × 每层多少字节,和栈上限比一比。 这道题 10 000 层,放心写; 下一道 10⁶ 层,想都别想。
7度量程序和生成器
8一页纸
| ★ 关键的一步 | 网格是图的特例:「邻居」= 上下左右四格,其余和本章 fast.cpp 一样 |
| ⚠ 两个一测就死的错法 | 忘了标记(死循环)/ 先看格子再判界(越界 UB) |
| ★★ 第三个错法 | 八连通 —— 抓获率沿墙密度单峰:0 / 9 / 79 / 128 / 89 / 22 / 2 |
| ★★ 真建图 vs 不建图 | 300 轮 0 次不一致;顶格建图 39 600 条边,网格版 0 条 |
| ★★★ 题面那句保证 | 「(1,1)、(n,m) 是空地」是命门,触发条件三层:158 → 84 → 26(一个不差) |
| ★★ 递归能不能用 | 门槛 17.4 万层 / 每层 48 字节 ⇒ 这道题 10⁴ 层安全、下一道 10⁶ 层必炸 |