题单 · 习题解析

洛谷 P1071 [NOIP 2009 提高组] 潜伏者

★★★ 提高组第一题,考的不是算法,是「把题面的每一句话都变成一行检查」—— 三条规则里前两条是**同一件事的两个方向**(一个 x 一个 y / **不同的 x 不同的 y**),第三条(26 个字母都要出现过)和前两条毫无关系;★★★ 这一页真正的功课是生成器:**照题面随机造三行,正解 300 轮全部输出 `Failed`** —— 随机两行必然撞冲突、字母也出不全 ⇒ 三个错法跟着一起 Failed,**对拍验的是零**(不是抓获率低,是这批数据根本没在问问题)⇒ 只能**反着造**(先摇一个 26 字母的置换当密码本,再随机原文加密出第一行);★★★ 而「26 个字母全出现」的概率是容斥算得出来的精确值:`L = 100`(题面顶格)**58.2242%**、`L = 50` 只有 0.77%,顺手在 1~100 里均匀摇 `L` 平均只有 **12.44%** —— ★ 而凑齐 26 个字母**期望要 100.21 个字母**,**题面的上限正好是 100**;★★★ 「只查一个方向」那个错法**五档里只有一档抓得到**(要 `enc` 自洽 + `dec` 冲突 + 26 字母全覆盖三件事同时成立),顺手写的三档全是精确的 0;★★★ 而**官方三组样例也一个都打不死它** —— 样例一的说明写着「A 和 B 对应相同的密字」,可那组数据里只出现了 A、B 两个字母 ⇒ 错的程序走到**覆盖检查**照样输出 Failed,**它答对了,但理由是错的** ⇒ 判据很便宜:**看到一组数据同时踩中两个 Failed 分支,就该再造一组只踩一个的**;★ 十五格「触发 ≡ 抓获」一个不差;⚠ 顺带一跤:顺手档第一版让三行长度各自随机,**违反了题面「第一行长度与第二行相等」** ⇒ 所有版本在长度检查那一步就一起返回了

原题:洛谷 P1071出自 第 47 章 字符串基础:读进来、切开、比对 的题单题面本地存档:2026-09-07
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

R 国和 S 国正陷入战火之中,双方都互派间谍,潜入对方内部,伺机行动。历尽艰险后, 潜伏于 S 国的 R 国间谍小 C 终于摸清了 S 国军用密码的编码规则:

  1. S 国军方内部欲发送的原信息经过加密后在网络上发送,原信息的内容与加密后所得的内容 均由大写字母 A~Z 构成(无空格等其他字符);
  2. S 国对于每个字母规定了对应的密字。加密的过程就是将原信息中的所有字母替换为其对应的密字;
  3. 每个字母只对应一个唯一的密字,不同的字母对应不同的密字。 密字可以和原字母相同。

小 C 的破译过程是这样的:扫描原信息,对于原信息中的字母 x,找到其在加密信息中的对应大写字母 y, 并认为在密码里 yx 的密字。如此进行下去直到停止于如下的某个状态:

  1. 所有信息扫描完毕,A~Z 所有 26 个字母在原信息中均出现过并获得了相应的密字;
  2. 所有信息扫描完毕,但发现存在某个(或某些)字母在原信息中没有出现
  3. 扫描中发现掌握的信息里有明显的自相矛盾或错误(违反 S 国密码的编码规则)。

现在请你帮助小 C:通过内线掌握的信息,尝试破译密码。然后利用破译的密码,翻译电报中的加密信息。

输入格式

共三行,每行为一个长度在 1 到 100 之间的字符串。

第一行,为小 C 掌握的一条加密信息;第二行,为第一行的加密信息所对应的信息; 第三行,为 R 国司令部要求小 C 翻译的加密信息。

输入数据保证所有字符串仅由大写字母 A~Z 构成,且第一行长度与第二行相等

输出格式

