题单 · 习题解析

洛谷 P1746 离开中山路

★★★ 官方样例的两个坐标都在主对角线上 ⇒ 它对「x 是行还是列」结构上说不出话;而抓获率沿密度掉的那一头,是「两版一起输出 -1」

原题:洛谷 P1746出自 第 14 章 BFS 广度优先搜索:迷宫最短路 的题单题面本地存档:2026-08-28
⚠ 先自己写一遍,再往下看

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

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1746,日期见页头。两边不一致时信原站。

题目背景

《爱与愁的故事第三弹 · shopping》最终章。

题目描述

爱与愁大神买完东西后,打算坐车离开中山路。现在爱与愁大神在 x₁, y₁ 处,车站在 x₂, y₂ 处。 现在给出一个 n × nn ≤ 1000)的地图,0 表示马路,1 表示店铺(不能从店铺穿过), 爱与愁大神只能垂直或水平着在马路上行进。爱与愁大神为了节省时间, 他要求最短到达目的地距离(每两个相邻坐标间距离为 1)。你能帮他解决吗?

输入格式

1 行包含一个数 n

2 行到第 n+1 行:整个地图描述(0 表示马路,1 表示店铺,注意两个数之间没有空格)。

n+2 行:四个数 x₁, y₁, x₂, y₂

输出格式

只有 1 行,即最短到达目的地距离。

数据规模与约定

对于 20% 数据,满足 1 ≤ n ≤ 100

对于 100% 数据,满足 1 ≤ n ≤ 1000

输入输出样例

输入

3
001
101
100
1 1 3 3

输出

4

1第一反应:本章那份 fast.cpp 搬过来 —— 而它换掉了三个主语

题单里这道题的注解写着「和本章例题几乎一样,起终点由输入给定」。 这句话是对的:算法一个字都不用新想,就是第 14 章那份 fast.cpp

⚠ 而第 13 章 P1451 刚说过一句:「和上一道几乎一样」是危险信号。 这道题正好又验了一次 —— 「几乎一样」的四个字里,有三个主语被换掉了, 三处各自能挂人,而且只有第一处会被官方样例挡住

第 14 章例题 这道题 样例挡得住吗
1 是什么 马路 店铺(走不了) ✓ 当场挡住
地图怎么读 一行一个字符串 一样,但数字之间没空格(题面专门提醒) ✓ 读都读不完
起终点 钉死在两个角上 由输入给定 x y 一个字都说不出来
这一页的排法

第 ②③④ 步一处一处过,第 ⑤ 步给出能 AC 的那一版。 ⚠ 第 ④ 步是这一页的主线,也是唯一需要动手造数据才抓得到的那处。

2第一处:0 和 1 的含义正好反过来

第 14 章那份 fast.cpp 里判断能不能走的是这一句:

    if (g[x][y] != '1') continue;      // 本章例题:'1' 是路

而这道题的题面写的是 0 表示马路,1 表示店铺。原样搬过来就是:

