题单 · 习题解析

洛谷 P1443 马的遍历

★★★ 十六个手抄的数字是一个数得完的 bug 空间;而「少了守卫会 WA」被 11025 组实测打了回来 —— 马的图是二分图

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

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

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

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

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

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

题目描述

有一个 n × m 的棋盘,在某个点 (x, y) 上有一个马,要求你计算出马到达棋盘上任意一个点最少要走几步。

输入格式

输入只有一行四个整数,分别为 n, m, x, y

输出格式

一个 n × m 的矩阵,代表马到达某个点最少要走几步(不能到达则输出 -1)。

数据规模与约定

对于全部的测试点,保证 1 ≤ x ≤ n ≤ 4001 ≤ y ≤ m ≤ 400

2022 年 8 月之后,本题去除了对输出保留场宽的要求。为了与之兼容,本题的输出以空格或者合理的场宽分割每个整数都将判作正确。

输入输出样例

输入

3 3 1 1

输出

0 3 2
3 -1 1
2 1 4

1第一反应:本章那份 fast.cpp,换一个方向数组

这道题在题单里的定位就是「BFS 模板题」—— 第 14 章那份 fast.cpp 搬过来,把四个方向换成八个,就完事了。

p1443.cpp★ 这一版就能 AC
// P1443 马的遍历 —— 能 AC 的那一版
//
// BFS 求最短步数,和本章正文那份 fast.cpp 是同一件事,只差**方向数组**:
// 本章正文:四个方向(上下左右)
// 这道题 :★ **马走日的八个方向** —— 十六个手抄的数字
//
// {1,2} {1,-2} {-1,2} {-1,-2} {2,1} {2,-1} {-2,1} {-2,-1}
//
// ⚠ 这一页整页都在说那十六个数字:**它是一个可以穷举的 bug 空间**
// (8 个方向 × 2 个分量 × 改成另外 3 个值 = 48 个变体,p1443Dirs.cpp 一个不落地跑完)。
//
// 两条一定要记住的:
// ① **标记要在入队时打,不是出队时** —— 出队才标记会让同一格被反复入队(第 ④ 步量了倍数);
// ② 到不了的格子输出 **-1**(`n = 1` 或 `m = 1` 时马根本动不了;样例 3×3 的正中间也是 -1)。
#include <bits/stdc++.h>
using namespace std;
static const int DX[8] = {1, 1, -1, -1, 2, 2, -2, -2};
static const int DY[8] = {2, -2, 2, -2, 1, -1, 1, -1};
static int n, m, sx, sy;
static int dist_[405][405];
static int qx[160005], qy[160005];
int main() {
if (scanf("%d %d %d %d", &n, &m, &sx, &sy) != 4) return 0;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++) dist_[i][j] = -1;
int head = 0, tail = 0;
dist_[sx][sy] = 0;
qx[tail] = sx; qy[tail] = sy; tail++;
while (head < tail) {
int x = qx[head], y = qy[head]; head++;
for (int d = 0; d < 8; d++) {
int nx = x + DX[d], ny = y + DY[d];
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
if (dist_[nx][ny] != -1) continue;
dist_[nx][ny] = dist_[x][y] + 1; // ★ 入队时就把距离写死(等于标记)
qx[tail] = nx; qy[tail] = ny; tail++;
}
}
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
printf("%d%c", dist_[i][j], j == m ? '\n' : ' ');
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

和本章正文那份 fast.cpp 的差别只有两处:

第 14 章 fast.cpp 这道题
方向 上下左右 ★ 马走日
dist 里的 -1 只表示「还没访问」 ★ 同时是要打印出去的答案(到不了)

马走日的八个方向,画出来是这样(K 是马,字母是它一步能到的八格):

    . a . b .
    c . . . d
    . . K . .
    e . . . f
    . g . h .

写成数组就是十六个手抄的数字

    DX[8] = { 1,  1, -1, -1,  2,  2, -2, -2}
    DY[8] = { 2, -2,  2, -2,  1, -1,  1, -1}
边界:那个 -1 是真的会被打印出来的
  • n = 1(或 m = 1)时马一步都走不了 —— 除起点外整张表全是 -1。 输入 1 5 1 1,输出就是 0 -1 -1 -1 -1
  • 官方样例 3 3 1 1 里,正中间那格也是 -1 —— 3 × 3 的中心,马跳不上去。

⇒ 这两处正是「dist 初始化成 -1、到不了就原样打印」这个写法免费送的。 本章正文里 -1 只是「没访问」,在这道题上它多了一个身份

2★★★ 那十六个数字,是一个可以数完的 bug 空间

方向数组没有任何算法难度,可它是这道题唯一会出错的地方 —— 十六个数字,手抄的时候少一个负号、把 1 写成 2,编译器一声不吭。