共一行。若破译密码停止时出现 2、3 两种情况,请你输出 Failed; 否则请输出利用密码翻译电报中加密信息后得到的原信息。

数据范围

NOIP2009 提高组第一题。时限 1 秒,内存 128000 KB(125 MB)。

输入输出样例

输入

AA
AB
EOWIE

输出

Failed

原信息中的字母 AB 对应相同的密字 ⇒ 违反第 3 条规则,输出 Failed

输入

QWERTYUIOPLKJHGFDSAZXCVBN
ABCDEFGHIJKLMNOPQRSTUVWXY
DSLIEWO

输出

Failed

字母 Z 在原信息中没有出现 ⇒ 停在第 2 种状态,输出 Failed

输入

MSRTZCJKPFLQYVAWBINXUEDGHOOILSMIJFRCOPPQCEUNYDUMPP
YIZSDWAHLNOVFUCERKJXQMGTBPPKOIYKANZWPLLVWMQJFGQYLL
FLSO

输出

NOIP

★ 正常破译出来的那一组。

1★ 一句话:建一张双向的字母表,三条规则一条都不能漏

★★ 题面那三条规则,每一条都对应一件要检查的事
题面 要检查什么
「每个字母只对应一个唯一的密字」 同一个原文字母 x 只能有一个密字 y
★「不同的字母对应不同的密字 同一个密字 y 只能来自一个原文字母 x
「26 个字母均出现过 A~Z 每一个都得在原信息里出现

⇒ ★★ 前两条是同一件事的两个方向(这张表要是个一一对应),漏掉哪个方向都会放过一整类输入; 第三条和前两条毫无关系,是另一件必须单独写的检查。

⚠ 读入用 cin >> a >> b >> c 读三个「词」就好 —— 官方样例的输入里真的带着尾随空格getline 反而要自己去 trim。

