usaco-3.2.5 魔板

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

把 8 个格子的颜色序列当作状态,三种操作是三个置换;在 8!=40320 个状态上做 BFS,按 A、B、C 的顺序扩展,目标第一次被生成就是最短且字典序最小的操作串。

OJ: roj

题目 ID: 2050

难度:普及+/提高-

标签:搜索BFS队列python

创建: 2026-10-01 05:06

更新: 2026-10-07 13:50

形式化题目

魔板是 2×42 \times 4 的格子,每格一种颜色,8 种颜色用 1∼81 \sim 8 表示。 从左上角出发顺时针读出的 8 个数构成一个状态,例如基本状态是 (1,2,3,4,5,6,7,8)(1,2,3,4,5,6,7,8),它对应

12348765 \begin{matrix} 1 & 2 & 3 & 4\\ 8 & 7 & 6 & 5 \end{matrix}

有三种操作(都只做置换,不改变数字集合):

操作 含义
A 交换上下两行
B 把最右一列插入最左(等价于每行循环右移一位)
C 中央 2×22 \times 2 顺时针旋转

给定一个目标状态,求从基本状态到它的长度最短的操作序列; 若有多条同样短的序列,取字典序最小的那条(按 A < B < C 比较)。

输入是目标状态顺时针读出的 8 个数,输出最短长度与对应的操作串。

读法约定:序列的前 4 位是上行从左到右,后 4 位是下行从右到左。 也就是说把序列画回魔板时,上行正着读、下行倒着读。这个约定决定了下面 三条操作公式的写法。

样例

输入 2 6 8 4 5 7 3 1,对应魔板

text
2 6 8 4
1 3 7 5

答案是 7 步,操作串 BCABCCB。逐步推演如下(每一步只列出变化后的魔板):

text
起点          1 2 3 4      序列 (1,2,3,4,5,6,7,8)
              8 7 6 5
--B-->        4 1 2 3      两行各右移一位
              5 8 7 6
--C-->        4 8 1 3      中央 2x2 顺时针转
              5 7 2 6
--A-->        5 7 2 6      上下交换
              4 8 1 3
--B-->        6 5 7 2
              3 4 8 1
--C-->        6 4 5 2
              3 8 7 1
--C-->        6 8 4 2
              3 7 5 1
--B-->        2 6 8 4      到达目标
              1 3 7 5

注意第 3 步做了 A 之后,魔板看起来是"上下交换",但顺时针读数要把两行各自逆序, 所以序列变成 (5,7,2,6,3,1,8,4)(5,7,2,6,3,1,8,4) 而不是简单的前后 4 位互换。这是本题最容易写错的地方。

正解

思路

先看最直白的做法。 题目要的是"最短 + 字典序最小",可以按长度从小到大枚举所有操作串:

text
for L = 0, 1, 2, ...:
    按字典序枚举所有长度为 L 的 A/B/C 串
    对每个串从头模拟一遍,检查是否得到目标

这个做法是对的——长度从小到大保证最短,同长度内按字典序遍历保证字典序最小—— 但它不可用。长度 LL 的串有 3L3^L 个,而本题答案最长可达 22, 322≈3.1×10103^{22} \approx 3.1 \times 10^{10},远超任何时限。

瓶颈在哪。 浪费有两个:

  1. 同一个状态被反复展开。 状态总数只有 8!=403208! = 40320(因为三种操作都是置换, 不会产生新数字),而长度 22 的操作串有 322≈3.1×10103^{22} \approx 3.1 \times 10^{10} 个, 平均每个状态被重复到达约 7.8×1057.8 \times 10^5 次。
  2. 每个候选串都从头重放。 明明只差最后一步,也要把前面 21 步重新走一遍。

于是应该换一个视角:把状态当节点、操作当边。

  • 节点:全部 8!=403208! = 40320 个排列(实测从基本状态出发确实能到达全部 40320 个);
  • 出边:每个节点恰好 3 条(A、B、C 在该状态上两两不同,已对全部 40320 个状态验证), 所以有向边总数恰好 E=3×40320=120960E = 3 \times 40320 = 120960;
  • 边权:全是 1,"最少操作数"就是无权图最短路。

无权最短路的标准工具就是 BFS:每个状态只入队一次,用一张访问表去重(代码里就是 parent 字典的键),4032040320 个状态扫完就停机。原来那个 O(3L)O(3^L) 的枚举被压成 O(8!)O(8!)。

剩下的关键问题:字典序怎么办。 BFS 天然只保证"最短",要同时保证"字典序最小", 必须守住两条纪律。

纪律一:每个状态生成儿子时,必须按 A、B、C 的顺序。