先看一个具体的抄错:最后那个 {-2,-1} 写成了 {-2, 1}(少一个负号)。

p1443Dir.cpp⚠ 少写一个负号
// P1443 ⚠ 错法:**方向数组抄错了一个数字**
//
// 和 p1443.cpp 只差一个负号:最后一个方向 `{-2,-1}` 写成了 `{-2, 1}`。
//
// 正确 DY[8] = {2, -2, 2, -2, 1, -1, 1, -1};
// 这里 DY[8] = {2, -2, 2, -2, 1, -1, 1, 1};
// ↑ 少了一个负号
//
// ⇒ `{-2,1}` 于是出现了两次,等于**整个 `{-2,-1}` 方向没了**(马少了一条腿)。
//
// ★★★ 它**过得了官方样例**(3 × 3、起点在左上角)——
// 那么小的棋盘、起点又在角上,往左上方向走的那几条腿根本用不着。
// p1443Count.cpp 把这件事量成了一张表:48 种「抄错一个数字」里,
// 3 × 3 放过去 14 种,而 **5 × 5 就一个不剩**。
#include <bits/stdc++.h>
using namespace std;
static const int DX[8] = {1, 1, -1, -1, 2, 2, -2, -2};
static const int DY[8] = {2, -2, 2, -2, 1, -1, 1, 1}; // ⚠ 最后那个应该是 -1
static int n, m, sx, sy;
static int dist_[405][405];
static int qx[160005], qy[160005];
int main() {
if (scanf("%d %d %d %d", &n, &m, &sx, &sy) != 4) return 0;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++) dist_[i][j] = -1;
int head = 0, tail = 0;
dist_[sx][sy] = 0;
qx[tail] = sx; qy[tail] = sy; tail++;
while (head < tail) {
int x = qx[head], y = qy[head]; head++;
for (int d = 0; d < 8; d++) {
int nx = x + DX[d], ny = y + DY[d];
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
if (dist_[nx][ny] != -1) continue;
dist_[nx][ny] = dist_[x][y] + 1;
qx[tail] = nx; qy[tail] = ny; tail++;
}
}
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
printf("%d%c", dist_[i][j], j == m ? '\n' : ' ');
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它过了官方样例 —— 和正解逐字节相同。

把棋盘换成 5 × 5,同一份代码就露馅了(起点仍在左上角):

    ★ 正解                 ⚠ 抄错一个负号
    0 3 2 3 2              0 3 2 5 2      <- 整张表只有这一格不一样
    3 4 1 2 3              3 4 1 2 3
    2 1 4 3 2              2 1 4 3 2
    3 2 3 2 3              3 2 3 2 3
    2 3 2 3 4              2 3 2 3 4

(1,4) 那一格本来 3 步就能到,它要 5 步 —— 因为通往那格的最后一跳 正好用的是被抄没了的 {-2,-1}25 个格子里错 1 个 —— 这也是这类 bug 难被发现的原因。

而「抄错一个数字」总共有多少种?8 个方向 × 2 个分量 × 改成另外 3 个值 = 48 种。 48 是一个能一个不落跑完的数字,那就别猜了,全跑一遍,看多大的棋盘才抓得干净:

棋盘边长 3 4 5 6 7 8 9
起点在角上 (1,1) 34 44 48 48 48 48 48
起点在正中 16 48 48 48 48 48 48

官方样例就是 3 × 3、起点 (1,1) 那一格 —— 48 种抄错里它放过去 14 种

★★★ bug 空间小到能数完的时候,别抽样,把它数完

这一页没有对第 ② 步做任何对拍 —— 不需要。 「抄错一个数字」是一个有限且很小的集合(48 个), 而对拍是抽样:它给你一个抓获率,穷举给你的是一句「一个都不剩」。

⇒ 这是第 12 章 P1010 那条的正面用法: 那道题因为没有第二个算法,被迫「反着验 + 全范围穷举」; 这道题是主动选穷举 —— 判据是「bug 空间数得完吗」,不是「有没有第二个算法」。

★ 而穷举给出的结论比抓获率有用得多把手上那组测试数据从 3 × 3 换成 5 × 5,48 种抄错一个不剩(起点在正中的话 4 × 4 就够)。 一个五分钟就能做的动作,比跑三百轮对拍管用。

为什么恰好是那 14 种漏网

把漏网的 14 种按「抄坏了哪个方向」分个组,它们全部集中在四个方向上 (度量程序直接把这四个印出来):

    {-1, 2}   {-1,-2}   { 2,-1}   {-2,-1}

样例的棋盘只有 3 × 3、起点又钉在左上角 —— 这四条腿在这张小图上几乎没有用武之地, 抄坏了也照样跑出同一份答案。

