10000个状态,全部找到最优解——我的魔表求解器和上帝数对上了

魔表07:最少步数——把最优解交给约束求解器

回顾

上一篇文章中,我们将魔表 \(14 \times 30\) 矩阵 \(\mathbf{M}\)\(\mathbb{Z}_{12}\) 下做了高斯消元,得到了预变换法的核心公式:

\[\boxed{\; \mathbf{k} = \mathbf{P} \cdot \begin{pmatrix} \mathbf{E}\mathbf{b} - \mathbf{F}\mathbf{t} \\[2pt] \mathbf{t} \end{pmatrix} \qquad \mathbf{t} \in \mathbb{Z}_{12}^{16} \;} \]

其中 \(\mathbf{E}\)\(14 \times 14\))是行变换矩阵,\(\mathbf{P}\)\(30 \times 30\))是列置换矩阵,\(\mathbf{F}\)\(14 \times 16\))是自由列系数矩阵。\(\mathbf{t}\)\(16\) 维自由变量,可取任意 \(0 \sim 11\) 的值。

如上一篇结尾所说,本篇的目标是:

\(12^{16}\)\(\mathbf{t}\) 中,找到使 \(\|\mathbf{k}\|_0\) 最小的那个——也就是魔表的最优解


一、问题重述

1.1 我们要最小化什么

\(\mathbf{k}_{\text{pivot}} = \mathbf{E}\mathbf{b} - \mathbf{F}\mathbf{t}\)(14 维),\(\mathbf{k}_{\text{free}} = \mathbf{t}\)(16 维)。经过列置换 \(\mathbf{P}\) 还原后,最终操作向量 \(\mathbf{k}\) 的非零分量数等于两者非零分量数之和。

\(\|\cdot\|_0\)\(\ell_0\)范数(向量中非零分量的个数),我们的目标是:

\[\boxed{\; \min_{\mathbf{t} \in \mathbb{Z}_{12}^{16}} \; \bigl\|\mathbf{E}\mathbf{b} - \mathbf{F}\mathbf{t}\bigr\|_0 \;+\; \bigl\|\mathbf{t}\bigr\|_0 \;} \]

换言之:\(12^{16}\)\(\mathbf{t}\)(约 \(1.8 \times 10^{17}\) 种)中,找到让总操作数最少的那一个。

1.2 博客例子

继续用第二篇文章中的打乱状态。14 维简化状态为:

\[\mathbf{x}_{14} = (8, 2, 9,\; 4, 1, 0,\; 2, 4, 10,\; 9, 3, 11, 6, 3)^T \]

