回溯法求解消消乐问题

问题描述

《开心消消乐》是一款乐元素研发的三消类休闲游戏。游戏中消除的对象为小动物的头像,包括小浣熊、小狐狸、小青蛙和小鸡等动物头像。玩家通过移动动物头像位置凑够同行/同列3个或3个以上即可消除。

现在定义消消乐规则如下:
1、游戏中共允许K种对象,分布在大小为M×N的格子布局中。
2、交换两个对象位置,凑够3个或3个以上即可消除,被消除的空格由正上方对象掉落填充。
3、可能出现三种消除方式: 同行(列)三个、同行(列)四个、同行(列)五个。分别得分1分、4分、10分。
4、当没有可通过交换消除的对象时,游戏终止。

要求

1、给定K, M, N编写代码计算通过一步操作(交换)可得的最大得分。

2. 在1的基础上利用回溯算法,找出X交换步骤之后的最大得分。

3. 对于数值较大的K、M、N、X,在允许近似最优解的情况下,对2中实现的算法进行优化剪枝。并与内容2中最终结果和执行速度进行比较。

实现

首先,我们需要随机生成包含指定种类数量k、指定大小的矩阵,而且这个矩阵初始化之后不应该包含大于等于三个相同种类的块相连的情况,不然就出现了初始矩阵不进行任何交换也会发生消除的局面,与游戏的要求不符。

因此在生成矩阵之后需要进行判断是否有可消除的,若有则修改最后一个相连的块为另一个随机数,并且把位置移回左上角重新检测,具体的伪代码如下:

Check_init(arr):
1 for i=0 to row	//row表示行数 
2 		for j=0 to col	// col表示列数
3 			sum=1
4			for k=i+1 to row-1 and arr[k][j]==arr[i][j]
5				sum++
6			if sum>=3
7				while arr[i][j]==arr[k-1][j] or arr[k-1][j]=0
8					arr[k-1][j]= random(K)//修改成另一个种数K以内随机数
9				i=0, j=0	//回到左上角重新开始检测
10			sum=1
11			for k=j+1 to col-1 and arr[i][k]==arr[i][j]
12				sum++
13			if sum>=3
14				while arr[i][j]==arr[i][k-1] or arr[i][k-1]==0
15					arr[i][k-1]=random(K)
16				i=0,j=0

接下来我们可以对矩阵在x步交换内的最大得分进行求解了,我们首先需要做一些准备工作,分别是准备以下三个工具函数:

(1)CheckRemove(arr,pos1,pos2):

功能是交换两个指定位置,并且计算因此产生直接消除所带来的得分,把得分返回。如果两个位置是同一个,那就变成直接检测当前位置进行十字搜索是否带来消除。

具体的实现是对两个指定位置进行交换,然后分别检测它们在上下左右四个方向上和同类最大相连的数量,如果数量大于等于3并且小于等于5就将对应的位置置为0,并且计算出对应的得分。

对应伪代码如下:

CheckRemove( &arr,pos1,pos2): //返回值为得分,arr要引用
1	X1=pos1.x , x2=pos2.x , y1=pos1.y , y2=pos2.y
2	if arr[x1][y1]==0 or arr[x2][y2]==0 
3		return 0
4	xsum1=1 , ysum1=1 , xsum2=1 , ysum2=1
5	两个for循环分别遍历(x1,y1)左侧和右侧元素,每趟循环xsum1加1,直到与(x1,y1)不同		//(x,y)表示arr[x][y]
65类似,遍历(x2,y2)左右两侧,每趟xsum2加1,直到与(x2,y2)不同
75类似,遍历(x1,y1)上下两侧,每趟ysum1加1,直到与(x1,y1)不同
85类似,遍历(x2,y2)上下两侧,每趟xsum2加1,直到与(x2,y2)不同
9	if xsum1 ∈ [3,5]
10		两个for循环遍历(x1,y1)左右两侧,相同元素置0,直到与(x1,y1)不同终止
11	if xsum2 ∈ [3,5]
12		两个for循环遍历(x2,y2)左右两侧,相同元素置0,直到与(x2,y2)不同终止
13	if ysum1 ∈ [3,5]
14		两个for循环遍历(x1,y1)上下两侧,相同元素置0,直到与(x1,y1)不同终止
15	if ysum2 ∈ [3,5]
16		两个for循环遍历(x2,y2)上下两侧,相同元素置0,直到与(x2,y2)不同终止
17	if xsum1>=3 or ysum1>=3
18		arr[x1][y1]=0
19	if xsum2>=3 or ysum2>=3
20		arr[x2][y2]=0
21	ans=point[xsum1]+point[ysum1]+point[xsum2]+point[ysum2]	//point数组记录相连的得分,point[3]=1,point[4]=4,point[5]=10,其它为0
22	if x1==x2 and y1==y2		//如果相同位置自身交换,得分要除2
23		ans=ans/2
24	return ans

(2) drop (arr):

功能是检测棋盘中的空位置,把上面的块掉落填充下来。

