如果你第一次听说“让电脑自己还原魔方”,很容易冒出一种直觉方案:把所有可能的状态全部记录下来,然后让程序从中找到一条最短路径。这个思路不是不行,但它会很快撞上一堵墙。三阶魔方的合法状态数量大约是 43,252,003,274,489,856,000 种,换成更直观的说法,这个数字比地球上所有沙子的总数还要多出很多个数量级。你不可能在一台普通电脑上枚举全部状态,更不可能把它们全部存进内存。
所以,真正能跑起来的魔方求解程序,几乎都绕不开一类算法:启发式搜索。这几天我亲手把“让电脑自己还原魔方”这个小项目从零写了一遍,感触最深的一点是:启发式搜索的能力不在于“暴力尝试所有可能性”,而在于它用一个聪明的“方向感”,把巨大的搜索空间压缩到可计算的范围。这篇文章会从问题定义、状态表示、启发函数、搜索算法、常见坑和适用边界几个角度,把整个流程拆开讲清楚。
1. 先看一个看似无解的问题:电脑怎么知道下一手该转哪一面
1.1 状态爆炸让你没法“记住所有答案”
假设你想让电脑求解魔方,最朴素的做法是把魔方所有合法状态和对应的解法都存起来,输入一个状态,直接查表。但正如开头所说,三阶魔方的状态数是一个天文数字。
这个数字不是随便乘出来的。我们可以做一个大致估算:角块有 8 个,每个角块有 3 种方向;棱块有 12 个,每个棱块有 2 种方向。不做任何约束时,排列组合大概是 (8! \times 3^8 \times 12! \times 2^{12})。但魔方不是随意转动就能达到所有组合的,它有三个独立的群论约束:角块方向之和必须等于 0(系数 3),棱块方向之和必须等于 0(系数 2),角块和棱块的位置排列必须满足相同的奇偶性(系数 2)。最终除以 12,就得到约 4.33×10^19 种合法状态。
这个数字意味着什么?哪怕你每秒能处理十亿种状态,把全部状态过一遍也需要十几亿年。所以,任何依赖“全量枚举”或者“全量存储”的方案,在通用硬件上都不现实。
这不是魔方独有的问题。很多现实问题都有类似的特征:状态空间特别大,但每一步能做的操作非常有限。搜索算法要解决的,恰恰是“不在所有状态里找,而是用尽可能少的步骤找到目标”。
1.2 把魔方还原理解成一次图搜索
你可能会问:既然不能全部枚举,那电脑怎么知道下一步该转哪个面?
换个角度看,魔方还原可以抽象成一张隐式图。图中的每个节点就是一个合法的魔方状态,每条边就是一次转动操作,比如顺时针转白面、逆时针转红面等。目标节点就是“六个面都同色”的复原状态。
这样,问题就变成了:给定一个起点节点,如何在隐式图中找到一条到目标节点的路径?这是典型的图搜索问题。BFS(广度优先搜索)可以找到最短路径,但它在魔方这种度数固定、状态极大的图上会迅速耗尽内存。DFS(深度优先搜索)不需要太多内存,但很可能陷入一条永远走不到目标的死胡同。
我们需要的是“带有方向感”的搜索。这时候,启发式搜索就登场了。
在动手写代码之前,可以先记住一个判断:启发式搜索解决的不是“能不能找到解”,而是“在有限的时间和内存里找到可用的解”。这个区别,是整个项目最核心的思路转变。
2. 写代码前的第一件事:用颜色数组和转动函数定义魔方状态
2.1 一个够用的状态表示
手写魔方求解器,第一步不是写搜索,而是把魔方的状态变成程序里的数据。状态表示设计得好不好,直接影响后面所有逻辑的复杂度。
常用做法是定义一个 6×3×3 的三维数组,分别对应魔方的六个面:白、黄、红、橙、蓝、绿。每个面有 9 个位置,用字符串或整数表示颜色。比如:
# 演示用状态表示:face[face_id][row][col] = color_id # 例如 0-白,1-黄,2-红,3-橙,4-蓝,5-绿 cube = [ [["W", "W", "W"], ["W", "W", "W"], ["W", "W", "W"]], # 白面 [["Y", "Y", "Y"], ["Y", "Y", "Y"], ["Y", "Y", "Y"]], # 黄面 # ... 其余面 ]这种方式直观、好调试,但缺点是内存占用比较大。搜索过程中会生成大量新状态,如果每次都复制整个 6×3×3 数组,性能会受影响。因此很多实现会用整数编码,把每个小面的颜色编码成整数并压缩成一个大整数,既减少内存,也能更快比较状态是否相同。
对初学者来说,不必一上来就追求极致压缩。先用数组表示,把逻辑跑通,再考虑优化。如果你在搜索过程中发现内存涨得太快,再切换到整数编码也不迟。
2.2 动作怎么定义,决定了后面所有代码
状态表示之后是动作定义。魔方的基本动作是三种:某个面顺时针转 90 度、逆时针转 90 度、转 180 度。三阶魔方有六个面,所以基本动作通常记为:U、D、L、R、F、B,加上带撇号的逆时针动作,例如 U'、D',以及 180 度动作 U2 等。
你的代码里最少要实现一个apply_move函数:输入一个状态和动作,输出新的状态。这个函数是搜索过程中被调用最频繁的,一定要小心实现。
转动某个面时,不仅要改变这个面的 9 个格子,还要改变与它相邻的 12 个格子。最容易出错的点时方向搞反。建议先用一个固定的符号约定,例如“顺时针方向是从该面的正面看过去的方向”。写完后做一个自检:对同一个动作连续执行 4 次,应该回到原状态。
2.3 先写一个状态合法性与可逆性自检
我建议在写搜索之前,先写两个辅助函数:
is_solved(state):判断当前状态是否是复原状态。inverse_move(move):返回当前动作的逆动作。
这两个函数非常简单,但它们能帮你验证整个状态表示和动作定义是否可靠。一个很实用的自检方式是:任取一个打乱状态,应用一个动作后再应用它的逆动作,看是否回到原状态。如果这个测试失败,搜索算法再正确,跑出来也是错的。
注意:先不要急着写复杂的启发函数。状态表示和动作函数有问题时,整个搜索都会在错误的数据上进行,浪费大量时间。
3. 启发式搜索到底“启发”了什么
3.1 从 BFS 和 DFS 的无力感说起
如果没有启发信息,搜索算法只能盲目扩展节点。BFS 会按步数一层层往外扩,虽然能找到最短路径,但魔方的分支因子大概有 12 到 18(去除重复转动和反向转动后实际会更少,但数量级依然不小),扩展几层后节点数量就会爆炸。DFS 则可能走到一个很深的错误分支里,如果没有深度限制,甚至永远不会回头。
启发式搜索的核心,是给每个状态算一个“乐观估计值”:
[ f(n) = g(n) + h(n) ]
其中:
- (g(n)) 是从初始状态到当前状态已经花费的步数;
- (h(n)) 是从当前状态到目标状态还需要多少步的估计值;
- (f(n)) 是“经过这个状态的总代价”的估计值。
如果 (h(n)) 永远不超过实际需要的步数,也就是“乐观估计”,那么用它驱动的搜索可以保证一旦找到解,就是最优解。这样的启发函数被称为“可采纳的”。
3.2 启发函数:给搜索一个“还差多远”的估计
对于魔方来说,一个常见的启发函数思路是“子目标距离”。
比如,我们可以只看角块,忽略棱块,专门研究“把 8 个角块放对位置和方向需要多少步”。这个子问题的状态空间比完整魔方小很多。我们可以在程序启动前预先计算一个“模式数据库”:为所有可能的角块子状态保存到达目标状态的最少步数。这样在搜索完整魔方时,直接把当前状态的角块模式查表,得到的值就是真实需要步数的下界,因为它忽略棱块后只会让问题变简单,不会让问题变难。
同理,还能为棱块建一个模式数据库。最后取两者中的最大值,作为完整魔方的启发函数。这种启发函数是非常强的,配合 IDA* 搜索,可以在合理时间内解出三阶魔方。
相比之下,如果只是用一个简单的“每个贴纸到目标位置的距离之和”,往往高估实际步数,导致无法得到最优解,但也能在一定程度上引导搜索方向。对演示程序和学习算法来说,可以先从简单启发函数开始,再逐步升级到模式数据库。
3.3 一个演示用的简单启发函数,和它的局限
为了让你理解,我写一个演示用的“伪启发函数”:
def simple_heuristic(state): distance = 0 # 遍历每个面的每个小格 for face in range(6): for row in range(3): for col in range(3): # 计算这个小格的颜色是否等于目标颜色 # 如果不是目标位置,就加一个固定惩罚值 if state[face][row][col] != target[face][row][col]: distance += 1 return distance // 12 # 粗略折算成“步数”这个函数非常简单,但它明显不够精确。它没有真正建模转动关系,甚至可能高估实际步数。所以它只能用于演示,不能作为生产级求解器的核心启发函数。如果你想解三阶魔方,更可靠的方向还是模式数据库或两阶段算法。
经验:先接受“启发函数不够好”的事实,把整套搜索流程跑通,再回头优化启发质量。不要一开始就追求完美。
4. 真正能跑起来的是 IDA*,不是 A*
4.1 A* 的问题:内存先撑不住
很多人第一个接触的启发式搜索算法是 A*。它维护一个优先队列,每次扩展 (f) 值最小的节点,直到找到目标。A* 在八数码等小状态空间问题中表现非常好,方法简单,又能保证最优解。
但直接用它解魔方会出现一个实际问题:内存。A* 需要不断把新节点加入 open 表,一旦状态空间很大,内存会快速被占满。三阶魔方的状态空间足够大,A* 很容易在找到解之前耗尽内存。虽然 A* 有很多改进变体,但对魔方这类超高分支问题,纯内存版并不是首选。
4.2 IDA* 的核心循环:迭代加深
IDA*(Iterative Deepening A*)是 A* 的一种迭代加深版本,它牺牲了一部分重复计算,换来了极低的内存占用。
它的思路是:
- 设定一个阈值 (bound),初始值可以设为 (h(初始状态))。
- 对当前状态进行深度优先搜索,但只扩展 (f(n) \le bound) 的节点。
- 如果当前阈值下找不到解,就把阈值增大到这次搜索中出现的最小 (f) 值,然后重新开始。
每次搜索都会丢掉上一轮的信息,因此看起来有些浪费。但深度优先搜索本身内存只和深度相关,所以 IDA* 可以轻松跑得很深,而不必把海量节点放进内存。
对于魔方,IDA* 是很多手写求解器的基础框架。它和模式数据库配合,能在个人电脑上解决大量打乱状态,甚至接近最优解。
4.3 搜索中最重要的剪枝:排除刚走过的反向转动
在 IDA* 的 DFS 过程中,有一个几乎必须加的剪枝:不能在上一步之后立刻执行它的逆操作。
举个例子,如果上一步是“白面顺时针转动 90 度”,那么下一步再去执行“白面逆时针转动 90 度”,等于把刚才的操作撤销。这种来回抖动会成倍增加搜索量,但完全不可能对最终结果有帮助。
更进一步的剪枝是:如果上一步转的是 U 面,那么下一步最好不要继续转 U 面,而是转其他面。因为两次连续转同一个面,可以合并为一次 U2 或 U' 等更短的操作,这在搜索中属于重复分支。这个剪枝不改变最终解的可达性,却能明显压缩搜索树。
下面是一个 IDA* 的骨架伪代码,方便你理解整体流程:
def ida_star(initial, max_bound): bound = heuristic(initial) while True: result = dfs(initial, 0, bound, None) if result == FOUND: return solution_path if result == INF: return None # 无解 bound = result # 下一轮提高阈值 def dfs(state, g, bound, last_move): f = g + heuristic(state) if f > bound: return f if is_solved(state): return FOUND next_min = INF for move in all_moves: if is_reverse(move, last_move): continue # 剪枝:不走回头路 new_state = apply_move(state, move) t = dfs(new_state, g + 1, bound, move) if t == FOUND: solution_path.append(move) return FOUND if t < next_min: next_min = t return next_min这个骨架非常抽象,但它包含了 IDA* 的关键动作:迭代阈值、DFS、返回新的最小阈值。实际工程中,你需要考虑性能、状态哈希、路径记录等细节。
5. 从“能解”到“解得更快”:模式数据库、缓存与调参
5.1 为什么单靠一个全局启发函数不够
如果你用上面的骨架,配一个简单的启发函数去解二阶魔方,可能会发现程序能在很短时间内给出解。但把它换成三阶魔方,程序可能跑很久都没有结果。原因在于,三阶魔方的搜索树更深、更宽,一个粗略的启发函数无法提供足够强的方向信息。搜索会向很多无意义的分支发展。
要提高求解能力,核心是提高启发函数的精确度。这也是整个项目中最有意思的部分。
5.2 模式数据库:把大目标拆成小目标
模式数据库(Pattern Database)是一种非常有效的思路。它的做法是:
- 不要直接计算整个魔方的解步数;
- 只关注其中一部分子问题,比如 8 个角块的位置和方向;
- 用 BFS 从目标状态反向遍历角块子问题的状态空间,记录每个子状态到达目标的最小步数;
- 运行时,把当前魔方状态中的角块图案提取出来,查询数据库,得到启发值。
这样得到的启发函数是可采纳的,因为它忽略了一部分信息(棱块),所以不会高估真实步数。而且它非常精准,能大幅减少搜索分支数。
更进一步的方案是同时构建角块模式数据库和棱块模式数据库,然后对两个启发值取最大值;或者把十几个棱块分成几组,分别建库,再取最大值。数据库粒度越细,启发越强,但预计算时间和内存也会增加。
我自己写的时候,觉得模式数据库是“手写魔方求解器能否真正跑起来”的分水岭。没有数据库,三阶魔方只能处于玩具级别;有了不错的数据库,普通电脑才能在可接受时间内解出复杂打乱状态。
5.3 工程化小技巧:缓存、并发、阈值调整
除了模式数据库,工程上还能做很多优化:
- 状态缓存:用哈希表记录已经访问过的状态及其最小步数,避免重复扩展。但要注意,IDA* 的迭代过程会丢弃状态,缓存策略要平衡内存和效率。
- 并发搜索:可以把不同的起始阈值或不同分支并行搜索,不过这会引入复杂度,不适合新手第一步就做。
- 阈值增长策略:经典 IDA* 的阈值增长是取上一轮“超过阈值的最小 f 值”。但也可以加入线性增长或加权增长,牺牲最优性换取更快的求解速度。
- 开局打乱与固定种子:调试时设置一个固定打乱序列,方便复现问题,而不是每次随机。
从我的体验看,先做一个能解二阶魔方的最小版本,再逐步引入模式数据库解三阶,比一上来就想挑战“几十步内解任意三阶”要稳妥得多。
6. 手写魔方求解器的常见坑与排查链路
6.1 明明算法没错,为什么搜索不终止
很多人写完 IDA* 后会发现:程序跑了几分钟还没有解输出。这时候不一定是算法本身错了,更常见的是下面几个问题:
- 启发函数返回 0 太频繁,导致阈值一直无法增长。可以检查一下,在非复原状态下,
heuristic是否始终大于 0。 - 动作定义有误,比如顺时针和逆时针写反了,导致搜索一直在原地打转。
- 剪枝过度,比如你把“连续两次转同一面”全部禁掉,但某些最优解确实需要连续转动同一面。这类剪枝要小心。
- 没有记录已访问状态,虽然 IDA* 天然会避免一些循环,但在某些弱启发下还是可能走很多重复路径。
6.2 推荐的上手顺序:从二阶到三阶,从可行到优化
如果你也想手写一个魔方求解器,我建议按下面的顺序推进,不要跳步:
- 先做二阶魔方:状态少很多,搜索容易跑通,适合验证启发式搜索框架。
- 把状态表示和动作函数测稳:连续执行四次同一动作必须回到原状态。
- 跑通 IDA最小示例*:用简单启发函数,尝试解随机打乱的二阶魔方。
- 加入剪枝:排除反向转动、合并连续同面转动。
- 再做三阶:引入模式数据库,从角块数据库开始,再尝试棱块分组数据库。
- 测量并优化:记录搜索节点数、耗时、解长度,观察不同参数下的变化。
6.3 一个实用的排查顺序
遇到“搜索不终止”“解出来的步骤错误”“程序卡死”等异常,我一般按这个顺序排查:
- 检查状态表示能否正确复原:手动把魔方打乱几步,再按逆序转回去,看是否复原。如果这一步不过,后面全无意义。
- 检查启发函数在已复原状态上是否返回 0:如果返回非 0,说明目标判断或启发函数不一致。
- 检查打印的搜索路径:从初始状态开始,依次应用路径上每个动作,看最终结果是否复原。这一步能发现动作序列方向反了或应用顺序错了。
- 检查搜索是否生成了重复状态:在节点数较少时打印状态指纹,看是否大量重复。
- 检查内存或节点数:如果节点数增长过快,先降低深度或换更强的启发函数。
这些排查步骤不复杂,但能帮你从“bug 堆里挣扎”变成“按图索骥”。
7. 这种“手写代码”真正教会你的,不是魔方
7.1 启发式搜索还能用在哪些地方
魔方只是启发式搜索的一个经典载体。类似的思路可以迁移到:
- 八数码、十五数码:经典搜索题,用曼哈顿距离做启发函数。
- 路径规划:比如自动驾驶或机器人的路径搜索,用 A* 或改进算法,将地图距离作为启发信息。
- 排课、调度和资源分配:在解空间极大的组合优化问题中,用搜索加剪枝找到可行解。
- 自然语言中的序列生成:一些解码过程也可以看成在巨大的输出序列空间里搜索高概率路径,启发函数或打分函数就是“方向感”。
可以说,你一旦理解了“状态 + 动作 + 启发函数”的抽象方式,很多问题都能用搜索框架重新描述一遍。
7.2 这类算法的适用边界
但也要说清楚边界。启发式搜索不是银弹。
- 如果问题的状态空间很小,用 BFS 或动态规划直接求最优解可能更简单直接。
- 如果启发函数很难构造,或者无法保证可采纳,搜索会退化,甚至比朴素算法更慢。
- 如果要解决三阶魔方的最优解,启发式搜索需要非常强的模式数据库和精心调优,个人从头手写并不容易。
- 如果是面向生产环境的速拧或最短步数求解,通常更常使用两阶段算法、群论预处理等专用方法,而不是通用搜索算法。
所以我的判断是:手写魔法求解器的价值,更多在于理解搜索算法的底层逻辑,以及锻炼“把现实问题建模成搜索问题”的能力。一旦你掌握了这个建模思路,后续再遇到复杂的路径、规划或优化问题,会更有方向感。
7.3 我建议你亲自动手写一遍的最小项目
如果你读完这篇文章,真的想试试,我建议做一个最小闭环:
- 支持二阶魔方;
- 实现状态数组;
- 实现基本转动;
- 实现一个简单启发函数;
- 跑通 IDA* 搜索;
- 能打印出求解步骤。
把这个流程走完,你对“启发式搜索到底猛在哪”会有比看十篇文章更深的体感。你会发现,真正猛的不是算法本身多么神奇,而是它能在极其庞大的状态空间中,稳稳地找到一条可行的路径。这种能力,才是让你在无数复杂问题面前依然能想办法下手的底层底气。