取负得 \(\mathbf{b} = -\mathbf{x}_{14} \bmod 12\)。通过预变换得 \(\mathbf{b}' = \mathbf{E}\mathbf{b}\)

\[\mathbf{b}' = (5,\; 1,\; 6,\; 7,\; 0,\; 10,\; 5,\; 3,\; 1,\; 2,\; 11,\; 8,\; 9,\; 2)^T \]

\(\mathbf{t} = \mathbf{0}\) 时得到特解:13 步(详见博客 06 第六节)。但这不是最优解——13 步中很可能有些操作是冗余的,可以通过选一个合适的 \(\mathbf{t} \neq \mathbf{0}\) 来消除。


二、F 矩阵的一个细节

在进入优化之前,有一个关于 \(\mathbf{F}\) 矩阵的性质值得注意。

2.1 实数域 vs 模 12

\(\mathbf{F}\)\(14 \times 16\) 矩阵。在实数域 \(\mathbb{R}\) 上,\(\mathbf{F}\) 的秩是 \(14\)(满行秩)。但在 \(\mathbb{Z}_{12}\) 下,秩只有 \(13\)——比满行秩少 \(1\)

这意味着 \(\mathbf{F}\)\(\mathbb{Z}_{12}\) 下有一个非零左零向量:

\[\mathbf{w} = (0,\;0,\;11,\;0,\;0,\;0,\;11,\;0,\;0,\;0,\;0,\;0,\;11,\;0)^T \]

满足 \(\mathbf{w}^T\mathbf{F} \equiv \mathbf{0} \pmod{12}\)(对所有 16 列)。

这也就是说,\(\mathbf{F}\) 矩阵的行2, 行6, 行12 线性相关

\[\mathbf{F}_{2} + \mathbf{F}_{6} + \mathbf{F}_{12} \equiv 0 \]

2.2 这意味着什么

对任意 \(\mathbf{t}\),左乘 \(\mathbf{w}^T\)

\[\begin{aligned} \mathbf{w}^T(\mathbf{E}\mathbf{b} - \mathbf{F}\mathbf{t}) &\equiv \mathbf{w}^T\mathbf{E}\mathbf{b} - \mathbf{w}^T\mathbf{F}\mathbf{t} \\ &\equiv \mathbf{w}^T\mathbf{E}\mathbf{b} \pmod{12} \end{aligned} \]

\(\mathbf{w}^T\mathbf{E}\mathbf{b}\) 的值不随 \(\mathbf{t}\) 改变。对于博客例子:

\[\mathbf{w}^T\mathbf{E}\mathbf{b} \equiv 11 \cdot b'_2 + 11 \cdot b'_6 + 11 \cdot b'_{12} \equiv 11(6 + 5 + 9) \equiv 11 \times 20 \equiv 4 \pmod{12} \]

由于结果 \(\mathbf{w}^T\mathbf{E}\mathbf{b} \neq 0\),所以本例对于任意的 \(\mathbf{t}\) 来说,\(\mathbf{E}\mathbf{b} - \mathbf{F}\mathbf{t}\) 中位置 \(\{2, 6, 12\}\) 至少有一个不能变为 \(0\)——也就是说至少 \(1\) 个 pivot 操作是强制的

需要说明的是,这个约束在第三节的 CP-SAT 建模中不需要显式添加,此条件被隐含满足。换句话说,\(\mathbf{w}\) 提供的是关于解空间结构的理论洞察,但在代码层面,它并不对应任何显式的约束。

这个信息对优化有方向性帮助,但本质上,我们还是需要系统地搜索最优 \(\mathbf{t}\)


三、用 CP-SAT 建模

3.1 为什么是约束求解器

\(\ell_0\) 最小化在整数域上是 NP 难问题。直接枚举 \(12^{16}\)\(\mathbf{t}\) 不可能。但现代约束规划(Constraint Programming, CP)求解器专门处理这类组合优化问题。

我们选择 Google 的 OR-Tools CP-SAT,一个成熟的有限域整数优化求解器。它内部实现了分支定界、冲突分析、域传播等算法,我们只需要把问题用数学约束表达出来,剩下的交给它。

3.2 变量与约束

引入三类变量:

变量 类型 含义
\(x_0,\dots,x_{13}\) 整数,\(0\sim11\) \(\mathbf{k}_{\text{pivot}}\) 的 14 个分量
\(t_0,\dots,t_{15}\) 整数,\(0\sim11\) \(\mathbf{t}\) 的 16 个分量
\(p_0,\dots,p_{13},\;q_0,\dots,q_{15}\) 布尔,\((0/1)\) 指示对应的 \(x\)\(t\) 是否为非零

约束 1:模等式的显式形式。 对于每行 \(i\),引入辅助整数变量 \(y_i\)(代表“借 12”的次数),将模等式写为普通线性等式:

\[x_i + \sum_{j=0}^{15} F_{ij} \cdot t_j = b'_i + 12 \cdot y_i \]

\(y_i\) 的值域并非任意。从 \(\sum_j F_{ij} \cdot t_j\) 的最大值可以推算 \(y_i\) 的精确上界(约 \(0 \sim 100\) 之间,取决于 \(F\) 各行的稀疏度)。等式两边都非负,避免了模运算引入的符号复杂性。

为什么不用 AddModuloEquality OR-Tools 提供了内置的模等式约束。但在实际测试中,我们发现它对大表达式(涉及 16 个 \(t\) 变量)的内部分解存在域传播问题——会将某些数学上正确的解错误地判定为不可行。显式等式 x + Ft = Eb + 12y 绕开了这个问题,并且数学上完全等价。

约束 2:指示变量的含义。 用半具体化约束(OnlyEnforceIf)建立 \(p_i \leftrightarrow (x_i \neq 0)\)\(q_j \leftrightarrow (t_j \neq 0)\) 的对应关系:

  • \(p_i = 0 \;\Longrightarrow\; x_i = 0\)
  • \(p_i = 1 \;\Longrightarrow\; x_i \neq 0\)

\(q_j\)\(t_j\) 同理。

目标函数:

\[\min \; \sum_{i=0}^{13} p_i \;+\; \sum_{j=0}^{15} q_j \]

整个模型 74 个变量、74 条约束(直接读取 CP-SAT 内部 proto 的统计值,含 14 个整数 \(x\)、16 个整数 \(t\)、14 个整数 \(y\)、14 个布尔 \(p\)、16 个布尔 \(q\) 以及等价的线性等式、指示约束和目标函数)。CP-SAT 可以高效处理。

3.3 关键代码

def solve_optimal(b_prime, F):
    """返回最小步数和对应的 x, t"""
    model = cp_model.CpModel()

    # 变量
    t = [model.NewIntVar(0, 11, f't{j}') for j in range(16)]
    x = [model.NewIntVar(0, 11, f'x{i}') for i in range(14)]
    y = [model.NewIntVar(0, y_max[i], f'y{i}') for i in range(14)]
    p = [model.NewBoolVar(f'p{i}') for i in range(14)]
    q = [model.NewBoolVar(f'q{j}') for j in range(16)]

    # 约束 1:x + Ft = b' + 12y
    for i in range(14):
        ft = sum(F[i][j] * t[j] for j in range(16))
        model.Add(x[i] + ft == b_prime[i] + 12 * y[i])

    # 约束 2:p ↔ x≠0, q ↔ t≠0
    for i in range(14):
        model.Add(x[i] == 0).OnlyEnforceIf(p[i].Not())
        model.Add(x[i] != 0).OnlyEnforceIf(p[i])
    for j in range(16):
        model.Add(t[j] == 0).OnlyEnforceIf(q[j].Not())
        model.Add(t[j] != 0).OnlyEnforceIf(q[j])

    # 目标
    model.Minimize(sum(p) + sum(q))

    # 求解
    solver = cp_model.CpSolver()
    status = solver.Solve(model)

    x_opt = [solver.Value(x[i]) for i in range(14)]
    t_opt = [solver.Value(t[j]) for j in range(16)]
    steps = int(solver.ObjectiveValue())
    return steps, x_opt, t_opt
点击查看求解器完整 Python 代码
"""
魔表最少步数求解器
==================
更新时间: 2026-07-08 21:25
作者: H_Elden (https://www.cnblogs.com/h-elden)
==================
基于 5 个原型 + P/Q 联动,直接生成 30 种基本操作。

核心改进(相比 6 原型旧方案):
  1. 原型从 6 个减为 5 个:v3 = R(v2),由 P/Q 联动推导,无需单独测量。
  2. 不再"先生成 64 列再去重",而是利用 P/Q 联动直接挑选 30 个代表操作。
  3. 原型向量直接用 14 维(去掉背面 4 角的冗余指针),无需后续删行。

操作记法:OptClock 记法
  每种操作用 5 个字符表示,如 UUDD u3'
  - 4 大写字母 = 4 个 pin 状态,顺序:左上(UL) 右上(UR) 左下(DL) 右下(DR)
    U = 弹起 (向你), D = 按下 (远离你)
  - 1 小写字母 = 转动哪个拨轮
    u = 转 U pin 旁的拨轮 (F 轮), d = 转 D pin 旁的拨轮 (B 轮)
  - 转动量:字母本身 = 顺时针 1 格;数字 = 顺时针 n 格;撇号 = 逆时针
    u3  = 顺时针 3 格
    u3' = 逆时针 3 格 (= 顺时针 9 格)
    u'  = 逆时针 1 格 (= 顺时针 11 格)

14 维坐标说明:
  18 维 = [正面 3×3 (9), 反面 3×3 (9)]
  背面 4 角 (B1,B3,B7,B9) 由不变量自动确定:B1=-F3, B3=-F1, B7=-F9, B9=-F7
  14 维 = [F1..F9, B2, B4, B5, B6, B8]  ← 去掉背面 4 角

数学模型:M_{14×30} · k ≡ -x (mod 12)
  - M:14×30 操作矩阵
  - k:30 维操作向量(每种操作的转动格数,0~11)
  - x:14 维指针状态(需要复原的打乱状态)

求解流程:
  5 原型 → C/D 生成 16 种 UL 向量 → P/Q 联动直接选 30 操作
  → 构建 M → Z_12 高斯消元得 E, F → CP-SAT 求 L0 最稀疏解
"""
import time
import numpy as np
from ortools.sat.python import cp_model


# ============================================================
#  第一部分:14↔18 维转换与对称变换算子 R, C, D
# ============================================================
# 14 维 = [F1..F9, B2, B4, B5, B6, B8]
# 背面 4 角由不变量补全:B1=-F3, B3=-F1, B7=-F9, B9=-F7

# 18 维中保留的行索引(去掉 B1=9, B3=11, B7=15, B9=17)
_ROW_14 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 12, 13, 14, 16]

def to_18(v14):
    """14 维 → 18 维(用不变量补全背面 4 角)"""
    v18 = np.zeros(18, dtype=int)
    v18[_ROW_14] = v14 % 12
    v18[9]  = (-v18[2]) % 12   # B1 = -F3
    v18[11] = (-v18[0]) % 12   # B3 = -F1
    v18[15] = (-v18[8]) % 12   # B7 = -F9
    v18[17] = (-v18[6]) % 12   # B9 = -F7
    return v18

def to_14(v18):
    """18 维 → 14 维(去掉背面 4 角)"""
    return v18[_ROW_14]

def to_matrices(v18):
    """18 维向量 → (正面 3×3, 反面 3×3)"""
    return v18[:9].reshape(3, 3), v18[9:].reshape(3, 3)

def to_vector(F, B):
    """(正面 3×3, 反面 3×3) → 18 维向量"""
    return np.concatenate([F.flatten(), B.flatten()])

def R_op(v14, k=1):
    """旋转 R^k:正面顺时针 k×90°,反面逆时针 k×90°(14 维输入输出)"""
    v18 = to_18(v14)
    F, B = to_matrices(v18)
    return to_14(to_vector(np.rot90(F, k=-int(k)), np.rot90(B, k=int(k))))

def C_op(v14):
    """正反互补:C(F,B) = H(-B, -F),H 为水平镜像(14 维输入输出)"""
    v18 = to_18(v14)
    F, B = to_matrices(v18)
    return to_14(to_vector(np.fliplr(-B), np.fliplr(-F)))

def D_op(v14):
    """对角镜像:正面转置,反面反对角转置(14 维输入输出)"""
    v18 = to_18(v14)
    F, B = to_matrices(v18)
    return to_14(to_vector(F.T, np.rot90(B, k=2).T))


# ============================================================
#  第二部分:5 个原型效果向量(14 维)
# ============================================================
# 固定拨轮为 UL、顺时针转 1 格,手动测量的 5 个基本原型。
# 第 6 个原型 v3 已被证明等于 R(v2)(P/Q 联动),不再单独测量。
#
# OptClock 记法:4 大写字母 = pin 状态 (UL UR DL DR)
#   U = 弹起, D = 按下; 小写 u = 转 U pin 旁拨轮, d = 转 D pin 旁拨轮
# 所有原型拨轮固定为 UL,故小写字母取决于 UL 的 pin 状态。

v0 = np.array([1,1,1,1,1,1,1,1,1,  0,0,0,0,0], dtype=int)       # UUUU u  无按钮按下
v1 = np.array([1,0,0,0,0,0,0,0,0,  11,0,11,11,0], dtype=int)    # DUUU d  只按 UL
v2 = np.array([1,1,0,1,1,1,1,1,1,  0,0,0,0,0], dtype=int)       # UDUU u  只按 UR
v4 = np.array([1,0,1,0,0,0,0,0,0,  11,11,11,11,0], dtype=int)   # DDUU d  按 UL+UR
v5 = np.array([1,0,0,0,0,0,0,0,1,  11,11,11,11,11], dtype=int)  # DUUD d  按 UL+DR

# v3 = R(v2):R 将 UL 拨轮的效果旋转到 UR 拨轮,
# 而 UR 与 UL 同在 Q 组(P/Q 联动),效果等价。
# 对应操作 UUUD u:只按 DR,转 U pin 旁拨轮。
v3 = R_op(v2) % 12  # ← 推导得出,无需测量


# ============================================================
#  第三部分:用 C, D 从 5 原型生成 16 种按钮状态(UL 拨轮)
# ============================================================
# 4 个按钮共 2^4 = 16 种按下/弹起组合。
# 每个原型与其补集(C 变换)交替排列,D 变换补上对角线镜像。
# label 使用 OptClock 记法:pin 串 + 小写字母。
# 小写 u/d 由 UL 的 pin 状态决定(U→u, D→d)。

ul_vecs, ul_lbls = [], []

def add(label, vec):
    ul_vecs.append(np.array(vec, dtype=int) % 12)
    ul_lbls.append(label)

add('UUUU u', v0)              # 无按钮
add('DDDD d', C_op(v0))        # 全按下
add('DUUU d', v1)              # 只按 UL
add('UDDD u', C_op(v1))        # 按 UR+DL+DR
add('UDUU u', v2)              # 只按 UR
add('DUDD d', C_op(v2))        # 按 UL+DL+DR
add('UUDU u', D_op(v2))        # 只按 DL
add('DDUD d', C_op(D_op(v2)))  # 按 UL+UR+DR
add('UUUD u', v3)              # 只按 DR (= R(v2))
add('DDDU d', C_op(v3))        # 按 UL+UR+DL
add('DDUU d', v4)              # 按 UL+UR
add('UUDD u', C_op(v4))        # 按 DL+DR
add('DUDU d', D_op(v4))        # 按 UL+DL
add('UDUD u', C_op(D_op(v4)))  # 按 UR+DR
add('DUUD d', v5)              # 按 UL+DR
add('UDDU u', C_op(v5))        # 按 UR+DL


# ============================================================
#  第四部分:P/Q 联动直接生成 30 种基本操作
# ============================================================
# P/Q 联动:在固定按钮状态下,四个拨轮分成两个等价组——
#   B 轮(按下按钮对应的拨轮,P 组):组内任意拨轮效果相同 → OptClock 小写 d
#   F 轮(弹起按钮对应的拨轮,Q 组):组内任意拨轮效果相同 → OptClock 小写 u
#
# 因此每种按钮状态只需取至多 2 个代表拨轮(1 个 B 轮 + 1 个 F 轮):
#   UUUU u(全弹起)→ 只有 F 轮,4 个全等         → 1 种操作
#   DDDD d(全按下)→ 只有 B 轮,4 个全等         → 1 种操作
#   其余 14 种 → 1 个 F 轮 + 1 个 B 轮            → 2 种操作
#   合计:1 + 1 + 14×2 = 30 种
#
# 不再生成 64 列再去重,直接挑选代表。

# OptClock pin 串 → 4-bit 按钮编码
# pin 串顺序:UL UR DL DR;bit 编码:bit0=UL, bit1=UR, bit2=DR, bit3=DL
def pin_to_bits(pin):
    b = 0
    if pin[0] == 'D': b |= 1   # UL
    if pin[1] == 'D': b |= 2   # UR
    if pin[3] == 'D': b |= 4   # DR
    if pin[2] == 'D': b |= 8   # DL
    return b

def rotate_bits(bits, k):
    """4-bit 左旋转 k 位 (k>0 = R 方向: UL→UR→DR→DL)"""
    k = k % 4
    return ((bits << k) | (bits >> (4 - k))) & 0xF

wheels = ['UL', 'UR', 'DR', 'DL']  # R 旋转方向

# UL 向量查找表:pin bits → 14 维向量
lookup = {pin_to_bits(lbl[:4]): v for lbl, v in zip(ul_lbls, ul_vecs)}

# 旋转扩展公式:e_{S, R^k(UL)} = R^k(e_{R^{-k}(S), UL})
def effect_vector(S_bits, wheel_idx):
    """计算按钮集 S 在拨轮 wheel_idx 下的效果向量 (14 维, mod 12)"""
    k = wheel_idx
    S0_bits = rotate_bits(S_bits, -k)   # R^{-k}(S)
    vec14 = lookup[S0_bits]             # E(R^{-k}(S), UL)  14 维
    return R_op(vec14, k) % 12          # R^k(...)           14 维

# 判断拨轮在当前按钮状态下是 B 轮还是 F 轮
def is_b_wheel(pin, wheel):
    """拨轮 wheel 对应的 pin 是否按下 (D)"""
    idx = {'UL': 0, 'UR': 1, 'DL': 2, 'DR': 3}
    return pin[idx[wheel]] == 'D'

# 直接生成 30 种操作:每种按钮状态取各组首个代表拨轮
# unique_info: [(14维向量, OptClock label), ...]
# label = pin串 + 空格 + 小写字母 (u 或 d)
unique_info = []

for label, _ in zip(ul_lbls, ul_vecs):
    pin = label[:4]
    S_bits = pin_to_bits(pin)
    seen_B = False  # 是否已取 B 轮代表
    seen_F = False  # 是否已取 F 轮代表

    for k, w in enumerate(wheels):
        if is_b_wheel(pin, w):
            if not seen_B:
                seen_B = True
                unique_info.append((effect_vector(S_bits, k), f"{pin} d"))
        else:
            if not seen_F:
                seen_F = True
                unique_info.append((effect_vector(S_bits, k), f"{pin} u"))

assert len(unique_info) == 30, f"预期 30 种操作,实际 {len(unique_info)}"

# 30 种操作的 OptClock label 列表
opt_labels = [info[1] for info in unique_info]


# ============================================================
#  第五部分:构建 14×30 矩阵 M
# ============================================================
# 原型向量已经是 14 维,M 矩阵直接构建,无需删行。

M = np.zeros((14, 30), dtype=int)
for c, (vec, _) in enumerate(unique_info):
    M[:, c] = vec


# ============================================================
#  第六部分:Z_12 高斯消元 → E, F, col_perm
# ============================================================
# 将 M 消元为 [I | F] 形式,同时记录行变换 E 和列置换 col_perm。
# 模 12 下 {1,5,7,11} 自逆,可直接作为主元归一用的乘法逆元。

m, n = M.shape  # 14, 30
E_mat = np.eye(m, dtype=int)
A_work = M.copy() % 12
col_perm = list(range(n))  # col_perm[新位置] = 原始列号

row = 0
for col in range(n):
    if row >= m:
        break
    # 在剩余子矩阵中找非零主元
    found_r, found_c = None, None
    for c2 in range(col, n):
        for r in range(row, m):
            if A_work[r, c2] != 0:
                found_r, found_c = r, c2
                break
        if found_r is not None:
            break
    if found_r is None:
        continue

    # 列交换
    if found_c != col:
        A_work[:, [col, found_c]] = A_work[:, [found_c, col]]
        col_perm[col], col_perm[found_c] = col_perm[found_c], col_perm[col]
    # 行交换
    if found_r != row:
        A_work[[row, found_r]] = A_work[[found_r, row]]
        E_mat[[row, found_r]] = E_mat[[found_r, row]]

    # 主元归一
    pivot = A_work[row, col]
    inv = pow(int(pivot), -1, 12)
    A_work[row] = (A_work[row] * inv) % 12
    E_mat[row] = (E_mat[row] * inv) % 12

    # 消去其他行
    for r in range(m):
        if r != row and A_work[r, col] != 0:
            factor = A_work[r, col]
            A_work[r] = (A_work[r] - factor * A_work[row]) % 12
            E_mat[r] = (E_mat[r] - factor * E_mat[row]) % 12
    row += 1

# 自由列系数矩阵 F (14×16)
F_mat = A_work[:14, 14:] % 12
F_list = [[int(F_mat[i, j]) for j in range(16)] for i in range(14)]

# y 变量的紧界(减少搜索空间)
_max_ft = [sum(F_list[i][j] * 11 for j in range(16)) for i in range(14)]
_y_max = [int((11 + mf) / 12) for mf in _max_ft]


# ============================================================
#  第七部分:CP-SAT 最少步数求解函数
# ============================================================
# 将通解 k = P⁻¹·[x; t] 代入 M·k ≡ b (mod 12) 后,
# 最少步数 = min ||k||_0 = min (非零 x 个数 + 非零 t 个数)
# 用布尔指示变量 p_i, q_j 表示 x_i, t_j 是否非零,最小化 sum(p) + sum(q)。

def solve_optimal(Eb_list, F_list, y_max):
    """CP-SAT 求解最少步数
    输入:Eb_list = E·b (mod 12) 的 14 个分量
         F_list  = 自由列系数矩阵 (14×16)
         y_max   = y 变量的上界
    返回:(steps, x_opt, t_opt, status_name) 或 (None, None, None, status_name)"""
    model = cp_model.CpModel()

    # 变量
    t = [model.NewIntVar(0, 11, f't{j}') for j in range(16)]
    x = [model.NewIntVar(0, 11, f'x{i}') for i in range(14)]
    y = [model.NewIntVar(0, y_max[i], f'y{i}') for i in range(14)]
    p = [model.NewBoolVar(f'p{i}') for i in range(14)]
    q = [model.NewBoolVar(f'q{j}') for j in range(16)]

    # 约束 1:x + Ft = b' + 12y
    for i in range(14):
        ft = sum(F_list[i][j] * t[j] for j in range(16))
        model.Add(x[i] + ft == Eb_list[i] + 12 * y[i])

    # 约束 2:p ↔ x≠0, q ↔ t≠0
    for i in range(14):
        model.Add(x[i] == 0).OnlyEnforceIf(p[i].Not())
        model.Add(x[i] != 0).OnlyEnforceIf(p[i])
    for j in range(16):
        model.Add(t[j] == 0).OnlyEnforceIf(q[j].Not())
        model.Add(t[j] != 0).OnlyEnforceIf(q[j])

    # 目标:最少步数 = 非零操作数
    model.Minimize(sum(p) + sum(q))

    # 求解
    solver = cp_model.CpSolver()
    status = solver.Solve(model)

    if status in [cp_model.OPTIMAL, cp_model.FEASIBLE]:
        x_opt = [solver.Value(x[i]) for i in range(14)]
        t_opt = [solver.Value(t[j]) for j in range(16)]
        steps = int(solver.ObjectiveValue())
        return steps, x_opt, t_opt, solver.StatusName(status)
    return None, None, None, solver.StatusName(status)


def solve_state(x14):
    """求解初始状态 x14 的最少步数复原方案
    输入:x14 = 14 维指针状态向量 (0~11)
    返回:(steps, k_full, status) 或 (None, None, status)
      - steps:  最少步数
      - k_full: 30 维操作向量 (每种操作的转动格数 0~11)
      - status: 求解器状态字符串"""
    b14 = (-x14) % 12
    Eb = (E_mat @ b14) % 12
    Eb_list = [int(Eb[i]) for i in range(14)]

    steps, x_opt, t_opt, status = solve_optimal(Eb_list, F_list, _y_max)
    if steps is None:
        return None, None, status

    # 还原 30 维解向量 k
    k_perm = np.zeros(30, dtype=int)
    for i in range(14):
        k_perm[i] = x_opt[i]
    for j in range(16):
        k_perm[14 + j] = t_opt[j]

    k_full = np.zeros(30, dtype=int)
    for np_idx in range(30):
        orig = col_perm[np_idx]
        k_full[orig] = k_perm[np_idx]

    return steps, k_full, status


# ============================================================
#  第八部分:交互式求解
# ============================================================

def optclock_move(label, k):
    """OptClock label + 系数 k (0~11) → 完整转动记法;k=0 返回 None"""
    k = k % 12
    if k == 0:
        return None
    if k <= 6:
        return label if k == 1 else f"{label}{k}"
    else:
        ccw = 12 - k
        return f"{label}'" if ccw == 1 else f"{label}{ccw}'"

row_names_14 = ['F1','F2','F3','F4','F5','F6','F7','F8','F9','B2','B4','B5','B6','B8']

def main():
    """交互式求解入口"""
    print("=" * 60)
    print("  魔表最少步数求解器 (5 原型 · 30 操作 · 14 维)")
    print("=" * 60)
    print()
    print("OptClock 记法:UUDD u3' = pin状态(UUDD) + 转U轮逆时针3格")
    print("  U=弹起 D=按下 | u=转U轮 d=转D轮 | 撇号=逆时针")
    print()
    # 输出全部 30 种基本操作
    # print("30 种基本操作:")
    # print(f"  {'#':>2s}  {'操作':<8s}")
    # print("  " + "-" * 20)
    # for i, label in enumerate(opt_labels):
    #     print(f"  {i+1:>2d}  {label:<8s}")
    # print()
    print("输入格式:14 个 0~11 的数字,空格分隔")
    print(f"顺序:{' '.join(row_names_14)}")
    print("示例:8 2 9 4 1 0 2 4 10 9 3 11 6 3")
    print("输入 'q' 退出")
    print()

    while True:
        user_input = input("请输入 14 个数字 > ").strip()
        if user_input.lower() == 'q':
            break

        try:
            nums = [int(x) for x in user_input.split()]
        except ValueError:
            print("错误:请输入数字,空格分隔")
            continue

        if len(nums) != 14:
            print(f"错误:需要 14 个数字,你输入了 {len(nums)} 个")
            continue
        if any(n < 0 or n > 11 for n in nums):
            print("错误:每个数字必须在 0~11 之间")
            continue

        x14 = np.array(nums, dtype=int)

        # 求解
        print("求解中...", end=" ", flush=True)
        t0 = time.perf_counter()
        steps, k_full, status = solve_state(x14)
        elapsed = time.perf_counter() - t0
        print(f"状态: {status},耗时 {elapsed:.2f}s")

        if steps is not None:
            print(f"最少步数: {steps}")

            # 验证 M·k ≡ b (mod 12)
            b14 = (-x14) % 12
            result = (M @ k_full) % 12
            ok = np.all(result == b14)
            print(f"验证: {'通过' if ok else '失败'}")

            print()
            print("操作方案:")
            moves = []
            for op_idx in range(30):
                k = int(k_full[op_idx])
                if k != 0:
                    move = optclock_move(opt_labels[op_idx], k)
                    moves.append(move)
                    print(f"  {len(moves):2d}. {move}")
            print()
            print("紧凑格式:")
            print(' '.join(moves))
        else:
            print("求解失败")

        print()


if __name__ == '__main__':
    main()


四、博客例子:从 13 步到 9 步

将博客例子的 \(\mathbf{b}'\) 输入 CP-SAT 模型:

  • t = 0 特解:13 步
  • CP-SAT 最优解9 步,耗时约 0.14 秒,状态 OPTIMAL(已证明全局最优)

4.1 解的构成

CP-SAT 找到的 \(\mathbf{x}\)\(\mathbf{t}\) 分别为:

\[\begin{aligned} \mathbf{x} &= (0, 0, 8, 9, 0, 0, 0, 3, 0, 0, 11, 0, 0, 2)^T \\ \mathbf{t} &= (0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 5, 8, 10, 0, 0)^T \end{aligned} \]

\(\mathbf{x}\) 中有 \(5\) 个非零元,\(\mathbf{t}\) 中有 \(4\) 个非零元,合计 9 步。比 \(\mathbf{t} = \mathbf{0}\) 时的 13 步减少了 \(4\) 步。

\(\mathbf{x}, \mathbf{t}\) 按置换 \(\mathbf{P}\) 还原为 30 维的 \(\mathbf{k}\),然后从 30 种操作中提取非零分量:

步骤 按钮状态 拨轮 \(k_i\) 动作
1 只按 UL UL 8 逆时针 4 格
2 只按 UL UR 9 逆时针 3 格
3 按 UL, DL, DR(不按 UR) UR 3 顺时针 3 格
4 按 UL, UR, DR(不按 DL) DL 11 逆时针 1 格
5 按 UL, UR DR 2 顺时针 2 格
6 按 DL, DR DR 2 顺时针 2 格
7 按 UR, DR UR 5 顺时针 5 格
8 按 UL, DR UL 8 逆时针 4 格
9 按 UL, DR UR 10 逆时针 2 格

代入验证:\(\mathbf{M}\mathbf{k} \equiv \mathbf{b} \pmod{12}\) ✓。9 步可解此状态。

4.2 交叉验证

为确认这个 9 步解确实是全局最优,我们用一位独立开发者公开发布的最优求解器 OptClock 对同一状态进行求解。该求解器也返回了 9 步,转换为我们的 30 操作格式后,非零分量完全一致。其输出内容如下:

9 moves is optimal:
 DUDD u3 DDUD u' DUUU d4' DDUU u2 UUDD d2 UDUD d5 DUUU u3' DUUD u2' DUUD d4'
Optimal solution found in 0.46 seconds.

两个完全独立的实现得到了相同的 9 步解,互相印证。


五、大规模验证:与 cube20.org 对比

5.1 为什么要做大规模验证

我们 30 种操作模型得到的"最优步数"和 cube20.org 基于 15 个群生成元得到的“上帝数”,是否指向同一个分布?

这是检验我们模型正确性的终极测试。如果是,那么两种不同的操作模型就是等价的群表示。

5.2 实验设计

  • 随机生成 \(10{,}000\) 个合法状态(14 个独立坐标,每个 \(0 \sim 11\) 均匀随机)
  • 固定随机种子 np.random.seed(42) 以便复现
  • 每题限时 \(30\) 秒,用 CP-SAT 求全局最优
  • 需要 OPTIMAL 状态(已证明最优),不接受 FEASIBLE(仅找到解但未证明最优)

5.3 结果

在 i9-12900H 平台上,10,000 题总计耗时约 \(31\) 分钟:

总样本数:       10000
已证明最优:     10000 (100.0%)
超时/未确定:    0 (0.0%)
平均耗时:       185ms/题
最慢耗时:       1705ms

无一超时。全部 10,000 个状态都证得了全局最优解。

5.4 步数分布

步数 样本数 样本占比 cube20 总体占比 偏差
6 5 0.05% 0.03% +0.02%
7 57 0.57% 0.67% −0.10%
8 795 7.95% 7.91% +0.04%
9 4146 41.46% 41.13% +0.33%
10 4759 47.59% 47.76% −0.17%
11 238 2.38% 2.48% −0.10%

(距离 0~5 和 12 在总体中占比极低(合计 \(< 0.001\%\)),在 \(10{,}000\) 样本中不出现在预期之内。)

  • 样本加权平均:9.43
  • 总体加权平均(cube20):9.43

由下图对比饼状图也可以直观看出,我们抽取 10000 个样本测试的结果,和总体分布数据偏差极小。

distribution_pie

5.5 偏差有多小?

\(10{,}000\) 个随机样本中,步数 \(d\) 的样本比例 \(\hat{p}\) 近似服从正态分布,标准误:

\[SE = \sqrt{\frac{\hat{p}(1-\hat{p})}{n}} \]

以步数 9 为例(\(\hat{p} \approx 41.46\%\)\(n = 10{,}000\)):

\[SE = \sqrt{\frac{0.4146 \times 0.59}{10000}} \approx 0.0049 = 0.49\% \]

\(95\%\) 置信区间为 \(\hat{p} \pm 2 \times SE \approx \pm 1.0\%\)

上表中全部偏差都在 \(\pm 0.33\%\) 以内,远小于置信区间宽度。换言之:

\(0.5\%\) 的统计精度下,两种操作模型的最优步数分布完全一致。

这个结果还有另一层含义。cube20 的分布是用 BFS 穷举全部 \(12^{14}\) 个状态得到的"真实值"。我们的样本占比与真实分布高度吻合,说明我们 CP-SAT 求出的每一个最优步数都经得起统计检验。如果我们的算法存在系统性错误(比如经常返回次优解),分布就会整体偏大,根本不可能在 \(0.5\%\) 精度上对上。换言之,独立于任何求解器之外、通过统计一致性获得的证据,本身就验证了我们算法的正确性。

5.6 cube20 距离12 状态验证

除了随机状态,我们还从 cube20.org 公开发布的 dist12.txt(包含全部 \(39{,}248\) 个距离为 \(12\) 的“最远状态”)中提取了前 \(100\) 个,用我们的模型逐一求解。

距离 12 的“最远状态”比随机状态难得多——证明“不存在 11 步解”需要更深的搜索树。这 100 个状态平均每题 7.6 秒,全部 \(100\) 个总计 \(762\) 秒(约 \(13\) 分钟)。

全部 \(100\) 个状态都得到了 \(12\) 步解,异常数为 \(0\)。与 cube20 的上帝数结论完全吻合。

测试环境:Intel Core i9-12900H,16 GB RAM,Windows 11,Python 3.11.4,OR-Tools CP-SAT 9.15。CP-SAT 默认以单线程搜索为主,同一题目在不同 CPU 上的耗时主要取决于单核性能。


六、讨论:两种模型,一个分布

6.1 我们做了什么

\(30\) 种(button_set, wheel)操作构建 \(14 \times 30\) 矩阵 \(\mathbf{M}\),在 \(\mathbb{Z}_{12}\) 下消元得到 \(\mathbf{E}, \mathbf{P}, \mathbf{F}\) 三个预计算矩阵,将最少步数求解转化为一个约束优化问题,交给 CP-SAT 求解器进行计算。

6.2 cube20 做了什么

\(15\) 种群生成元(touch pattern)在 \(9\) 维 coset 坐标上做双向 BFS 搜索,穷举全部 \(12^{14}\) 个状态,确定了上帝数 \(= 12\) 和完整的距离分布。

6.3 为什么分布一致

操作集不一样(\(30\) vs \(15\)),坐标表示不一样(\(14\) 维 vs \(9\) 维 coset),求解方法不一样(CP-SAT vs BFS),但最优步数分布完美吻合。

原因在于:尽管操作的表象不同,它们生成的群是相同的——\(\mathbb{Z}_{12}^{14}\)。两组操作互为等价生成元。上帝数是群的性质,不随生成元的选取而改变。


七、系列终章:回顾与总结

七篇博客,从零开始探索一个看似简单的小玩具(魔表 / Rubik's Clock),最终抵达了它的数学核心。

  1. 魔表01——建立模 12 运算基础,发现操作可交换、效果可线性叠加
  2. 魔表02——将全部 64 种操作用线性方程组建模,得到 \(18 \times 64\) 矩阵
  3. 魔表03——利用旋转 R、补 C、对角镜像 D 三种对称性,将 64 种操作归约为 6 个原型
  4. 魔表04——用代码从 6 个原型生成完整的 64 列矩阵 A
  5. 魔表05——解剖矩阵:发现 P/Q 联动、不变量、空间分解,将模型简化为 \(14 \times 30\)
  6. 魔表06——在 \(\mathbb{Z}_{12}\) 下直接消元,得到预变换法和包含 16 个自由变量的通解公式
  7. 本文——将 L0 稀疏优化建模为约束规划问题,用 CP-SAT 求全局最优,大规模验证与 cube20 结论一致

核心脉络:

\[\text{机械结构} \;\longrightarrow\; \text{对称性} \;\longrightarrow\; \text{线性代数} \;\longrightarrow\; \text{最优解} \]

数学建模是核心——矩阵 \(\mathbf{M}\)、行变换 \(\mathbf{E}\)、列置换 \(\mathbf{P}\)、通解公式,这些都来自对魔表结构的理解。CP-SAT 只是最后一公里的工具——它帮我们找到了最优解,但真正让这一切成为可能的是前面六篇博文打下的数学基础。


参考资料

Tom Rokicki,Rubik's Clock God's Number is 12,cube20.org.

链接:https://cube20.org/clock/

OR-Tools CP-SAT Solver,Google.

链接:https://developers.google.com/optimization/cp/cp_solver

OptClock: an optimal Rubik's Clock solver,SpeedSolving 论坛.

链接:https://www.speedsolving.com/threads/optclock-optimal-rubiks-clock-solver.47747/

NP-hard 问题,百度百科.

链接:https://baike.baidu.com/item/NP-hard/10680083

posted @ 2026-07-28 11:07  H_Elden  阅读(73)  评论(0)    收藏  举报