把长度恰好为 kk 的操作串按字典序排序,等价于把 kk 位三进制数按大小排序: 第 1 位最重要,第 2 位次之……所以"字典序"就是"逐层比较"。 可以归纳证明:

  • 第 0 层只有空串,按字典序入队;
  • 设第 kk 层的状态按各自路径的字典序入队。第 k+1k+1 层由第 kk 层依次出队、 每个状态按 A、B、C 生成得到,于是它的入队顺序正是 “{第 kk 层字典序串} × {A, B, C}”,也就是长度 k+1k+1 的串按字典序排列。

因此每一层内部,队列顺序就是路径字典序。目标第一次被生成时,生成它的路径 在同一层里最早,也就是所有等长合法串中字典序最小的那条;而它所处的层又是 BFS 第一次碰到目标的层,所以长度也最短。

纪律二:第一次到达就锁定,不再更新。

if nxt in parent: continue 同时干了两件事——去重,以及保护"先到者字典序更小"这个事实。 后来者不可能更短,而按纪律一它的字典序也不可能更小,所以直接丢弃是正确的。

这两条不是可以随手写反的细节。 把扩展顺序改成 C、B、A,长度仍然正确, 但 8 组真实数据里有 5 组答案字符串变错(例如样例会从 BCABCCB 变成 BCBCCBA), 见 problem-analysis-workspace/verify_op_order.py。

第三个实现细节:不要顺手把路径字符串塞进队列。 很多写法会让队列同时携带 “走到当前状态的路径串”,这样每个状态都要复制一份字符串。本题最大长度 22 看着不大, 但 40320 个状态各存一份,在 Python 里的对象开销很可观。改成只存 parent[state] = (前驱状态, 操作名),命中目标后沿指针回溯一次即可——既省内存, 也让队列元素更轻。

把三条操作写成序列公式。 状态用 8 元组 ss 表示,s0…s3s_0 \ldots s_3 是上行从左到右、 s4…s7s_4 \ldots s_7 是下行从右到左。三种操作就是:

A(s)=(s7,s6,s5,s4,s3,s2,s1,s0)B(s)=(s3,s0,s1,s2,s5,s6,s7,s4)C(s)=(s0,s6,s1,s3,s4,s2,s5,s7) \begin{aligned} A(s) &= (s_7, s_6, s_5, s_4, s_3, s_2, s_1, s_0)\\ B(s) &= (s_3, s_0, s_1, s_2, s_5, s_6, s_7, s_4)\\ C(s) &= (s_0, s_6, s_1, s_3, s_4, s_2, s_5, s_7) \end{aligned}
  • AA 交换上下两行后,两行的顺时针方向都反了,所以各自逆序,等于把整个序列倒过来;
  • BB 是"每行循环右移一位":上行 s0s1s2s3→s3s0s1s2s_0 s_1 s_2 s_3 \to s_3 s_0 s_1 s_2; 下行的从左到右读法是 s7s6s5s4s_7 s_6 s_5 s_4,右移后变成 s4s7s6s5s_4 s_7 s_6 s_5, 再倒回去写就得到新序列的后 4 位 s5s6s7s4s_5 s_6 s_7 s_4;
  • CC 只动中央四格,在序列上就是下标 1,2,5,61, 2, 5, 6 四个位置顺时针轮换。

可以拿基本状态口算验一下 BB:代入 s=(1,2,3,4,5,6,7,8)s = (1,2,3,4,5,6,7,8) 得到 (4,1,2,3,6,7,8,5)(4,1,2,3,6,7,8,5), 画回魔板是 4 1 2 34\,1\,2\,3 / 5 8 7 65\,8\,7\,6,与题面的 B 示范一致。

算法总览。

text
parent = {基本状态: None}, queue = [基本状态]
while queue 非空:
    state = queue.popleft()
    for (name, op) in [(A, apply_a), (B, apply_b), (C, apply_c)]:   # 顺序即字典序
        nxt = op(state)
        if nxt 已访问: continue
        parent[nxt] = (state, name)
        if nxt == 目标: 沿 parent 回溯,返回操作串
        queue.append(nxt)

复杂度为什么够。 状态数 8!=403208! = 40320 是常数上限,每个状态出队一次、扩展 3 条边, 总转移约 1.2×1051.2 \times 10^5 次。实测最坏的一组(答案长 22)耗时 0.045s、峰值 23.8MB, 对 1000ms / 128MB 分别有约 22 倍和 5 倍余量。

代码

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 05:07
# update_at: 2026-10-01 05:13

import sys
from collections import deque

# 状态统一用"顺时针取出的 8 个数字"表示:前 4 位是上行(按从左到右读,序列上却是从右往左),
# 后 4 位是下行(从左到右)。基本状态就是这个序列的字典序最小值 (1,2,3,4,5,6,7,8)。
IDENTITY = (1, 2, 3, 4, 5, 6, 7, 8)


