0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1746,日期见页头。两边不一致时信原站。
题目背景
《爱与愁的故事第三弹 · shopping》最终章。
题目描述
爱与愁大神买完东西后,打算坐车离开中山路。现在爱与愁大神在 x₁, y₁ 处,车站在 x₂, y₂ 处。
现在给出一个 n × n(n ≤ 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 表示店铺。原样搬过来就是:
// ⚠ 错法一: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;}点「运行 ▶」看结果
它在官方样例上就输出 -1 —— 因为终点 (3, 3) 在这份代码眼里是一堵墙,永远到不了。
把样例跑一遍要五秒钟,它当场给你一个 -1,而正确答案是 4。
⇒ 值得记住的是比例:这道题四个真实会犯的错里, 样例只挡得住这一个。下面三个它一个都拦不下来。
3第二处:地图逐格 cin >> 读整数 —— 题面自己提醒过的那句
地图看着就是一堆 0 和 1,第一反应当然是开一个 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;}点「运行 ▶」看结果
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。打印出来的数字换台机器就变,
而且它和「读入方式错了」这件事已经没有关系了。
⇒ 现在它发现读入流坏了就报到 stderr 并 return 1,stdout 一个字节都不输出。
这是第 14 章 P1443 那条教训的直接套用:
演示错误写法的代码,绝不许悄悄少做一步把自己救活,也不许改去演示另一件事。
4★★★ 第三处:x 是行还是列 —— 官方样例对这件事结构上说不出话
题面给的是 x₁ y₁ x₂ y₂,地图是一行一行给的,所以 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;}点「运行 ▶」看结果
它和正解逐字节相同,也输出 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 空间数得完的时候,别抽样,数完它 —— 只不过那一页数的是「有多少种抄错」,这一页数的是「有多少种输入抓得到」。
它每一轮都发生 —— 每一份输入都被换了坐标。看不出来只是因为换完之后答案没变: 地图对称、起终点在对角线上、或者两条路碰巧一样长。
⇒ 这是第 11 章 P1115 那条的另一面: 对拍的抓获数是一个计数,不是概率。 那一页数的是「满足触发条件的轮数」, 这一页数的是「触发之后答案真的变了的轮数」—— 触发率 100%,可见率 24%。
5★ 三处都改对,这一版就能 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;}点「运行 ▶」看结果
和第 14 章正文那份 fast.cpp 的差别,就是上面那三处,一行算法都没动。
题面没有保证起终点连通,也没说到不了要输出什么。
这一页统一按「dist 初始 -1,到不了就原样打印 -1」处理 ——
四个版本口径一致,对拍比的才是同一件事。
⇒ 而这个口径是有代价的,第 ⑦ 步就栽在它上面。
6⚠ 上一章的惯性:用 DFS 求最短路
第 13 章整章都在网格上写 DFS,到这道题很自然会接着写 DFS:一路走到终点,拿步数去更新答案。 光这样是指数的(第 14 章正文第 ⑥ 步那个实验就是它), 于是几乎所有人都会顺手加一句剪枝:
if (d >= best[x][y]) return; // 到这一格已经有更短或一样短的走法了
// ⚠ 错法四(也是「上一章的惯性」):用 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;}点「运行 ▶」看结果
加完这一句它就不再是指数了,而且答案全对 —— 对拍对它一个字都说不出来(第 ⑦ 步:精确的 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 那一行正好是 n²;dfs 那一行是 n⁴ 的量级(n = 50 时 调用次数 / n⁴ = 0.942)。
n = 1000 时 n² = 10⁶、n⁴ = 10¹²:
- 时间:
dfs要调用约10¹²次 —— 不是慢几倍,是差六个数量级。 - ★★ 栈:最深递归层数实测正好是
n² − 2(98 / 398 / 898 / 1598 / 2498,一个不差)。n = 1000就是 999998 层。 BFS 那一百万个格子是排在堆上的队列里,毫无压力; 递归的一百万层排在栈上 —— 这正是第 14 章那张选择表里 「网格特别大 ⇒ 用 BFS,DFS 递归深度会爆栈」的具体数字。
⇒ 所以这一版也自带一道闸(调用次数超上限就报错退出),理由和第 ③ 步那一版一样。
7★★★ 对拍:抓获率沿密度会掉头 —— 而掉下去的那一段是假的
// 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,而且不用跑也知道:
整张图全是马路时,答案就是曼哈顿距离
|x1 - x2| + |y1 - y2|把 x 和 y 交换,两个绝对值只是换了个前后顺序,和还是那个和。
⇒ 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 = 8⇒n = 1占八分之一,300 轮里 37 轮。)
⇒ 两条都不是「再多跑几百轮就能抓到」,它们是被输入的形状挡住的。
8这一页所有数字都出自这一份
// 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 章 P2367 连 scanf 都不够、
第 12 章 P1010 顶格 47 MB)。这道题不用:
顶格 n = 1000 的输入一共 1001019 字节(0.95 MB)。
按第 10 章 P1104 那条尺子 —— 看的是字节数,不是数的个数 ——
一百万格听着很多,可它每格只占一个字符,默认的 cin >> string 一行一行读绰绰有余。
⇒ 「要不要读入优化」是一道三十秒的算术题,别凭「n 有一百万」这句话下结论。
- ★★★ 「和上一道几乎一样」时,去数一数有几个主语被换掉了。
这道题换了三个(
1的含义、地图怎么读、起终点从哪来), 而官方样例只挡得住第一个。 - ★★★ 「它过了样例」还有第三种原因:这组样例在结构上问不出这个问题。
1 1 3 3两个点都在主对角线上,交换行列是恒等变换 —— 不是概率低,是这一类输入的抓获率恒等于 0(11520 组数完,对角线那 1536 组一组都不行)。 - ★★★ 抓获率往下掉的时候,先问「这一段里两边比的还是同一件事吗」。
密度 70% 那一档 300 轮里有 165 轮两版一起输出
-1—— 对拍记的是「通过」,验的是零。换成保证可达的生成器,曲线就不掉头了。