具体的实现就是遍历矩阵每一个元素,如果为0就意味着是空的,把上方的块都挪动下来即可。

伪代码如下:

Drop(arr):
1 for i=0 to row-1
2 for j=0 to col-1
3	 if arr[i][j]==0
4		 for k=i to 0
5			 if k==0
6				 arr[k][j]=0
7			 else
8				 arr[k][j]=arr[k-1][j]

(3) DropPoint(arr):

功能是结合Drop函数和checkRemove函数,找出当前状态下所有可能发生的掉落填充,最终返回因此产生的得分。

具体的实现是对矩阵每一个位置调用CheckRemove函数,找出可以消除的位置置为0,然后调用drop函数把上方块填充下来,然后从左上角开始重新进行检测是否有可消除。并且过程中记录掉落填充带来的消除所得总分,然后返回。

伪代码如下:

DropPoint(arr)
1	ans=0
2	drop(arr)
3	for i=0 to row-1
4	for j=0 to col-1
5		if arr[i][j]==0
6			continue
7		pos.x=i , pos.y=j
8		tmp=CheckRemove(arr,pos,pos)
9		if tmp>0
10			drop(arr)
11			ans=ans+tmp
12			i=0
13			j=0

接下来我们就可以编写最核心的递归函数了,这里用到了无剪枝的回溯法,整体的搜索过程和dfs较为相像。

这个递归函数我设计的返回值为最大得分,形参为当前步数、当前得分、当前矩阵,这三个形参都是用来记录递归时每一步的状态。

首先设置一个显式的返回条件:当前步数大于要求步数x时便返回当前分数。这是因为当前步数大于要求步数会不符合要求,可以直接回溯。

接下来开始遍历二维数组中的每一个元素,对于为0的元素可以直接continue进行下一趟循环,因为空位不可能发生交换,对每一个非空元素我们只需要考虑用它和右侧、下侧这两个相邻元素交换的情况,而不需要考虑四个方向,不然会导致重复。

当分别考虑与右侧、下侧交换时,我们调用CheckRemove函数计算两个相邻元素交换之后的得分,如果得分为0意味着交换不会带来消除,因此我们没有必要进行这步交换,所以不做任何操作;如果得分不为0这意味着这步交换会带来消除,可能会产生最大得分,因此要递归调用自身,并且给参数中的当前得分加上CheckRemove和DropPoint计算出的消除和掉落再消除得分。

最终当步数大于指定步数或递归到底部时,遍历整个二维数组都找不到交换可得分的情况,就不会继续递归,把最大得分返回到上一层函数,逐层返回,最终得出最大得分。

还有几点细节需要仔细说说,首先是我们每步递归都需要传入一个二维数组作为实参,然后对这个二维数组进行交换、消除、掉落调整后再传给下一次的递归函数,因此递归过程中实际上保存了每一个不同状态的二维数组,从而保证结果不会出错。

核心无剪枝回溯函数的伪代码如下:

//返回最大得分
Search(arr,step,nowpoint):
1 if step>x		//x是用户指定的步数
2	 return nowpoint
3 ans=nowpoint
4 for i=0 to row-1
5 for j=0 to col-1
6	 if arr[i][j]==0
7		 continue
8	 pos.x ,pos.y=i,j 
9	 pos1.x ,pos1.y=i,j+1
10	 pos2.x ,pos2.y=i+1,j
11	 if j<col-1 and arr[i][j+1]!=0
12		 arr1=arr
13		 tmp=CheckRemove(arr1,pos,pos1)
14		 if tmp!=0
15			 drop_point=DropPoint(arr1)
16			 newpoint=search(step+1,arr1,nowpoint+tmp+drop_point)
17			 if newpoint>ans
18				 ans=newpoint
19 	 if i<row-1 and arr[i+1][j]!=0
20		 arr1=arr
21		 tmp=CheckRemove(arr1,pos,pos2)
22		 if tmp!=0
23			 drop_point=DropPoint(arr1)
24			 newpoint=search(step+1,arr1,nowpoint+tmp+drop_point)
25			 if newpoint>ans
26				 ans=newpoint
27 return ans

剪枝

我这里采用的剪枝方法如下:

(1) 在第n层递归中,计算当前得分的平均值,即用前n-1层的总分P0除以n-1得到Pa。(即Pa=P0/(n-1))

(2) 然后用这个平均分Pa乘上剩余步数(即X-n+1步)得到一个理论最大剩余可得分P1。(即P1=Pa*(x-n+1))

(3) P1我们可以给它乘上一个参数C,这个参数C可自由决定,C越大越难发生剪枝。(即P2=P1*C)

(4) 最终判断P2加上前n-1层总分P0是否小于当前最大得分P3,若小于则直接返回,视作这条路不可能找出比当前最优解P3更好的情况,否则继续往下搜索。

具体的伪代码如下:

if P0/(n-1)*(x-n+1)*C+P0 < P3
		return P0

