数回(Slither Link)游戏的自动求解算法

      数回是一个很有趣的智力小游戏,与数独游戏同属数字类的休闲小游戏,不过比数独要更轻松一点,更容易上手。前段时间我开始玩数回,在IOS和安卓上都有很多免费的数回小游戏,玩得不亦乐乎,不过后面难度大的玩起来就一点也不轻松了,需要进行大量的猜测,而这些软件都没有中途建立恢复点的功能,一旦猜错了想回退去往往忘了该回退到哪了,整个盘面一乱就完了。作为一个会编程的(连程序猿都算不上),就想自己编一个能建立标记点和和回滚功能的数回小游戏,于是开始学习python下的图形界面编程。可是很快想法就变了,何不干脆编个自动求解的程序?于是主要目标又从界面转向自动求解算法上去了 ,对界面的设计也就不怎么上心了,没有开发什么建立恢复点的功能(其实也不难,比算法简单,只是心思都到算法上去了,开发界面纯粹只是为了有一个展现和调试环境,见上一遍博客),于是就花了一个星期开发了一个最简界面,剩下的时间都花到算法的开发上去了。开始没想到时间会用得这么长,也幸好这个春节假期也是相当的长(鲁迅说过,人世间的悲欢并不相通。为那些没能看到庚子年的春天的不幸人们默哀,然而很快他们就不会再被提起,或许会有很多形式上的记念,然而从举办这些记念的人的心里,这些记念更多的是为了让人们忘却吧),终于在正式复工的最后一天,在各种想法的混乱决策和与各种bug斗争了几天以后,完成了这个算法,可以用很不精炼的python代码(主要是各种想法中反反复复,留下了一些很不必要的东西)在几秒钟内求解各种数回软件上的最难等级的盘面,真是值得庆祝一下。

      最终的代码其实并不长,才刚到千行,之所以用了这么长时间,是因为找遍了网上,并没有找到一点求解数回的算法的文章,只能自己开发。相比之下,数独等游戏的讨论就多得多,看来这个还有点冷门。从最基本的估算就知道,简单的深部优先搜索从时间复杂度上来看是行不通的,实际上我在完成这个算法后也尝试过用一下简单深度优秀搜索看看效果,结果是时间是不可接受的。那么要开发一个可接受的算法,先得对这个游戏的人肉解题过程有些了解才行。

    其实玩过数回的人都知道,玩数回只要记住两个要点基本上就差不多了,第一,数回是有一些定式的,比如最基本的两个3;第二,需要猜测时,从线头开始。

    数回的定式不多,常用的基本上就是33,30这几个,

        

    当然还有一些比较复杂的,但实际很少用到。如果将连线也考虑进去的话,定式会多一此,但那太难记了。这几个定式虽然少,但是可以为玩家提供一个突破口,从突破口开始,慢慢“蚕食”整个盘面,从而完成整个游戏。而蚕食的过程,很重要一点的技巧就是从线头处开始要简明一些,而不是去点那些没有任何线索的空白处。

    另外,玩数回一个新手不怎么喜欢用到,而老手大多会用到的方法是,把确定不会连接的线标记出来,这样有助于分析盘面,更容易看出连接关系。一般游戏中会提供一个打叉的功能,比如像这样:

  最后再强调一点,就是所有的线条最后要连成一个不交叉的单环,而不能是几个独立的小环。    

  好了,其实从玩法上要说就也就这么多了。不过怎么编程去实现,确实让我想了好一段时间。最后,我确定了一个定式匹配+深度优先遍历的算法。总的两说,就是

1.先遍历整个盘面,寻找可以匹配的定式,将可以确定状态的连线的状态进行设置。这个状态包括必然有连接(把头连上)或者必然无连接(打个叉)。

2.在上一轮改变过状态的点附近进行定式匹配,确定可以确定的状态,反复进行本步骤,直到没有可以确定的状态。

3.选择一个线头,猜测一步连接,然后执行2的操作

反复递归执行2,3,如果遇到死局,则恢复(roll back)盘面到上一个猜测前的状态,换一个方向走一步。如果一个点所有的可能连接方向都失败,就返回到上一级,在上一级恢复到上一次猜测前状态,进行一次新的猜测。

整个过程就是一个典型的深度优先递归搜索过程。

从方法上来讲,其实没有什么新意,不过在具体实施上,还得先解决几个问题。首先第一个,定式应该选哪几个?如果仅仅只是以上说的几个定式,那其实大部分还是得靠猜,性能肯定好不了,而且总的定式有哪些呢?我也没空去研究,于是我干脆先编了个程序,算出了所有2*2方格内所有可能的连接方式!比如这些:

                            