⚠ 而它不是「所有带负号的方向」这么整齐的规律:{1,-2}{-2,1} 同样带负号, 却一个都没漏。⇒ 能说的只有一句:这四个是量出来的。 (这正是第 13 章 P1596 那条「触发条件也要量、不能推」的同一个动作。)

样例查不出来不是运气,是它的形状决定的第 13 章 P1596 那条「抓获率是一张曲面」的又一个现场: 这里的两个旋钮是棋盘边长起点在哪)。

3⚠ 第二个坑:标记打在入队的时候,还是出队的时候

本章正文里写死过一句:每个格子只进队一次。这句话靠的是「入队时就把 dist 写死」。

而很自然的另一种写法是「先塞进队列,等它出队再看要不要处理」:

p1443Late.cpp⚠ 出队才标记(答案是对的)
// P1443 ⚠ 错法:**出队的时候才标记**
//
// 「先把邻居都塞进队列,等轮到它出队再看要不要处理」—— 这个写法看着也说得通。
// ★ 而且它的**答案是对的**,靠的是出队后那一句 `if (vis[x][y]) continue;`:
// 同一个格子第一次出队时距离最小(BFS 层序),后面那几次直接被挡掉。
//
// ⚠ 它的代价在**入队次数**上:一个格子会被它**每一个已经出过队的邻居**各塞一次。
// p1443Count.cpp 第 ② 段量出来的(400 × 400、起点左上角,一共 160000 格):
// 入队时标记(p1443.cpp) 160000 次
// 出队才标记(这一版) 635209 次 —— **3.97 倍**
// ⚠ 注意不是「8 倍」:边上的格子邻居不满八个,而且**只有已经出过队的邻居才会塞它**。
// ⇒ 「大概是 8nm」这种数是**推出来的**,写进正文之前得跑一遍。
//
// ★ 这一版**能 AC**(3.97 倍换不来 TLE),它是那类「**只坏复杂度、不坏答案**」的写法
// (第 53 章那条)—— **对拍一个字都说不出来**:p1443Gen.cpp 300 轮,它一次都不会被抓到。
//
// ⚠⚠ 而把那一句守卫去掉会怎样?草稿里写的是「答案就真的错了」——
// **实测把这句打回来了**,见 p1443LateBad.cpp。
#include <bits/stdc++.h>
using namespace std;
static const int DX[8] = {1, 1, -1, -1, 2, 2, -2, -2};
static const int DY[8] = {2, -2, 2, -2, 1, -1, 1, -1};
static int n, m, sx, sy;
static int dist_[405][405];
static bool vis[405][405];
/** ⚠ 队列得开大:顶格实测 635209 次入队,这里留了一倍余量 */
static const int QCAP = 1300000;
static int qx[QCAP + 5], qy[QCAP + 5], qd[QCAP + 5];
int main() {
if (scanf("%d %d %d %d", &n, &m, &sx, &sy) != 4) return 0;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++) dist_[i][j] = -1;
int head = 0, tail = 0;
qx[tail] = sx; qy[tail] = sy; qd[tail] = 0; tail++;
while (head < tail) {
int x = qx[head], y = qy[head], d0 = qd[head]; head++;
if (vis[x][y]) continue; // ★ 就是这一句在保答案
vis[x][y] = true;
dist_[x][y] = d0;
for (int d = 0; d < 8; d++) {
int nx = x + DX[d], ny = y + DY[d];
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
if (vis[nx][ny]) continue;
if (tail >= QCAP) { // ⚠ 装不下就喊出来,绝不悄悄少做一步
fprintf(stderr, "队列装不下:入队次数超过 %d\n", QCAP);
return 1;
}
qx[tail] = nx; qy[tail] = ny; qd[tail] = d0 + 1; tail++;
}
}
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
printf("%d%c", dist_[i][j], j == m ? '\n' : ' ');
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它的答案全对 —— 靠的是出队后那一句 if (vis[x][y]) continue;: 同一个格子第一次出队时距离最小(BFS 层序),后面那几次直接被挡掉。

代价在入队次数上(顶格 400 × 400、起点左上角,一共 160000 格):

版本 入队次数 倍数
p1443.cpp(入队时标记) 160000 1
p1443Late.cpp(出队才标记) 635209 3.97
★★ 「大概是 8nm」是推出来的,跑一遍才知道是 3.97 倍

一个格子会被它每一个已经出过队的邻居各塞一次, 所以第一反应是「八个邻居 ⇒ 8nm」。实测 3.97 倍,差一半。

差在哪:一个格子被塞几次,等于它有几个「上一层」的邻居。 在马的图上(下一步就会说到,那是一张二分图)邻居正好分在上一层和下一层两边 —— 下一层那些轮到它们出队时,这一格早就 vis 了,一次都不会塞。 ⇒ 八个邻居里真正会塞它的只有一半,再加上边角上邻居不满八个,落到 3.97。