由于消消乐大量情况的复杂性,我们的剪枝算法很难得到精确解,性能和准确率会呈现负相关的关系,可根据自己需要来修改其中的参数C,因为C越大代表剪枝的条件越苛刻,因此C越大准确率越高,但是效率会变低。

测试

这里有组我编写的测试数据:

链接:https://pan.baidu.com/s/1T931ZbSPcttAe4ztA3Uwig
提取码:lcjo

图形界面

VS可以使用Qt、EasyX图形库,Dev可以用EGE图形库。

我当时用的是EGE图形库,具体实现的时候比较粗糙,直接一帧一帧更新棋盘。

在网上找些素材包图片然后裁剪一下即可。

我实现的结果如下:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

效率分析

(1) 不同步数X的影响:

首先固定k=4,m=5,n=5,然后测试不同的x的运行时间,如下分别为x=1,2,3,4,5,6,7,8的数据(无剪枝):

在这里插入图片描述

可以看出在k、m、n不变时,x越大运行时间越长,这是因为步数x越大递归树高度越高,总递归次数就会增多。而且曲线似乎无规律可循,也完全不符合最坏情况的指数增长,因此进一步证明了使用的回溯算法在时间复杂度上的难以确定性。

(2) 不同种数K的影响:

使用无剪枝回溯法测试在m=7、n=7、x=3情况下K=3,4,5,6,7的运行时间:

在这里插入图片描述

可以看出当m、n、x不变时,种数k越大运行时间越短,这是因为当种数变多之后每个种类的个数减少了,要交换两个元素带来消除得分变难了,相当于递归树的分支变少了,总递归次数减少。

(3) 不同行数m、列数n的影响:

显而易见,当k、x不变时,m、n越大运行时间会越长,因为递归树的分支变多了,总递归次数会增多,而且算法中大量的开销都来自于对整个棋盘的遍历,m、n变大开销必定增大。这里不再进行实验测试了。

未剪枝与剪枝后的对比

我这里选用的种数k=4、行数m=7、列数n=7、步数x=5,然后分别测试剪枝参数C=3,4,5(具体C是什么见前文)的情况:

~~~懒人不想排版

(1) C=3:

未剪枝结果(分) 剪枝结果(分) 未剪枝时间(ms) 剪枝时间(ms)
第一组 32 32 2821 55
第二组 22 22 1605 50
第三组 23 23 1748 44
第四组 30 27 2701 57
第五组 34 31 3014 33
第六组 26 19 2532 55
第七组 27 27 3208 144
第八组 27 21 6285 494
第九组 23 20 3570 111
第十组 28 25 2117 88
平均值 27.2 24.7 2960.1 113.1
准确率 40%

在这里插入图片描述

在这里插入图片描述

可见C=3剪枝后效率已经大幅提升,剪枝后的平均运行时间仅为原来的1/26,但是准确率只有40%。

(2) C=4:

未剪枝结果(分) 剪枝结果(分) 未剪枝时间(ms) 剪枝时间(ms)
第一组 30 30 2053 97
第二组 21 21 1143 247
第三组 24 24 1493 152
第四组 26 23 7972 51
第五组 28 27 4046 268
第六组 25 24 3056 186
第七组 21 20 5668 196
第八组 24 23 4351 479
第九组 22 22 5786 502
第十组 26 26 2423 57
平均值 24.7 24 3799.1 223.5
准确率 50%

在这里插入图片描述

在这里插入图片描述

C=4的时候剪枝后的运行时间有所增加,剪枝后平均时间为剪枝前的1/17,准确率虽然提升了10%而已,但是剪枝前和剪枝后的平均得分仅仅相差0.7分,因此C=4比C=3的准确率会更高,但是运行时间更长。

(3) C=5:

未剪枝结果(分) 剪枝结果(分) 未剪枝时间(ms) 剪枝时间(ms)
第一组 30 30 8251 360
第二组 38 38 2204 167
第三组 29 29 8876 241
第四组 24 23 7458 352
第五组 23 23 8088 452
第六组 26 26 5495 507
第七组 23 23 2018 68
第八组 29 29 10383 266
第九组 22 22 2315 346
第十组 33 33 7779 52
平均值 27.7 27.6 6286.7 281.1
准确率 90%

在这里插入图片描述

在这里插入图片描述

可以看到C=5的时候剪枝后准确率大幅提高了,同时剪枝后运行时间也略微增加了,此时剪枝前后的平均得分仅相差0.1,因此性能上是比较好的。

~~~下面是个表格,懒人不想排版

C 3 4 5
剪枝后时间(ms) 113.1 223.5 281.1
平均分差(分) 2.5 0.7 0.1
准确率(%) 40 50 90

总而言之,对于k=4,m=7,n=7,x=5的这个具体情境来说,参数C选用5的准确率会比较高,同时保证一定的效率提升。对于具体的问题,我们可以根据自己的需要来设置C的值,需要准确率则把C调大,需要运行效率则把C调小,也可以反复测试找出最适合的参数C。

  • 2
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值