usaco-4.1.4 解密牛语

GitHub跳转原题关系图返回列表

逆向枚举每步加密的 (C, O, W) 三元组做记忆化搜索,用字母守恒、首 C/末 W 固定、连续段必为目标子串、邻接修复下界四重剪枝判断能否还原目标句。

OJ: roj

题目 ID: 2064

难度:普及+/提高-

标签:搜索DFS剪枝字符串python

创建: 2026-10-01 05:56

更新: 2026-10-07 13:50

形式化题目

给定目标句 T=T= Begin the Escape execution at the Break of Dawn(∣T∣=47|T|=47,其中不含大写 C、O、W)和一个由它加密 kk 次(k⩾0k \geqslant 0,允许为 0)得到的串 ss。

一次加密把当前串切成四段并插入三个标记:

A+X+Y+Z  ⟶  A+C+Y+O+X+W+ZA + X + Y + Z \;\longrightarrow\; A + \mathrm{C} + Y + \mathrm{O} + X + \mathrm{W} + Z

即在四个切点处依次放入 C、O、W(保证 C 在 O 前、O 在 W 前),然后把「C 与 O 之间」和「O 与 W 之间」两段整体交换。判断 ss 能否还原为 TT:能则给出加密次数 kk,不能则给出否定答案。

下面用样例展示一次解密(逆向一步加密)的完整切分:密文的四段各自对应原文的哪一段,以及交换回去的结果。

分段 在样例密文中的切片 切片内容 还原后位置
AA(C 之前) Begin the EscC… Begin the Esc 第 1 段(不动)
YY(C 与 O 之间) C…BreO ution at the Bre 第 3 段
XX(O 与 W 之间) O…execW ape exec 第 2 段
ZZ(W 之后) W…Dawn ak of Dawn 第 4 段(不动)

把切分结果按 A+X+Y+ZA + X + Y + Z 重新拼接,恰好得到 Begin the Escape execution at the Break of Dawn。观察重点:AA 与 ZZ 原地不动,只有夹在标记里的 XX、YY 两段互换——这正是后文所有剪枝的来源;输入只有一行,表格中的切法对应该样例的唯一解,加密次数即 C 的个数 11。

正解

思路

朴素做法。 解密与加密互逆:任选一个 C(位置 ii)、它右侧的一个 O(位置 jj)、再右侧的一个 W(位置 kk),把 v[i+1..j]v[i{+}1..j] 与 v[j+1..k]v[j{+}1..k] 两段换回去、删掉三个标记,得到

t=v[..i]⏟A+v[j+1..k]⏟X+v[i+1..j]⏟Y+v[k+1..]⏟Zt = \underbrace{v[..i]}_{A} + \underbrace{v[j{+}1..k]}_{X} + \underbrace{v[i{+}1..j]}_{Y} + \underbrace{v[k{+}1..]}_{Z}

对 tt 递归,直到串等于 TT(成功)或无路可走(失败)。密文最多含 c=9c=9 组标记,每层要枚举 O(c3)O(c^3) 个三元组,朴素搜索的节点数是爆炸性的,直接写必然超时——必须找到能把分支砍掉的不变量。

剪枝 1:守恒量。 每步只删掉一个 C、一个 O、一个 W,其余字母只是换位置。因此若 ss 可解,必须满足:非标记字母的多重集与 TT 完全一致;count(C)=count(O)=count(W)=c\mathrm{count(C)}=\mathrm{count(O)}=\mathrm{count(W)}=c;∣s∣=47+3c|s| = 47 + 3c。另外 TT 里没有大写 C/O/W,所以加密次数恒等于 C 的个数,搜索只需判存在性,不必记录深度。

剪枝 2:首 C 之前、末 W 之后的字符永远不会动。 一次交换只影响所选 C 到 W 之间的区间,前缀 v[..i]v[..i] 与后缀 v[k+1..]v[k{+}1..] 整体保留;而首个 C 左侧不存在可选的 C,故任何一步都不可能把字符移过第一个 C,对称地也移不过最后一个 W。于是任意可解状态都必须满足「首个 C 之前的串是 TT 的前缀、末个 W 之后的串是 TT 的后缀」,递归入口逐层检查即可。

剪枝 3(最强):连续段必为 TT 的子串。 把串按 C/O/W 切成若干极大非标记段。每步的交换边界都落在标记上,所以一段要么整体被搬运、要么在标记被删除时与邻居合并——段内字母在所有后代中始终保持连续且同序。因此当前串的每一段都必须是 TT 的连续子串,候选三元组产生的新串(含三处合并出的新段)也一样。这一条把绝大多数分支在生成瞬间就判死,是搜索量从不可接受降到万级的关键。

剪枝 4:邻接修复次数下界。 把非标记序列里相邻的字母对与 TT 的相邻字母对比较,统计「TT 中不相邻」的缺口数。一次交换恰好改动 33 个邻接位置(两处段界加一处接缝),所以每步至多修复 33 个缺口;若缺口数超过剩余步数 3c3c,立即失败。

下表汇总四重剪枝在每层入口的检查顺序(先便宜后昂贵):

剪枝 入口检查 依据 作用
守恒 $ s =47+3c$,C/O/W 计数相等,字母多重集相同
固定前后缀 首个 C 前是 TT 前缀、末个 W 后是 TT 后缀 交换够不到两端 每层都强制与 TT 逐字对齐
连续段 每个极大非标记段 ∈substrings(T)\in \mathrm{substrings}(T) 段内字母永远连续 生成候选时即时砍掉绝大多数分支
邻接下界 缺口数 ⩽3c\leqslant 3c 一次交换改 3 个邻接位置 淘汰「还差很多步却没标记可删」的状态

最后用 @cache 记忆化所有失败状态:不同路径到达同一串时直接复用结论。四重剪枝叠加后,10 组真实测试数据的搜索树被压到 103∼10610^3 \sim 10^6 个节点,全部在秒级完成。

代码

Python 版:

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++ 版(同一算法):

cpp
/**
 * 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;
}

复杂度

最坏情况下仍是指数级:深度为 cc(C 的个数,⩽25\leqslant 25),每层枚举 O(c3)O(c^3) 个三元组,状态总数上界为各层可达串的个数。实践上由剪枝 3 主导,真实数据(c⩽9c \leqslant 9)访问约 10610^6 个节点,Python 实测最慢一组 1.4s、峰值内存约 183MB(本地 20s 限时内全部通过)。空间复杂度为 O(不同状态数×∣s∣)O(\text{不同状态数} \times |s|),由 @cache 保存失败状态产生。

总结

把加密过程倒过来就是「选一组 C/O/W 换回两段、删掉标记」的搜索问题,关键不在搜索本身,而在于找到状态合法的必要条件:字母多重集与长度守恒、首 C/末 W 两端固定、极大非标记段必须是目标串的连续子串、邻接缺口受 3c3c 限制。其中连续段性质(段内字母永远连续)剪枝威力最大,配合失败状态记忆化,让原本爆炸的搜索在秒级完成;由于目标句不含大写 C/O/W,答案中的加密次数直接等于输入里 C 的个数。