题单 · 习题解析

洛谷 B3625 迷宫寻路

★ 网格就是图;★★★ 「八连通」那个错法的抓获率沿墙密度**单峰**(0/9/79/**128**/89/22/2,两头都是 0);★★★ 题面「(1,1) 和 (n,m) 均为空地」是命门,触发条件**三层**:脏 158 → 起点是墙 84 → 真被抓 26(一个不差);★★ 递归门槛 17.4 万层 / 每层 48 字节 ⇒ 这道题 10⁴ 层安全

原题:洛谷 B3625出自 第 30 章 图上的 DFS 与 BFS、连通性 的题单题面本地存档:2026-08-30
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

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.cpp★ 这一版就能 AC
// ★ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

这道题只问「通不通」,不问「几步」⇒ BFS 和 DFS 都行,随便挑一个。

2⚠ 三个错法,两个被官方样例一测就死

b3625NoVis.cpp✗ 忘了标记走过的格子
// ✗ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

泛洪的三句话是「看邻居 → 没去过就去 → 去了就标记」。少了最后一句, A 把 B 推进队列、B 又把 A 推回来 —— 队列永远空不了

⚠ 它的现形方式不是「答案错」,而这恰恰是对拍最不擅长的一类

所以这一版加了一道保险:入队超过 10⁷ 次就打 LOOP 退出。 ★ 否则它会把 check:viz 整个挂住 —— 第 4 章 P1731 真踩过这一脚。

演示「会死循环 / 会爆栈」的错误写法时,得先给它一个出口,否则挂住的是你的闸门。

b3625Bound.cpp✗ 先看格子、再判界
// ✗ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
    if (g[nx][ny] == '#' || vis[nx][ny]) continue;      // ← 这一句先跑了
    if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;

nx = -1g[-1]未定义行为 —— 可能崩、可能读到垃圾、也可能什么事都没有。 ⚠⚠ 「什么事都没有」才是最坏的情况:本机跑一百遍全对,交上去 RE 在第 3 个点。 ⇒ 这一版用 .at() 把 UB 换成确定的异常,好让那个错在页面上真的现形一次 (第 45 章那条:演示错误写法,要让那个错可复现)。

3★★ 第三个错法:顺手写成了八个方向 —— 而它的抓获率是一条单峰曲线

b3625Eight.cpp✗ 八连通(样例照样打 Yes)
// ✗ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

八连通把图变宽了 ⇒ 它只会多说 Yes,不会多说 No。官方样例放过了它。

★★★ 只拧「墙的百分比」,别的都不动 —— 两头都是 0,峰在中间
墙的百分比 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]) —— 一个字都不知道「上下左右」是什么

b3625Graph.cpp★ 只认邻接表(300 轮 0 次不一致)
★★ 「选存法里包括不存」的网格版
顶格 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 → 26:两次收窄,而每一次都能说清楚为什么
  • 158 → 84:终点是墙根本坑不到它 —— 它只对起点免检, 对别的格子都老实判了 g[nx][ny] == '#',所以从不把墙格标记成走过 ⇒ 终点是墙时它照样答 No恰好对(实测 0 次错,可证);
  • 84 → 26:起点是墙还不够,还得从这堵墙里泛洪出去、真能摸到终点 —— 只有这时候它才会把 No 说成 Yes

⇒ ★★ 本书量过好多次「满足触发条件 ↔ 真被抓」,比值从一个不差差 150 倍都有; 这一页是目前分层最清楚的一次:三层,而且中间那一层是精确的 0、最后一层一个不差。抓获率对不上时别急着加轮数,去把中间那几层数出来。

★ 而按第 12 章那套三分法:题面那句保证对这个写法是命门 (不写那句检查,26/300 轮直接错)—— 一行 if 就能让它彻底变成噪声。

6★★ 递归能不能用:一道算术题,答案是「能,余量 17.4 倍」

b3625Dfs.cpp★ 递归 DFS 也能 AC

上一轮 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⁶ ★★ 必炸
★★★ 48 字节这个数在两个完全不同的 dfs 上重现了

P5318 那份「精简递归」量出来是 174 000 ~ 174 500 层 / 48 字节, 这一页这个网格 dfs 量出来一模一样

⇒ ★★ 那条公式不是玄学:只要函数体里没有 std::string 那种要现摆一个临时对象的东西, 每层就都是那几十个字节。(P5318 里带 out += to_string(u) 的那版是 341 字节 / 2.4 万层,差 7 倍。)

⇒ 所以「这道题能不能写递归」是一道三十秒的算术题最深多少层 × 每层多少字节,和栈上限比一比。 这道题 10 000 层,放心写; 下一道 10⁶ 层,想都别想

7度量程序和生成器

b3625Count.cpp度量程序(本页所有数字都出自它)
b3625Gen.cpp数据生成器

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⁶ 层必炸