又或者这些

       

有了这些所有可能的连接方式,在程序上就可以将所有符合当前2*2方块中的数字和现有连接/禁止关系的连接方式都匹配出来,那么,所有可能的匹配方式中,如果某个连接都存在,那这个连接就必然存在;反之,如果某个连接都不存在,那这个连接必然禁止。举个例子,在3 3 ,1 1 这个组合中,必然会连接或禁止的线有这些 

在实际盘面中,很少有所有4个格子的数字都被设定的情况,所有实际上需要尝试空白块中所有可能的数字组合,比如这个,需要尝试 从3 3,1 0 到 3 3 ,1,3这些组合。当然,好消息是实际上有些组合是不可能的,另外,如果有一些边已经被设置上了确定的状态,那么就可以排除很多可能,比如如果是这个样子,则它实际上只有这一种可能了,我们可以放心地连接和禁止相应的线。

另外,这个2*2数字方块,实际上有3*3个端点,它实际上是一个3*3点方阵。而每个点有上下左右四个方向的连接。在2*2方块中的连接已经画出来了,但每条线实际上是必须向外延伸的。在四边中心的点上,如果在图中只连接了一端,那么意味着它向外连接出去,而四个角点则可能会有两个方向 ,比如这个连接,实际应该是或者或者另外几种可能,视其角点的连接状态而定,每个角点,如果没有连接或有一 连接,则外部会有两种可能的连接状态,是一个2的组合的关系。

道理想明白后,接下来是怎么编程了。为了减少比对的运算,我把每个方块内的每种可能数字设置一个位来表示,一共也就0到3这4种可能,用4位就可以了,如果一个格子里的数字已经被指定了,那么它就只有对应的哪一位被置位,如果没有设定,就先把4位都置上,再在运行过程中根据其周边的连接情况进行修改,具体的说,周围每连上一根线,它的最小可能值就少一位,每被禁止一根线,它的最大可能值就少一位。在进行比对时,把2*2格子里的4个4位掩码组合成一个16位掩码,与每个定式的掩码比较,如果定式的掩码各个位都被包含在其中,则该定式是一种可能的组合,运算上就是一个简单的先位与再判等的运算。

同样,我把每个点的4个方向的连接状态和禁止状态也用分别一个位表示,一共4位连接状态和4位禁止状态,一个3*3的点阵组合成一个36位的连接状态字和禁止状态字。因为现在都是64位的系统,我也没在意字长超过32位了。实际上连接和禁止状态是有重复的,至少中间这个点的完全不需要,已经被其四周的点涵盖了,如果要在32位系统上实现,可以很简单的把中间这个点去掉就变成32位的了。 与定式的连接关系进行位比较判断,这要比较两次,一次是当前盘面中所有已连接的线在定式中必须有,通过位或运算再与定式判等可得,另一个是当前被禁止的线不能在定式中存在连接,通过位与运算判0可得。总之,使用了掩码以后判断都是位与或非的运算,速度快多了。

当得到了一堆所有的可能连接关系后,把这些定式的掩码再进行一次与运算,所有定式中都存在的连接肯定是必然连接。把所有定式再做一次或运算,所有定式中都不存在的连接必然要禁止。执行这些动作,然后再在被修改的点的附近反复进行这种匹配操作。

至于这个附近怎么定义,我也没想太多,就选了以这个点的左上小方块的4个点为左上顶点的4个3*3点小方块进行新的匹配,实际效果还不错,很多简单的盘面经过这种操作连任何猜测都不需要就直接解决了。

以上是有匹配结果的,可见如果盘面上现有的条件越多,可以匹配的定式就越少,其共性的边也就越多,可确定的也就越多,这也是每次在被改变的点附近进行新的匹配的原因,因为信息增多了。不过如果一个2*2盘面没有一个定式可以匹配,那就一定不对了,表明此路不通,需要回滚了。

以上是定式匹配,实际上除了定式匹配,每一步连接或禁止操作都可以推导出一些必然的结果,可以直接执行,因此在连接和禁止操作中也是一个递归的操作。

一次连接操作的流程大致是这样的:

1,判断本次连接可不可行,我设的条件有是不是已经禁止,是不是已经在已知的失败盘面中,是否连接点已经有两个连接了(形成交叉),是否会让一个格子周围的边数超过设定,会不会形成小环。如果有违反以上规则的,返回失败标识。

