题单 · 习题解析

洛谷 P1032 [NOIP 2002 提高组] 字串变换

★★★ 双向 BFS 的第二个前提:反向边你得会走 —— 规则是单向的,反向侧要用反规则;两个 bug 都是对拍打出来的,而官方样例两次都没报警

原题:洛谷 P1032出自 第 18 章 迭代加深与双向 BFS 的题单题面本地存档:2026-08-28
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目背景

⚠ 本题不保证存在靠谱的多项式复杂度的做法测试数据非常的水,各种做法都可以通过,不代表算法正确。 因此本题题目和数据仅供参考。本题为搜索题,本题不接受 hack 数据。

(洛谷把这道题标注为「疑似错题」。)

题目描述

已知有两个字串 AB 及一组字串变换的规则(至多 6 个规则),形如:

  • A1 → B1
  • A2 → B2

规则的含义为:在 A 中的子串 A1 可以变换为 B1A2 可以变换为 B2 ……

例如:A = abcdB = xyz,变换规则为 abc → xuud → yy → yz, 则此时 A 可以经过一系列的变换变为 B,其变换的过程为:

    abcd  ->  xud  ->  xy  ->  xyz

共进行了 3 次变换,使得 A 变换为 B

输入格式

第一行有两个字符串 AB。 接下来若干行,每行有两个字符串 AiBi,表示一条变换规则。

输出格式

若在 10 步(包含 10 步)以内能将 A 变换为 B,则输出最少的变换步数; 否则输出 NO ANSWER!

数据规模与约定

对于 100% 数据,保证所有字符串长度的上限为 20,且均由小写英文字母组成。

【题目来源】NOIP 2002 提高组第二题。

输入输出样例

输入

abcd xyz
abc xu
ud y
y yz

输出

3

⚠ 这一组样例把本页两个候选错法都放过了(都输出 3)—— 第 ③ 步会看到。

1先看单向 BFS,以及它为什么不够

规则是「子串替换」,每次变换后得到一个新串 —— 这就是一张图:点是串,边是一次变换。 问最短步数,那就是 BFS:

p1032Uni.cpp第一版:单向 BFS
// P1032 的**第一版**:老老实实**单向 BFS**,从 A 铺到 B。
//
// 题目:给两个串 A、B 和至多 6 条规则 `Ai → Bi`(把子串 Ai 换成 Bi)。
// 问最少几步能把 A 变成 B;**10 步以内**做不到就输出 `NO ANSWER!`。
//
// 它**是对的**,写起来也最自然:一层一层往外铺,第一次碰到 B 就是最短。
//
// ⚠ 问题在分支数:每个状态的后继 = 6 条规则 × 每条在串里出现的位置(串长 ≤ 20),
// 记作 b。铺到第 10 层就是 `b¹⁰`。
// ⇒ 页面第 ③ 步量了它和双向 BFS 差多少 —— 差的是**指数**,不是常数。
#include <bits/stdc++.h>
using namespace std;
static const int MAXLEN = 20;
static int n; // 规则条数
static string from_[8], to_[8];
static long long pushed = 0; // 一共入队多少个状态 —— 这一页的尺子
/** 把 s 按所有规则、所有出现位置变换一次,得到所有后继 */
static vector<string> nexts(const string& s) {
vector<string> out;
for (int i = 0; i < n; i++)
for (size_t p = s.find(from_[i]); p != string::npos; p = s.find(from_[i], p + 1)) {
string t = s.substr(0, p) + to_[i] + s.substr(p + from_[i].size());
if ((int)t.size() <= MAXLEN) out.push_back(t); // ★ 长度上限,见文件头 ②
}
return out;
}
int main(int argc, char** argv) {
string A, B;
if (!(cin >> A >> B)) return 0;
while (n < 6 && (cin >> from_[n] >> to_[n])) n++;
if (A == B) { cout << 0 << "\n"; return 0; }
unordered_map<string, int> dist{{A, 0}};
vector<string> cur{A};
int ans = -1;
for (int level = 0; level < 10 && ans < 0 && !cur.empty(); level++) {
vector<string> nxt;
for (const string& s : cur) {
for (const string& t : nexts(s)) {
if (dist.count(t)) continue;
dist[t] = level + 1;
pushed++;
if (t == B) { ans = level + 1; break; }
nxt.push_back(t);
}
if (ans >= 0) break;
}
cur.swap(nxt);
}
if (ans >= 0 && ans <= 10) cout << ans << "\n";
else cout << "NO ANSWER!\n";
if (argc > 1 && string(argv[1]) == "count") cerr << "pushed=" << pushed << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

是对的。问题在分支数:一个状态的后继 = 6 条规则 × 每条规则在串里出现的位置数, 记作 b。铺到第 10 层就是 b¹⁰

★ 关键的一步

第 18 章第 8 步的双向 BFS,在这道题上正好符合前提: 起点和终点都明确AB 都在输入里给死了)。