⇒ 这就是硬规矩第 1 条的日常形态:能写成断言的数字,一个都别用推的。

★★ 而这个错法,对拍一个字都说不出来

它答案全对,只是慢 —— 是第 53 章那条「只坏复杂度、不坏答案」的又一例。 ⇒ 对拍在这儿是瞎的(下面第 ⑤ 步:900 轮,精确的 0)。 量它只能换一把尺子:数入队次数。

★ 顺带说清楚:它能 AC。3.97 倍换不来 TLE,顶格那一档两版都在 0.01 秒量级 (A 机 6.18.33.2-microsoft-standard-WSL2,8 核 / 7 GB,2026-08-27 独占实测,ps 看过)—— 秒表在这道题上根本量不动,这也是「数次数」的理由。

4★★★ 把那句守卫也去掉 —— 我以为会 WA,实测一次都没 WA

p1443Late.cpp 里保答案的是出队后那一句 if (vis[x][y]) continue;。把它删掉:

p1443LateBad.cpp⚠⚠ 少了那句守卫
// P1443 ⚠⚠ 错法:出队才标记,**而且少了那一句守卫**
//
// 和 p1443Late.cpp 只差一行:出队之后那句 `if (vis[x][y]) continue;` 没了。
//
// ⚠⚠ 草稿里给这一版写的判词是「答案真的错了」——**实测把它打回来了**:
// p1443Count.cpp 第 ④ 段把 n, m ≤ 14 的**每一个起点**都比了一遍,
// **11025 组里 0 组不一致**。
//
// ★ 为什么错不了:**马每走一步必定换一次格子颜色**(黑 ↔ 白)——
// 那张图是**二分图**,同一层的两个格子之间不可能有边。
// ⇒ 队列里一个格子的所有副本**距离都相同**,写几次都写同一个数,答案坏不了。
// ⚠ 这不是「这个写法没问题」,是「**这道题的图刚好救了它**」:
// 把方向换成八连通(第 13 章 P1596 那种,有奇环),同一个写法 **n = 2 起就给错答案**。
//
// ⇒ 它真正的代价不是 WA,是**入队次数根本装不下**(p1443Count.cpp 第 ③ 段):
// 8 × 8 ( 64 格) 691 次
// 16 × 16 ( 256 格) 129 861 次
// 24 × 24 ( 576 格) 10 373 317 次 —— 每格一万八
// 32 × 32 (1024 格) 超过 2 × 10⁷,掐掉
// ⇒ 顶格 400 × 400 上它是 **MLE / TLE**,不是 WA。
//
// ⚠ 所以这一版必须自带一道闸:装不下就**报错退出**,绝不像草稿那样悄悄少入几次队
// (那样它会打印出一份「看着挺像样」的错答案,而错因根本不在算法上)。
#include <bits/stdc++.h>
using namespace std;
static const int DX[8] = {1, 1, -1, -1, 2, 2, -2, -2};
static const int DY[8] = {2, -2, 2, -2, 1, -1, 1, -1};
static const long long QCAP = 20000000; // 2 × 10⁷ 次入队就当「装不下」
static int n, m, sx, sy;
static int dist_[405][405];
static bool vis[405][405];
int main() {
if (scanf("%d %d %d %d", &n, &m, &sx, &sy) != 4) return 0;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++) dist_[i][j] = -1;
vector<pair<int, int>> q; // (格子编号, 距离)
q.push_back({(sx - 1) * m + (sy - 1), 0});
for (size_t h = 0; h < q.size(); h++) {
int id = q[h].first, d0 = q[h].second;
int x = id / m + 1, y = id % m + 1;
vis[x][y] = true; // ⚠⚠ 少了 if (vis[x][y]) continue;
dist_[x][y] = d0;
for (int d = 0; d < 8; d++) {
int nx = x + DX[d], ny = y + DY[d];
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
if (vis[nx][ny]) continue;
if ((long long)q.size() >= QCAP) {
fprintf(stderr, "队列装不下:入队次数超过 %lld(n = %d, m = %d)\n", QCAP, n, m);
return 1;
}
q.push_back({(nx - 1) * m + (ny - 1), d0 + 1});
}
}
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
printf("%d%c", dist_[i][j], j == m ? '\n' : ' ');
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

草稿里给这一版写的判词是「同一个格子会被处理好几次,后来的更长路径把 dist 改大, 答案真的错了」。听着很有道理 —— 而它是错的

n, m ≤ 14每一个起点都比一遍:

比了几组 和正解不一致的
11025 0
★★★ 它错不了,不是因为写对了,是因为这道题的图刚好救了它