2,如果通过了检测,就保存本次修改会改变的状态值到一个记录列表中,然后修改相应的状态值

3,推导本次操作后一些可以一步得到的结果,我设定的判断有,如果已经让一个格子里的数字被满足,则可以禁止该格子所有未被连接的边,如果一个端点已经有两个方向被连接,则禁止另外两个方向,如果新连接端点已经有两个方向被禁止,则它仅剩的一个方向必然要被连接,如果新连接的端点与旁边的一个端点连接会形成一个独立的小环,则禁止这两点之间的连接。以上的操作如果任意一个出现失败返回,则返回失败标识,此后会被回滚。

同样,禁止操作也是这个流程,

1,判断禁止操作是否可行,包括是否已经被连接,是否会让一个有数字的格子剩下的可用边少于所需的值,是否会让一个有一点连接的端点形成3个方向的禁止从而成为死路,如果有以上情况,返回错误标识

2.如果没有以上情况,保存会被改动的状态,修改状态值。

3,推导必然会导致的结果,包括如果一个有数字的格子所剩可用边正好等于该格子所需的边时,连接剩下的这些边。如果一个有一端连接的端点被禁止了两个方向,只剩一个可用方向,则连接该方向。如果一个无连接的端点被禁止了3个方向,则禁止最后一个方向。以上操作如有任意一个返回错误,则返回错误。

在连接和禁止操作中如果返回错误,则需要进行撤销操作,撤销到最近的一次猜测前的状态。为了便于执行撤销操作,需要在每一步修改状态前保存需要修改的状态。每次操作会被修改的状态包括两个端点的连接状态和这个边的两个相邻格子的数字状态。另外还设定了一个操作累加编号,这样便于指定回滚到哪一次操作。这个号一直累加,我同时还把每次操作记录到了一个log文件中,后来对我找bug起了很大的作用。

这里说一下我所用的数据结构。为了图方便,我用一个格子类来记录一个格子和其左上顶点的状态,现在看起来如果把格子和点分开来存所需空间和记录内容会少一些,不过懒得动了。每次修改就会涉及三个格子类的改动,我叫他们起点,终点和邻居。这个类里面的信息有设定的数值,可能的数值掩码,点的连接关系和禁止关系掩码,当前周边有多少边连上了或禁止了等等,最后还有两个参数,一个叫线号,一个叫信息号,等会再说这两个。我把这些格子类组成一个二维数组,一个m*n格子的盘面,实际上有(m+!)*(n+1)个点,而我为了避免每次操作时进行越界判断,又在左边和上边加了一行一列格子,所以一共是(m+2)*(n+2)个格子。在初始化时,将四个角格子设为0,四个外边的行列格子里的可能的数字掩码设为3,也就是可能为0和1,在禁止盘面主体向外的连接关系,这样就把真正的盘面包起来了,又不用每次去判边界和进行复杂的边界处理,付出少少的空间代价,大大减少代码处理的复杂度。

好了,接下来说说线号这个东西。因为每次匹配和猜测都要从线的端点进行判断,特别是还要判断是否构成小环,所以我设了一个列表来保存每个线段的两个端点,为了区分这些线段,给它们每个设定一个线号。线段的操作有三种,分别是:

1,产生一个新线段,在连接两个没有任何连接的端点时就会产生一条新线段,这时给它分配一个线号,然后存到当前存在的线段列表里。新信号是单调增加的,保证每个都是新的没用过。

2,延伸一个已有的线段,像贪吃蛇一样长长,把线段列表里原有的端点替换成新的端点。

3,合并两个线段。这个最麻烦,我设定是大号吃小号,当然也可以老号吃新号,我在这两个方案里想来想去哪个号,最后发现其实都一样。被吃掉的就要从当前存在列表里移出,可不能就丢掉了,加上凶手的编号存到一个已移除的列表里,将来说不定还有诈尸的机会。记得把新的列表里的端点改成新的端点,还有把原来被吃掉的那个的一个外侧端点(也就是合并后的线的一个端点)的线号改成当前的新线号。

通过线号,寻找端点和判断小环的操作就简单多了。而在回滚操作时,也需要恢复线号和线号列表中的内容,所以有关的信息也需要保存在操作记录中。