p1071.cpp★ 正解:enc / dec 两张表 + 覆盖检查
// P1071 潜伏者 —— 正解:建一张双向的字母表,三种「无解」一个都不能漏
//
// ★ 题面把编码规则写成了三条,而**每一条都对应一件要检查的事**:
// ② 加密就是把原信息里的字母换成它的密字 -> 同一个原文字母 x 只能有一个密字 y;
// ③ 不同的字母对应不同的密字 -> 同一个密字 y 只能来自一个原文字母 x;
// ①「A~Z 全部出现过」才算破译完成 -> 26 个字母**都要**在原信息里出现过。
// ⇒ ★★ 前两条是**双向**的,漏掉哪一个方向都会放过一整类输入(见 p1071One.cpp);
// 第三条则和前两条完全无关,是另一件事(见 p1071Cover.cpp)。
//
// ⚠ 输入用 `cin >> a >> b >> c` 读三个「词」,这样行末的空格和换行怎么摆都无所谓
// —— 官方样例的输入里**真的带着尾随空格**。
#include <bits/stdc++.h>
using namespace std;
int main() {
string a, b, c;
if (!(cin >> a >> b >> c)) return 0;
if (a.size() != b.size()) { printf("Failed\n"); return 0; }
int enc[26], dec[26];
memset(enc, -1, sizeof(enc));
memset(dec, -1, sizeof(dec));
for (size_t i = 0; i < b.size(); i++) {
int x = b[i] - 'A'; // 原文字母
int y = a[i] - 'A'; // 它的密字
if (enc[x] != -1 && enc[x] != y) { printf("Failed\n"); return 0; } // 一个 x 两个 y
if (dec[y] != -1 && dec[y] != x) { printf("Failed\n"); return 0; } // 一个 y 两个 x
enc[x] = y; dec[y] = x;
}
for (int x = 0; x < 26; x++)
if (enc[x] == -1) { printf("Failed\n"); return 0; } // 有字母没出现过
string out;
for (char ch : c) out.push_back((char)('A' + dec[ch - 'A']));
printf("%s\n", out.c_str());
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

2⚠ 三个错法,各拆掉三条规则里的一条

✗ 错法一:只查了一个方向(漏掉「不同字母对应不同密字」)
   if (enc[x] != -1 && enc[x] != y) Failed;    // ✓ 一个 x 只能有一个 y
   // ⚠ 少了这一句:if (dec[y] != -1 && dec[y] != x) Failed;
   enc[x] = y; dec[y] = x;

★ 触发条件要三件事同时成立enc 方向自洽 + dec 方向有冲突 + 26 个字母全出现过。 ⇒ 下面会看到,这个形状顺手写的生成器一辈子造不出来

p1071One.cpp✗ 错法一:只查一个方向
// P1071 ✗ 错法一:只查了一个方向
//
// ⚠ 题面第 ③ 条是**两句话**:「每个字母只对应一个唯一的密字」+「**不同的字母对应不同的密字**」。
// 只写前一句(`enc[x]` 一致)就漏掉了后一句 —— 两个不同的原文字母**可以**被映到同一个密字。
// ★ 官方**样例一**考的正是这个:密文 `AA` ↔ 原文 `AB` ⇒ A 和 B 的密字都是 A。
// ★ 触发条件:**enc 方向自洽、可 dec 方向有冲突、而且 26 个字母都出现过**。
#include <bits/stdc++.h>
using namespace std;
int main() {
string a, b, c;
if (!(cin >> a >> b >> c)) return 0;
if (a.size() != b.size()) { printf("Failed\n"); return 0; }
int enc[26], dec[26];
memset(enc, -1, sizeof(enc));
memset(dec, -1, sizeof(dec));
for (size_t i = 0; i < b.size(); i++) {
int x = b[i] - 'A', y = a[i] - 'A';
if (enc[x] != -1 && enc[x] != y) { printf("Failed\n"); return 0; }
enc[x] = y; dec[y] = x; // ⚠ 少了「一个 y 只能来自一个 x」那一句
}
for (int x = 0; x < 26; x++) if (enc[x] == -1) { printf("Failed\n"); return 0; }
string out;
for (char ch : c) out.push_back((char)('A' + dec[ch - 'A']));
printf("%s\n", out.c_str());
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
✗ 错法二:冲突查全了,忘了「26 个字母都要出现过」

★ 触发条件:没有任何冲突,但 26 个字母没出全。官方样例二考的正是这个(原信息里没有 Z)。 ⚠ 而它错得很难看:没破译出来的那些位置只能瞎打一个字符 —— 屏幕上几乎看不出来,评测机只会给你一个 WA。

p1071Cover.cpp✗ 错法二:忘了覆盖检查
✗ 错法三:两行读反了

题面的输入顺序是「第一行加密信息、第二行信息」—— 和直觉正好相反 (大多数人会以为「先给原文,再给密文」)。 ★ 触发条件:把两行交换之后,答案和原来不一样。 ⚠ 注意这不等于「一定错」:交换之后仍然可能双双 Failed,那就看不出来了。

p1071Rev.cpp✗ 错法三:把加密信息当成原文

3★★★ 这一页真正的功课:照题面随机造数据,300 轮全是 Failed

p1071Count.cpp★ 「26 个字母全出现」的概率 —— 容斥算的,精确值
// P1071 解析页上那几个「生成器为什么必须反着造」的数字的出处。
// ./p1071Count 人话版
// ./p1071Count csv 给 check:viz 用
//
// ★★ 它只回答一个问题,而这个问题决定了整页的对拍策略:
// **随机造一段长度 L 的原文,26 个字母全出现的概率有多大?**
// —— 这是「[生成器该照抄题面的比值](/sol/p1020/)」在这道题上的具体形态:
// 题面允许 L 小到 1、大到 100,而顺手写的生成器在 `1..100` 里均匀摇 L
// ⇒ 绝大多数轮次连 26 个字母都凑不齐,正解直接 Failed,**对拍在验零**。
//
// ⚠ 这个概率用容斥算是精确的(不用估、不用跑蒙特卡洛):
// P(全覆盖) = Σ_{k=0..26} (-1)^k C(26,k) ((26-k)/26)^L
#include <bits/stdc++.h>
using namespace std;
static double coverProb(int L) {
double p = 0;
for (int k = 0; k <= 26; k++) {
double term = 1;
for (int i = 0; i < k; i++) term = term * (26 - i) / (i + 1); // C(26,k)
term *= pow((26.0 - k) / 26.0, (double)L);
p += (k % 2 ? -term : term);
}
return p;
}
int main(int argc, char** argv) {
bool csv = argc > 1 && string(argv[1]) == "csv";
/* 顺手写的生成器:L 在 1..100 里均匀摇 ⇒ 平均覆盖概率 */
double avg = 0;
for (int L = 1; L <= 100; L++) avg += coverProb(L);
avg /= 100.0;
/* 期望要多长才凑得齐 26 个字母(优惠券收集问题)*/
double need = 0;
for (int i = 1; i <= 26; i++) need += 26.0 / i;
/* 最小的、覆盖概率超过一半的 L */
int half = 0;
for (int L = 26; L <= 100000; L++) if (coverProb(L) >= 0.5) { half = L; break; }
if (csv) {
printf("cover100,%.4f\n", coverProb(100) * 100.0);
printf("cover50,%.4f\n", coverProb(50) * 100.0);
printf("avg,%.4f\n", avg * 100.0);
printf("need,%.2f\n", need);
printf("half,%d\n", half);
return 0;
}
printf("① 随机原文长度 L,26 个字母全出现的概率:\n");
for (int L : {26, 50, 75, 100}) printf(" L = %3d -> %.4f%%\n", L, coverProb(L) * 100.0);
printf("② 顺手写的生成器(L 在 1..100 里均匀摇)平均覆盖概率只有 %.4f%%\n", avg * 100.0);
printf("③ 期望要 %.2f 个字母才凑得齐 26 种(优惠券收集);\n", need);
printf(" 而覆盖概率第一次过半,要 L = %d\n", half);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★★★ 题面那个 100,正好卡在优惠券收集的期望上

随机一段长度 L 的原文,26 个字母全出现的概率(容斥公式,精确值):

L 26 50 75 ★ 100(题面顶格)
全覆盖概率 0.0000% 0.7745% 21.4134% 58.2242%

★★★ 而凑齐 26 个字母期望要 100.21 个字母26 × H₂₆)—— 题面的长度上限正好是 100。 ⇒ 于是「26 个字母都出现过」这件事,在题面顶格时也只有一半多一点的机会成立; 而顺手写的生成器在 1..100 里均匀摇 L,平均覆盖概率只有 12.44% (覆盖概率第一次过半要 L = 94)。

⚠⚠ 更糟的还在后面:随机造的两行之间几乎必然撞出冲突照题面随机造三行,正解 300 轮全部输出 Failed ⇒ 三个错法多半也跟着一起 Failed,对拍记「通过」,而它验的是「一致有两种:都算对了,和都没算」)。

⇒ ★★ 出路只有一条:反着造 —— 先随机一个 26 字母的置换(那就是密码本), 再随机原文,拿置换加密出第一行。这样造出来的数据天生没有冲突。 (第 13 章 P1162第 40 章 P1888 那条的又一次。)

4★★★ 对拍:十五格「触发 ≡ 抓获」,而最深的那个坑只有一档抓得到

p1071Gen.cpp★ 生成器:档 1 起全是「反着造」
// P1071 的生成器:./p1071Gen 种子 [档位]
//
// ⚠⚠ 这道题最难写的不是正解,是生成器 —— 照题面「三行随机大写字母」造出来的数据,
// **正解几乎每一轮都输出 Failed**(随机两行必然撞出冲突、26 个字母也出不全)
// ⇒ 三个错法多半跟着一起 Failed,对拍在**验零**
// ([「一致有两种:都算对了,和都没算」](/sol/p1746/))。
// ⇒ ★★ 出路是**反着造**:先随机一个 26 字母的置换(那就是密码本),
// 再随机原文,拿置换加密出第一行 —— 这样造出来的数据天生没有冲突。
// ([第 13 章 P1162](/sol/p1162/)、[第 40 章 P1888](/sol/p1888/) 那条的又一次。)
//
// 档位:
// 0 ★ 顺手写的:三行随机大写字母(⚠ 前两行等长 —— 题面保证了这一条;正解几乎恒 Failed)
// 1 ⚠ 反着造:随机置换 + 随机原文 ⇒ 没有冲突,但 26 个字母**不一定**出全
// 2 ⚠ 反着造 + 保证 26 个字母全出现 ⇒ 正解一定输出翻译结果
// 3 ⚠⚠ 专门档:26 个字母全出现,**但故意让两个原文字母共用一个密字**
// (★ 这是 p1071One「只查一个方向」唯一现形的形状)
// 4 ★ 最终档:上面几档混着来
//
// ⚠ rng() 一律先落到具名变量再传参([第 24 章 P1776](/sol/p1776/) 那一跤)。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1;
int mode = argc > 2 ? atoi(argv[2]) : 0;
mt19937 rng(seed * 1000003u + 20260907u);
if (mode == 4) { unsigned r = rng() % 4u; mode = (int)r; } // 最终档:随机挑一档
auto randLen = [&](int lo, int hi) { unsigned r = rng() % (unsigned)(hi - lo + 1); return lo + (int)r; };
if (mode == 0) {
int len12 = randLen(1, 100); // ⚠ 题面保证「第一行长度与第二行相等」
for (int i = 0; i < 3; i++) {
int len = (i < 2) ? len12 : randLen(1, 100);
string s;
for (int k = 0; k < len; k++) { unsigned c = rng() % 26u; s.push_back((char)('A' + c)); }
printf("%s\n", s.c_str());
}
return 0;
}
/* 反着造:先摇一个置换当密码本 */
vector<int> perm(26);
for (int i = 0; i < 26; i++) perm[i] = i;
shuffle(perm.begin(), perm.end(), rng);
string plain;
if (mode >= 2) { // 保证 26 个字母全出现
for (int i = 0; i < 26; i++) plain.push_back((char)('A' + i));
int extra = randLen(0, 74);
for (int k = 0; k < extra; k++) { unsigned c = rng() % 26u; plain.push_back((char)('A' + c)); }
shuffle(plain.begin(), plain.end(), rng);
} else {
int len = randLen(1, 100);
for (int k = 0; k < len; k++) { unsigned c = rng() % 26u; plain.push_back((char)('A' + c)); }
}
if (mode == 3) { // ⚠ 让两个原文字母共用一个密字
unsigned u = rng() % 26u, v = rng() % 25u;
int x = (int)u, y = (int)v; if (y >= x) y++;
perm[y] = perm[x];
}
string cipher;
for (char ch : plain) cipher.push_back((char)('A' + perm[ch - 'A']));
int len3 = randLen(1, 100);
string third;
for (int k = 0; k < len3; k++) { unsigned c = rng() % 26u; third.push_back((char)('A' + c)); }
printf("%s\n%s\n%s\n", cipher.c_str(), plain.c_str(), third.c_str());
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

五档 × 300 轮

档位 正解答 Failed 的轮数 ✗ 只查一个方向 ✗ 忘了覆盖 ✗ 两行读反
0 ★ 顺手写的(三行随机字母) 300 0 9 0
1 ⚠ 反着造(置换 + 随机原文) 275 0 275 25
2 ⚠ 反着造 + 保证 26 字母全出现 0 0 0 298
3 ⚠⚠ 全覆盖 + 故意让两个原文字母共用一个密字 300 300 0 0
4 ★ 最终档(前四档混着来) 214 69 68 86
★★★ 四条读得出来的结论
  1. ★★★ 顺手写的那一档(档 0)三个错法几乎一个都抓不到,而原因是「300 轮全是 Failed」 —— 随机两行必然撞冲突、26 个字母也出不全 ⇒ 所有版本一起答 Failed。 ⇒ 这一页是「对拍在验零」最干净的一次:不是抓获率低,是这批数据根本没在问问题

  2. ★★★ 「只查一个方向」那个错法,只有档 3 抓得到,而档 3 的形状要三件事同时成立enc 自洽 + dec 冲突 + 26 字母全覆盖)。 ⚠ 顺手写的三档全是精确的 0 —— 这个形状必须照着触发条件专门造: 先摇一个置换,再故意把两个字母的密字改成同一个,同时保证原文覆盖全 26 个字母。 ⇒ 第 32 章 P1462 那条:抓不到时别加轮数,去想那条线在哪儿、然后照着它造。

  3. ★★ 档 2 和档 3 各自把两个错法打成能证的 0: 档 2(反着造 + 全覆盖)⇒ 没有冲突、也不缺字母 ⇒ 前两个错法检查的东西根本不会发生; 档 3(全覆盖 + 有 dec 冲突)⇒ 正解在冲突那一步就 Failed 了 ⇒ 覆盖检查那一句没轮到执行。 ⇒ ★ 第 7 章 P1638 那条:为一个 bug 精心造的档位,正是另一个 bug 的盲区。

  4. ★★★ 十五格「触发 ≡ 抓获」一个不差 —— 三条触发条件各是一句能写下来的话(见第 ② 步)。 ⚠ 而写这一页时踩过一次:档 0 第一版让三行长度各自随机, 于是前两行几乎总不等长 ——那违反了题面那句「第一行长度与第二行相等」, 所有版本在长度检查那一步就一起返回了。 ⇒ 顺手写的生成器也要守题面的每一句保证,否则量到的是一批题目根本不会给的输入。