两头各铺 5 层,b¹⁰ 变成 2 × b⁵ —— 省的是指数,不是常数

2★★★ 而双向 BFS 还有第二个前提:反向边你得会走

p1032.cpp★ 这一版就能 AC
// P1032 字串变换 —— 双向 BFS(★ 这一版就能 AC)
//
// 题目:给两个串 A、B 和至多 6 条规则 `Ai → Bi`(把子串 Ai 换成 Bi)。
// 问最少几步能把 A 变成 B;**10 步以内**做不到就输出 `NO ANSWER!`。
//
// ★ 这道题为什么是双向 BFS 的教科书:本章第 8 步说过双向 BFS 有一个**前提** ——
// **起点和终点都明确**。这道题正好两头都给死了(A 和 B 都在输入里)。
//
// ★★★ 而它值多少钱,要算在**指数**上,不是算在时间上:
// 每个状态的分支数 = 6 条规则 × 每条规则在串里出现的位置(串长 ≤ 20)—— 记作 b。
// 单向 BFS 要铺到第 10 层:`b¹⁰`;
// 双向各铺 5 层:`2 × b⁵`。
// ⇒ b = 10 的话是 `10¹⁰` 对 `2 × 10⁵` —— **五万倍**,而不是「快一倍」。
//
// ⚠ 两个必须做对的地方:
// ① **按层扩展**(每次把当前较小的那一侧整层展开),并且在生成新状态时就查对面 ——
// ⚠⚠ 而且**要把这一整层扫完再取最小**,不能一碰到相遇点就返回:
// 同一层里不同的相遇点,对面的深度可能不一样。
// (这一条是被对拍抓出来的:草稿「碰到就返回」,双向给 6 而单向给 5。)
// ② **串长超过 20 就丢掉**:规则可以让串变长(样例里 `y → yz`),
// 状态空间本身是无界的。题面说「所有字符串长度的上限为 20」,这一版据此剪枝。
// ③ ★★★ **从 B 那一侧走的时候必须用「反规则」`Bi → Ai`。**
// 规则是**单向**的(`Ai → Bi`,不能反着用),所以这张图的边是**有向**的。
// ⇒ [第 15 章 P1135](/sol/p1135/) 那条:「从终点反着跑一次」缺一个主语 ——
// **这张图的边是不是无向的**。这道题不是,所以反向侧要建反图。
// ⚠ 这一条是被对拍抓出来的:草稿两侧都用正向规则,
// 双向和单向 BFS 在 16 组里对不上 7 组,**而且两个方向的错都有**
// (有时答案偏大、有时偏小)。
//
// ⚠⚠ 题面开头那段免责声明要认真读:「**本题不保证存在靠谱的多项式复杂度的做法。
// 测试数据非常的水,各种做法都可以通过,不代表算法正确。**」
// ⇒ 这道题「能过」和「算法对」是两件事,页面第 ④ 步量了这句话。
#include <bits/stdc++.h>
using namespace std;
static const int MAXLEN = 20;
static int n; // 规则条数
static string from_[8], to_[8];
static long long pushed = 0; // 一共入队多少个状态 —— 这一页的尺子
/** 把 s 变换一次,得到所有后继。
* ★★★ back = true 时用**反规则**(`Bi → Ai`)—— 见文件头 ③,这是这道题最容易写错的一处。 */
static vector<string> nexts(const string& s, bool back) {
vector<string> out;
for (int i = 0; i < n; i++) {
const string& L = back ? to_[i] : from_[i];
const string& R = back ? from_[i] : to_[i];
for (size_t p = s.find(L); p != string::npos; p = s.find(L, p + 1)) {
string t = s.substr(0, p) + R + s.substr(p + L.size());
if ((int)t.size() <= MAXLEN) out.push_back(t); // ★ 长度上限,见文件头 ②
}
}
return out;
}
int main(int argc, char** argv) {
string A, B;
if (!(cin >> A >> B)) return 0;
while (n < 6 && (cin >> from_[n] >> to_[n])) n++;
if (A == B) { cout << 0 << "\n"; return 0; }
unordered_map<string, int> da{{A, 0}}, db{{B, 0}};
vector<string> qa{A}, qb{B};
int ans = -1;
while (!qa.empty() && !qb.empty() && ans < 0) {
// ★ 每次展开**较小的那一侧**的一整层
bool forward = qa.size() <= qb.size();
vector<string>& cur = forward ? qa : qb;
unordered_map<string, int>& mine = forward ? da : db;
unordered_map<string, int>& other = forward ? db : da;
if (mine[cur[0]] >= 5) break; // 两边各 5 层,合起来就是 10 步
vector<string> nxt;
int best = INT_MAX;
for (const string& s : cur) {
int d = mine[s];
for (const string& t : nexts(s, !forward)) {
auto it = other.find(t);
// ⚠⚠ 这里**不能一碰到就返回**:同一层里不同的相遇点,对面的深度可能不一样。
// 草稿就是「第一个相遇点就 return」,被对拍当场抓到(双向给 6、单向给 5)。
// ⇒ 把这一整层扫完,取 da + db 的**最小值**。
if (it != other.end()) best = min(best, d + 1 + it->second);
if (mine.count(t)) continue;
mine[t] = d + 1;
nxt.push_back(t);
pushed++;
}
}
if (best != INT_MAX) { ans = best; break; }
cur.swap(nxt);
}
if (ans >= 0 && ans <= 10) cout << ans << "\n";
else cout << "NO ANSWER!\n";
if (argc > 1 && string(argv[1]) == "count") cerr << "pushed=" << pushed << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

B 那一侧往回走,走的不是原来的规则,而是反规则 Bi → Ai —— 因为规则 Ai → Bi单向的,不能反着用。

★★★ 这正是第 15 章 P1135 那条,原封不动地又来了一次

「从终点反着跑一次」缺一个主语 —— 这张图的边是不是无向的。

八数码每一步可逆(空格挪过去再挪回来),所以那道题成立电梯的步长由出发那层决定,当场就错; 这道题的规则是单向替换,也不可逆 —— 反向侧必须建反图。

⚠ 而我写这一页时照样踩了:草稿两侧都用正向规则, 双向和单向 BFS 在 36 组对拍里对不上 7 组 —— 而且两个方向的错都有

p1032Fwd.cpp错法:反向侧用了正向规则
// P1032 的**错法**:双向 BFS 写对了,**但从 B 那一侧还在用正向规则**。
//
// 题目:给两个串 A、B 和至多 6 条规则 `Ai → Bi`(把子串 Ai 换成 Bi)。
// 问最少几步能把 A 变成 B;**10 步以内**做不到就输出 `NO ANSWER!`。
//
// 规则 `Ai → Bi` 是**单向**的(不能反着用)⇒ 这张图的边是**有向**的。
// 从 B 往回走,走的应该是**反规则** `Bi → Ai`;用正向规则走出来的根本不是「B 的前驱」。
//
// ★★★ [第 15 章 P1135](/sol/p1135/) 那条:**「从终点反着跑一次」缺一个主语 ——
// 这张图的边是不是无向的。** 八数码的每一步可逆,所以那道题成立;
// 电梯和这道题都不可逆,照搬就错。
//
// ⚠⚠ 而它错得**两个方向都有**:有时答案偏大(走不到,报 NO ANSWER!),
// 有时答案**偏小**(用正向规则从 B 走出来的串,和 A 那边接上了一条根本不存在的路)。
// ⇒ 页面第 ④ 步量了它 —— 而**官方样例照样给 3**。
#include <bits/stdc++.h>
using namespace std;
static const int MAXLEN = 20;
static int n; // 规则条数
static string from_[8], to_[8];
static long long pushed = 0; // 一共入队多少个状态 —— 这一页的尺子
/** 把 s 变换一次,得到所有后继。
* ★★★ back = true 时用**反规则**(`Bi → Ai`)—— 见文件头 ③,这是这道题最容易写错的一处。 */
static vector<string> nexts(const string& s, bool back) {
vector<string> out;
for (int i = 0; i < n; i++) {
const string& L = back ? to_[i] : from_[i];
const string& R = back ? from_[i] : to_[i];
for (size_t p = s.find(L); p != string::npos; p = s.find(L, p + 1)) {
string t = s.substr(0, p) + R + s.substr(p + L.size());
if ((int)t.size() <= MAXLEN) out.push_back(t); // ★ 长度上限,见文件头 ②
}
}
return out;
}
int main(int argc, char** argv) {
string A, B;
if (!(cin >> A >> B)) return 0;
while (n < 6 && (cin >> from_[n] >> to_[n])) n++;
if (A == B) { cout << 0 << "\n"; return 0; }
unordered_map<string, int> da{{A, 0}}, db{{B, 0}};
vector<string> qa{A}, qb{B};
int ans = -1;
while (!qa.empty() && !qb.empty() && ans < 0) {
// ★ 每次展开**较小的那一侧**的一整层
bool forward = qa.size() <= qb.size();
vector<string>& cur = forward ? qa : qb;
unordered_map<string, int>& mine = forward ? da : db;
unordered_map<string, int>& other = forward ? db : da;
if (mine[cur[0]] >= 5) break; // 两边各 5 层,合起来就是 10 步
vector<string> nxt;
int best = INT_MAX;
for (const string& s : cur) {
int d = mine[s];
for (const string& t : nexts(s, false)) { // ★ 错在这里:两侧都用正向规则
auto it = other.find(t);
// ⚠⚠ 这里**不能一碰到就返回**:同一层里不同的相遇点,对面的深度可能不一样。
// 草稿就是「第一个相遇点就 return」,被对拍当场抓到(双向给 6、单向给 5)。
// ⇒ 把这一整层扫完,取 da + db 的**最小值**。
if (it != other.end()) best = min(best, d + 1 + it->second);
if (mine.count(t)) continue;
mine[t] = d + 1;
nxt.push_back(t);
pushed++;
}
}
if (best != INT_MAX) { ans = best; break; }
cur.swap(nxt);
}
if (ans >= 0 && ans <= 10) cout << ans << "\n";
else cout << "NO ANSWER!\n";
if (argc > 1 && string(argv[1]) == "count") cerr << "pushed=" << pushed << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 它错得两个方向都有 —— 而官方样例放它过去了
字母表大小 反向侧用正向规则被抓(200 组) 其中答案偏小 其中答案偏大
2 59 / 200 22 26
3 62 / 200 6 21
5 93 / 200 1 3
26 29 / 200 0 0
  • 偏大好理解:反着走走不到,报了 NO ANSWER! 或者更大的步数;
  • 偏小才是要命的:用正向规则从 B 走出来的串,和 A 那一侧接上了 一条根本不存在的路 —— 它给出的「最短步数」是编的。

⇒ 一个错法同时能把答案报大和报小,这在本书里不多见。 ★ 而官方样例给的是 3,和正解一模一样 —— 又一次「样例挡不挡得住要一个一个试」

3⚠⚠ 还有一个 bug,是对拍当场抓出来的:同一层要取最小

双向 BFS 相遇时答案是 da + db。可在同一层里,不同的相遇点对面的深度可能不一样 ——

    展开 A 侧第 d 层时,生成了 t1 和 t2,两个都在 B 侧的表里:
        db[t1] = 4   ->  答案 = d + 1 + 4
        db[t2] = 2   ->  答案 = d + 1 + 2     <- 这个才对

草稿写的是「碰到第一个相遇点就 return」,于是抓到哪个算哪个。 ⇒ 对拍第一次跑就打出来了:双向给 6,单向给 5。 正解那一版把这一整层扫完再取最小。

★ 这两个 bug 都不是「想出来」的,是对拍打出来的

这一页写代码的过程,是本书少见的「对拍连着抓两次」:

  1. 先抓出「同层没取最小」(双向 6 / 单向 5);
  2. 改完再抓出「反向侧用了正向规则」(36 组里 7 组不一致,两个方向的错都有)。

⇒ 而官方那一组样例,两次都没有报警

4★★★ 字母表大小:照题面随机造,一半是平凡数据

生成器随机造 6 条规则、随机造 A,再从 A 真的走 5 步得到 B(保证有解)。 唯一的旋钮是字母表大小

p1032Gen.cpp数据生成器
// P1032 对拍生成器:`./p1032Gen <seed> [字母表大小] [A 的长度] [走几步]`
// 字母表大小 默认 3 —— ★★★ **这个旋钮是这一页的全部**
// A 的长度 默认 6
// 走几步 默认 4(B 由 A 真的走这么多步得到 ⇒ 答案 ≤ 这个数)
//
// 造法:随机造 6 条规则(`Ai → Bi`,长度 1~2 变 1~3),随机造 A,
// 再从 A **真的走 k 步**得到 B。⇒ 输入一定有解,而且答案 ≤ k。
//
// ★★★ 字母表大小为什么是那个旋钮:一个状态的后继数
// = 6 条规则 × 每条规则的左边在串里出现的位置数。
// 字母只有 2 个的时候,长度 1 的规则**几乎每个位置都能匹配** ⇒ 分支数接近串长 × 6;
// 字母有 26 个的时候,一条长度 2 的规则在长度 6 的串里平均只匹配 0.007 次 ⇒ 分支数接近 0。
// ⇒ **照题面「均由小写英文字母组成」随机造,造出来的几乎全是一步到头的平凡数据。**
// 页面第 ③ 步把这条曲线画出来了。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1;
mt19937 rng(seed * 2654435761u + 31u);
int alpha = argc > 2 ? atoi(argv[2]) : 3;
int la = argc > 3 ? atoi(argv[3]) : 6;
int steps = argc > 4 ? atoi(argv[4]) : 4;
alpha = max(1, min(26, alpha));
la = max(1, min(12, la));
steps = max(0, min(10, steps));
auto rndStr = [&](int lo, int hi) {
int len = lo + (int)(rng() % (hi - lo + 1));
string t;
for (int i = 0; i < len; i++) t += char('a' + rng() % alpha);
return t;
};
vector<pair<string, string>> rules;
for (int i = 0; i < 6; i++) rules.push_back({rndStr(1, 2), rndStr(1, 3)});
string A = rndStr(la, la), B = A;
for (int i = 0; i < steps; i++) { // 从 A 真的走 steps 步得到 B
vector<string> cand;
for (auto& r : rules)
for (size_t p = B.find(r.first); p != string::npos; p = B.find(r.first, p + 1)) {
string t = B.substr(0, p) + r.second + B.substr(p + r.first.size());
if ((int)t.size() <= 20) cand.push_back(t);
}
if (cand.empty()) break;
B = cand[rng() % cand.size()];
}
printf("%s %s\n", A.c_str(), B.c_str());
for (auto& r : rules) printf("%s %s\n", r.first.c_str(), r.second.c_str());
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
字母表 起点分支数合计(200 组) 双向入队 单向入队 单向 ÷ 双向 A == B 的平凡数据
2 2 527 36 102 261 120 7.2× 2 / 200
3 1 616 31 456 434 026 13.8× 1 / 200
5 857 13 177 64 154 4.9× 8 / 200
10 372 2 367 3 973 1.7× 42 / 200
26(照题面) 144 344 333 0.97× ← 双向反而略亏 102 / 200
⇒ 「均由小写英文字母组成」这句话,把生成器带到了最平的那一头

字母有 26 个的时候,一条长度 2 的规则在长度 6 的串里几乎匹配不到 —— 于是 200 组里有 102 组 A 根本走不动A == B,答案 0)。

在那种数据上,双向 BFS 比单向还慢一点(多维护一张表和一次查表), 「双向 BFS 值多少钱」被量成了 0.97 倍

⇒ 想量出它真正的价值,得把字母表压小(字母表 3 那一档是 13.8 倍)。 ★ 又一次「抓获率 / 收益的主语是值域」(第 11 章 P1908第 17 章 P1434 都是同一件事)。

⚠⚠ 而题面开头那段免责声明也正是这个意思: 「测试数据非常的水,各种做法都可以通过,不代表算法正确。

5⚠ 一条「听起来必需」的剪枝,实测只值 4%

规则可以让串变长(样例里就有 y → yz),状态空间本身是无界的 —— 所以几乎所有题解都会写「串长超过 20 就丢掉」。这一页原本也打算把不写它当成错法。

实测把这个打回来了。

p1032Nolimit.cpp对照:去掉长度上限
// P1032 的**对照版**:双向 BFS 写对了,只是**去掉了「串长超过 20 就丢掉」那一句**。
//
// ⚠ 它**不是错法** —— 这一页原来打算把它当错法写,被实测打回来了。
//
// 草稿的理由听起来很硬:规则可以让串**变长**(样例里就有 `y → yz`),
// 状态空间本身是无界的 ⇒「必须剪长度」。
// ★★★ 可这句话缺了一步算术:两侧各只铺 **5** 层,每步最多把串加长 2 ⇒
// 串长最多 `|A| + 10`。而题面保证 `|A| ≤ 20`……
// **在生成器给的规模上,这条线大多数时候根本碰不到。**
//
// 实测(每档 200 组,字母表 3):
// A 长 6:入队 31456 → 31456(**1.00 倍**,一次都没碰到那条线)
// A 长 9:入队 64811 → 65020(1.00 倍)
// A 长 12:入队 104424 → 108502(**1.04 倍**)
// 而**答案一次都没有不同**。
//
// ⇒ 结论要说准:这条剪枝**不是必需的,也不值钱** —— 它省的是 4%,不是一个数量级。
// ⚠ 这是[第 10 章 P1068](/sol/p1068/) 那条的又一次:**别把「听起来有道理的提醒」当结论用**。
#include <bits/stdc++.h>
using namespace std;
static const int MAXLEN = 20;
static int n; // 规则条数
static string from_[8], to_[8];
static long long pushed = 0; // 一共入队多少个状态 —— 这一页的尺子
/** 把 s 按所有规则、所有出现位置变换一次,得到所有后继 */
static vector<string> nexts(const string& s) {
vector<string> out;
for (int i = 0; i < n; i++)
for (size_t p = s.find(from_[i]); p != string::npos; p = s.find(from_[i], p + 1)) {
string t = s.substr(0, p) + to_[i] + s.substr(p + from_[i].size());
out.push_back(t); // ★ 错在这里:没有长度上限
}
return out;
}
int main(int argc, char** argv) {
string A, B;
if (!(cin >> A >> B)) return 0;
while (n < 6 && (cin >> from_[n] >> to_[n])) n++;
if (A == B) { cout << 0 << "\n"; return 0; }
unordered_map<string, int> da{{A, 0}}, db{{B, 0}};
vector<string> qa{A}, qb{B};
int ans = -1;
while (!qa.empty() && !qb.empty() && ans < 0) {
// ★ 每次展开**较小的那一侧**的一整层
bool forward = qa.size() <= qb.size();
vector<string>& cur = forward ? qa : qb;
unordered_map<string, int>& mine = forward ? da : db;
unordered_map<string, int>& other = forward ? db : da;
if (mine[cur[0]] >= 5) break; // 两边各 5 层,合起来就是 10 步
vector<string> nxt;
for (const string& s : cur) {
int d = mine[s];
for (const string& t : nexts(s)) {
if (mine.count(t)) continue;
auto it = other.find(t);
if (it != other.end()) { ans = d + 1 + it->second; break; } // ★ 生成时就查对面
mine[t] = d + 1;
nxt.push_back(t);
pushed++;
}
if (ans >= 0) break;
}
cur.swap(nxt);
}
if (ans >= 0 && ans <= 10) cout << ans << "\n";
else cout << "NO ANSWER!\n";
if (argc > 1 && string(argv[1]) == "count") cerr << "pushed=" << pushed << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
A 的长度 带长度上限的入队数 去掉之后 倍数 答案不同
6 31 456 31 456 1.00× 0 / 200
9 64 811 65 020 1.00× 0 / 200
12 104 424 108 502 1.04× 0 / 200
★ 关键的一步

草稿的理由缺了一步算术:两侧各只铺 5 层,每步最多把串加长 2 ⇒ 串长最多 |A| + 10

|A| = 6 时最长 16,那条线一次都碰不到|A| = 12 时才刚过 20,省下 4%。

⇒ 这条剪枝不是必需的,也不值钱。 ★ 又一次第 10 章 P1068 那条:别把「听起来有道理的提醒」当结论用 —— 先花三十秒算一算它到底碰不碰得到。

6度量程序

p1032Count.cpp度量程序
// P1032 的度量程序 —— 这一页所有数字都出自这一份。
//
// `./p1032Count` 人看的版本
// `./p1032Count csv` 一行一项,给 scripts/check-viz.mjs 写断言用
//
// 四段:
// ① 官方样例:四个版本各输出什么(★ 两个错法它都放过了);
// ② ★★★ **字母表大小**这个旋钮:分支数、单向 vs 双向的入队状态数;
// ⇒ 照题面「均由小写英文字母组成」随机造,造出来的几乎全是平凡数据;
// ③ ★★★ 两个错法的抓获率:
// · 反向侧用正向规则(**这张图是有向的**);
// · 不剪串长(只坏复杂度、不坏答案);
// ④ 单向 BFS 在字母表小的时候要铺多少 —— 带上限,不然它根本不返回。
#include <bits/stdc++.h>
using namespace std;
static bool CSV = false;
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");
}
static const int MAXLEN = 20;
static int n;
static string from_[8], to_[8];
static long long pushed, cap_;
static bool blew;
static vector<string> nexts(const string& s, bool back, bool limit) {
vector<string> out;
for (int i = 0; i < n; i++) {
const string& L = back ? to_[i] : from_[i];
const string& R = back ? from_[i] : to_[i];
if (L.empty()) continue;
for (size_t p = s.find(L); p != string::npos; p = s.find(L, p + 1)) {
string t = s.substr(0, p) + R + s.substr(p + L.size());
if (!limit || (int)t.size() <= MAXLEN) out.push_back(t);
}
}
return out;
}
/** mode 0 = 正解双向;1 = 反向侧用正向规则(错);2 = 不剪串长;3 = 单向 BFS */
static string solve(const string& A, const string& B, int mode, long long cap = 0) {
pushed = 0; cap_ = cap; blew = false;
bool limit = (mode != 2);
if (A == B) return "0";
if (mode == 3) {
unordered_map<string, int> dist{{A, 0}};
vector<string> cur{A};
for (int level = 0; level < 10 && !cur.empty(); level++) {
vector<string> nxt;
for (const string& s : cur)
for (const string& t : nexts(s, false, limit)) {
if (dist.count(t)) continue;
dist[t] = level + 1;
if (++pushed >= cap && cap) { blew = true; return "?"; }
if (t == B) return to_string(level + 1);
nxt.push_back(t);
}
cur.swap(nxt);
}
return "NO ANSWER!";
}
unordered_map<string, int> da{{A, 0}}, db{{B, 0}};
vector<string> qa{A}, qb{B};
while (!qa.empty() && !qb.empty()) {
bool forward = qa.size() <= qb.size();
vector<string>& cur = forward ? qa : qb;
unordered_map<string, int>& mine = forward ? da : db;
unordered_map<string, int>& other = forward ? db : da;
if (mine[cur[0]] >= 5) break;
vector<string> nxt;
int best = INT_MAX;
for (const string& s : cur) {
int d = mine[s];
bool back = (mode == 1) ? false : !forward;
for (const string& t : nexts(s, back, limit)) {
auto it = other.find(t);
if (it != other.end()) best = min(best, d + 1 + it->second);
if (mine.count(t)) continue;
mine[t] = d + 1;
nxt.push_back(t);
if (++pushed >= cap && cap) { blew = true; return "?"; }
}
}
if (best != INT_MAX) return best <= 10 ? to_string(best) : string("NO ANSWER!");
cur.swap(nxt);
}
return "NO ANSWER!";
}
/** 和 p1032Gen.cpp 逐字一致 */
static void gen(int seed, int alpha, int la, int steps, string& A, string& B) {
mt19937 rng((unsigned)seed * 2654435761u + 31u);
alpha = max(1, min(26, alpha));
la = max(1, min(12, la));
steps = max(0, min(10, steps));
auto rndStr = [&](int lo, int hi) {
int len = lo + (int)(rng() % (hi - lo + 1));
string t;
for (int i = 0; i < len; i++) t += char('a' + rng() % alpha);
return t;
};
n = 6;
for (int i = 0; i < 6; i++) { from_[i] = rndStr(1, 2); to_[i] = rndStr(1, 3); }
A = rndStr(la, la);
B = A;
for (int i = 0; i < steps; i++) {
vector<string> cand;
for (int i2 = 0; i2 < 6; i2++)
for (size_t p = B.find(from_[i2]); p != string::npos; p = B.find(from_[i2], p + 1)) {
string t = B.substr(0, p) + to_[i2] + B.substr(p + from_[i2].size());
if ((int)t.size() <= 20) cand.push_back(t);
}
if (cand.empty()) break;
B = cand[rng() % cand.size()];
}
}
int main(int argc, char** argv) {
CSV = (argc > 1 && string(argv[1]) == "csv");
const long long CAP = 3000000;
/* ① 官方样例 */
{
n = 3;
from_[0] = "abc"; to_[0] = "xu";
from_[1] = "ud"; to_[1] = "y";
from_[2] = "y"; to_[2] = "yz";
string a = solve("abcd", "xyz", 0), b = solve("abcd", "xyz", 1);
string c = solve("abcd", "xyz", 2), d = solve("abcd", "xyz", 3);
if (!CSV) printf("① 官方样例:正解 %s|反向侧用正向规则 %s|不剪串长 %s|单向 BFS %s"
"(★ 两个错法样例都放过了)\n", a.c_str(), b.c_str(), c.c_str(), d.c_str());
row("sample", {a == "3", b == "3", c == "3", d == "3"});
}
/* ② ★★★ 字母表大小这个旋钮 */
{
if (!CSV) printf("\n② 字母表大小:分支数、以及单向 / 双向要铺多少(每档 200 组)\n");
vector<long long> out;
for (int alpha : {2, 3, 5, 10, 26}) {
long long branch = 0, uni = 0, bi = 0;
int trivial = 0, uniBlew = 0;
for (int s = 1; s <= 200; s++) {
string A, B;
gen(s, alpha, 6, 5, A, B);
branch += (long long)nexts(A, false, true).size();
if (A == B) trivial++;
solve(A, B, 0, CAP);
bi += pushed;
solve(A, B, 3, CAP);
if (blew) uniBlew++;
uni += pushed;
}
out.push_back(branch); out.push_back(bi); out.push_back(uni);
out.push_back(trivial); out.push_back(uniBlew);
if (!CSV) printf(" 字母表 %2d:起点分支数合计 %4lld|双向入队 %8lld|单向入队 %9lld"
"(撞上限 %d 组)|A == B 的平凡数据 %d 组\n",
alpha, branch, bi, uni, uniBlew, trivial);
}
row("alpha", out);
}
/* ③ ★★★ 两个错法的抓获率 */
{
if (!CSV) printf("\n③ 两个错法各被抓多少(每档 200 组)\n");
vector<long long> out;
for (int alpha : {2, 3, 5, 26}) {
int badFwd = 0, badLen = 0, smaller = 0, bigger = 0;
for (int s = 1; s <= 200; s++) {
string A, B;
gen(s, alpha, 6, 5, A, B);
string ok = solve(A, B, 0, CAP);
string f = solve(A, B, 1, CAP);
string l = solve(A, B, 2, CAP);
if (f != ok) {
badFwd++;
if (ok != "NO ANSWER!" && f != "NO ANSWER!") {
if (atoi(f.c_str()) < atoi(ok.c_str())) smaller++; else bigger++;
}
}
if (l != ok) badLen++;
}
out.push_back(badFwd); out.push_back(smaller); out.push_back(bigger); out.push_back(badLen);
if (!CSV) printf(" 字母表 %2d:反向侧用正向规则 %3d / 200(其中答案偏小 %d、偏大 %d)"
"|不剪串长 %d / 200\n", alpha, badFwd, smaller, bigger, badLen);
}
row("catch", out);
}
/* ④ 不剪串长:只坏复杂度 */
{
// ⚠ 草稿这里用 A 的长度 6,量出来入队数**一模一样(1.00 倍)** ——
// 因为规则每步最多把串加长 2,五层下来 6 + 10 = 16,**根本长不到 20**。
// ⇒ 那一档量的是个空壳。换成 A 长 12(12 + 10 = 22 > 20)才碰得到这条线。
if (!CSV) printf("\n④ 「不剪串长」值多少:A 的长度决定它碰不碰得到那条线(每档 200 组)\n");
vector<long long> out;
for (int la : {6, 9, 12}) {
long long withLimit = 0, without = 0;
int diff = 0;
for (int s = 1; s <= 200; s++) {
string A, B;
gen(s, 3, la, 5, A, B);
string x = solve(A, B, 0, CAP); withLimit += pushed;
string y = solve(A, B, 2, CAP); without += pushed;
if (x != y) diff++;
}
out.push_back(withLimit); out.push_back(without); out.push_back(diff);
if (!CSV) printf(" A 长 %2d:入队 %lld → %lld(%.2f 倍)|答案不同 %d / 200\n",
la, withLimit, without, without * 1.0 / withLimit, diff);
}
row("nolimit", out);
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

7一页纸

关键的一步 双向 BFS(起点终点都明确),两头各铺 5 层 —— 省的是指数
哪一版能 AC p1032.cpp;单向 BFS 在字母表小的数据上要多铺 13.8 倍
最容易写错的一处 ★★★ 反向侧必须用反规则 Bi → Ai —— 规则是单向的,这张图有向
第二容易写错的一处 同一层里要扫完再取最小,不能碰到第一个相遇点就返回
这一页的主线 那两个 bug 都是对拍打出来的,而官方样例两次都没报警
其中「反向侧用正向规则」还同时能把答案报大和报小
被实测打回的一条 「串长超过 20 就丢掉」听起来必需,实测只值 4%|A| + 10 ≤ 22,大多碰不到)
生成器那条 照题面「小写英文字母」随机造,200 组里 102 组 A == B
双向 BFS 在那种数据上比单向还慢一点(0.97×)