把 8 个格子的颜色序列当作状态,三种操作是三个置换;在 8!=40320 个状态上做 BFS,按 A、B、C 的顺序扩展,目标第一次被生成就是最短且字典序最小的操作串。
OJ: roj
题目 ID: 2050
难度:普及+/提高-
标签:搜索BFS队列python
创建: 2026-10-01 05:06
更新: 2026-10-07 13:50
形式化题目
魔板是
有三种操作(都只做置换,不改变数字集合):
| 操作 | 含义 |
|---|---|
| A | 交换上下两行 |
| B | 把最右一列插入最左(等价于每行循环右移一位) |
| C | 中央 |
给定一个目标状态,求从基本状态到它的长度最短的操作序列; 若有多条同样短的序列,取字典序最小的那条(按 A < B < C 比较)。
输入是目标状态顺时针读出的 8 个数,输出最短长度与对应的操作串。
读法约定:序列的前 4 位是上行从左到右,后 4 位是下行从右到左。 也就是说把序列画回魔板时,上行正着读、下行倒着读。这个约定决定了下面 三条操作公式的写法。
样例
输入 2 6 8 4 5 7 3 1,对应魔板
2 6 8 4
1 3 7 5答案是 7 步,操作串 BCABCCB。逐步推演如下(每一步只列出变化后的魔板):
起点 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 之后,魔板看起来是"上下交换",但顺时针读数要把两行各自逆序,
所以序列变成
正解
思路
先看最直白的做法。 题目要的是"最短 + 字典序最小",可以按长度从小到大枚举所有操作串:
for L = 0, 1, 2, ...:
按字典序枚举所有长度为 L 的 A/B/C 串
对每个串从头模拟一遍,检查是否得到目标这个做法是对的——长度从小到大保证最短,同长度内按字典序遍历保证字典序最小——
但它不可用。长度
瓶颈在哪。 浪费有两个:
- 同一个状态被反复展开。 状态总数只有
(因为三种操作都是置换, 不会产生新数字),而长度 22 的操作串有 个, 平均每个状态被重复到达约 次。 - 每个候选串都从头重放。 明明只差最后一步,也要把前面 21 步重新走一遍。
于是应该换一个视角:把状态当节点、操作当边。
- 节点:全部
个排列(实测从基本状态出发确实能到达全部 40320 个); - 出边:每个节点恰好 3 条(A、B、C 在该状态上两两不同,已对全部 40320 个状态验证),
所以有向边总数恰好
; - 边权:全是 1,"最少操作数"就是无权图最短路。
无权最短路的标准工具就是 BFS:每个状态只入队一次,用一张访问表去重(代码里就是
parent 字典的键),
剩下的关键问题:字典序怎么办。 BFS 天然只保证"最短",要同时保证"字典序最小", 必须守住两条纪律。
纪律一:每个状态生成儿子时,必须按 A、B、C 的顺序。
把长度恰好为
- 第 0 层只有空串,按字典序入队;
- 设第
层的状态按各自路径的字典序入队。第 层由第 层依次出队、 每个状态按 A、B、C 生成得到,于是它的入队顺序正是 “{第 层字典序串} × {A, B, C}”,也就是长度 的串按字典序排列。
因此每一层内部,队列顺序就是路径字典序。目标第一次被生成时,生成它的路径 在同一层里最早,也就是所有等长合法串中字典序最小的那条;而它所处的层又是 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 元组
交换上下两行后,两行的顺时针方向都反了,所以各自逆序,等于把整个序列倒过来; 是"每行循环右移一位":上行 ; 下行的从左到右读法是 ,右移后变成 , 再倒回去写就得到新序列的后 4 位 ; 只动中央四格,在序列上就是下标 四个位置顺时针轮换。
可以拿基本状态口算验一下
算法总览。
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)复杂度为什么够。 状态数
代码
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++ 版(同一算法):
/**
* 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;
}复杂度
设状态数
- 时间:
,与输入规模无关(输入永远只有 8 个数), 是常数级。实测最大规模数据 0.045s。 - 空间:
parent字典最多 40320 条(状态 → (前驱, 操作名))记录, 队列同时最多存一层多一点的状态,回溯列表长度不超过 22。整体, 实测峰值 23.8MB。
作为对照,朴素枚举要模拟
总结
- 把"枚举操作串"换成"搜索状态图"。 状态只有
个,而操作串有 个。 一旦把重复的状态合并,"最短"就是无权图最短路,BFS 是最自然的工具。 - 字典序靠"生成顺序"而不是"事后比较"实现。 按 A、B、C 的顺序扩展儿子, 层内队列顺序就自动等于路径字典序;第一次到达即锁定, 不需要保存路径串、也不需要任何字符串比较。把顺序写反(C、B、A)会让 5/8 组数据出错。
- 只存前驱,不存路径。
parent[state] = (前驱, 操作名)让每个状态的存储开销是常数, 命中后再回溯出答案,避免了 40320 份字符串对象。 - 注意序列与魔板的对应方向。 顺时针读法使得下行是倒着读的, 所以操作 A 是"两段各自逆序",不是"前后 4 位互换"。
- 状态数足够小,是本题能用 BFS 的前提。 8 个格子只有
种排列, 这既是搜索空间的硬上限,也是复杂度的来源。
图示解析
从输入到答案只有一条主线:编码 → 建图 → BFS → 溯源。
目标状态(8 个数)
|
v
顺时针读数编码成 8 元 tuple <- 可哈希,可直接当 dict 的键
|
v
从基本状态 (1,2,3,4,5,6,7,8) 出发
按 A、B、C 顺序生成儿子 <- 顺序 = 字典序
|
v
目标第一次被生成
| 距离最短:BFS 按层扩展
| 字典序最小:同层中最早入队
v
沿 parent 指针回溯 -> 操作串BFS 的分层情况(把 BFS 跑满全图时统计的各层状态数):
| 层 |
本层新增状态数 | 累计状态数 | 说明 |
|---|---|---|---|
| 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 个被生成,立即返回 |
可以看到两点:每一层新增数量会因去重而少于
若把 BFS 跑满全图:可达状态恰好 40320 个(即全部排列),最大距离为 22,
最远的状态共 5 个,其中之一是