1. 从兴趣到实践:我的推箱子求解器之旅
几年前,我在啃《Java并发编程实战》这本书的时候,里面提到可以用多线程来解决推箱子游戏,当时就觉得这个想法特别酷。推箱子这游戏,规则简单到一句话就能说清:把箱子推到目标点。但真要写个程序让它自己找到解法,那复杂度可就上来了。这不只是写个游戏AI,更像是在一个庞大的迷宫里,用代码当手电筒,一条路一条路地去探。我这个人吧,就喜欢这种有挑战性的东西,于是二话不说,就动手开干了。
我做的这个程序,我管它叫 SokobanSolver。它的核心思路其实很“暴力”:把推箱子地图的每一个状态,想象成迷宫里的一个岔路口。从起点开始,让人物尝试上下左右四个方向移动。每走一步,就生成一个新的地图状态(比如箱子位置变了,人站的地方也变了)。然后,就像走迷宫一样,对每一个新状态,继续尝试四个方向,如此反复,深度优先地搜索下去,直到某个状态里所有的箱子都恰好落在目标点上——恭喜,通关路径找到了。这个思路听起来直白,但真跑起来,问题一大堆。地图稍微复杂点,比如箱子一多,岔路口(也就是可能的地图状态)数量就会爆炸式增长,搜索空间大得吓人。单线程版本跑一个中等难度的关卡,等上几分钟是常事,更别提那些“变态”图了,跑着跑着程序就“撑死”了——内存溢出(OutOfMemoryError),直接崩溃。
所以,我的目标很明确:第一,得让它跑得快;第二,得让它能处理更复杂的关卡,别动不动就“撑死”。这就引出了两个关键的优化方向:多线程和智能剪枝。多线程好理解,我一个人找路慢,我找一群“小工”(线程)同时分头去找,总有一个能先找到吧?这能直接提升搜索速度。而智能剪枝,就像给这个“暴力”的搜索过程装上一个大脑,在出发前或者走到一半时,能判断出“哎,这条路明显是死胡同,别走了”,或者“这条路虽然能走,但肯定比已知的某条路还绕远,放弃吧”。这样就能提前砍掉大量无用的搜索分支,节省时间和内存。这篇文章,我就想跟你详细聊聊,我怎么把这两个技术用到我的 SokobanSolver 里,让它从一个傻乎乎的“暴力搜索器”,进化成一个更聪明、更高效的“求解器”。
2. 理解基础:单线程暴力搜索的骨架
在聊优化之前,我们得先看看这个“笨办法”是怎么工作的。理解了基础,才能明白优化到底优化在了哪里。我的 SokobanSolver 程序,主要分为三大模块:地图表示、移动逻辑和搜索核心。
2.1 地图的数字化:如何让程序“看懂”关卡
程序可看不懂图形化的墙壁和箱子。所以第一步,是把地图抽象成计算机能处理的数据。我用了最直接的方法:用不同的字符代表不同的元素。比如,# 代表墙壁,空格(或 -)代表空地,$ 代表箱子,. 代表目标点,@ 代表人,+ 代表站在目标点上的人,* 代表在目标点上的箱子。整个地图就是一个字符串数组(或者一个用特殊分隔符连接起来的大字符串)。
光能表示还不够,地图必须有效。我写了一个 MapChecker 类来做校验。它要检查好几件事:地图是不是规整的长方形?有没有非法字符?墙壁是不是把游戏区域完全封闭了起来(不然人或者箱子不就掉出去了)?地图里有且仅有一个“人”吗?这些检查保证了我们搜索的起点是一个合法的、可解的推箱子局面。
这里有个我觉得挺有意思的细节:检查墙壁是否封闭。我用的是一种“沿墙走”的深度搜索算法。首先找到一面墙(比如第一行的第一个 #),然后就从这里开始,只沿着墙壁字符(#)走,上下左右四个方向,看能不能不离开墙壁地走一圈回到原点。如果能,说明墙壁至少形成了一个闭环。当然,这个方法有个小缺陷,它不能判断这个闭环是不是唯一的(比如地图中间有个孤立的墙圈),但对于大多数标准关卡,这已经足够可靠了。
2.2 移动的规则:推与走的逻辑
有了静态的地图,接下来要定义动态的规则:人怎么动?ManMover 这个类就是干这个的。它的核心函数 moveOneStep,接收一个地图对象和一个方向,然后返回移动后的新地图对象(注意,不是修改原地图,而是生成新的,这很重要)。
移动无非两种情况:空手走和推箱子。判断逻辑很直观:
- 如果人想去的下一格是空地或目标点,那么直接走过去。
- 如果下一格是箱子,那就得看箱子的下一格是什么。如果箱子的下一格是空地或目标点,那人就可以把箱子推过去一格,自己站到箱子的位置上。
- 其他情况(比如撞墙,或者箱子后面是墙或另一个箱子),则移动非法,返回原地图。
这里我定义了一个 IMapMoveRule 接口,用来抽象“移动后地图格子如何变化”的规则。比如,人(@)从目标点(.)上走开,原来那格应该变回目标点(.),而不是空地。通过接口,以后如果想实现一些“变异”规则(比如冰面滑动、一次推多个箱子),就很容易扩展了。
2.3 深度优先搜索:一条道走到黑
核心的搜索算法,我最初实现的是深度优先搜索(DFS)。你可以把它想象成一个人拿着粉笔在迷宫里走,遇到岔路随便选一条,一直走到头,如果发现是死胡同,就退回到上一个岔路口,换另一条没走过的路试试。
在代码里,我维护了一个栈(Stack)。一开始,把初始地图放进去。然后进入循环:从栈顶取出一个地图状态,检查这个状态是不是已经解决了(所有箱子都在目标点上)。如果没解决,就让人尝试向四个方向各移动一步,生成最多四个新的地图状态。对于每一个合法的新状态,先检查是不是之前已经走到过(用一个 HashSet 记录所有见过的地图字符串),如果没见过,就把它压入栈中,


629

被折叠的 条评论
为什么被折叠?