5★★★ 官方三组样例:一种终止状态一组,可它们仍然放过了最深的那个错法

★★ 而放过的原因很具体:样例一被「覆盖检查」抢先了
✗ 只查一个方向 ✗ 忘了覆盖 ✗ 两行读反
样例一(冲突 → Failed 放过 放过 放过
样例二(缺字母 → Failed 放过 放过
样例三(正常 → NOIP 放过 放过

★ 出题人是照着三种终止状态各给了一组样例的 —— 这在本书里很少见 (隔壁 P1308 那两组各只打死一个错法)。 ⚠⚠ 可「只查一个方向」那个错法,三组样例一个都打不死,而原因干净得能一句话说完:

样例一的说明写着「AB 对应相同的密字」,可那组数据里只出现了 AB 两个字母 —— 于是「只查一个方向」的程序走到覆盖检查那一步照样输出 Failed它答对了,但理由是错的。

⇒ ★★★ 这是「这组样例在结构上问不出这个问题」最微妙的一次: 样例确实覆盖了那条规则,可它同时也满足另一条规则的失败条件, 于是错的程序被后一条救了。 ⇒ ★★ 判据也很便宜:看到一组数据同时踩中两个 Failed 分支,就该再造一组只踩一个的。

6★ 哪一版就已经能过了

★ 这道题只有一版,而它的检查清单正好是题面那三条
版本 结果 说明
p1071.cpp AC 两张表 + 一次覆盖检查,一共二十行
✗ 只查一个方向 WA 三组样例全放过,顺手写的对拍三档是精确的 0
✗ 忘了覆盖 WA 样例二就死
✗ 两行读反 WA 样例三就死

⇒ ★★ 一句话带走:这道题挂在提高组第一题的位置上,考的不是算法,是「把题面的每一句话都变成一行检查」 —— 而它最值得记住的一条,是那个「反着造」的动作: 照题面随机造三行,300 轮全是 Failed;先摇一个置换再造,才算真的在测。