题单 · 习题解析

洛谷 P1379 八数码难题

★★★ 状态图 BFS:难的是「邻居是什么」;验算走逆序对奇偶那条完全无关的路(181440 = 9!/2);⚠⚠ 而「倒着随机走 k 步」造出来的不是「距离 k 的状态」

原题:洛谷 P1379出自 第 15 章 BFS 变形:多源 BFS 与状态图搜索 的题单题面本地存档:2026-08-28
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

3 × 3 的棋盘上,摆有八个棋子,每个棋子上标有 18 的某一数字。 棋盘中留有一个空格,空格用 0 来表示。空格周围的棋子可以移到空格中。 要求解的问题是:给出一种初始布局(初始状态)和目标布局 (为了使题目简单,设目标状态为 123804765), 找到一种最少步骤的移动方法,实现从初始布局到目标布局的转变。

输入格式

输入初始状态,一行九个数字,空格用 0 表示。

输出格式

只有一行,该行只有一个数字,表示从初始状态到目标状态需要的最少移动次数。 保证测试数据中无特殊无法到达目标状态数据。

输入输出样例

输入

283104765

输出

4

样例解释 —— 下图展示了样例中从初始状态到目标状态的一种方案,共需要 4 步; 并且可以证明,不存在更优的策略:

P1379 样例解释:从 283104765 走到 123804765 的四步

1★ 把「格子」换成「状态」—— 这一版就能 AC

第 14 章的 BFS 走在网格上;这道题走在状态图上, 而 BFS 本身一个字都不用改

