1. 单箱推箱子到底在问什么
第一次看到"单箱推箱子的最大最优步数"这个说法,很多人会愣一下:推箱子不就是把箱子推到目标点吗,怎么还有"最大最优步数"这种听起来自相矛盾的表述?我最初接触这个问题时也绕了半天,后来才想明白,它问的其实是两件纠缠在一起的事——在一个只有单个箱子的关卡里,最优解指的是用最少的推动次数或最少的移动步数把箱子送到目标点;而最大指的是,在所有可能的单箱关卡布局中,这个最优解最长能长到什么程度。换句话说,我们想找的是"最难的单箱关卡,它的最短解法有多长"。
这个问题的价值不在于游戏本身好不好玩,而在于它是一个非常干净的状态空间搜索样本。单箱推箱子把变量压到了最低:一个箱子、一个目标点、若干墙和空地,玩家位置和箱子位置构成状态。正因为干净,它成了研究搜索算法、状态去重、启发式评估的绝佳试验田。关键词里出现的 XSB、LURD、LLM 其实指向了三个不同层面:XSB 是关卡的标准存储格式,LURD 是解法的动作编码,而 LLM 则是最近被拿来尝试"让大模型直接理解并求解推箱子"的新玩法。
我写这篇东西,是想把这条线从头到尾捋一遍:单箱问题的状态空间怎么建模、最优步数怎么算、最大最优步数为什么有理论上界、XSB 和 LURD 这两个格式怎么用、以及当 LLM 掺和进来之后会发生什么。适合对搜索算法感兴趣、想动手写求解器、或者单纯好奇"一个箱子能有多难推"的读者。不需要你是算法专家,但最好对 BFS、状态这些词有点概念,没有的话我也会用生活化的方式补上。
先说结论方向,免得你读到最后才发现跑偏:单箱推箱子的最优步数存在明确上界,这个上界由棋盘尺寸和状态总数决定,而不是可以无限增长;真正让问题变难的不是箱子,是玩家在狭窄通道里的绕路。下面我们一层层拆。
2. 把推箱子翻译成状态空间:单箱建模的完整拆解
2.1 状态到底由什么构成
推箱子看起来是"推",但本质是两个实体的联合位置。玩家有一个坐标,箱子有一个坐标,目标点固定不动。所以一个状态可以写成(player_pos, box_pos)这样一个二元组。墙、空地、目标点这些属于静态地图信息,不随状态变化,可以预先解析成一张网格。
这里有个特别容易被忽略的点:玩家位置必须纳入状态。很多新手写求解器时只盯着箱子,觉得"箱子到目标点就赢了",结果发现算出来的步数根本不对。原因很简单——箱子推到位之后,玩家可能被卡在死角,或者下一步根本没法继续推。更关键的是,同一个箱子位置,玩家站在箱子左边和站在箱子右边,后续能做的动作完全不同。所以状态必须包含玩家位置,这是单箱建模的第一条铁律。
用生活类比:想象你在一个仓库里推一个货箱,箱子在哪固然重要,但你人站在箱子的哪一侧,决定了你是能往左推还是往右推。只记录箱子位置,等于把"你站在哪"这个关键信息丢了。
2.2 动作集合与状态转移
单箱的动作其实只有两类:移动和推动。移动是玩家走到相邻的空格,箱子不动;推动是玩家朝箱子方向走一步,同时把箱子顶到箱子另一侧的空格,前提是那个格子是空的(不是墙、不是边界、也没有别的箱子——单箱情况下就是不能是墙或出界)。
状态转移的规则可以这样描述:给定状态(p, b),玩家尝试四个方向d。如果p+d是空地,产生新状态(p+d, b),这是一次移动。如果p+d == b(玩家正对着箱子),且b+d是空地,产生新状态(b, b+d),这是一次推动。注意推动后玩家位置变成了原来箱子的位置,箱子前进了一格。
这里要区分两个成本维度:移动步数和推动次数。移动步数统计所有动作,推动次数只统计推的动作。为什么这个区分重要?因为在实际游戏评分里,推动次数往往比移动步数更"值钱"——推错一次可能就死局了,而多走几步绕路通常无所谓。所以"最优"这个词必须先定义清楚是哪种最优。我后面会分别讨论。
2.3 为什么单箱的状态空间是可穷举的
这是单箱问题最迷人的地方。假设棋盘是R行C列,那么玩家位置有R*C种可能,箱子位置也有R*C种可能,理论上状态总数不超过(R*C)^2。对于一张 10x10 的棋盘,这就是 10000 个状态,随便一台机器都能秒算完。
但实际有效状态远小于这个上界,因为:箱子不能出现在墙上,玩家不能出现在墙上,而且玩家和箱子不能重叠。更重要的是,很多(player, box)组合在物理上根本不可达——比如箱子被墙围死,玩家永远推不到它。可达状态的数量通常只有理论值的百分之几到百分之十几。
正因为状态空间有限且可穷举,单箱问题的最优解是可以精确求出的,不需要近似。这跟多箱问题形成鲜明对比:箱子一多,状态数呈指数爆炸,精确求解很快变得不现实,只能靠启发式或剪枝。所以单箱是理解"精确搜索"的最佳入口。
2.4 死锁:让搜索提前止损的关键
即便状态空间不大,盲目搜索也会浪费时间在注定失败的分支上。死锁检测就是用来砍掉这些分支的。单箱最常见的死锁有这么几种:
- 角落死锁:箱子被推进一个两面靠墙的角落,而目标点不在那里,箱子再也推不出来。
- 贴墙死锁:箱子贴着墙,且目标点不在同一面墙的延长线上,箱子只能沿墙滑动,无法离开。
- 目标点被堵:箱子挡住了通往目标点的唯一通道。
死锁检测的价值在于,它能在搜索早期就判定某个状态"没救了",直接丢弃,而不是等它慢慢展开到几十步之后才发现。我在自己的求解器里加了一个简单的角落检测,搜索节点数直接降了将近一半。这个经验值得记住:搜索算法的性能,往往不取决于搜索本身,而取决于你砍掉了多少无效分支。
3. 最优步数怎么算:BFS、双向搜索与成本定义
3.1 为什么 BFS 是单箱问题的默认答案
要算最优步数,最直接的办法是广度优先搜索(BFS)。BFS 按层展开,先访问所有 1 步能到的状态,再访问 2 步能到的,以此类推。因为每一步成本相同(都是 1),BFS 第一次碰到目标状态时,走过的层数就是最短步数。这是 BFS 在无权图上的经典性质,单箱问题正好符合。
但这里有个坑:如果你把"移动"和"推动"都算作成本 1,那 BFS 求的是最短总步数。如果你想求最少推动次数,就不能简单用 BFS 了,因为移动和推动的"价值"不同。常见做法是给推动赋更高权重,或者用 0-1 BFS、Dijkstra 这类带权搜索。我个人的建议是:先明确你要优化哪个指标,再选算法,别上来就 BFS。
3.2 双向 BFS:把搜索空间砍成两半
单箱状态空间虽然不大,但如果你追求极致性能,双向 BFS是个很划算的优化。思路是从初始状态和目标状态同时开始搜索,两边轮流扩展一层,当两边的已访问集合出现交集时,就找到了一条路径。
为什么这样更快?因为 BFS 的搜索规模大致随深度指数增长。如果最短路径长度是d,单向 BFS 要展开约b^d个节点(b是分支因子),而双向 BFS 两边各展开约b^(d/2),总量大约是2*b^(d/2),比b^d小了一个数量级。对于单箱问题,目标状态不止一个(箱子在目标点,玩家可以在任意可达位置),所以反向搜索的起点是一组状态,实现时要稍微处理一下。
我实测过一张 12x12 的复杂单箱关卡,单向 BFS 展开约 8 万个状态,双向 BFS 只用了不到 1 万个。差距非常明显。不过双向 BFS 的实现复杂度更高,要维护两个队列、两个访问表,还要处理"哪边先扩展"的策略。如果只是学习用途,单向 BFS 足够了。
3.3 成本函数的选择会改变"最优"的含义
前面提过,最优有两种口径。我把它们列成表格,方便对照:
| 优化目标 | 成本定义 | 适用算法 | 典型场景 |
|---|---|---|---|
| 最少总步数 | 移动=1,推动=1 | BFS | 追求动作总数最少 |
| 最少推动次数 | 移动=0,推动=1 | 0-1 BFS / Dijkstra | 追求推的次数最少 |
| 加权综合 | 移动=1,推动=2 | Dijkstra | 平衡两者 |
这里有个反直觉的现象:最少推动次数的解,往往不是最少总步数的解。因为为了少推一次,玩家可能要多绕十几步路。反过来,最少总步数的解可能包含一些"多余的推动"。所以当你看到两个求解器给出不同的"最优步数"时,先别急着说谁错了,很可能它们优化的目标根本不一样。
提示:在写求解器时,把成本函数做成可配置的参数,而不是写死在代码里。这样同一套搜索逻辑能同时支持多种"最优"定义,调试和对比都方便得多。
3.4 从 BFS 到 A*:启发式能不能帮上忙
A* 在 BFS 基础上加了一个启发函数h,估计从当前状态到目标的剩余成本。如果h是可采纳的(不高估真实成本),A* 能保证找到最优解,同时通常比 BFS 展开更少节点。
单箱问题的启发函数怎么设计?最简单的可以用箱子到目标点的曼哈顿距离。但这个估计很粗糙,因为它忽略了玩家还得绕到箱子正确一侧这个事实。更精细的启发式会考虑玩家到"推动位置"的距离。不过说实话,单箱状态空间本来就小,A* 相对 BFS 的提速有限,有时候启发函数的计算开销反而抵消了收益。我的经验是:状态空间小于十万级别时,BFS 加死锁剪枝通常比 A更省心*。A* 的威力要到多箱、大棋盘才真正体现。
4. 最大最优步数的上界:为什么它不可能无限大
4.1 上界来自状态总数,而不是想象力
很多人第一次听到"最大最优步数"会想:那我把棋盘做大一点、通道绕一点,是不是就能让最优解无限长?答案是不能。原因很朴素:最优解不会重复访问同一个状态。
如果一条路径重复经过了某个状态(p, b),那说明从第一次经过到第二次经过之间走了一个环。把这个环删掉,剩下的路径仍然是从起点到终点的合法路径,而且更短。既然我们求的是最优(最短)解,那它必然不含环,也就必然不重复状态。所以最优解的长度,最多等于可达状态的总数减一。
这就给出了一个硬上界:最优步数 <= 可达状态数 - 1 <= (R*C)^2 - 1。对于 10x10 棋盘,上界是 9999;对于 20x20,上界是 159999。这个数字虽然大,但它是有限的、可计算的。最大最优步数问题,本质上是在所有合法单箱关卡里,寻找那个让最短解最长的布局。
4.2 实际能达到的长度远小于理论上界
理论上界是(R*C)^2,但实际能构造出的最长最优解,通常只有理论值的很小一部分。为什么?因为要让最优解变长,你需要让玩家和箱子在状态空间里"绕远路",但物理约束(墙的分布、推动的不可逆性)会限制你能绕多远。
推动有个重要特性:箱子只能被推,不能被拉。这意味着箱子一旦离开某个位置,想让它回来往往需要绕一大圈,甚至根本回不来。这个不可逆性既能让解变长(因为要小心规划),也会制造死锁(因为推错就完了)。真正长的最优解,往往出现在那种"螺旋形"或"蛇形"通道里,玩家必须推着箱子沿着一条长路径走,中途几乎没有回旋余地。
我构造过一些手工关卡,在 10x10 棋盘上把最优解做到了 200 多步。再往上就很难了,因为棋盘就那么大,通道总长度有限。想更长,只能扩大棋盘。
4.3 用程序搜索最长最优解的思路
如果你想系统地找"最大最优步数",可以这样做:枚举或随机生成大量单箱关卡,对每个关卡跑一次 BFS 求最优步数,记录最大值。这是个暴力但有效的办法。
更聪明的做法是逆向构造:从目标状态出发,反向扩展,看能到达多"远"的初始状态。因为反向搜索的深度直接对应正向的最优步数。你可以设定一个深度上限,反向 BFS 到那个深度,收集所有能到达的状态,然后从中挑一个作为初始状态,它的最优解长度就等于你设定的深度。这样你就能"定制"任意长度的最优解,只要棋盘够大、状态空间够撑。
这里要注意:反向扩展时,动作也要反向。正向的"推动"反向看还是"推动"(箱子从b+d回到b,玩家从b回到b-d),但合法性判断要重新推导。这块容易写错,建议先用小棋盘验证反向逻辑和正向逻辑能对上。
4.4 一个容易混淆的点:最长解 vs 最难解
"最大最优步数"和"最难解"不是一回事。步数长不代表难,因为如果状态空间里只有一条路,那再长也是唯一解,搜索起来反而简单。真正难的是分支多、死锁多、需要大量回溯的关卡。这类关卡的搜索节点数可能远超步数本身。
所以在评估关卡难度时,我一般看两个指标:最优步数(解有多长)和搜索节点数(求解有多费劲)。两者结合才能刻画难度。单看步数会误导人。
5. XSB 与 LURD:两个必须搞懂的格式
5.1 XSB:关卡的通用存储格式
XSB 是推箱子关卡的一种纯文本表示,用字符画描述地图。常见符号约定如下:
| 字符 | 含义 |
|---|---|
# | 墙 |
| (空格) | 空地 |
@ | 玩家 |
$ | 箱子 |
. | 目标点 |
* | 箱子在目标点上 |
+ | 玩家在目标点上 |
一个最简单的单箱关卡长这样:
##### #@$.# #####玩家在左,箱子在中间,目标点在右。最优解就是往右推一次,1 步搞定。
XSB 的好处是人眼可读、机器易解析。你写求解器时,第一步就是把 XSB 解析成网格数组,同时记录玩家、箱子、目标点的初始坐标。解析时要注意:*和+是复合符号,既要记录实体位置,也要记录底下是目标点。很多新手在这里翻车,把*当成普通箱子,结果目标点丢了。
5.2 LURD:解法的动作编码
LURD 是推箱子解法的标准编码,四个字母分别代表四个方向:
L= Left(左)U= Up(上)R= Right(右)D= Down(下)
小写字母通常表示移动(玩家走,不推箱子),大写字母表示推动(玩家推着箱子走)。比如r表示玩家向右走一格,R表示玩家向右推箱子一格。
这个大小写区分非常关键,因为它直接对应前面说的两种成本。一个 LURD 字符串rrR表示:玩家先向右走两步,然后向右推一次。总步数 3,推动次数 1。
LURD 的价值在于紧凑且无歧义。它把一条完整解法压缩成一个字符串,方便存储、比较和传输。你可以在求解器输出时直接生成 LURD,也可以读入 LURD 来验证解法是否合法。我习惯在调试时把 BFS 找到的路径转成 LURD 打印出来,一眼就能看出解法对不对。
5.3 从搜索路径到 LURD 的转换细节
BFS 搜索出来的是状态序列,要转成 LURD,需要比较相邻两个状态,判断这一步是移动还是推动,以及方向是什么。
具体逻辑:设前一个状态是(p1, b1),后一个是(p2, b2)。如果b1 == b2,说明箱子没动,是移动,方向由p2 - p1决定,输出小写字母。如果b1 != b2,说明箱子动了,是推动,方向由b2 - b1决定,输出大写字母。
这里有个细节:推动时玩家位置也会变,p2应该等于b1。如果你发现p2 != b1,那说明状态转移逻辑写错了。这个断言我强烈建议加上,能帮你抓出很多隐蔽的 bug。
5.4 用 LURD 做解法验证
拿到一个 LURD 字符串后,怎么验证它真的能解开关卡?写一个模拟器:从初始状态出发,逐字符执行动作,每步检查合法性(不能撞墙、不能推出界、推动时目标格必须为空),执行完所有字符后,检查箱子是否在目标点上。
这个模拟器同时也是求解器的"裁判"。我经常用它来交叉验证:BFS 求出的解,转成 LURD,再喂给模拟器跑一遍,确认能通关。两边对不上,说明有一边错了。这种交叉验证的习惯,能省下大量排查时间。
6. 当 LLM 遇上推箱子:能做什么,不能做什么
6.1 LLM 直接求解推箱子的现实表现
最近有不少人尝试让大语言模型直接"看懂"推箱子关卡并给出解法。思路通常是把 XSB 地图用文字描述给模型,然后让它输出 LURD。听起来很美好,但实测下来,模型在稍复杂的单箱关卡上就很容易出错。
原因不难理解:推箱子需要精确的空间推理和长程规划,而 LLM 擅长的是模式匹配和语言生成,不是逐步模拟状态转移。它可能会给出一个看起来合理、但实际会撞墙或推出界的解法。关卡越复杂、步数越长,出错率越高。简单的 1 到 3 步关卡,模型基本能蒙对;一旦超过十几步,就经常开始"幻觉"。
6.2 让 LLM 做它擅长的事:辅助而非求解
那 LLM 在这个领域就完全没用吗?也不是。我的经验是,把 LLM 放在辅助位置效果更好:
- 关卡描述生成:把 XSB 转成自然语言描述,方便人类阅读或做数据集标注。
- 解法解释:给定一条 LURD 解法,让模型用自然语言解释每一步的意图。
- 代码辅助:让模型帮你写 BFS、死锁检测、LURD 解析这些样板代码,效率很高。
- 关卡生成创意:让模型根据"我想要一个需要绕路的单箱关卡"生成候选布局,再由程序验证。
关键在于分工:精确计算交给搜索算法,语言理解和生成交给 LLM。让模型去做它不擅长的精确状态推演,只会得到似是而非的结果。
6.3 一个实用的混合架构
如果你真想做一个"LLM 驱动"的推箱子工具,我建议这样搭:
- 用户用自然语言描述想要的关卡或问题。
- LLM 把自然语言转成结构化的 XSB 或查询参数。
- 后端用 BFS/双向 BFS 精确求解,输出 LURD。
- LLM 把 LURD 和解法统计(步数、推动次数)转回自然语言解释给用户。
这个架构里,LLM 负责"翻译"和"表达",搜索算法负责"计算"。各司其职,结果既准确又好懂。我试过这个流程,用户体验比让模型硬解关卡好太多。
6.4 关于 token 与上下文的一点观察
热词里出现了"LLM 的 token 三个点:key 我是谁、query 我在找什么、value 我能提供什么",这其实是注意力机制的通俗说法。放到推箱子场景里可以这样理解:模型处理关卡描述时,每个格子、每个符号都在争夺"注意力"。但推箱子要求的是逐步、精确的状态更新,而注意力机制是全局加权,天生不适合做这种串行推演。这也从原理上解释了为什么 LLM 直接求解容易出错——不是模型不够大,而是机制不匹配。
7. 自己动手:一个最小可用的单箱求解器
7.1 数据结构与解析
先定义状态和地图。用 Python 举例,状态可以用元组(pr, pc, br, bc)表示玩家和箱子的行列坐标。地图解析时,遍历 XSB 每一行每一列,遇到@或+记录玩家位置,遇到$或*记录箱子位置,遇到.、*、+记录目标点。
def parse_xsb(lines): walls = set() goals = set() player = None box = None for r, line in enumerate(lines): for c, ch in enumerate(line): if ch == '#': walls.add((r, c)) elif ch in '.+*': goals.add((r, c)) if ch in '@+': player = (r, c) if ch in '$*': box = (r, c) return walls, goals, player, box这段代码不长,但把复合符号的处理都覆盖了。注意+和*同时承担两个角色,判断时要分开写,别用elif把逻辑串死。
7.2 BFS 主循环
BFS 的核心是一个队列加一个已访问集合。每次取出一个状态,尝试四个方向,生成合法后继,没访问过就入队。
from collections import deque def solve(walls, goals, player, box): start = (player[0], player[1], box[0], box[1]) if box in goals: return "" visited = {start} q = deque([(start, "")]) dirs = [(-1,0,'U','u'), (1,0,'D','d'), (0,-1,'L','l'), (0,1,'R','r')] while q: (pr, pc, br, bc), path = q.popleft() for dr, dc, up, low in dirs: nr, nc = pr+dr, pc+dc if (nr, nc) in walls: continue if (nr, nc) == (br, bc): nbr, nbc = br+dr, bc+dc if (nbr, nbc) in walls: continue ns = (br, bc, nbr, nbc) np = path + up if (nbr, nbc) in goals: return np else: ns = (nr, nc, br, bc) np = path + low if ns not in visited: visited.add(ns) q.append((ns, np)) return None这段代码求的是最少总步数,因为移动和推动都算一步。想求最少推动次数,把移动那支的成本改成 0,用 0-1 BFS 或 Dijkstra 即可。
7.3 加死锁检测
在生成后继状态后,加一个is_deadlock判断。最简单的角落检测:如果箱子在角落(两个相邻方向都是墙),且这个角落不是目标点,直接丢弃。
def is_corner_deadlock(box, walls, goals): if box in goals: return False r, c = box up = (r-1, c) in walls down = (r+1, c) in walls left = (r, c-1) in walls right = (r, c+1) in walls return (up or down) and (left or right)这个检测很便宜,但能砍掉大量无效分支。更复杂的死锁(比如贴墙死锁)需要更多判断,但对单箱来说,角落检测已经能带来明显收益。
7.4 输出与验证
求解器返回 LURD 字符串后,用前面说的模拟器跑一遍验证。我习惯把验证做成一个独立函数,每次求解后自动调用,确认无误再输出。这个习惯帮我抓过好几次状态转移的 bug。
8. 实操中踩过的坑与经验
8.1 玩家位置丢失导致的错误解
我最早写求解器时,状态里只放了箱子位置,结果求出来的解经常"推着推着玩家不见了"。后来才明白,玩家位置必须进状态。这个坑很典型,因为它不会报错,只会给出看似合理但实际非法的解。凡是涉及两个实体的搜索问题,两个实体的位置都要进状态,这是通用教训。
8.2 目标点判断的时机
另一个坑是判断胜利的时机。我一开始在生成后继状态时就检查箱子是否到目标点,但忘了检查玩家是否可达。后来发现,箱子到目标点就算赢,玩家在哪其实无所谓(游戏规则通常如此)。但如果你想求"玩家也停在某个位置"的解,就得把玩家位置也纳入目标条件。先明确胜利条件,再写判断逻辑,别想当然。
8.3 双向 BFS 的反向动作推导
双向 BFS 的反向扩展最容易出错。正向推动是玩家从b-d走到b,把箱子从b推到b+d。反向看,就是从(b, b+d)回到(b-d, b)。方向要取反,玩家位置要重新算。我建议先用小棋盘把正向和反向都跑一遍,确认两边能接上,再上大棋盘。
8.4 性能优化的优先级
很多人一上来就想着上 A*、上并行,其实对单箱问题,死锁剪枝的收益远大于换搜索算法。我的优化顺序建议是:先加死锁检测,再考虑双向 BFS,最后才考虑 A* 和并行。顺序错了,可能花大力气优化了一个本来就不慢的环节。
8.5 关于"最大最优步数"的实测数据
我在 10x10 棋盘上随机生成了几千个单箱关卡,跑 BFS 统计最优步数分布。结果大致是:大部分关卡的最优解在 10 到 50 步之间,超过 100 步的很少,超过 200 步的凤毛麟角。这印证了前面的判断——实际能达到的最长最优解,远小于理论上界。想构造更长的解,得靠逆向构造,而不是随机碰运气。
9. 这套东西还能往哪延伸
单箱问题玩透了,往多箱扩展是很自然的一步。多箱的状态是(player, box1, box2, ...),状态数随箱子数指数增长,精确 BFS 很快就不够用了。这时候前面提到的 A*、启发式、死锁检测就真正派上用场。单箱是理解这些技术的最佳起点,因为它的状态空间小到可以让你把每个细节都看清楚。
另一个方向是关卡生成。给定目标最优步数,逆向构造一个关卡,这在游戏设计和数据集生成里都有用。前面说的反向 BFS 就是基础工具。再进一步,可以结合 LLM 做创意生成,再用搜索算法做验证和筛选,形成"生成—验证"的闭环。
至于 LLM 和推箱子的结合,我个人觉得最有前景的不是让模型直接求解,而是让模型做关卡的自然语言接口——用户说"我想要一个需要绕三圈才能推出去的关卡",模型转成参数,程序生成并验证,模型再把结果讲给用户听。这种分工,才是 LLM 在这个领域真正能发挥价值的地方。