马每走一步,必定换一次格子颜色(棋盘黑白格:日字跳法一定落到异色格上)。

    B W B W       起点是 B,走一步一定在 W,再一步一定回 B
    W B W B       ⇒ 距离的奇偶性 = 格子的颜色
    B W B W       ⇒ 同一层(同色)的两个格子之间,不可能有边
    W B W B

那张图是二分图。于是队列里同一个格子的所有副本距离都相同 —— 写几次都写同一个数,答案坏不了。

⚠ 这句话的主语是这道题,不是这个写法。换一张有奇环的图,它立刻就错: 把方向换成八连通(第 13 章 P1596 那种,斜着也算相邻), 同一份写法 n = 2 起就给错答案。4 × 4、起点左上角,第一行:

第 1 行
★ 正解 0 1 2 3
⚠ 少了守卫 0 2 3 4

「这个写法行不行」这句话缺一个主语,主语是那张图有没有奇环。

那它的代价在哪儿?还是入队次数 —— 而这一回不是「多几倍」,是装不下

棋盘 格子数 入队次数 每格
8 × 8 64 691 11
16 × 16 256 129861 507
24 × 24 576 10373317 18009
32 × 32 1024 ★ 超过 2 × 10⁷,掐掉
⚠ 所以这一版必须自带一道闸,绝不许悄悄少入几次队

草稿里那份 p1443LateBad.cpp 是这么写的:队列开成定长数组, 满了就 if (tail >= 1300000) continue; —— 静默截断

于是它在 400 × 400 上「跑得通」,还打印出一份看着挺像样的矩阵。 可那份矩阵错在队列被截断上,和「少了守卫」这件事毫无关系 —— 一份演示代码,演示的是另一个东西。

⇒ 现在它超限就 fprintf(stderr, ...) 报「队列装不下」并 return 1。 顶格那一档它的正确表现是 MLE / TLE,不是 WA

5对拍:三档 × 300 轮 —— 而它对两个错法是精确的 0

p1443Gen.cpp生成器:一行 n m x y
// P1443 对拍生成器:`./p1443Gen <seed> [maxN]`
//
// 输出就是这道题的一行输入:`n m x y`。
//
// ⚠ `maxN` 默认 **12**,不是题面的 400 —— 这一档是被 p1443LateBad.cpp 逼出来的:
// 那一版少了守卫,入队次数在 24 × 24 就到一千万、32 × 32 装不下(p1443Count.cpp 第 ③ 段)。
// ⇒ **能把四个版本一起放进对拍的规模,正好只有这么大。**
// 而这恰恰是这一页要说的事:对拍在这道题上**看不见**那两个「标记时机」的错法
// (它们答案全对),它只抓得到方向数组抄错的那一类。
//
// ★ 另一档 `maxN = 3` 用来复现「官方样例那么大的数据什么都查不出来」。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1u;
int maxN = argc > 2 ? atoi(argv[2]) : 12;
if (maxN < 1) maxN = 1;
mt19937 rng(seed);
int n = (int)(rng() % (unsigned)maxN) + 1;
int m = (int)(rng() % (unsigned)maxN) + 1;
int x = (int)(rng() % (unsigned)n) + 1;
int y = (int)(rng() % (unsigned)m) + 1;
printf("%d %d %d %d\n", n, m, x, y);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
生成器那一档 p1443Dir(抄错方向) p1443Late ⚠⚠ p1443LateBad
n, m ≤ 3(样例那么大) 18 0 0
n, m ≤ 5 91 0 0
n, m ≤ 12 231 0 0
★★★ 「对拍 0 次」的第三种原因:被测的那一版根本没算错

第 12 章 P1226 分过两种:题面挡死了,和生成器缺一档。 这一页是第三种,而且判法完全不同:

那不是一个会给出错答案的 bug。

Late 只坏复杂度;LateBad 在这张图上连复杂度之外的东西都没坏(答案对), 坏的是它装不下。两个都不是「加轮数」或者「换档位」能抓到的东西 —— ⇒ 加轮数之前先问一句:我要抓的这件事,会体现在输出上吗?

★ 而生成器的默认档 n, m ≤ 12 也不是随便定的: LateBad 在 24 × 24 就要一千万次入队 —— 能把三个版本一起放进对拍的规模,只有这么大。

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

