逆向枚举每步加密的 (C, O, W) 三元组做记忆化搜索,用字母守恒、首 C/末 W 固定、连续段必为目标子串、邻接修复下界四重剪枝判断能否还原目标句。
OJ: roj
题目 ID: 2064
难度:普及+/提高-
标签:搜索DFS剪枝字符串python
创建: 2026-10-01 05:56
更新: 2026-10-07 13:50
形式化题目
给定目标句 Begin the Escape execution at the Break of Dawn(
一次加密把当前串切成四段并插入三个标记:
即在四个切点处依次放入 C、O、W(保证 C 在 O 前、O 在 W 前),然后把「C 与 O 之间」和「O 与 W 之间」两段整体交换。判断
下面用样例展示一次解密(逆向一步加密)的完整切分:密文的四段各自对应原文的哪一段,以及交换回去的结果。
| 分段 | 在样例密文中的切片 | 切片内容 | 还原后位置 |
|---|---|---|---|
Begin the EscC… |
Begin the Esc |
第 1 段(不动) | |
C…BreO |
ution at the Bre |
第 3 段 | |
O…execW |
ape exec |
第 2 段 | |
W…Dawn |
ak of Dawn |
第 4 段(不动) |
把切分结果按 Begin the Escape execution at the Break of Dawn。观察重点:
正解
思路
朴素做法。 解密与加密互逆:任选一个 C(位置
对
剪枝 1:守恒量。 每步只删掉一个 C、一个 O、一个 W,其余字母只是换位置。因此若
剪枝 2:首 C 之前、末 W 之后的字符永远不会动。 一次交换只影响所选 C 到 W 之间的区间,前缀
剪枝 3(最强):连续段必为
剪枝 4:邻接修复次数下界。 把非标记序列里相邻的字母对与
下表汇总四重剪枝在每层入口的检查顺序(先便宜后昂贵):
| 剪枝 | 入口检查 | 依据 | 作用 |
|---|---|---|---|
| 守恒 | $ | s | =47+3c$,C/O/W 计数相等,字母多重集相同 |
| 固定前后缀 | 首个 C 前是 |
交换够不到两端 | 每层都强制与 |
| 连续段 | 每个极大非标记段 |
段内字母永远连续 | 生成候选时即时砍掉绝大多数分支 |
| 邻接下界 | 缺口数 |
一次交换改 3 个邻接位置 | 淘汰「还差很多步却没标记可删」的状态 |
最后用 @cache 记忆化所有失败状态:不同路径到达同一串时直接复用结论。四重剪枝叠加后,10 组真实测试数据的搜索树被压到
代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 06:54
# update_at: 2026-10-01 06:54
import sys
from functools import cache
T: str = "Begin the Escape execution at the Break of Dawn" # 目标原文
N: int = len(T) # 47
PAIRS: set[tuple[str, str]] = set(zip(T, T[1:])) # T 中全部相邻字母对
SORTED_T: list[str] = sorted(T) # T 的字母多重集
STRIP_COW: dict[int, None] = {ord(ch): None for ch in "COW"} # 删掉 C/O/W 的翻译表
@cache
def dfs(s: str) -> bool:
"""判断密文 s 能否解密成 T:逆向枚举一步加密的 (C, O, W),失败状态全部记忆化。"""
if s == T:
return True
c: int = s.count("C")
# 守恒剪枝:每解密一次长度 -3、C/O/W 各减 1,其余字母不变
if c == 0 or len(s) != N + 3 * c or s.count("O") != c or s.count("W") != c:
return False
# 首个 C 之前、末个 W 之后的字符在所有交换中都不可移动
p: int = s.find("C")
q: int = s.rfind("W")
if s[:p] != T[:p] or s[q + 1:] != T[N - (len(s) - q - 1):]:
return False
# 连续性剪枝:每段极大非标记子串只会被整段搬运,永远连续,故必是 T 的子串
if any(seg and T.find(seg) < 0 for seg in s.replace("O", "C").replace("W", "C").split("C")):
return False
# 邻接剪枝:非标记序列里每个「T 中不相邻」的字母对至少要一次交换才能修复,
# 一次交换只改 3 个邻接位置,故缺扣数不能超过剩余步数 3c
nm: str = s.translate(STRIP_COW)
if (N - 1) - sum((a, b) in PAIRS for a, b in zip(nm, nm[1:])) > 3 * c:
return False
# 枚举一组 (C, O, W):i < j < k,逆着一步加密还原
cs: tuple[int, ...] = tuple(i for i, ch in enumerate(s) if ch == "C")
os_: tuple[int, ...] = tuple(i for i, ch in enumerate(s) if ch == "O")
ws: tuple[int, ...] = tuple(i for i, ch in enumerate(s) if ch == "W")
for i in cs:
a: str = s[:i] # 前段 A
for j in os_:
if j <= i:
continue
y: str = s[i + 1:j] # C~O 之间的 Y
for k in ws:
if k <= j:
continue
# 交换两段并删掉标记:A + X + Y + Z
if dfs(a + s[j + 1:k] + y + s[k + 1:]):
return True
return False
def main() -> None:
data = iter(sys.stdin.buffer.read().splitlines())
s: str = next(data).decode()
# 其余字母的多重集是守恒量,先与目标比一次
if sorted(ch for ch in s if ch not in "COW") != SORTED_T:
print("0 0")
elif dfs(s):
print(f"1 {s.count('C')}") # 加密次数 = C 的个数
else:
print("0 0")
if __name__ == "__main__":
main()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-06 10:36
* update_at: 2026-10-06 10:36
*/
// 解密牛语:逆向枚举一步加密的 (C, O, W) 三元组做记忆化搜索。
// 四重剪枝:字母守恒、首 C/末 W 固定、非标记段必为 T 的子串、邻接缺口 <= 3c。
#include <iostream>
#include <string>
#include <vector>
#include <set>
#include <algorithm>
#include <utility>
typedef long long ll;
const std::string T = "Begin the Escape execution at the Break of Dawn"; // 目标原文
const int N = 47; // 目标原文长度
std::set<std::pair<char, char> > adj_pairs; // T 中所有相邻字母对
std::set<std::string> failed; // 记忆化:所有判定失败的密文状态
// 预处理 T 的相邻字母对集合,供邻接剪枝使用
void init_pairs() {
for (int i = 0; i + 1 < N; i++)
adj_pairs.insert(std::make_pair(T[i], T[i + 1]));
}
// 把串按 C/O/W 切开,检查每个极大非标记段是否都是 T 的连续子串。
// 依据:段内字母只会被整段搬运,永远连续,所以必须本来就是 T 的子串。
bool check_segments(const std::string &v) {
std::string seg = "";
for (int i = 0; i < (int)v.size(); i++) {
if (v[i] == 'C' || v[i] == 'O' || v[i] == 'W') {
if (!seg.empty() && T.find(seg) == std::string::npos) return false;
seg = "";
} else {
seg += v[i];
}
}
if (!seg.empty() && T.find(seg) == std::string::npos) return false;
return true;
}
// 判断密文 v 能否解密成 T
bool dfs(const std::string &v) {
if (v == T) return true; // 已经还原
if (failed.count(v)) return false; // 之前判定过失败
int c = 0, o = 0, w = 0;
for (int i = 0; i < (int)v.size(); i++) {
if (v[i] == 'C') c++;
if (v[i] == 'O') o++;
if (v[i] == 'W') w++;
}
// 守恒剪枝:每解密一次长度 -3、C/O/W 各减 1,其余字母不变
if (c == 0 || (int)v.size() != N + 3 * c || o != c || w != c) {
failed.insert(v);
return false;
}
// 固定前后缀剪枝:交换够不到首个 C 之前与末个 W 之后的字符
int p = v.find('C'); // 首个 C 的位置
int q = v.rfind('W'); // 末个 W 的位置
if (v.substr(0, p) != T.substr(0, p)) {
failed.insert(v);
return false;
}
if (v.substr(q + 1) != T.substr(N - ((int)v.size() - q - 1))) {
failed.insert(v);
return false;
}
// 连续段剪枝(最强):每个非标记段必须是 T 的子串
if (!check_segments(v)) {
failed.insert(v);
return false;
}
// 邻接缺口剪枝:删掉标记后,每一处「T 中不相邻」的相邻字母对
// 至少要一次交换才能修复,而一次交换只改 3 个邻接位置
std::string nm = "";
for (int i = 0; i < (int)v.size(); i++)
if (v[i] != 'C' && v[i] != 'O' && v[i] != 'W') nm += v[i];
int gaps = 0;
for (int i = 0; i + 1 < (int)nm.size(); i++)
if (!adj_pairs.count(std::make_pair(nm[i], nm[i + 1]))) gaps++;
if (gaps > 3 * c) {
failed.insert(v);
return false;
}
// 枚举一组 (C, O, W):位置 i < j < k,逆着一步加密把两段换回去
std::vector<int> cs, os, ws;
for (int i = 0; i < (int)v.size(); i++) {
if (v[i] == 'C') cs.push_back(i);
if (v[i] == 'O') os.push_back(i);
if (v[i] == 'W') ws.push_back(i);
}
for (int a = 0; a < (int)cs.size(); a++) {
int i = cs[a];
for (int b = 0; b < (int)os.size(); b++) {
int j = os[b];
if (j <= i) continue;
for (int e = 0; e < (int)ws.size(); e++) {
int k = ws[e];
if (k <= j) continue;
// 还原结果 = A + X + Y + Z(两段交换并删掉三个标记)
std::string t = v.substr(0, i) // A
+ v.substr(j + 1, k - j - 1) // X(原 O~W 段换到前面)
+ v.substr(i + 1, j - i - 1) // Y(原 C~O 段换到后面)
+ v.substr(k + 1); // Z
if (dfs(t)) return true;
}
}
}
failed.insert(v); // 所有出路的失败,记忆化
return false;
}
int main() {
init_pairs();
std::string s;
std::getline(std::cin, s);
// 去掉行尾可能混入的 \r 与空白
while (!s.empty() && (s[s.size() - 1] == '\r' || s[s.size() - 1] == ' '))
s.erase(s.size() - 1);
// 其余字母的多重集是守恒量,先与目标比一次,能快速排除大部分输入
std::string st = "";
for (int i = 0; i < (int)s.size(); i++)
if (s[i] != 'C' && s[i] != 'O' && s[i] != 'W') st += s[i];
std::sort(st.begin(), st.end());
std::string tt = T;
std::sort(tt.begin(), tt.end());
if (st != tt) {
std::cout << "0 0" << std::endl;
return 0;
}
if (dfs(s))
std::cout << "1 " << std::count(s.begin(), s.end(), 'C') << std::endl;
else
std::cout << "0 0" << std::endl;
return 0;
}复杂度
最坏情况下仍是指数级:深度为 @cache 保存失败状态产生。
总结
把加密过程倒过来就是「选一组 C/O/W 换回两段、删掉标记」的搜索问题,关键不在搜索本身,而在于找到状态合法的必要条件:字母多重集与长度守恒、首 C/末 W 两端固定、极大非标记段必须是目标串的连续子串、邻接缺口受