def apply_a(state: tuple[int, ...]) -> tuple[int, ...]:
    """操作 A:交换上下两行,两行各自还需要左右翻转一次才符合顺时针读法。"""
    return state[4:][::-1] + state[:4][::-1]


def apply_b(state: tuple[int, ...]) -> tuple[int, ...]:
    """操作 B:每行循环右移一位(最右一列插入最左)。"""
    return (state[3],) + state[:3] + state[5:] + (state[4],)


def apply_c(state: tuple[int, ...]) -> tuple[int, ...]:
    """操作 C:中央四格顺时针旋转,只有序列下标 1、2、5、6 四个位置参与。"""
    return (state[0], state[6], state[1], state[3], state[4], state[2], state[5], state[7])


# 枚举顺序就是题目要求的字典序:A < B < C。BFS 逐层扩展,层内队列按路径字典序出队,
# 所以每个状态第一次被写入 parent 时,拿到的既是最近距离,也是同距离里字典序最小的路径。
OPS = (("A", apply_a), ("B", apply_b), ("C", apply_c))


def shortest_ops(target: tuple[int, ...]) -> str:
    """从基本状态出发 BFS,返回到达 target 的最短且字典序最小的操作序列。"""
    if target == IDENTITY:  # 不用动一步,直接返回,省掉一次完整 BFS
        return ""

    parent: dict[tuple[int, ...], tuple[tuple[int, ...], str] | None] = {IDENTITY: None}
    queue = deque([IDENTITY])

    while queue:
        state = queue.popleft()
        for name, op in OPS:
            nxt = op(state)
            if nxt in parent:  # 已经用更短或字典序更小的路径到达过
                continue
            parent[nxt] = (state, name)
            if nxt == target:  # 第一次生成即最优,可以立刻回溯
                steps: list[str] = []
                cur = nxt
                while parent[cur] is not None:
                    prev, op_name = parent[cur]
                    steps.append(op_name)
                    cur = prev
                return "".join(reversed(steps))
            queue.append(nxt)

    return ""  # 走不到这里:8! 个状态全部可达,目标一定会被生成


def solve() -> None:
    target = tuple(map(int, sys.stdin.buffer.read().split()))
    ops = shortest_ops(target)

    print(len(ops))
    # 题面要求除最后一行外每行 60 个字符,按 60 切块输出。
    print("\n".join(ops[i : i + 60] for i in range(0, len(ops), 60)))


if __name__ == "__main__":
    solve()

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 09:53
 * update_at: 2026-10-06 09:53
 */
// main.cpp:BFS 求魔板从基本状态到目标状态的最短、字典序最小操作序列。
#include <iostream>
#include <map>
#include <queue>
#include <string>

typedef long long ll;

// 三种操作在顺时针序列上的下标重排:新序列第 i 位取自旧序列 OPS[op][i] 位
const int OPS[3][8] = {
    {7, 6, 5, 4, 3, 2, 1, 0}, // A:交换上下两行,序列整体翻转
    {3, 0, 1, 2, 5, 6, 7, 4}, // B:最右一列插入最左列
    {0, 6, 1, 3, 4, 2, 5, 7}  // C:中央 2x2 顺时针旋转
};
const int POW9[8] = {1, 9, 81, 729, 6561, 59049, 531441, 4782969}; // 9 进制各位权值

std::map<int, int> prev_state; // prev_state[状态] = 前驱状态编码,基本状态记为 -1
std::map<int, char> prev_op;   // prev_op[状态] = 到达该状态所用的操作字母
std::queue<int> bfs_queue;     // BFS 队列,元素为状态编码

// 把 8 位排列编码成 9 进制整数。每一位是 1~8、不含 0,所以编码唯一。
int encode(const int s[8]) {
    int code = 0;
    for (int i = 0; i < 8; i++) {
        code += s[i] * POW9[i];
    }
    return code;
}

// 把 9 进制编码还原成 8 位排列 s。
void decode(int code, int s[8]) {
    for (int i = 0; i < 8; i++) {
        s[i] = code % 9;
        code /= 9;
    }
}