p1746Wall.cpp⚠ 把店铺当成了马路
// ⚠ 错法一:0 / 1 的含义照搬了本章例题
//
// 第 14 章正文那份 fast.cpp 里,判断能不能走的那一句是:
//
// if (g[x][y] != '1') continue; // 本章例题:'1' 是路,'0' 是墙
//
// 而这道题的题面写的是:**0 表示马路,1 表示店铺(不能从店铺穿过)**——
// 正好反过来。把 fast.cpp 搬过来时最容易原样留下的就是这一句。
//
// ★ 好消息是它**当场就露馅**:官方样例的终点 (3,3) 在这份代码眼里是「墙」,
// BFS 永远到不了,直接输出 -1。
// ⇒ 这是这一页四个错法里唯一一个「样例就挡住了」的,别的三个都过得去。
//
// 除了那一句,其余和 p1746.cpp 逐字相同。
#include <bits/stdc++.h>
using namespace std;
const int DX[4] = {-1, 1, 0, 0};
const int DY[4] = {0, 0, -1, 1};
int main() {
int n;
if (!(cin >> n)) return 0;
vector<string> g(n);
for (int i = 0; i < n; i++) cin >> g[i];
int sx, sy, tx, ty;
cin >> sx >> sy >> tx >> ty;
sx--; sy--; tx--; ty--;
vector<vector<int>> dist(n, vector<int>(n, -1));
queue<pair<int, int>> q;
dist[sx][sy] = 0;
q.push({sx, sy});
while (!q.empty()) {
auto [i, j] = q.front();
q.pop();
if (i == tx && j == ty) break;
for (int d = 0; d < 4; d++) {
int x = i + DX[d], y = j + DY[d];
if (x < 0 || x >= n || y < 0 || y >= n) continue;
if (g[x][y] != '1') continue; // ⚠ 就是这一句:把店铺当成了马路
if (dist[x][y] != -1) continue;
dist[x][y] = dist[i][j] + 1;
q.push({x, y});
}
}
cout << dist[tx][ty] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它在官方样例上就输出 -1 —— 因为终点 (3, 3) 在这份代码眼里是一堵墙,永远到不了。

✓ 这是这一页四个错法里唯一一个「样例就够了」的

把样例跑一遍要五秒钟,它当场给你一个 -1,而正确答案是 4

⇒ 值得记住的是比例:这道题四个真实会犯的错里, 样例只挡得住这一个。下面三个它一个都拦不下来。

3第二处:地图逐格 cin >> 读整数 —— 题面自己提醒过的那句

地图看着就是一堆 0 和 1,第一反应当然是开一个 int 数组逐格读进来:

p1746Int.cpp⚠ 逐格 cin >> int
// ⚠ 错法二:地图逐格 `cin >> a[i][j]` 读整数
//
// 地图看着就是一堆 0 和 1,第一反应当然是开一个 int 数组逐格读。
// 而题面在输入格式那一节**专门提醒过一句**:
//
// 「0 表示马路,1 表示店铺,注意两个数之间没有空格」
//
// `cin >> int` 是按**空白分隔的 token** 读的 —— 一整行 "001" 在它眼里是**一个** token,
// 读成十进制的 1。于是:
//
// · 第 1 行 "001" → a[0][0] = 1,一行三格只填了一格;
// · 后面的 token 会顺着往前挪,**把最后那行的起终点坐标也一起吃进地图里**;
// · token 不够了,剩下的 `cin >>` 全部失败。
//
// ⇒ 它不是「读错了一两格」,是**从第一行起整个错位**。
// n = 1000 时更彻底:一行是 1000 位的数,超出 int,第一次 `cin >>` 就置了 failbit。
//
// ⚠⚠ 这一版自带一道闸(照 P1443 那条教训:演示错误写法的代码,绝不许悄悄去做别的事)。
// 不加闸的话,读失败后 sx = sy = tx = ty = 0,减一变成 -1,
// 接下来就是 dist[-1][-1] —— 那是 UB,打印出来的数字换台机器就变,
// 而它和「读入方式错了」这件事已经没关系了。
// ⇒ 现在它发现读入流坏了就报到 stderr 并 return 1,**stdout 一个字节都不输出**。
#include <bits/stdc++.h>
using namespace std;
const int DX[4] = {-1, 1, 0, 0};
const int DY[4] = {0, 0, -1, 1};
int main() {
int n;
if (!(cin >> n)) return 0;
vector<vector<int>> a(n, vector<int>(n, 0));
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++) cin >> a[i][j]; // ⚠ 就是这一句
int sx, sy, tx, ty;
cin >> sx >> sy >> tx >> ty;
if (!cin) { // ← 那道闸,理由见文件头
fprintf(stderr, "读入流已经坏了:n = %d,地图要 %d 个数,"
"而整份输入按空白分隔一共只有 %d 个 token\n", n, n * n, n + 4);
return 1;
}
sx--; sy--; tx--; ty--;
if (sx < 0 || sx >= n || sy < 0 || sy >= n || tx < 0 || tx >= n || ty < 0 || ty >= n) {
fprintf(stderr, "读出来的坐标 (%d,%d) (%d,%d) 根本不在地图里\n", sx + 1, sy + 1, tx + 1, ty + 1);
return 1;
}
vector<vector<int>> dist(n, vector<int>(n, -1));
queue<pair<int, int>> q;
dist[sx][sy] = 0;
q.push({sx, sy});
while (!q.empty()) {
auto [i, j] = q.front();
q.pop();
if (i == tx && j == ty) break;
for (int d = 0; d < 4; d++) {
int x = i + DX[d], y = j + DY[d];
if (x < 0 || x >= n || y < 0 || y >= n) continue;
if (a[x][y] != 0) continue;
if (dist[x][y] != -1) continue;
dist[x][y] = dist[i][j] + 1;
q.push({x, y});
}
}
cout << dist[tx][ty] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

cin >> int 是按空白分隔的 token 读的。一整行 001 在它眼里是一个 token, 读成十进制的 1。于是官方样例那份输入,它数出来的 token 只有这些:

    1      101      100      1      1      3      3
    ^      ^        ^        \______________________/
    第 1 行 第 2 行  第 3 行     这四个是起终点坐标,被当成地图吃掉了

9 个格子只填上 3 个,而且填进去的还是整行的十进制值; 剩下 6 格没数了,cin >> sx >> sy >> tx >> ty 全部失败。

★★ 题面里那句提醒,是「情报」不是废话

第 12 章 P1226 把题面里的约束分成三类:情报 / 命门 / 噪声, 判据是同一个动作 —— 造一档违反它的数据,看有没有哪一版的行为变了

这道题的「注意两个数之间没有空格」是最纯粹的情报: 把样例的地图改成 0 0 1 这种带空格的写法,同一份 p1746Int.cpp 立刻就对了。 ⇒ 出题人专门写这一句,说的就是「这里有一个和算法无关的坑」。

★ 顶格那一档更彻底:n = 1000 时一行是一个 1000 位的数, 第一次 cin >> 就超出 int 范围、直接置了 failbit —— 一格都读不进来。

⚠ 这一版为什么自带一道闸

不加闸的话,读入失败后 sx = sy = tx = ty = 0,减一变成 -1, 接着就是 dist[-1][-1] —— 那是 UB。打印出来的数字换台机器就变, 而且它和「读入方式错了」这件事已经没有关系了。

⇒ 现在它发现读入流坏了就报到 stderrreturn 1stdout 一个字节都不输出。 这是第 14 章 P1443 那条教训的直接套用: 演示错误写法的代码,绝不许悄悄少做一步把自己救活,也不许改去演示另一件事。

4★★★ 第三处:x 是行还是列 —— 官方样例对这件事结构上说不出话

题面给的是 x₁ y₁ x₂ y₂,地图是一行一行给的,所以 x 是行号、y 是列号。 把两者当反了会怎样:

p1746Swap.cpp⚠ 把 (x, y) 当成了 (列, 行)
// ⚠ 错法三:把 (x, y) 当成了 (列, 行)
//
// 题面给的是 `x1 y1 x2 y2`。地图是**一行一行**给的,
// 所以 x 是**行号**、y 是**列号** —— 这份代码把两者当反了,
// 于是它在同一张地图上求的是「转置之后那两个点」之间的最短路。
//
// ★★★ 这一页的主线就是它:**官方样例对这件事一个字都说不出来。**
// 样例的坐标是 `1 1 3 3` —— 两个点**都在主对角线上**,
// 而交换行列在对角线上是**恒等变换**((1,1) 换完还是 (1,1))。
// ⇒ 这不是「答案碰巧撞上了」,是**结构上不可能被这组样例分辨**。
//
// 抓它要么对拍(p1746Gen.cpp),要么把小规模的输入空间数完(p1746Count.cpp 第 ② 段)。
//
// 除了 BFS 起终点那两行,其余和 p1746.cpp 逐字相同。
#include <bits/stdc++.h>
using namespace std;
const int DX[4] = {-1, 1, 0, 0};
const int DY[4] = {0, 0, -1, 1};
int main() {
int n;
if (!(cin >> n)) return 0;
vector<string> g(n);
for (int i = 0; i < n; i++) cin >> g[i];
int sx, sy, tx, ty;
cin >> sx >> sy >> tx >> ty;
sx--; sy--; tx--; ty--;
swap(sx, sy); // ⚠ 就是这两句:x 被当成了列号
swap(tx, ty);
vector<vector<int>> dist(n, vector<int>(n, -1));
queue<pair<int, int>> q;
dist[sx][sy] = 0;
q.push({sx, sy});
while (!q.empty()) {
auto [i, j] = q.front();
q.pop();
if (i == tx && j == ty) break;
for (int d = 0; d < 4; d++) {
int x = i + DX[d], y = j + DY[d];
if (x < 0 || x >= n || y < 0 || y >= n) continue;
if (g[x][y] != '0') continue;
if (dist[x][y] != -1) continue;
dist[x][y] = dist[i][j] + 1;
q.push({x, y});
}
}
cout << dist[tx][ty] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它和正解逐字节相同,也输出 4 而这一次不是「碰巧撞上了」:

    官方样例的坐标:  1 1  →  3 3

    (1,1) 交换行列  =  (1,1)      <- 主对角线上的点,交换行列原地不动
    (3,3) 交换行列  =  (3,3)

两个点都落在主对角线上,交换行列在它们身上是恒等变换。 不管地图长什么样、答案是几,这组样例都不可能分辨出这个 bug。

★★★ 「它过了样例」的第三种原因:这组样例在结构上问不出这个问题

第 8 章 P1024 分过两种:它真的对,和数据替它对了(根正好落在扫描网格上)。 这一页是第三种,而且更硬:

不是概率低,是这组输入所在的那一类里,抓获率恒等于 0。

3 × 3全部合法输入数完(512 张地图 × 所有起终点落在马路上的有序对), 一共 11520 组:

这一类输入 有几组 分辨得出「行列反了」的
全部 11520 2768(24.0%)
★ 起终点都在主对角线上 1536 0

4 × 4 上同一件事:全部 4456448 组里有 1367240 组(30.7%)分辨得出, 而对角线那一类 327680 组里仍然是 ★ 0

官方样例正好是那 1536 组里的一组。 这和第 14 章 P1443 那条是同一个动作:bug 空间数得完的时候,别抽样,数完它 —— 只不过那一页数的是「有多少种抄错」,这一页数的是「有多少种输入抓得到」。

★ 顺带:这个 bug 一半的时候看不出来,不是因为它「不常发生」

每一轮都发生 —— 每一份输入都被换了坐标。看不出来只是因为换完之后答案没变: 地图对称、起终点在对角线上、或者两条路碰巧一样长。

⇒ 这是第 11 章 P1115 那条的另一面: 对拍的抓获数是一个计数,不是概率。 那一页数的是「满足触发条件的轮数」, 这一页数的是「触发之后答案真的变了的轮数」—— 触发率 100%,可见率 24%。

5★ 三处都改对,这一版就能 AC

p1746.cpp★ 这一版就能 AC
// P1746 离开中山路 —— BFS 最短路(★ 这一版就能 AC)
//
// 题目:n × n 的地图,'0' 是马路、'1' 是店铺(不能穿过),
// 给定起点 (x1, y1) 和终点 (x2, y2),求最少走几步(只能上下左右)。
//
// 算法上它就是第 14 章正文那份 fast.cpp,一个字都不用新想。
// 但「几乎一样」的四个字里,藏着三处**被换掉的主语**,三处都能让人挂掉:
//
// ① 0 / 1 的含义反了 —— 本章例题里 '1' 是路,这道题里 '1' 是店铺。
// ② 地图**两个数之间没有空格**(题面自己写着这句),不能逐格 cin >> int。
// ③ 起终点由输入给定 —— 于是多出一个「x 是行还是列」的问题,
// 而官方样例的两个点都在主对角线上,对这件事一个字都说不出来。
//
// 这三处分别对应 p1746Wall.cpp / p1746Int.cpp / p1746Swap.cpp。
//
// 复杂度:每个格子最多进队一次,O(n²)。n ≤ 1000 ⇒ 一百万格,随便过。
#include <bits/stdc++.h>
using namespace std;
const int DX[4] = {-1, 1, 0, 0};
const int DY[4] = {0, 0, -1, 1};
int main() {
int n;
if (!(cin >> n)) return 0;
vector<string> g(n);
for (int i = 0; i < n; i++) cin >> g[i]; // ★ 一整行读成字符串,别逐个数字读
int sx, sy, tx, ty;
cin >> sx >> sy >> tx >> ty;
sx--; sy--; tx--; ty--; // 题面是 1 开始的
vector<vector<int>> dist(n, vector<int>(n, -1));
queue<pair<int, int>> q;
dist[sx][sy] = 0;
q.push({sx, sy});
while (!q.empty()) {
auto [i, j] = q.front();
q.pop();
if (i == tx && j == ty) break; // 第一次碰到终点就已经是最短
for (int d = 0; d < 4; d++) {
int x = i + DX[d], y = j + DY[d];
if (x < 0 || x >= n || y < 0 || y >= n) continue;
if (g[x][y] != '0') continue; // ★ '0' 才是马路(本章例题正好反过来)
if (dist[x][y] != -1) continue;
dist[x][y] = dist[i][j] + 1; // 入队时就定下距离并标记
q.push({x, y});
}
}
// ⚠ 题面没说「保证能到」。到不了就输出 -1 —— 三个错法版本口径一致,
// 这样对拍比的才是同一件事(这一页第 ⑤ 步会说,这个口径本身有代价)。
cout << dist[tx][ty] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

和第 14 章正文那份 fast.cpp 的差别,就是上面那三处,一行算法都没动。

⚠ 一处题面没说的事:到不了怎么办

题面没有保证起终点连通,也没说到不了要输出什么。 这一页统一按「dist 初始 -1,到不了就原样打印 -1」处理 —— 四个版本口径一致,对拍比的才是同一件事。

⇒ 而这个口径是有代价的,第 ⑦ 步就栽在它上面。

6⚠ 上一章的惯性:用 DFS 求最短路

第 13 章整章都在网格上写 DFS,到这道题很自然会接着写 DFS:一路走到终点,拿步数去更新答案。 光这样是指数的(第 14 章正文第 ⑥ 步那个实验就是它), 于是几乎所有人都会顺手加一句剪枝:

    if (d >= best[x][y]) return;      // 到这一格已经有更短或一样短的走法了
p1746Dfs.cpp⚠ DFS + 剪枝(答案是对的)
// ⚠ 错法四(也是「上一章的惯性」):用 DFS 求最短路
//
// 第 13 章整章都在网格上写 DFS,到这道题很自然就会接着写 DFS:
// 一路往下走,走到终点就拿当前步数去更新答案。
//
// 光这样是**指数**的(每条路都要走完,第 14 章正文第 ⑥ 步那个实验就是它)。
// 于是几乎所有人都会顺手加一句剪枝:
//
// if (d >= best[x][y]) return; // 到这一格已经有更短或一样短的走法了
//
// ★ 加完这一句,**答案是对的** —— 它不再是「枚举所有路径」,
// 而变成了「每格记住目前最好的步数,更好就往下推」(其实就是 SPFA 的 DFS 版)。
// ⇒ 对拍抓不到它。要量它只能换一把尺子:**数 dfs 被调用了多少次**。
//
// 它输在哪儿(p1746Count.cpp 第 ③ 段量的就是这个):
// · 一个格子会被反复改进、反复往下推,调用次数远远不是 O(n²);
// · 递归深度 = 当前路径长度,最坏能到 n² 层 —— n = 1000 就是一百万层栈帧。
// BFS 那份的队列是**堆**上的,一百万个元素毫无压力;递归的一百万层在**栈**上,装不下。
//
// ⚠ 所以这一版只在很小的 n 上有意义。它自带一道闸:调用次数超上限就报错退出
// (理由同 p1746Int.cpp 文件头:演示错误写法的代码不许悄悄少做一步把自己救活)。
#include <bits/stdc++.h>
using namespace std;
const int DX[4] = {-1, 1, 0, 0};
const int DY[4] = {0, 0, -1, 1};
const long long CALL_LIMIT = 200000000LL;
int n, tx, ty;
vector<string> g;
vector<vector<int>> best;
long long calls = 0;
void dfs(int x, int y, int d) {
if (++calls > CALL_LIMIT) {
fprintf(stderr, "dfs 调用次数超过 %lld,这一档 DFS 根本跑不完\n", CALL_LIMIT);
exit(1);
}
if (d >= best[x][y]) return; // ★ 就是这一句把指数压了下来,也是它答案对的原因
best[x][y] = d;
if (x == tx && y == ty) return; // 到终点就不用再往外走了
for (int k = 0; k < 4; k++) {
int nx = x + DX[k], ny = y + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
if (g[nx][ny] != '0') continue;
dfs(nx, ny, d + 1);
}
}
int main() {
if (!(cin >> n)) return 0;
g.assign(n, "");
for (int i = 0; i < n; i++) cin >> g[i];
int sx, sy;
cin >> sx >> sy >> tx >> ty;
sx--; sy--; tx--; ty--;
best.assign(n, vector<int>(n, INT_MAX));
dfs(sx, sy, 0);
cout << (best[tx][ty] == INT_MAX ? -1 : best[tx][ty]) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

加完这一句它就不再是指数了,而且答案全对 —— 对拍对它一个字都说不出来(第 ⑦ 步:精确的 0)。 量它只能换一把尺子:dfs 被调用了多少次(空地图、起点左上角、终点右下角):

n 10 20 30 40 50
★ BFS 入队次数 100 400 900 1600 2500
dfs 调用次数 7551 138301 734251 2377401 5889751
⚠ 最深递归层数 98 398 898 1598 2498

BFS 那一行正好是 dfs 那一行是 n⁴ 的量级(n = 50调用次数 / n⁴ = 0.942)。

★★ 顶格那一档:两个数都装不下

n = 1000n² = 10⁶n⁴ = 10¹²

  • 时间dfs 要调用约 10¹² 次 —— 不是慢几倍,是差六个数量级
  • ★★ :最深递归层数实测正好是 n² − 2(98 / 398 / 898 / 1598 / 2498,一个不差)。 n = 1000 就是 999998 层。 BFS 那一百万个格子是排在上的队列里,毫无压力; 递归的一百万层排在上 —— 这正是第 14 章那张选择表里 「网格特别大 ⇒ 用 BFS,DFS 递归深度会爆栈」的具体数字。

⇒ 所以这一版也自带一道闸(调用次数超上限就报错退出),理由和第 ③ 步那一版一样。

7★★★ 对拍:抓获率沿密度会掉头 —— 而掉下去的那一段是假的

p1746Gen.cpp生成器:两个旋钮 —— 边长和店铺密度
// P1746 对拍生成器:`./p1746Gen <seed> [maxN] [密度%] [要不要保证可达]`
//
// 输出就是这道题的一整份输入:n、n 行地图、最后一行 x1 y1 x2 y2。
//
// ★ 它有**两个**旋钮,而这一页的重点正是「抓获率不是一个数」:
//
// · maxN —— 网格边长(1 ~ maxN 随机),默认 8;
// · 密度% —— 每一格是店铺 '1' 的概率,默认 30。
//
// ⚠ 密度这个旋钮**两头都是精确的 0**(p1746Count.cpp 第 ④ 段量的就是它):
// · 密度 0:整张图全是马路,答案就是曼哈顿距离 |dx| + |dy| ——
// 而它对「行列交换」是**对称的**,p1746Swap.cpp 永远给出同一个数;
// · 密度高:起终点多半根本不连通,两版一起输出 -1 —— 也「一致」。
// ⇒ 抓得到它的只有中间那一段。
//
// ★ 第四个参数 = 1 时会一直重造,直到起终点真的连通(造够 500 次还不成就
// 在第 1 行和第 n 列上凿出一条 L 形通路)。它用来把「都输出 -1」那一类轮次排掉,
// 看剩下的比对里还剩多少抓获率。
//
// 起终点保证落在马路上(否则「从店铺出发」这件事题面根本没定义)。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<string> g;
static bool reachable(int sx, int sy, int tx, int ty) {
const int DX[4] = {-1, 1, 0, 0}, DY[4] = {0, 0, -1, 1};
vector<vector<char>> vis(n, vector<char>(n, 0));
queue<pair<int, int>> q;
vis[sx][sy] = 1;
q.push({sx, sy});
while (!q.empty()) {
auto [x, y] = q.front();
q.pop();
if (x == tx && y == ty) return true;
for (int k = 0; k < 4; k++) {
int a = x + DX[k], b = y + DY[k];
if (a < 0 || a >= n || b < 0 || b >= n) continue;
if (g[a][b] != '0' || vis[a][b]) continue;
vis[a][b] = 1;
q.push({a, b});
}
}
return false;
}
int main(int argc, char** argv) {
unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1u;
int maxN = argc > 2 ? atoi(argv[2]) : 8;
int dens = argc > 3 ? atoi(argv[3]) : 30;
int need = argc > 4 ? atoi(argv[4]) : 0;
if (maxN < 1) maxN = 1;
if (dens < 0) dens = 0;
if (dens > 100) dens = 100;
mt19937 rng(seed);
n = (int)(rng() % (unsigned)maxN) + 1;
int sx = 0, sy = 0, tx = 0, ty = 0;
for (int tries = 0; ; tries++) {
g.assign(n, string(n, '0'));
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
if ((int)(rng() % 100u) < dens) g[i][j] = '1';
sx = (int)(rng() % (unsigned)n); sy = (int)(rng() % (unsigned)n);
tx = (int)(rng() % (unsigned)n); ty = (int)(rng() % (unsigned)n);
g[sx][sy] = '0'; // 起终点必须是马路
g[tx][ty] = '0';
if (!need) break;
if (reachable(sx, sy, tx, ty)) break;
if (tries >= 500) { // 兜底:凿一条 L 形通路出来,保证一定连通
for (int j = 0; j < n; j++) g[sx][j] = '0';
for (int i = 0; i < n; i++) g[i][ty] = '0';
g[tx][ty] = '0';
break;
}
}
printf("%d\n", n);
for (int i = 0; i < n; i++) printf("%s\n", g[i].c_str());
printf("%d %d %d %d\n", sx + 1, sy + 1, tx + 1, ty + 1);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

生成器的第二个旋钮是店铺密度。300 轮跑下来(maxN = 8):

店铺密度 0% 10% 20% 30% 40% 50% 60% 70%
p1746Swap 被抓 0 23 64 93 93 73 51 43
这 300 轮里「两版都输出 -1 0 3 11 34 73 115 144 165
★ 换成「保证可达」的生成器 0 23 67 101 116 118 118 131
★★★ 「一致」有两种:都算对了,和都没算

第一行看着是一条先升后降的曲线(峰在 30%~40%), 照第 13 章 P1596 那条「抓获率是一张曲面」,很容易就下结论说 「密度太高时 bug 更难触发」。

那句话是错的。 看第二行:密度 70% 那一档,300 轮里有 165 轮 正解和错法一起输出 -1 —— 起终点根本不连通。 那 165 轮里两边逐字节相同,对拍记一次「通过」,可它什么都没验

⇒ 把生成器换成「一直重造,直到起终点真的连通」(第四个参数给 1), 同一个旋钮上的抓获数一路升到 131,不再掉头

下降那一段不是 bug 变难抓了,是对拍在比一个双方都没算的答案。 ⇒ 看到抓获率往下走,先问一句:这一段里,两边比的还是同一件事吗?

★★★ 另一头那个 0 是能证出来的:曼哈顿距离对交换行列是对称的

密度 0% 那一档是精确的 0,而且不用跑也知道: 整张图全是马路时,答案就是曼哈顿距离

    |x1 - x2| + |y1 - y2|

xy 交换,两个绝对值只是换了个前后顺序,和还是那个和。 ⇒ p1746Swap.cpp 在空地图上永远给出正确答案。

★ 所以这条曲线两头的 0 原因完全不同: 一头是「bug 在这一档上真的不会错」,另一头是「两边都没算」。 这正是第 12 章 P1226 那条「对拍 0 次有两种原因」的第三、第四种。

四个版本一起进对拍(密度 30%、保证可达、300 轮):

版本 被抓轮数 漏掉的那些轮,是哪些
p1746Wall(0/1 反了) 239 / 300 ★ 漏掉的 61 轮 ≡ 起终点是同一格的 61 轮
p1746Int(逐格读整数) 263 / 300 ★ 漏掉的 37 轮 ≡ n = 1 的 37 轮
p1746Swap(行列反了) 101 / 300 触发率 100%,可见率约三成(第 ④ 步)
p1746Dfs(DFS + 剪枝) 0 / 300 它的答案是对的
★★ 那两个「≡」是数出来的,不是估出来的

第 11 章 P1115 那条说:对拍的抓获数是一个计数,不是概率 —— 抓不到的时候别加轮数,去数一数。 这张表里两行都能数到底:

  • p1746Wall 漏掉的 61 轮,每一轮都是起终点落在同一格上 (答案 0,任何版本都不会错)。61 轮里 61 轮,一个不差。
  • p1746Int 漏掉的 37 轮,每一轮都是 n = 1。 ★ 这不是巧合:n = 1 时地图只有一个数字,「两个数之间没有空格」这句话根本不成立 —— 逐格 cin >> int 在这一档上是对的。(maxN = 8n = 1 占八分之一,300 轮里 37 轮。)

⇒ 两条都不是「再多跑几百轮就能抓到」,它们是被输入的形状挡住的

8这一页所有数字都出自这一份

p1746Count.cpp度量:全空间穷举 + DFS 次数 + 密度曲线
// P1746 的度量程序 —— 这一页所有数字都出自这一份。
//
// `./p1746Count` 人看的版本
// `./p1746Count csv` 一行一项,给 scripts/check-viz.mjs 写断言用
//
// 四段:
// ① 官方样例为什么挡不住「行列反了」—— 那两个点都在主对角线上;
// ② 把 3 × 3 / 4 × 4 的**全部合法输入**数完:有多少种分辨得出行列反了;
// ③ DFS(带剪枝)的调用次数和递归深度,对照 BFS 的入队次数;
// ④ 抓获率沿「店铺密度」这个旋钮是一条**两头都是 0** 的曲线,
// 而两头的 0 原因完全不同(曼哈顿距离对称 / 根本不连通)。
//
// ⚠ 第 ④ 段是**照着 p1746Gen.cpp 逐字复制**的一份生成逻辑(同样的 mt19937、
// 同样的取数顺序),所以它给的数应该和 check-viz 里真跑对拍的那张表**完全一样** ——
// 两条独立的路给同一个数,这本身就是一道交叉验证。
#include <bits/stdc++.h>
using namespace std;
static bool CSV = false;
static const int DX[4] = {-1, 1, 0, 0};
static const int DY[4] = {0, 0, -1, 1};
static void row(const char* key, const vector<long long>& v) {
if (!CSV) return;
printf("%s", key);
for (long long x : v) printf(",%lld", x);
printf("\n");
}
/* 从 (sx,sy) 出发的 BFS。★ 起点即使踩在店铺上也照走 —— p1746Swap.cpp 就是这么干的。 */
static vector<vector<int>> bfsFrom(const vector<string>& g, int sx, int sy) {
int n = (int)g.size();
vector<vector<int>> d(n, vector<int>(n, -1));
queue<pair<int, int>> q;
d[sx][sy] = 0;
q.push({sx, sy});
while (!q.empty()) {
auto [x, y] = q.front();
q.pop();
for (int k = 0; k < 4; k++) {
int a = x + DX[k], b = y + DY[k];
if (a < 0 || a >= n || b < 0 || b >= n) continue;
if (g[a][b] != '0' || d[a][b] != -1) continue;
d[a][b] = d[x][y] + 1;
q.push({a, b});
}
}
return d;
}
/* ---------- ② 把 n × n 的全部合法输入数完 ---------- */
struct EnumResult { long long total, differ, diagTotal, diagDiffer; };
static EnumResult enumerateAll(int n) {
EnumResult r{0, 0, 0, 0};
int cells = n * n;
for (long long mask = 0; mask < (1LL << cells); mask++) {
vector<string> g(n, string(n, '0'));
for (int c = 0; c < cells; c++)
if (mask >> c & 1) g[c / n][c % n] = '1';
// 从每一格出发各跑一次 BFS(起点允许踩在店铺上)
vector<vector<vector<int>>> D(cells);
for (int c = 0; c < cells; c++) D[c] = bfsFrom(g, c / n, c % n);
for (int s = 0; s < cells; s++) {
if (g[s / n][s % n] != '0') continue;
for (int t = 0; t < cells; t++) {
if (g[t / n][t % n] != '0') continue;
int sx = s / n, sy = s % n, tx = t / n, ty = t % n;
int okAns = D[s][tx][ty];
int sw = sy * n + sx; // 把 (x,y) 当成 (列,行)
int bad = D[sw][ty][tx];
bool diag = (sx == sy && tx == ty);
r.total++;
if (diag) r.diagTotal++;
if (okAns != bad) { r.differ++; if (diag) r.diagDiffer++; }
}
}
}
return r;
}
/* ---------- ③ DFS(带剪枝)的调用次数与递归深度 ---------- */
static int gN, gTx, gTy;
static vector<string> gG;
static vector<vector<int>> gBest;
static long long gCalls;
static int gDepth;
static void dfs(int x, int y, int d) {
gCalls++;
if (d > gDepth) gDepth = d;
if (d >= gBest[x][y]) return;
gBest[x][y] = d;
if (x == gTx && y == gTy) return;
for (int k = 0; k < 4; k++) {
int a = x + DX[k], b = y + DY[k];
if (a < 0 || a >= gN || b < 0 || b >= gN) continue;
if (gG[a][b] != '0') continue;
dfs(a, b, d + 1);
}
}
/* 空地图上 BFS 的入队次数(带「碰到终点就 break」那一句,和 p1746.cpp 一致) */
static long long bfsPushes(int n) {
vector<string> g(n, string(n, '0'));
vector<vector<int>> d(n, vector<int>(n, -1));
queue<pair<int, int>> q;
long long pushes = 1;
d[0][0] = 0;
q.push({0, 0});
while (!q.empty()) {
auto [x, y] = q.front();
q.pop();
if (x == n - 1 && y == n - 1) break;
for (int k = 0; k < 4; k++) {
int a = x + DX[k], b = y + DY[k];
if (a < 0 || a >= n || b < 0 || b >= n) continue;
if (d[a][b] != -1) continue;
d[a][b] = d[x][y] + 1;
q.push({a, b});
pushes++;
}
}
return pushes;
}
/* ---------- ④ 密度曲面(生成逻辑照抄 p1746Gen.cpp) ---------- */
struct DensResult { int caught, unreachable; };
static DensResult scanDensity(int dens, int maxN, int rounds, bool need) {
DensResult r{0, 0};
for (int seed = 1; seed <= rounds; seed++) {
mt19937 rng((unsigned)seed);
int n = (int)(rng() % (unsigned)maxN) + 1;
vector<string> g;
int sx = 0, sy = 0, tx = 0, ty = 0;
for (int tries = 0; ; tries++) {
g.assign(n, string(n, '0'));
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
if ((int)(rng() % 100u) < dens) g[i][j] = '1';
sx = (int)(rng() % (unsigned)n); sy = (int)(rng() % (unsigned)n);
tx = (int)(rng() % (unsigned)n); ty = (int)(rng() % (unsigned)n);
g[sx][sy] = '0';
g[tx][ty] = '0';
if (!need) break;
if (bfsFrom(g, sx, sy)[tx][ty] != -1) break;
if (tries >= 500) {
for (int j = 0; j < n; j++) g[sx][j] = '0';
for (int i = 0; i < n; i++) g[i][ty] = '0';
g[tx][ty] = '0';
break;
}
}
int okAns = bfsFrom(g, sx, sy)[tx][ty];
int bad = bfsFrom(g, sy, sx)[ty][tx];
if (okAns != bad) r.caught++;
if (okAns == -1) r.unreachable++;
}
return r;
}
int main(int argc, char** argv) {
CSV = (argc > 1 && string(argv[1]) == "csv");
/* ① 官方样例:两个点都在主对角线上 */
{
vector<string> g = {"001", "101", "100"};
int okAns = bfsFrom(g, 0, 0)[2][2];
int bad = bfsFrom(g, 0, 0)[2][2]; // 交换行列之后还是同两个点
if (!CSV) {
printf("① 官方样例 3 × 3,起终点 (1,1) → (3,3)\n");
printf(" 正解 %d,行列反了的那版 %d —— 交换行列在主对角线上是恒等变换\n\n", okAns, bad);
}
row("sample", {okAns, bad});
}
/* ② 数完 3 × 3 和 4 × 4 的全部合法输入 */
{
EnumResult e3 = enumerateAll(3);
if (!CSV) {
printf("② 3 × 3 的全部合法输入(512 张地图 × 所有起终点在马路上的有序对)\n");
printf(" 一共 %lld 组,分辨得出「行列反了」的 %lld 组(%.1f%%)\n",
e3.total, e3.differ, 100.0 * (double)e3.differ / (double)e3.total);
printf(" 其中「起终点都在主对角线上」的 %lld 组,分辨得出的 %lld 组\n\n",
e3.diagTotal, e3.diagDiffer);
}
row("enum3", {e3.total, e3.differ, e3.diagTotal, e3.diagDiffer});
EnumResult e4 = enumerateAll(4);
if (!CSV) {
printf(" 4 × 4:一共 %lld 组,分辨得出的 %lld 组(%.1f%%);"
"对角线那一类 %lld 组里 %lld 组\n\n",
e4.total, e4.differ, 100.0 * (double)e4.differ / (double)e4.total,
e4.diagTotal, e4.diagDiffer);
}
row("enum4", {e4.total, e4.differ, e4.diagTotal, e4.diagDiffer});
}
/* ③ DFS 的调用次数 / 递归深度,对照 BFS 的入队次数 */
{
vector<long long> calls, depth, push;
vector<int> sizes = {10, 20, 30, 40, 50};
if (!CSV) printf("③ 空地图上,DFS(带剪枝)和 BFS 各要做多少次\n");
for (int n : sizes) {
gN = n; gTx = n - 1; gTy = n - 1;
gG.assign(n, string(n, '0'));
gBest.assign(n, vector<int>(n, INT_MAX));
gCalls = 0; gDepth = 0;
dfs(0, 0, 0);
long long p = bfsPushes(n);
calls.push_back(gCalls);
depth.push_back(gDepth);
push.push_back(p);
if (!CSV)
printf(" n = %2d:dfs 调用 %10lld 次(最深 %6d 层), BFS 入队 %6lld 次,"
"n² = %5d,dfs / n⁴ = %.3f\n",
n, gCalls, gDepth, p, n * n, (double)gCalls / pow((double)n, 4));
}
if (!CSV) printf("\n");
row("dfsCalls", calls);
row("dfsDepth", depth);
row("bfsPush", push);
}
/* ④ 密度曲面 */
{
vector<int> denss = {0, 10, 20, 30, 40, 50, 60, 70};
vector<long long> caught, unreach, caughtC, unreachC;
if (!CSV) printf("④ 「行列反了」的抓获率沿店铺密度走(maxN = 8,300 轮)\n");
for (int d : denss) {
DensResult a = scanDensity(d, 8, 300, false);
DensResult b = scanDensity(d, 8, 300, true);
caught.push_back(a.caught);
unreach.push_back(a.unreachable);
caughtC.push_back(b.caught);
unreachC.push_back(b.unreachable);
if (!CSV)
printf(" 密度 %2d%%:抓到 %3d / 300(其中不可达 %3d 轮);"
"保证可达之后抓到 %3d / 300\n", d, a.caught, a.unreachable, b.caught);
}
if (!CSV) printf("\n");
row("dens", {0, 10, 20, 30, 40, 50, 60, 70});
row("densCatch", caught);
row("densUnreach", unreach);
row("densCatchConn", caughtC);
row("densUnreachConn", unreachC);
}
/* ⑤ 顶格那一档的读入到底有多大 */
{
long long bytes = (long long)strlen("1000\n") + 1000LL * 1001LL
+ (long long)strlen("1 1 1000 1000\n");
if (!CSV)
printf("⑤ 顶格 n = 1000 的输入一共 %lld 字节(%.2f MB)—— 这道题读入不是瓶颈\n",
bytes, (double)bytes / 1048576.0);
row("bytes", {bytes});
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

9一张总表

版本 错在哪 官方样例 全空间穷举 顶格 n = 1000 结果
p1746Wall 1 当成了马路 ✗ 给 -1 WA
p1746Int 逐格 cin >> int ✗ 读不完 ✗ 一格都读不进 WA / RE
p1746Swap x 当成了列号 24.0% 的输入抓得到 WA
p1746Dfs 用 DFS 求最短 ✓ 答案全对 10¹² 次 + 百万层栈 TLE / RE
p1746 10⁶ 次入队 AC
★ 和算法无关、值得顺手量一下的一条:这道题的读入不大

前面好几道题栽在读入上(第 6 章 P2367scanf 都不够、 第 12 章 P1010 顶格 47 MB)。这道题不用

顶格 n = 1000 的输入一共 1001019 字节(0.95 MB)。 按第 10 章 P1104 那条尺子 —— 看的是字节数,不是数的个数 —— 一百万格听着很多,可它每格只占一个字符,默认的 cin >> string 一行一行读绰绰有余。

⇒ 「要不要读入优化」是一道三十秒的算术题,别凭「n 有一百万」这句话下结论。

这一页记住三句话
  1. ★★★ 「和上一道几乎一样」时,去数一数有几个主语被换掉了。 这道题换了三个(1 的含义、地图怎么读、起终点从哪来), 而官方样例只挡得住第一个
  2. ★★★ 「它过了样例」还有第三种原因:这组样例在结构上问不出这个问题。 1 1 3 3 两个点都在主对角线上,交换行列是恒等变换 —— 不是概率低,是这一类输入的抓获率恒等于 0(11520 组数完,对角线那 1536 组一组都不行)。
  3. ★★★ 抓获率往下掉的时候,先问「这一段里两边比的还是同一件事吗」。 密度 70% 那一档 300 轮里有 165 轮两版一起输出 -1 —— 对拍记的是「通过」,验的是零。换成保证可达的生成器,曲线就不掉头了。