到这里基本上就说完了,实际运行效果,我从一些数回游戏上抄了几个图来测试,基本上简单难度的图不用猜测都能完成,难一些的图就需要猜测一些。然后我在这里因为有些BUG,死活解不了,花了几天的时间,最后不得不把过程写到一个log文件中人肉复盘才发现了问题所在,这里就要讲下我这次最失败的设计,就是前面说过的信息值这个东西,更准确的说,不是信息值本身不好,而是我实现时用的数据结构不好,没有把信息值放到格子类里面,而是另外列了一个结构数组,容易出错,这个错让我花了大量的精力去排查。开始怀疑是回滚操作没有完全恢复盘面,于是想办法排查了回滚前后的状态,后来发现回滚操作是对的,但是回滚的位置没弄对。除了这个BUG后还是不对,只能又去人肉复盘第一次猜测前后的盘面状态,最终发现是因为数据结构的叠床架屋,造成信息结构和格子类中的数据在回滚后没有良好同步更新,造成匹配定式时计算连接状态掩码错误。

而信息值本身,还是证明很有用的。因为要进行猜测,那么当然是从已知条件比较多的地方开始比较好,为了表征一个块的信息含量,我设定了一个信息值,比如设定了数字的,信息值肯定要大一些,而3和0的值肯定要大于2和1的值,同时没增加一个连接或者禁止,也要增加一些信息值,具体增加多少,其实也就是随便拍了个脑瓜定了个数。然后在每次需要猜测时,从所以线的端点里,挑一个综合信息值最大的来进行尝试。这个综合信息值也就是说不是这个块单独的信息值,而是它周围的信息值的总和,至于周围多大,我还是拍个脑瓜,先设成以该点为左上顶点的2*2方格了,其实现在想想应该以该点为中心的2*2方格或者更大一点的范围比较合理。不过当时以调通为先,没想那么多了。而且为了除BUG,我一度屏蔽了这些可能会引入BUG的代码,简化流程,最后调通时这个功能实际上是没有的。一时高兴试了几个简单的图,都是秒过,感觉没必要再折腾了。后来从一个ipad游戏上抄了一个最大的图,这下就觉得有点慢了,花了大概30多秒,查了一下LOG有20多万步,哎呀妈呀,实在有点多呀,想起还有这么个功能被屏蔽着,就打开试试吧,这一开不要紧,刚点下去就出来了,吓得我还以为是上次的结果没清掉(我这游戏有个存答案的功能),又仔细的重新开游戏,看看没有存下答案,再点下去,还是秒过,这下真把我乐坏了,查看了log,才两千多步,百倍的差距啊!这还是随便排脑瓜设计的信息值函数,就有这么大的威力,所以算法的改进才是根本,代码的优化只是锦上添花。

最后不得不说个事,我一度信心膨胀,从网上找了个40*50的超高难度地图,结果可耻的失败了。想了一下,我这种简单的深度优先在处理超大版面时深度太深了,肯定是不行的。改进的方案应该是采用分块的方式,将大图分成小块,算出每个小块中的所有可能,再与邻近的块尝试可能的拼接。举个栗子,一个3*3的盘面,我现在已经有了所有2*2的定式,那么先从左上开始,匹配所有可能的2*2定式,这也是我在程序里做的事,但是匹配完后不是寻找确定边就完事了,而是将每个匹配上的2*2定式设置在盘面上,滑动一格,去试试旁边的2*2能够在满足这个匹配的边缘条件下能匹配多少,最后就得到了一个能够满足这个3*3的盘面的所有定式。这种匹配结果保存下来,即使回滚也不用一滚到底。分块的思想实际上就是把定式再扩展到更大的尺寸。当然,太大了这个可能性太多,都存下来不现实,因此大于一定的时候,不是靠穷举小一些的定式来拼接,而是在其中采用猜测-推演的方式生成,每个块里推进到边界就不再向外,这样每次回滚也就只需要回滚一个小块里的内容,保存也只需要保存里面的几个猜测点就可以了,需要的保存值要少一些。当然内存够大的话多存点也没关系,分成多大的小块呢,我觉得10*10应该差不多。

要实现对这种超大版面的解算,用python估计是不行了,得用C才行,因为分块,所以可以支持多线程并行,效率还可以高一些,因为要进行大量的特征字比较,所以最好再用上超标量指令集,要不再上个CUDA?嗯,我觉得还是算了吧。我就是玩玩游戏,犯不上和它拼命。反正一般的图都能秒出了,够我用了。

游戏放在我的下载里了,代码比较乱,完整记录了一个不幸误入泥潭的倒霉蛋的挣扎痕迹,需要的同学自取吧。用关键字Slither link搜索,目前还是全站唯一,唉,这个游戏真够冷门的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值