int main() {
    int target[8];
    for (int i = 0; i < 8; i++) {
        std::cin >> target[i];
    }
    int start[8] = {1, 2, 3, 4, 5, 6, 7, 8}; // 基本状态
    int start_code = encode(start);
    int target_code = encode(target);

    // BFS:邻居固定按 A、B、C 顺序扩展,只在第一次到达时记录前驱。
    // 这样首次弹出目标时的路径既是最短的,也是同长度里字典序最小的。
    prev_state[start_code] = -1;
    bfs_queue.push(start_code);
    while (!bfs_queue.empty()) {
        int cur = bfs_queue.front();
        bfs_queue.pop();
        if (cur == target_code) {
            break;
        }
        int s[8];
        decode(cur, s);
        for (int op = 0; op < 3; op++) {
            int nxt[8];
            for (int i = 0; i < 8; i++) {
                nxt[i] = s[OPS[op][i]];
            }
            int nxt_code = encode(nxt);
            if (prev_state.find(nxt_code) == prev_state.end()) {
                prev_state[nxt_code] = cur;
                prev_op[nxt_code] = 'A' + op;
                bfs_queue.push(nxt_code);
            }
        }
    }

    // 从目标沿前驱回溯到基本状态,得到的是逆序的操作序列
    std::string answer;
    int cur = target_code;
    while (cur != start_code) {
        answer += prev_op[cur];
        cur = prev_state[cur];
    }
    ll len = answer.size();
    for (ll i = 0; i < len / 2; i++) {
        char tmp = answer[i];
        answer[i] = answer[len - 1 - i];
        answer[len - 1 - i] = tmp;
    }

    std::cout << len << "\n";
    // 除最后一行外每行输出 60 个字符
    for (ll i = 0; i < len; i += 60) {
        if (i > 0) {
            std::cout << "\n";
        }
        std::cout << answer.substr(i, 60);
    }
    return 0;
}

复杂度

设状态数 VV,转移数 EE。三个操作都是置换,状态永远是 1∼81 \sim 8 的排列,故

V⩽8!=40320,E=3V⩽120960. V \leqslant 8! = 40320, \qquad E = 3V \leqslant 120960 .
  • 时间:O(V+E)=O(8!)O(V + E) = O(8!),与输入规模无关(输入永远只有 8 个数), 是常数级。实测最大规模数据 0.045s。
  • 空间:parent 字典最多 40320 条 (状态 → (前驱, 操作名)) 记录, 队列同时最多存一层多一点的状态,回溯列表长度不超过 22。整体 O(8!)O(8!), 实测峰值 23.8MB。

作为对照,朴素枚举要模拟 322≈3.1×10103^{22} \approx 3.1 \times 10^{10} 个操作串, 是 BFS 转移次数(约 1.2×1051.2 \times 10^5)的 2.6×1052.6 \times 10^5 倍—— 这就是"每个状态只展开一次"带来的收益。

总结

  • 把"枚举操作串"换成"搜索状态图"。 状态只有 8!=403208! = 40320 个,而操作串有 3223^{22} 个。 一旦把重复的状态合并,"最短"就是无权图最短路,BFS 是最自然的工具。
  • 字典序靠"生成顺序"而不是"事后比较"实现。 按 A、B、C 的顺序扩展儿子, 层内队列顺序就自动等于路径字典序;第一次到达即锁定, 不需要保存路径串、也不需要任何字符串比较。把顺序写反(C、B、A)会让 5/8 组数据出错。
  • 只存前驱,不存路径。 parent[state] = (前驱, 操作名) 让每个状态的存储开销是常数, 命中后再回溯出答案,避免了 40320 份字符串对象。
  • 注意序列与魔板的对应方向。 顺时针读法使得下行是倒着读的, 所以操作 A 是"两段各自逆序",不是"前后 4 位互换"。
  • 状态数足够小,是本题能用 BFS 的前提。 8 个格子只有 8!8! 种排列, 这既是搜索空间的硬上限,也是复杂度的来源。

图示解析

从输入到答案只有一条主线:编码 → 建图 → BFS → 溯源。

text
  目标状态(8 个数)
        |
        v
  顺时针读数编码成 8 元 tuple        <- 可哈希,可直接当 dict 的键
        |
        v
  从基本状态 (1,2,3,4,5,6,7,8) 出发
  按 A、B、C 顺序生成儿子            <- 顺序 = 字典序
        |
        v
  目标第一次被生成
        |    距离最短:BFS 按层扩展
        |    字典序最小:同层中最早入队
        v
  沿 parent 指针回溯 -> 操作串

BFS 的分层情况(把 BFS 跑满全图时统计的各层状态数):

层 kk 本层新增状态数 累计状态数 说明
0 1 1 只有基本状态
1 3 4 A、B、C
2 7 11 有重复,去重后只剩 7
3 14 25
4 26 51
5 51 102
6 92 194
7 159 353 目标在此层第 92 个被生成,立即返回

可以看到两点:每一层新增数量会因去重而少于 3k3^k(第 2 层只有 7 个而不是 9 个), 这正是"每个状态只展开一次"在起作用;而第 7 层共有 159 个状态,目标是其中第 92 个 被生成的状态,意味着同层里还有 91 条路径字典序更小但不是答案—— BFS 一条都不需要额外比较,队列顺序已经替我们排好了。 (实际运行时一旦生成目标就立刻返回,不会把这一层剩下的 67 个状态也展开。)

若把 BFS 跑满全图:可达状态恰好 40320 个(即全部排列),最大距离为 22, 最远的状态共 5 个,其中之一是 (4,3,1,2,5,6,7,8)(4,3,1,2,5,6,7,8)。