p1443Count.cpp度量:48 种抄错 + 入队次数 + 二分图那条
// ★★★ P1443 的度量程序:把三件「猜不得的事」全量出来
//
// 用法:./p1443Count 人话版
// ./p1443Count csv 给 check:viz 用
//
// ① **方向数组抄错**是一个可以**数完**的 bug 空间:
// 马走日的八个方向是十六个手抄的数字
// {1,2} {1,-2} {-1,2} {-1,-2} {2,1} {2,-1} {-2,1} {-2,-1}
// 「抄错一个数字」总共只有 8 个方向 × 2 个分量 × 改成另外 3 个值 = **48** 种。
// ⇒ 一个不落地全跑一遍,看**多大的棋盘才抓得干净** —— 这比对拍便宜得多。
//
// ② 「入队时标记」和「出队才标记」的**入队次数**差多少(n = m = 400)。
//
// ③ 「出队才标记、又少了那句守卫」那一版(p1443LateBad.cpp):
// ⚠⚠ 草案里写的是「它答案真的错」—— **实测把这句打回来了**。
// 在马的图上它 11025 组数据一次都没错;错不了的原因是**马每走一步必换格子颜色**
// (黑↔白),⇒ 那张图是**二分图**,同一层的两个格子之间没有边,
// 于是队列里永远不会出现「距离更大的那份」把答案改坏。
// 它真正的代价在**入队次数**上:那不是「多几倍」,是**装不下**。
// ★ 而把方向换成八连通(第 13 章 P1596 那种,有奇环 ⇒ 不是二分图),
// 同一个写法 n = 2 起就给错答案 —— 见第 ⑤ 段。
#include <bits/stdc++.h>
using namespace std;
static const int CDX[8] = {1, 1, -1, -1, 2, 2, -2, -2};
static const int CDY[8] = {2, -2, 2, -2, 1, -1, 1, -1};
/** 八连通(上下左右 + 四个斜角)—— 有奇环,不是二分图 */
static const int EDX[8] = {1, 1, 1, 0, 0, -1, -1, -1};
static const int EDY[8] = {1, 0, -1, 1, -1, 1, 0, -1};
static int DX[8], DY[8];
/** 正常 BFS(入队时标记);返回距离矩阵,enq 里带回入队次数 */
static vector<int> bfs(int n, int m, int sx, int sy, const int* dx, const int* dy, long long* enq) {
vector<int> d((size_t)n * m, -1);
vector<int> q;
q.reserve((size_t)n * m);
d[(size_t)(sx - 1) * m + (sy - 1)] = 0;
q.push_back((sx - 1) * m + (sy - 1));
long long pushes = 1;
for (size_t h = 0; h < q.size(); h++) {
int x = q[h] / m + 1, y = q[h] % m + 1;
for (int k = 0; k < 8; k++) {
int nx = x + dx[k], ny = y + dy[k];
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
size_t id = (size_t)(nx - 1) * m + (ny - 1);
if (d[id] != -1) continue;
d[id] = d[(size_t)(x - 1) * m + (y - 1)] + 1;
q.push_back((int)id);
pushes++;
}
}
if (enq) *enq = pushes;
return d;
}
/**
* 出队才标记。
* guard = true :出队后那句 `if (vis) continue;` 还在
* guard = false:那句没了
* ⚠ 没守卫时入队次数会炸 ⇒ 必须给一个上限 cap:
* 超了就返回空 vector(表示「这一档量不出来」),enq 带回 -1。
*/
static vector<int> bfsLate(int n, int m, int sx, int sy, const int* dx, const int* dy,
bool guard, long long cap, long long* enq) {
vector<int> d((size_t)n * m, -1);
vector<char> vis((size_t)n * m, 0);
vector<pair<int, int>> q; // (格子, 距离)
q.push_back({(sx - 1) * m + (sy - 1), 0});
long long pushes = 1;
for (size_t h = 0; h < q.size(); h++) {
int id = q[h].first, d0 = q[h].second;
if (guard && vis[id]) continue;
vis[id] = 1;
d[id] = d0;
int x = id / m + 1, y = id % m + 1;
for (int k = 0; k < 8; k++) {
int nx = x + dx[k], ny = y + dy[k];
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
size_t nid = (size_t)(nx - 1) * m + (ny - 1);
if (vis[nid]) continue;
q.push_back({(int)nid, d0 + 1});
if (++pushes > cap) { if (enq) *enq = -1; return {}; }
}
}
if (enq) *enq = pushes;
return d;
}
/** 48 个变体里,在 n × n、起点 (sx,sy) 上被抓到几个 */
static int caughtAt(int n, int sx, int sy) {
int c = 0;
memcpy(DX, CDX, sizeof DX);
memcpy(DY, CDY, sizeof DY);
for (int k = 0; k < 8; k++)
for (int comp = 0; comp < 2; comp++)
for (int v : {-2, -1, 1, 2}) {
int* a = comp == 0 ? DX : DY;
int old = a[k];
if (v == old) continue;
a[k] = v;
long long t;
if (bfs(n, n, sx, sy, DX, DY, &t) != bfs(n, n, sx, sy, CDX, CDY, &t)) c++;
a[k] = old;
}
return c;
}
int main(int argc, char** argv) {
bool csv = (argc > 1 && string(argv[1]) == "csv");
const long long CAP = 20000000; // 2 × 10⁷ 次就当「装不下」(和 p1443LateBad.cpp 那道闸同一个数)
long long t;
/* ① 48 个变体 × 棋盘边长:多大才抓得干净 */
int corner[7], center[7];
for (int n = 3; n <= 9; n++) {
corner[n - 3] = caughtAt(n, 1, 1);
center[n - 3] = caughtAt(n, (n + 1) / 2, (n + 1) / 2);
}
int firstCleanCorner = 0, firstCleanCenter = 0;
for (int i = 0; i < 7; i++) {
if (!firstCleanCorner && corner[i] == 48) firstCleanCorner = i + 3;
if (!firstCleanCenter && center[i] == 48) firstCleanCenter = i + 3;
}
/* 官方样例(3 × 3、起点角上)放过去、而 8 × 8 抓得到的有几个 */
int onlyBig = corner[5] - corner[0];
/* 那些漏网的变体,出在哪几个方向上(3 × 3、起点角上) */
string leakDirs;
memcpy(DX, CDX, sizeof DX);
memcpy(DY, CDY, sizeof DY);
for (int k = 0; k < 8; k++) {
bool leak = false;
for (int comp = 0; comp < 2 && !leak; comp++)
for (int v : {-2, -1, 1, 2}) {
int* a = comp == 0 ? DX : DY;
int old2 = a[k];
if (v == old2) continue;
a[k] = v;
long long tt;
if (bfs(3, 3, 1, 1, DX, DY, &tt) == bfs(3, 3, 1, 1, CDX, CDY, &tt)) leak = true;
a[k] = old2;
if (leak) break;
}
if (leak) leakDirs += (leakDirs.empty() ? "" : " ") + string("{")
+ to_string(CDX[k]) + "," + to_string(CDY[k]) + "}";
}
/* ② 入队次数(n = m = 400,起点左上角,格子一共 160000 个) */
long long e0 = 0, e1 = 0;
bfs(400, 400, 1, 1, CDX, CDY, &e0);
bfsLate(400, 400, 1, 1, CDX, CDY, true, (long long)4e9, &e1);
/* ③ 「少了守卫」那一版的入队次数:不是多几倍,是装不下 */
const int BADN[4] = {8, 16, 24, 32};
long long badEnq[4];
for (int i = 0; i < 4; i++) bfsLate(BADN[i], BADN[i], 1, 1, CDX, CDY, false, CAP, &badEnq[i]);
/* ④ ⚠ 而它的**答案**在这道题上一次都没错:n, m ≤ 14 的每一个起点全比一遍 */
int groups = 0, badAns = 0;
for (int n = 1; n <= 14; n++)
for (int m = 1; m <= 14; m++)
for (int sx = 1; sx <= n; sx++)
for (int sy = 1; sy <= m; sy++) {
groups++;
if (bfs(n, m, sx, sy, CDX, CDY, &t)
!= bfsLate(n, m, sx, sy, CDX, CDY, false, CAP, &t)) badAns++;
}
/* ⑤ 反证:同一个写法,方向换成八连通(有奇环 ⇒ 不是二分图)就立刻错 */
int firstEight = 0;
for (int n = 2; n <= 12 && !firstEight; n++)
if (bfs(n, n, 1, 1, EDX, EDY, &t) != bfsLate(n, n, 1, 1, EDX, EDY, false, CAP, &t))
firstEight = n;
auto row0 = [](const vector<int>& d, int m) {
string s;
for (int j = 0; j < m; j++) s += (j ? " " : "") + to_string(d[j]);
return s;
};
string eOk = row0(bfs(4, 4, 1, 1, EDX, EDY, &t), 4);
string eBad = row0(bfsLate(4, 4, 1, 1, EDX, EDY, false, CAP, &t), 4);
if (csv) {
printf("corner");
for (int i = 0; i < 7; i++) printf(",%d", corner[i]);
printf("\ncenter");
for (int i = 0; i < 7; i++) printf(",%d", center[i]);
printf("\nvariants,48\nonlyBig,%d\nleakDirs,%s\n", onlyBig, leakDirs.c_str());
printf("firstCleanCorner,%d\nfirstCleanCenter,%d\n", firstCleanCorner, firstCleanCenter);
printf("enqEarly,%lld\nenqLate,%lld\nlateRatio100,%lld\n", e0, e1, e1 * 100 / e0);
for (int i = 0; i < 4; i++) printf("bad%d,%lld\n", BADN[i], badEnq[i]);
printf("cap,%lld\n", CAP);
printf("groups,%d\nbadAns,%d\n", groups, badAns);
printf("firstEight,%d\neightOk,%s\neightBad,%s\n", firstEight, eOk.c_str(), eBad.c_str());
return 0;
}
printf("① 方向数组是十六个手抄的数字 ——「抄错一个」总共只有 48 种,全跑一遍:\n\n");
printf(" 棋盘边长 3 4 5 6 7 8 9\n");
printf(" 起点在角上 ");
for (int i = 0; i < 7; i++) printf(" %3d ", corner[i]);
printf(" ← 官方样例就是 3 × 3、起点 (1,1)\n");
printf(" 起点在正中 ");
for (int i = 0; i < 7; i++) printf(" %3d ", center[i]);
printf("\n\n");
printf(" ⇒ 官方样例放过去、8 × 8 抓得到的:%d 种\n", onlyBig);
printf(" 它们全出在这几个方向上:%s\n", leakDirs.c_str());
printf(" ⇒ 把样例换成 %d × %d(起点角上)/ %d × %d(起点正中),48 种一个不剩\n\n",
firstCleanCorner, firstCleanCorner, firstCleanCenter, firstCleanCenter);
printf("② 入队次数(n = m = 400,起点左上角,格子一共 160000 个):\n");
printf(" 入队时标记(正解) %9lld 次\n", e0);
printf(" 出队才标记(带守卫) %9lld 次 —— %.2f 倍\n\n", e1, (double)e1 / (double)e0);
printf("③ 把那句守卫也去掉,入队次数不是「多几倍」,是**装不下**:\n");
for (int i = 0; i < 4; i++) {
printf(" %2d × %-2d(%4d 格) ", BADN[i], BADN[i], BADN[i] * BADN[i]);
if (badEnq[i] < 0) printf("超过 %lld 次,掐掉\n", CAP);
else printf("%12lld 次 —— 每格 %.0f 次\n", badEnq[i], (double)badEnq[i] / (BADN[i] * BADN[i]));
}
printf("\n④ ⚠ 可是它的**答案**呢?n, m <= 14 的每一个起点全比一遍:\n");
printf(" %d 组里和正解不一致的:**%d 组**\n", groups, badAns);
printf(" ⇒ 「少了守卫就会 WA」这句话在这道题上是**错的** —— 马每走一步必换格子颜色,\n");
printf(" 那张图是二分图,同一层之间没有边。\n\n");
printf("⑤ 反证:方向换成八连通(有奇环 ⇒ 不是二分图),同一个写法立刻错:\n");
printf(" n = %d 起就和正解不一致;4 × 4 的第一行:\n", firstEight);
printf(" 正解 %s\n 少守卫 %s\n", eOk.c_str(), eBad.c_str());
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

7一张总表

版本 错在哪 官方样例 3 × 3 5 × 5 顶格 400 × 400 结果
p1443Dir 方向数组少一个负号 ✗ 差一格 WA
p1443Late 出队才标记 入队 3.97 AC
⚠⚠ p1443LateBad 连守卫也没了 装不下 MLE / TLE
p1443 入队 160000 次 AC

四个版本在官方样例上的输出,逐字节完全相同。

★ 和算法无关、但值得知道的一条:输出格式

题面末尾那句「2022 年 8 月之后,本题去除了对输出保留场宽的要求」是有来历的 —— 这道题当年要求每个数字占固定场宽(洛谷现在给的样例输出里,每行末尾还留着那几个空格)。 现在空格分隔和场宽分隔都判对,所以 printf("%d%c", …, j == m ? '\n' : ' ') 就够了。

⇒ 但它提醒了一件事:「输出格式」是一类对拍抓得到、而自己看样例常常看不出来的错 (样例里那几个尾随空格,肉眼根本注意不到)。

这一页记住三句话
  1. ★★★ bug 空间数得完的时候,别抽样,数完它。 十六个手抄数字 ⇒ 48 种抄错,全跑一遍只要一秒。 而穷举给的结论比抓获率有用:把测试数据从 3 × 3 换成 5 × 5,48 种一个不剩
  2. ★★★ 「这个写法会不会 WA」缺一个主语。 「出队才标记、还少了守卫」在这道题上 11025 组一次都没错 —— 因为马的图是二分图; 换成八连通(有奇环),同一份写法 n = 2 起就错。 ⇒ 而它真正的代价是入队次数装不下:24 × 24 每格一万八次。
  3. ★★ 对拍抓不到的东西,要先问它会不会体现在输出上。 两个「标记时机」错法在 900 轮对拍里是精确的 0, 不是概率低、也不是生成器缺档 —— 它们的答案本来就是对的。 量它得换一把尺子:数入队次数(而秒表在这道题上量不动)。