第 14 章的网格 这道题
一个「点」 一个格子 (x, y) ★ 棋盘的一种摆法(长度 9 的字符串)
一条「边」 上下左右相邻 挪一次空格0 和它上下左右某个棋子交换)
距离 走几步 挪几次
「第一次到达 = 最短」 ✓ 一模一样
p1379.cpp★ 这一版就能 AC
// P1379 八数码难题 —— 状态图上的 BFS(★ 这一版就能 AC)
//
// 题目:3 × 3 棋盘,八个棋子(1~8)+ 一个空格(0)。空格周围的棋子可以移进空格。
// 给定初始布局,问最少几步能变成目标布局 `123804765`。
//
// ★ 关键的一步:**把「格子」换成「状态」**。
// 第 14 章的 BFS 走在网格上:一个格子是一个点,上下左右是边。
// 这道题走在**状态图**上:
//
// 一个「点」 = 棋盘的一种摆法(九个数字,写成一个长度 9 的字符串)
// 一条「边」 = 挪一次空格(空格和它上下左右某个棋子交换)
//
// 一旦接受这个抽象,BFS 一个字都不用改:第一次到达 = 最短。
//
// ⚠⚠ 而这道题**全部的难点就在「邻居是什么」**上:
// 状态存成字符串之后,很容易顺手用一维下标 `i ± 1` / `i ± 3` 去找邻居 ——
// `i ± 3` 是对的(上下),可 **`i ± 1` 会跨行**:下标 2 的右边不是下标 3,
// 下标 2 已经在第 0 行最右边了。那一版见 p1379Flat.cpp。
//
// 状态用 map<string,int> 存距离:可达状态一共 181440 个(p1379Count.cpp 第 ② 段),
// 每个状态最多 4 个邻居 —— 规模很小,map 完全够用。
#include <bits/stdc++.h>
using namespace std;
const string GOAL = "123804765";
int main() {
string s;
if (!(cin >> s)) return 0;
map<string, int> dist_;
queue<string> q;
dist_[s] = 0;
q.push(s);
while (!q.empty()) {
string cur = q.front();
q.pop();
if (cur == GOAL) { cout << dist_[cur] << "\n"; return 0; }
int p = (int)cur.find('0');
int r = p / 3, c = p % 3; // ★ 先还原成行列,再判边界
const int DR[4] = {-1, 1, 0, 0};
const int DC[4] = {0, 0, -1, 1};
for (int k = 0; k < 4; k++) {
int nr = r + DR[k], nc = c + DC[k];
if (nr < 0 || nr > 2 || nc < 0 || nc > 2) continue;
string nxt = cur;
swap(nxt[p], nxt[nr * 3 + nc]);
if (dist_.count(nxt)) continue; // ⚠ 用 count 查,别用 dist_[nxt](那会插进去)
dist_[nxt] = dist_[cur] + 1;
q.push(nxt);
}
}
cout << -1 << "\n"; // 题面保证不会走到这儿
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 一个 C++ 上的小坑:用 count 查,别用 dist[s]

mapoperator[] 查不到就会插一个进去(值是 0)。 拿它当「访问过没有」用的话,第一次查询就把这个状态插进去了 —— 既多占内存,判断也会乱。⇒ 这里一律 dist_.count(nxt)

2⚠⚠ 这道题唯一会错的地方:邻居到底是什么

状态存成 "283104765" 之后,空格的位置就是一个下标 p。 「上下左右」写成 p - 3 / p + 3 / p - 1 / p + 1 看着天经地义 —— 上下那两个是对的,左右那两个会跨行

    下标:  0 1 2        p = 2 已经在第 0 行最右边了,
            3 4 5        它的 p + 1 是下标 3 —— 那是第 1 行最左边,
            6 7 8        棋盘上这两格根本不挨着。
p1379Flat.cpp⚠⚠ 一维下标 ±1(跨行也当相邻)
// ⚠⚠ 错法:状态是字符串,就顺手用一维下标 ±1 / ±3 找邻居
//
// 状态存成 "283104765" 这样的字符串之后,空格的位置就是一个下标 `p`。
// 「上下左右」写成 `p - 3 / p + 3 / p - 1 / p + 1` 看着天经地义 ——
// 上下那两个是对的,**左右那两个会跨行**:
//
// 下标: 0 1 2 p = 2 在第 0 行最右边,
// 3 4 5 它的 `p + 1` 是下标 3 —— 那是第 1 行最左边,
// 6 7 8 棋盘上这两格根本不挨着。
//
// ★★★ 草稿在这儿写过一句判词,**被实测打回来了**,值得原样记下来:
//
// 「八数码的合法移动保持一个不变量 —— 去掉 0 之后那八个数字的逆序对数奇偶性不变,
// 9! 种摆法正好被劈成互不相通的两半(各 181440);而跨行移动打破了这个不变量,
// ⇒ 这一版能到达的状态数会从 181440 变成 362880。」
//
// **实测:还是 181440。** 原因一句话 ——
// 横向挪空格(不管跨不跨行)在「去掉 0 的那八个数字」里,
// 只是让 0 从某个棋子的左边挪到右边,**棋子之间的相对顺序一点没动**
// ⇒ 逆序对数根本不变。那 80640 条「跨行」的假边,**两端奇偶全都相同**(逐条验过)。
//
// ⇒ 那它到底干了什么?**把路走短了。**(p1379Count.cpp 第 ③ 段)
//
// 可达状态 181440 → 181440 (一个没变)
// 最远距离 30 步 → 28 步
// 被算小的状态 155579 / 181440(86%)
//
// ⚠ 所以它是一个**只会把答案算小、永远不会算大**的 bug:
// 离目标越远越容易现形,而**官方样例只有 4 步 —— 挡不住它**。
#include <bits/stdc++.h>
using namespace std;
const string GOAL = "123804765";
int main() {
string s;
if (!(cin >> s)) return 0;
map<string, int> dist_;
queue<string> q;
dist_[s] = 0;
q.push(s);
while (!q.empty()) {
string cur = q.front();
q.pop();
if (cur == GOAL) { cout << dist_[cur] << "\n"; return 0; }
int p = (int)cur.find('0');
const int D[4] = {-3, 3, -1, 1}; // ⚠⚠ 就是这一行:一维下标直接加减
for (int k = 0; k < 4; k++) {
int np = p + D[k];
if (np < 0 || np > 8) continue; // 只挡住了「掉出数组」,没挡住「跨行」
string nxt = cur;
swap(nxt[p], nxt[np]);
if (dist_.count(nxt)) continue;
dist_[nxt] = dist_[cur] + 1;
q.push(nxt);
}
}
cout << -1 << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它逐字节过了官方样例(也给 4)。

3★★★ 验算走一条和 BFS 完全无关的路:逆序对的奇偶性

先给这道题建一个「靶子」:从目标态 BFS 铺满整张状态图,能到达多少个状态?

实测 181440 个,而九个数字的全排列有 9! = 362880 种 —— 正好一半的摆法根本到不了目标态(所以题面才要专门写一句「保证无法到达的数据不会出现」)。

这个 181440 值得用另一条完全无关的路验一遍:

★★ 八数码有一个一步都改不了的不变量

0 拿掉,只看那八个数字排成的序列,数它的逆序对个数

  • 横着挪空格:在这个序列里,只是 0 从某个棋子的左边挪到了右边 —— 棋子之间的相对顺序一点没动,逆序对数不变。
  • 竖着挪空格:0 跨过了 2 个棋子,逆序对数最多变 ±2

⇒ 不管怎么挪,逆序对数的奇偶性永远不变。 于是 9! 种摆法被劈成互不相通的两半,只有和目标态同奇偶的那一半有解。

9! = 362880 种摆法一个个枚举、分类计数:

和目标态同奇偶 不同奇偶
有几种摆法 181440 181440

⇒ 和 BFS 铺出来的 181440 一个不差,而这两条路一行代码都不共享 (一个是队列 + 层序,一个是数逆序对)。 这正是第 7 章 P1147 那条:验算最好走一条和算法完全无关的路。

★ 顺带一句:「逆序对的奇偶性是个不变量」这件事, 第 11 章 P1966 那页也用过一次 —— 那里是「交换两个数改变逆序对奇偶」。

4⚠⚠ 我拿这条不变量去判那个 bug —— 判错了

草稿里的判词,和实测

草稿写的是:

跨行移动打破了这个不变量 ⇒ 那一版能到达的状态数会从 181440 变成 362880, 它把两个连通块焊在一起了。

实测:还是 181440,一个都没多。

原因就在上一步那句话里,我自己写了却没用上: 横着挪空格(不管跨不跨行)只是让 0 从某个棋子的左边挪到右边,棋子的相对顺序一点没动。 「跨行」这件事在去掉 0 的序列里根本不存在 —— 它照样保持奇偶性。

把这句话直接验一遍:那 80640 条「跨行」的假边,两端奇偶全都相同(逐条数过)。

那它到底干了什么?把路走短了

★ 正解 ⚠⚠ 一维 ±1
可达状态数 181440 181440(一个没变
最远距离 30 28 步
181440 个状态里,距离被算 155579(86%)

⇒ 它是一个只会把答案算小、永远不会算大的 bug。 离目标越远越容易现形,而官方样例只有 4 步。

5★★★ 生成器:「倒着随机走 k 步」造出来的,不是「距离 k 的状态」

p1379Gen.cpp生成器:两个 mode,差别很大
// P1379 对拍生成器:`./p1379Gen <seed> [k] [mode]`
//
// ⚠ **不能随便打乱九个数字就当输入** —— 9! 种摆法里有一半根本到不了目标态
// (逆序对奇偶性,p1379Count.cpp 第 ② 段)。随机排列有 50% 概率是无解的,
// 而题面明说「保证测试数据中无特殊无法到达目标状态数据」。
// ⇒ 这是[第 13 章 P1162](/sol/p1162/) 那条的又一次现场:
// **有一类题最难写的不是正解,是生成器** —— 先问「随手造的数据有多大概率是合法输入」。
//
// ★★★ 两个 mode,而这一页最值钱的一条就在它们的差别上:
//
// mode 0(默认):从目标态**倒着随机走 k 步**。造出来的一定有解 —— 可
// ⚠⚠ **走 k 步不等于「距离 k」**:随机游走会绕回来。
// 实测(p1379Count.cpp 第 ⑤ 段):倒着走 30 步,造出来的状态**平均只有 8.65 步远**,
// 而全图 181440 个状态的平均距离是 **21.50 步**。
// ⇒ 这一档**永远待在目标附近**,对「把路走短」那类 bug 几乎瞎。
//
// mode 1:先把整张状态图 BFS 一遍,**直接从「真实距离 ≥ k」的状态里随机挑**。
// ⇒ 同一个 bug,mode 0 的 30 步档抓 49 / 300,mode 1 的 k = 25 档抓 **291 / 300**。
#include <bits/stdc++.h>
using namespace std;
static const int DR[4] = {-1, 1, 0, 0};
static const int DC[4] = {0, 0, -1, 1};
/** mode 1 用:把整张状态图从目标态 BFS 一遍,挑出「距离 ≥ k」的那些状态。 */
static vector<string> farStates(int k) {
unordered_map<string, int> d;
queue<string> q;
string goal = "123804765";
d[goal] = 0;
q.push(goal);
while (!q.empty()) {
string cur = q.front();
q.pop();
int p = (int)cur.find('0');
for (int t = 0; t < 4; t++) {
int nr = p / 3 + DR[t], nc = p % 3 + DC[t];
if (nr < 0 || nr > 2 || nc < 0 || nc > 2) continue;
string nxt = cur;
swap(nxt[p], nxt[nr * 3 + nc]);
if (d.count(nxt)) continue;
d[nxt] = d[cur] + 1;
q.push(nxt);
}
}
vector<string> out;
for (auto& [s, v] : d) if (v >= k) out.push_back(s);
sort(out.begin(), out.end()); // ⚠ 排一下序,unordered_map 的遍历顺序不保证可复现
return out;
}
int main(int argc, char** argv) {
unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1u;
int steps = argc > 2 ? atoi(argv[2]) : 30;
int mode = argc > 3 ? atoi(argv[3]) : 0;
if (steps < 0) steps = 0;
mt19937 rng(seed);
if (mode == 1) { // ★ 直接从远处挑
vector<string> far = farStates(steps);
printf("%s\n", far[rng() % far.size()].c_str());
return 0;
}
string s = "123804765";
for (int i = 0; i < steps; i++) {
int p = (int)s.find('0');
int r = p / 3, c = p % 3;
vector<int> ok;
for (int k = 0; k < 4; k++) {
int nr = r + DR[k], nc = c + DC[k];
if (nr < 0 || nr > 2 || nc < 0 || nc > 2) continue;
ok.push_back(nr * 3 + nc);
}
int np = ok[rng() % ok.size()];
swap(s[p], s[np]);
}
printf("%s\n", s.c_str());
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

不能随便打乱九个数字当输入 —— 有一半是无解的(第 ③ 步)。 所以生成器从目标态倒着随机走 k:这样造出来的一定有解, 而且「离目标多远」这个旋钮看起来握在手里。

300 轮,它抓那个 bug 抓到几次:

倒着走几步 4 10 20 30
⚠⚠ 一维 ±1 被抓 0 0 20 49
★★★ 「86% 的状态是错的」和「300 轮只抓到 49 次」为什么不矛盾

上一步刚量过:181440 个状态里,被算小的有 155579 个(86%)。 可对拍 300 轮只抓到 49 次 —— 差得太远了。

问题出在「倒着走 k 步」这句话上:随机游走会绕回来,走 k 步 ≠ 距离 k。

全图 181440 个状态 倒着走 4 步 10 步 20 步 30 步
平均离目标多远 21.50 2.00 3.60 6.49 8.65

⇒ 走 30 步造出来的状态,平均只有 8.65 步远 —— 生成器一直在目标附近打转, 而这个 bug 恰恰只在远处才现形。

★ 换一种造法(mode 1):先把整张图 BFS 一遍,直接从「真实距离 ≥ 25」的 32800 个状态里随机挑

生成器 倒着走 30 步 ★ 直接挑距离 ≥ 25 的
300 轮抓到 49 291

同一个 bug、同样 300 轮,49 → 291。

★ 这条和之前几页的关系

第 13 章 P1596 说过「抓获率是一张曲面,顺手写的生成器一直待在最矮的那条边上」。 这一页是同一件事的新形态,而且更隐蔽:

  • 那几页的旋钮(规模、值域、密度)拧了就直接生效
  • 这里的旋钮是「倒着走几步」,看着也像是在控制距离, ⚠ 可它和真正的距离之间隔着一次随机游走 —— 拧到 30,实际只有 8.65。

拧一个旋钮之前,先量一下它到底控制着什么。

6★ 顺带:从目标态反着 BFS 也行 —— 因为这张图的边是无向的

八数码的每一步都是可逆的(把棋子挪回去就行)⇒ 状态图的边是无向的 ⇒

    从初始态走到目标态的最少步数  ==  从目标态走到初始态的最少步数

所以也可以从目标态出发铺满整张表,再 O(1) 查:

p1379Rev.cpp★ 从目标态反着铺满(对照用)
// ★ 另一种写法:从**目标态**反着 BFS,一次把整张表铺出来
//
// 八数码的每一步移动都是**可逆的**(把棋子挪回去就行)——
// 也就是说这张状态图的边是**无向**的。于是
//
// 从初始态走到目标态的最少步数 == 从目标态走到初始态的最少步数
//
// ⇒ 可以从**目标态**出发 BFS 一次,把 181440 个可达状态的距离全算出来,再 O(1) 查表。
//
// ⚠ 对这道题(一次只问一个初始态)它并不划算:正解走到目标就停了,
// 而这一版每次都要把整张图铺满。它的意义在别处 ——
//
// ★★★ **「反着跑一次就够」这句话缺一个主语:主语是这张图的边是不是无向的。**
// 这道题、[P1747](/sol/p1747/)(马 + 象的走法成对相反)、[P1332](/sol/p1332/)(网格)都成立;
// 而同一轮的 [P1135 奇怪的电梯](/sol/p1135/) 里,边是**有向**的
// (在 i 层能上到 i + K[i],回不来),同一个动作**当场就错**。
#include <bits/stdc++.h>
using namespace std;
const string GOAL = "123804765";
int main() {
string s;
if (!(cin >> s)) return 0;
map<string, int> dist_;
queue<string> q;
dist_[GOAL] = 0; // ★ 从目标态出发
q.push(GOAL);
while (!q.empty()) {
string cur = q.front();
q.pop();
int p = (int)cur.find('0');
int r = p / 3, c = p % 3;
const int DR[4] = {-1, 1, 0, 0};
const int DC[4] = {0, 0, -1, 1};
for (int k = 0; k < 4; k++) {
int nr = r + DR[k], nc = c + DC[k];
if (nr < 0 || nr > 2 || nc < 0 || nc > 2) continue;
string nxt = cur;
swap(nxt[p], nxt[nr * 3 + nc]);
if (dist_.count(nxt)) continue;
dist_[nxt] = dist_[cur] + 1;
q.push(nxt);
}
}
auto it = dist_.find(s);
cout << (it == dist_.end() ? -1 : it->second) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★★ 而「反着跑一次就够」这句话缺一个主语

主语是:这张图的边是不是无向的。

  • 这道题(每一步可逆)、P1747(12 个方向成对相反)、 P1332(网格四连通)—— 都成立
  • 而同一轮的 P1135 奇怪的电梯 里,边是有向的 (在第 i 层能上到 i + K[i],可从那儿回不来)—— 同一个动作当场就错

⇒ 两页放在一起看:同一个技巧,一个成立一个不成立,差别只在「边有没有方向」。

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

p1379Count.cpp度量:可达状态数 + 奇偶判据 + 假边 + 生成器那条曲线
// P1379 的度量程序 —— 这一页所有数字都出自这一份。
//
// `./p1379Count` 人看的版本
// `./p1379Count csv` 一行一项,给 scripts/check-viz.mjs 写断言用
//
// 四段:
// ① 从目标态 BFS 铺满:能到达多少个状态、最远几步;
// ② ★★★ 换一条**和 BFS 完全无关**的路验它:逆序对奇偶性判据 ——
// 9! 种摆法按「去掉 0 之后逆序对数的奇偶」正好劈成两半,各多少个;
// ③ ⚠ 一维下标 ±1 那个错法:它能到达多少状态(那个不变量被打破了);
// ④ 它的抓获率:从目标态倒着走 k 步造输入,正解和它差多少。
#include <bits/stdc++.h>
using namespace std;
static bool CSV = false;
static const string GOAL = "123804765";
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");
}
/** 从 GOAL 出发 BFS。flat = true 时用一维下标 ±1 / ±3 找邻居(那个错法)。 */
static unordered_map<string, int> bfsAll(bool flat) {
unordered_map<string, int> d;
queue<string> q;
d[GOAL] = 0;
q.push(GOAL);
const int DR[4] = {-1, 1, 0, 0};
const int DC[4] = {0, 0, -1, 1};
const int D1[4] = {-3, 3, -1, 1};
while (!q.empty()) {
string cur = q.front();
q.pop();
int p = (int)cur.find('0');
for (int k = 0; k < 4; k++) {
int np;
if (flat) {
np = p + D1[k];
if (np < 0 || np > 8) continue;
} else {
int nr = p / 3 + DR[k], nc = p % 3 + DC[k];
if (nr < 0 || nr > 2 || nc < 0 || nc > 2) continue;
np = nr * 3 + nc;
}
string nxt = cur;
swap(nxt[p], nxt[np]);
if (d.count(nxt)) continue;
d[nxt] = d[cur] + 1;
q.push(nxt);
}
}
return d;
}
/** 去掉 0 之后,那八个数字的逆序对数的奇偶。 */
static int parityOf(const string& s) {
string t;
for (char ch : s) if (ch != '0') t += ch;
int inv = 0;
for (size_t i = 0; i < t.size(); i++)
for (size_t j = i + 1; j < t.size(); j++)
if (t[i] > t[j]) inv++;
return inv & 1;
}
int main(int argc, char** argv) {
CSV = (argc > 1 && string(argv[1]) == "csv");
/* ① 从目标态铺满:可达状态数 + 最远距离 + 步数分布 */
auto ok = bfsAll(false);
long long reach = (long long)ok.size();
int deepest = 0;
string farthest;
for (auto& [s, v] : ok) if (v > deepest || (v == deepest && s > farthest)) { deepest = max(deepest, v); }
for (auto& [s, v] : ok) if (v == deepest) { farthest = s; break; }
if (!CSV) {
printf("① 从目标态 %s BFS 铺满:能到达 %lld 个状态,最远 %d 步(例如 %s)\n",
GOAL.c_str(), reach, deepest, farthest.c_str());
printf(" 9! = %d,也就是说**一半的摆法根本到不了**\n\n", 362880);
}
row("reach", {reach, deepest});
/* ② ★★★ 完全无关的第二条路:逆序对奇偶性 */
{
string base = "012345678";
long long same = 0, diff = 0;
int goalP = parityOf(GOAL);
do {
if (parityOf(base) == goalP) same++; else diff++;
} while (next_permutation(base.begin(), base.end()));
if (!CSV) {
printf("② 换一条和 BFS 完全无关的路:**去掉 0 之后那八个数字的逆序对数奇偶性**,\n"
" 合法移动一步都改不了它。9! 种摆法里\n"
" 和目标态同奇偶的 %lld 个 / 不同的 %lld 个\n", same, diff);
printf(" ⇒ 和第 ① 段那个 %lld %s\n\n", reach, same == reach ? "**一个不差**" : "对不上(出事了)");
}
row("parity", {same, diff, same == reach ? 1 : 0});
}
/* ③ ⚠ 一维下标 ±1 那个错法:它能到达多少状态、把多少个距离算小了
*
* ⚠⚠ 草稿在这儿写的是「跨行移动打破了奇偶不变量 ⇒ 可达状态数 181440 → 362880」。
* **实测把它打回来了:还是 181440。** 原因一句话就能说清 ——
* 横向挪空格(不管跨不跨行)在「去掉 0 之后的那八个数字」里
* 只是让 0 从一个棋子的左边挪到右边,**棋子之间的相对顺序一点没动**
* ⇒ 逆序对数根本不变,奇偶自然也不变。
* 下面把这句话直接验一遍:把所有「跨行」的假边逐条拿出来,看两端奇偶是不是都一样。
*/
{
auto bad = bfsAll(true);
long long reachBad = (long long)bad.size();
int deepBad = 0;
for (auto& [s, v] : bad) { (void)s; deepBad = max(deepBad, v); }
// 那些「跨行」的假边:p 和 p+1 不在同一行(p = 2 或 5)
long long fakeEdges = 0, fakeSameParity = 0;
for (auto& [s, v] : ok) {
(void)v;
int p = (int)s.find('0');
for (int np : {p - 1, p + 1}) {
if (np < 0 || np > 8) continue;
if (p / 3 == np / 3) continue; // 同一行 —— 那是真边
string t = s;
swap(t[p], t[np]);
fakeEdges++;
if (parityOf(t) == parityOf(s)) fakeSameParity++;
}
}
// 它把多少个状态的距离算小了
long long shorter = 0, sameDist = 0;
for (auto& [s, v] : ok) {
auto it = bad.find(s);
if (it == bad.end()) continue;
if (it->second < v) shorter++;
else if (it->second == v) sameDist++;
}
if (!CSV) {
printf("③ 一维下标 ±1 那个错法(跨行也当成相邻):能到达 %lld 个状态,最远 %d 步\n",
reachBad, deepBad);
printf(" ⚠ 我以为可达数会翻倍(181440 → 362880),**实测没有** ——\n");
printf(" 那 %lld 条「跨行」的假边里,两端奇偶相同的有 %lld 条(横向挪 0 不动棋子的相对顺序)\n",
fakeEdges, fakeSameParity);
printf(" ⇒ 它真正干的事是**把路走短了**:%lld 个可达状态里,"
"距离被算小的 %lld 个、不变的 %lld 个\n\n", reach, shorter, sameDist);
}
row("flatReach", {reachBad, deepBad});
row("fakeEdges", {fakeEdges, fakeSameParity});
row("flatShorter", {shorter, sameDist});
}
/* ④ 抓获率:从目标态倒着随机走 k 步造输入 */
{
auto bad = bfsAll(true);
vector<long long> caught;
vector<int> steps = {4, 10, 20, 30};
const int DR[4] = {-1, 1, 0, 0};
const int DC[4] = {0, 0, -1, 1};
if (!CSV) printf("④ 从目标态倒着走 k 步造输入(300 轮),正解和那个错法差几次\n");
for (int k : steps) {
int c = 0;
for (int seed = 1; seed <= 300; seed++) {
mt19937 rng((unsigned)seed);
string s = GOAL;
for (int i = 0; i < k; i++) {
int p = (int)s.find('0');
vector<int> cand;
for (int t = 0; t < 4; t++) {
int nr = p / 3 + DR[t], nc = p % 3 + DC[t];
if (nr < 0 || nr > 2 || nc < 0 || nc > 2) continue;
cand.push_back(nr * 3 + nc);
}
swap(s[p], s[cand[rng() % cand.size()]]);
}
if (ok[s] != bad[s]) c++;
}
caught.push_back(c);
if (!CSV) printf(" 倒着走 %2d 步:%3d / 300\n", k, c);
}
if (!CSV) printf("\n");
row("flatCatch", caught);
/* ⑤ ★★★ 「86% 的状态答案是错的」和「300 轮只抓到 49 次」为什么不矛盾:
* **倒着随机走 k 步,造出来的不是「距离 k 的状态」** —— 随机游走会绕回来。
* 下面把两件事量出来:生成器造出来的状态平均离目标多远、全图平均多远;
* 以及换一种造法(直接从 BFS 表里挑远处的状态)抓获率会怎样。
*/
{
// 全图的平均距离
long long sumAll = 0;
for (auto& [s, v] : ok) { (void)s; sumAll += v; }
long long avgAll100 = sumAll * 100 / reach;
// 生成器各档造出来的状态,平均真实距离
vector<long long> avgGen100;
for (int k : steps) {
long long sum = 0;
for (int seed = 1; seed <= 300; seed++) {
mt19937 rng((unsigned)seed);
string s = GOAL;
for (int i = 0; i < k; i++) {
int p = (int)s.find('0');
vector<int> cand;
for (int t = 0; t < 4; t++) {
int nr = p / 3 + DR[t], nc = p % 3 + DC[t];
if (nr < 0 || nr > 2 || nc < 0 || nc > 2) continue;
cand.push_back(nr * 3 + nc);
}
swap(s[p], s[cand[rng() % cand.size()]]);
}
sum += ok[s];
}
avgGen100.push_back(sum * 100 / 300);
}
// 换一种造法:直接从「真实距离 ≥ 25」的状态里挑
vector<string> far;
for (auto& [s, v] : ok) if (v >= 25) far.push_back(s);
sort(far.begin(), far.end());
int cFar = 0;
for (int seed = 1; seed <= 300; seed++) {
mt19937 rng((unsigned)seed);
const string& s = far[rng() % far.size()];
if (ok[s] != bad[s]) cFar++;
}
if (!CSV) {
printf("⑤ 为什么「86%% 的状态是错的」和「300 轮只抓到 49 次」不矛盾:\n");
printf(" 全图 %lld 个状态的平均距离是 %.2f 步;\n", reach, avgAll100 / 100.0);
for (size_t i = 0; i < steps.size(); i++)
printf(" 倒着随机走 %2d 步,造出来的状态平均只有 %.2f 步远\n",
steps[i], avgGen100[i] / 100.0);
printf(" ⇒ **随机游走会绕回来,走 k 步 ≠ 距离 k。**\n");
printf(" 换一种造法:直接从「真实距离 ≥ 25」的 %zu 个状态里随机挑 —— 抓到 %d / 300\n\n",
far.size(), cFar);
}
row("avgDist", {avgAll100});
row("avgGen", avgGen100);
row("farCatch", {(long long)far.size(), cFar});
}
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

8一张总表

版本 错在哪 官方样例 可达状态 300 轮对拍 结果
⚠⚠ p1379Flat 一维下标 ±1 跨行 181440(没变) 倒着走 30 步 49 / 直接挑远处 291 WA
p1379Rev 181440 0 ★ AC(但每次都铺满全图)
p1379 181440 0 AC
这一页记住三句话
  1. ★★ 状态图 BFS 就是把「格子」换成「状态」、把「相邻」换成「一步能变成的状态」。 BFS 本身一个字都不用改;全部难度在「邻居是什么」上 —— 这道题里那就是「一维下标 ±1 会跨行」。
  2. ★★★ 验算要走一条和算法完全无关的路。 BFS 铺出 181440 个可达状态,逆序对奇偶性判据也给 181440(9! / 2),两条路一行不共享。 ⚠ 但别拿这条不变量去猜别的:我用它判「跨行会打破奇偶性」, 实测那 80640 条假边两端奇偶全都相同 —— 那个 bug 干的是把路走短(86% 的状态被算小)。
  3. ★★★ 拧一个旋钮之前,先量一下它到底控制着什么。 「从目标态倒着随机走 30 步」听着像是在造「30 步远的状态」, 实测平均只有 8.65 步远(全图平均 21.50)—— 换成「直接从距离 ≥ 25 的状态里挑」,同一个 bug 的抓获数从 49 变成 291