有了Python语言,编程者可以对算24游戏相关内容做一个比较彻底的分析。为什么必须python语言,其它语言不行么?当然也可以,只是python让很多算法可以比较简单地实现,让研究过程更有乐趣。
算24全分析
问题1:扑克牌玩法的算24游戏有多少种不同的组合
问题转化为一个组合问题:从1~13,抽取4个数字的组合,抽取可以重复。转化为苹果和盘子的语境就是:从13种不同的苹果中任意抽取,放在4个相同的盘子里,盘子不允许为空。
解:写一个函数,传两个参数:
- 供选择的列表choose
- 抽取n个
返回值为所有可能的组合。
算法原则:首先抽取前n-1个数字,然后第n个数字的抽取不可小于前面第n-1个数字。一般来说,所谓不允许重复,在算法上就是通过从小到大按顺序排列来实现的。
有了python的yield语句,代码显得很简洁。
def makeCombinationRepeat(choose, n):
if n==0:
return []
elif n==1:
for x in choose:
yield [x]
else:
preList= makeCombinationRepeat(choose, n-1)
for item in preList:
for x in choose:
if x>= item[-1]:
yield item.copy()+[x]
调用:
num=[i for i in range(1,14)]
ret= makeCombinationRepeat(num,4)
print(len(list(ret)))
结果显示:1820
应该说,这是一个相当小的数字,
问题2:其中有多少种组合是有解的
这就稍微复杂一点,必须做求解的判断。我们采用最朴素的算法,也正是大多数算24游戏所采用的操作方法:从现有牌组中任选两个数字,进行所有可能的计算,将结果放回剩下的数字中。继续这种操作,直到最后只剩下一个数字。检查它是否等于24,在所有递归操作中,有一种情况成立即说明有解。这里将涉及4个小算法:三个个辅助算法(任性两个数字、所有可能的计算、剩下的数字),和最后可解性判断的算法。
辅助算法一:任选两个数字
这是传统的组合问题,在苹果盘子语境下是:不同的苹果,相同的盘子,盘子不可为空。
把题目扩展一下:写一个函数,传两个参数:
- 供选择的列表choose
- 抽取n个
返回值为所有可能的组合。
我们仍然用最朴素的算法设计思路,选择第一个数字,或者不选。如果选择第一个数字,问题转化为从剩下的个数字再选n-1个;如果步选第一个数字,问题转化为从剩下的数字中选择n个。特殊情况是:如果n=0,返回是空集;如果n=1,则选择恰好是choose中每个数字一次;如果n等于choose的长度,则直接选全体;另外,如果n大于choose的长度,当然就应当报错了。
def makeCombination(choose, n):
if n==0:
return []
elif n==1:
for x in choose:
yield [x]
elif len(choose)==n:
yield choose
else:
list1= makeCombination(choose[1:], n)
list2= makeCombination(choose[1:], n-1)
for item in list1:
yield item.copy()
for item in list2:
ret= item.copy()+ choose[0:1]
yield ret
辅助算法二:所有可能的计算
经典玩法下,只有加减乘除四种计算可用,考虑到减法和除法有计算顺序,实际有六种计算。
也是一个简单算法:
def makeAllCalc(twoNum):
ret= set()
ret.add(twoNum[0]+ twoNum[1])
ret.add(twoNum[0]- twoNum[1])
ret.add(twoNum[0]* twoNum[1])
ret.add(twoNum[1]- twoNum[0])
if twoNum[1]!=0:
ret.add(twoNum[0]/ twoNum[1])
if twoNum[0]!=0:
ret.add(twoNum[1]/ twoNum[0])
return list(ret)
之所以用set转化一下,只是为了尽量减少可能的重复,就是+0-0,*1/1这种。
辅助算法三:剩下的数字
这也是一个很简单的算法,从一个列表中减去另一个列表。
def removeList(list1, list2):
list1= list1.copy()
for item in list2:
if item in list1:
list1.remove(item)
return list1
核心算法:可解性判断
算法过程就是前面描述的,如果所给的数组只有1个数字,我们判断它是不是24就可以了。考虑到计算过程中会有浮点数出现,我们把判断放松一点,最终计算结果与24足够接近即可,比如24±0.0001。当然,如果使用分数计算则可以精确等于。
如果是2个数字以上,我们先从中选出2个数字(大约6种可能),然后每组数字按6种可能的计算(共约36种可能)得到一个新的数字。与剩下的数字合并为一个新组,进行递归计算。
def tryCalc(suit):
if len(suit)==1:
return abs(suit[0]-24)<0.001
else:
chooseTwo= makeCombination(suit, 2)
for two in chooseTwo:
left= removeList(suit, two)
allRes= makeAllCalc(two)
for res in allRes:
ret= tryCalc(left+[res])
if ret:
return True
return False
调用,一个一个判断计算性。
num=[i for i in range(1,14)]
ret= makeCombinationRepeat(num,4)
cc= 0
cd= 0
for x in ret:
canSolve= tryCalc(x)
if canSolve:
cd+=1
cc+=1
print(f'总数:{cc},其中可解的组合数量:{cd}。')
得到结果,1362种是可解的,大约占到3/4。
总数:1820,其中可解的组合数量:1362。
[Finished in 4.3s]
问题3:扩展问题——算23,算25,等等
如果你经常亲自玩这个算24的游戏,有时会遇到我们能算23,也能算25,就是一时想不出怎么算24。我们直观的感觉,算23和25机会都应当少得多,以前日本也有类似算10的游戏,似乎也有因子太少的问题,不如算24的机会多。其他数字怎样?比如36,48,甚至60,虽然因子也很多,但数字太大可能会失去一些较小数字的机会。我们猜想之所以只有算24流行,是因为在所有目标数字中,4张牌组能够算出24的机会最多。
是否真的如此呢?让我们编程验证一下,我们把上面的算法略改一下就可以看看计算其他数字的可解比例。改变算法为比较任何数字,只需增加一个参数target。
def tryCalcAny(suit, target):
if len(suit)==1:
return abs(suit[0]-target)<0.001
else:
chooseTwo= makeCombination(suit, 2)
for two in chooseTwo:
left= removeList(suit, two)
allRes= makeAllCalc(two)
for res in allRes:
ret= tryCalcAny(left+[res], target)
if ret:
return True
return False
运行的代码如下。我们计算从0到100的所有可能性。
cc=0
cd={}
minNum= 0
maxNum= 101
for t in range(minNum, maxNum):
cd[t]= 0
for x in ret:
for t in range(minNum, maxNum):
canSolve= tryCalcAny(x,t)
if canSolve:
cd[t]+=1
cc+=1
print(f'4张牌组合总数:{cc},其中:')
for t in range(minNum, maxNum):
print(f' 算{t},可解的数量为:{cd[t]},比例为:{round(cd[t]/cc*100,4)}%')
不知道读者看到计算结果什么感觉,我个人是大为吃惊,虽然24的计算可解比例是一个局部高点,而且高于所有以后的数字;但不如20以及所有18以前的数字——你相信么?给你4张牌,你有更大的概率算出17而不是24。而且算出25的机会,其实略小于算23的机会!
总的规律是:数字越小的可计算比例越高,因子越多的可计算比例最高。
所有100以内的目标数字中,可计算比例最低的是最大的质数97,仅20%左右。可计算比例最高的,是最小的质数2,在全部1820种牌组中,仅62种组合不能算出2,可计算率高达96%+。像36、48、60、72这种因子较多的数字,都形成了局部高点,但总的来说,数字越大可计算的比例越小。
为什么只有算24的游戏最流行呢?只怕或许没什么特别的原因。
4张牌组合总数:1820,其中:
算0,可解的数量为:1629,比例为:89.5055%
算1,可解的数量为:1580,比例为:86.8132%
算2,可解的数量为:1758,比例为:96.5934%
算3,可解的数量为:1651,比例为:90.7143%
算4,可解的数量为:1612,比例为:88.5714%
算5,可解的数量为:1581,比例为:86.8681%
算6,可解的数量为:1590,比例为:87.3626%
算7,可解的数量为:1542,比例为:84.7253%
算8,可解的数量为:1534,比例为:84.2857%
算9,可解的数量为:1540,比例为:84.6154%
算10,可解的数量为:1540,比例为:84.6154%
算11,可解的数量为:1486,比例为:81.6484%
算12,可解的数量为:1535,比例为:84.3407%
算13,可解的数量为:1414,比例为:77.6923%
算14,可解的数量为:1449,比例为:79.6154%
算15,可解的数量为:1440,比例为:79.1209%
算16,可解的数量为:1424,比例为:78.2418%
算17,可解的数量为:1240,比例为:68.1319%
算18,可解的数量为:1397,比例为:76.7582%
算19,可解的数量为:1197,比例为:65.7692%
算20,可解的数量为:1372,比例为:75.3846%
算21,可解的数量为:1254,比例为:68.9011%
算22,可解的数量为:1244,比例为:68.3516%
算23,可解的数量为:1108,比例为:60.8791%
算24,可解的数量为:1362,比例为:74.8352%
算25,可解的数量为:1074,比例为:59.011%
算26,可解的数量为:1163,比例为:63.9011%
算27,可解的数量为:1136,比例为:62.4176%
算28,可解的数量为:1171,比例为:64.3407%
算29,可解的数量为:942,比例为:51.7582%
算30,可解的数量为:1193,比例为:65.5495%
算31,可解的数量为:890,比例为:48.9011%
算32,可解的数量为:1106,比例为:60.7692%
算33,可解的数量为:1035,比例为:56.8681%
算34,可解的数量为:892,比例为:49.011%
算35,可解的数量为:1019,比例为:55.989%
算36,可解的数量为:1270,比例为:69.7802%
算37,可解的数量为:821,比例为:45.1099%
算38,可解的数量为:830,比例为:45.6044%
算39,可解的数量为:949,比例为:52.1429%
算40,可解的数量为:1157,比例为:63.5714%
算41,可解的数量为:723,比例为:39.7253%
算42,可解的数量为:997,比例为:54.7802%
算43,可解的数量为:718,比例为:39.4505%
算44,可解的数量为:982,比例为:53.956%
算45,可解的数量为:952,比例为:52.3077%
算46,可解的数量为:726,比例为:39.8901%
算47,可解的数量为:677,比例为:37.1978%
算48,可解的数量为:1139,比例为:62.5824%
算49,可解的数量为:762,比例为:41.8681%
算50,可解的数量为:845,比例为:46.4286%
算51,可解的数量为:725,比例为:39.8352%
算52,可解的数量为:849,比例为:46.6484%
算53,可解的数量为:633,比例为:34.7802%
算54,可解的数量为:932,比例为:51.2088%
算55,可解的数量为:798,比例为:43.8462%
算56,可解的数量为:915,比例为:50.2747%
算57,可解的数量为:647,比例为:35.5495%
算58,可解的数量为:613,比例为:33.6813%
算59,可解的数量为:590,比例为:32.4176%
算60,可解的数量为:1110,比例为:60.989%
算61,可解的数量为:564,比例为:30.989%
算62,可解的数量为:586,比例为:32.1978%
算63,可解的数量为:790,比例为:43.4066%
算64,可解的数量为:800,比例为:43.956%
算65,可解的数量为:736,比例为:40.4396%
算66,可解的数量为:837,比例为:45.989%
算67,可解的数量为:532,比例为:29.2308%
算68,可解的数量为:621,比例为:34.1209%
算69,可解的数量为:587,比例为:32.2527%
算70,可解的数量为:839,比例为:46.0989%
算71,可解的数量为:500,比例为:27.4725%
算72,可解的数量为:1054,比例为:57.9121%
算73,可解的数量为:495,比例为:27.1978%
算74,可解的数量为:497,比例为:27.3077%
算75,可解的数量为:634,比例为:34.8352%
算76,可解的数量为:613,比例为:33.6813%
算77,可解的数量为:689,比例为:37.8571%
算78,可解的数量为:769,比例为:42.2527%
算79,可解的数量为:456,比例为:25.0549%
算80,可解的数量为:877,比例为:48.1868%
算81,可解的数量为:598,比例为:32.8571%
算82,可解的数量为:453,比例为:24.8901%
算83,可解的数量为:447,比例为:24.5604%
算84,可解的数量为:874,比例为:48.022%
算85,可解的数量为:507,比例为:27.8571%
算86,可解的数量为:424,比例为:23.2967%
算87,可解的数量为:466,比例为:25.6044%
算88,可解的数量为:726,比例为:39.8901%
算89,可解的数量为:395,比例为:21.7033%
算90,可解的数量为:864,比例为:47.4725%
算91,可解的数量为:570,比例为:31.3187%
算92,可解的数量为:497,比例为:27.3077%
算93,可解的数量为:426,比例为:23.4066%
算94,可解的数量为:388,比例为:21.3187%
算95,可解的数量为:437,比例为:24.011%
算96,可解的数量为:831,比例为:45.6593%
算97,可解的数量为:373,比例为:20.4945%
算98,可解的数量为:519,比例为:28.5165%
算99,可解的数量为:612,比例为:33.6264%
算100,可解的数量为:654,比例为:35.9341%
整个计算过程超过10分钟。
问题4:计算能力最强的牌组
我们换一个角度来看分析,有些牌组计算能力就比较强,可以算23、24、25等等很多数字;而另外一些计算能力就弱一些。比如四个1,它的计算能力就很弱,能够计算的数字仅0,1,2,3,4,更多的就没法算了。我们想看看不同牌组的计算力如何。仍然以0~100的目标数字为限,超过100的数字不在统计。当然非整数也不统计在内。
cc=0
minNum= 0
maxNum= 101
cf= 0
for x in ret:
cd= []
for t in range(minNum, maxNum):
canSolve= tryCalcAny(x,t)
if canSolve:
cd+=[t]
ce= len(cd)
print(f'牌组{x},可计算的整数共有{ce}种,分别是{cd}。')
if ce>cf:
cf= ce
suit= x
cc+=1
print(f'其中计算力最强的牌组是{suit},可计算的整数共有{cf}种。')
大约10分钟后得到计算结果,限于篇幅,就不一一展示每个牌组的计算能力了。最后得到计算力最强的牌组为:[2, 3, 7, 12]。
可计算的整数共有82种,分别是:0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 39, 40, 41, 42, 44, 45, 46, 47, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 63, 65, 66, 67, 72, 73, 75, 76, 77, 78, 79, 81, 83, 84, 85, 86, 89, 90, 91, 92, 93, 95, 96, 100。
问题5:给出计算过程
前面只是做了能否计算的判断,下一个需求是给出计算过程。我们用一个简单的办法来表示计算过程,即三个公式。比如用[2, 3, 7, 12]计算24的方法,简单表示为:‘7+12,3+19,2+22,’。
为了得到计算过程,在问题2的基础上,有两个算法的代码略做修改。
辅助算法二:所有可能的计算
def makeAllCalc2(twoNum):
ret= {}
ret[twoNum[0]+ twoNum[1]]= str(twoNum[0])+'+'+str(twoNum[1])
ret[twoNum[0]* twoNum[1]]= str(twoNum[0])+'*'+str(twoNum[1])
if twoNum[0]>= twoNum[1]:
ret[twoNum[0]- twoNum[1]]= str(twoNum[0])+'-'+str(twoNum[1])
else:
ret[twoNum[1]- twoNum[0]]= str(twoNum[1])+'-'+str(twoNum[0])
if twoNum[1]!=0:
ret[twoNum[0]/ twoNum[1]]= str(twoNum[0])+'/'+str(twoNum[1])
if twoNum[0]!=0:
ret[twoNum[1]/ twoNum[0]]= str(twoNum[1])+'-'+str(twoNum[0])
return [[x,ret[x]] for x in ret]
返回值除了两个数字的计算结果外,还附带了计算公式。负数的出现毫无意义,所以取消小减大的可能性。两个方向的保留。
判断并获得解法
def tryCalc2(suit,target):
if len(suit)==1:
return abs(suit[0]-target)<0.001, ''
else:
chooseTwo= makeCombination(suit, 2)
for two in chooseTwo:
left= removeList(suit, two)
allRes= makeAllCalc2(two)
for res in allRes:
ret, expr= tryCalc2(left+res[0:1],target)
if ret:
return True, res[1]+ ','+ expr
return False, ''
把多个结果的公式,简单地用逗号’,'连接起来。
下面显示最强牌组[2,3,7,12]计算0~30的目标数的解法:
for i in range(0,43):
ret= tryCalc2([2, 3, 7, 12],i)
print(i, ret[1])
结果如下:
0 12-7,5-3,2-2,
1 12-7,5-3,2/2,
2 7-3,12/2,6.0-4,
3 7*3,12*2,24-21,
4 12-7,3+5,8/2,
5 12-7,3-2,5/1,
6 12-7,3+5,8-2,
7 7+12,19+2,21/3,
8 7+12,19-3,16/2,
9 12-7,5-2,3*3,
10 12-7,3+5,2+8,
11 7+12,3+19,22/2,
12 3*2,7-6,12/1,
13 7+12,3*2,19-6,
14 7+12,19-3,16-2,
15 7*3,12/2,21-6.0,
16 12-7,3+5,2*8,
17 12-7,3*5,2+15,
18 7+12,19-3,2+16,
19 7+12,3-2,19/1,
20 7+12,3+19,22-2,
21 12-7,5+2,3*7,
22 12*3,7*2,36-14,
23 12+3,15*2,30-7,
24 7+12,3+19,2+22,
25 7+12,3*2,19+6,
26 7*12,84/3,28.0-2,
27 7*3,12/2,21+6.0,
28 7-3,12*2,4+24,
29 12+3,7*2,15+14,
30 7*12,84/3,2+28.0,
上面的计算过程,仅仅是给出一个计算方案,没有考虑计算方案的复杂程度(虽然这几个看起来都比较简单)。这就引出了下一个问题——
问题6:不同牌组的计算难度
这是算24最有趣的一部分,有些牌组很容易得到计算方案,有些牌组却百思不得其解,很可能被误认为无解。最有名的一些难度牌组如:[1,5,5,5],[3,3,7,7]等。
为了实现这种分析,必须拿到所有的解法。对于一套牌组来说,它的解题难度是由最简单的那个解法决定的。我们对所有的解法,逐一打分,返回分数最低的,作为整个牌组的难度分。
得到所有解的算法
def tryCalc3(suit, target):
if len(suit)==1:
yield abs(suit[0]-target)<0.001, ''
else:
chooseTwo= makeCombination(suit, 2)
for two in chooseTwo:
left= removeList(suit, two)
allRes= makeAllCalc2(two)
for res in allRes:
xlist= tryCalc3(left+res[0:1],target)
for ret, expr in xlist:
if ret:
yield True, res[1]+ ','+ expr
yield False, ''
难度判断算法
判定牌组的难度是比较主观的,在计算机看来,不同的解法之间并没有什么难度的差别可言。只是对于有着某种计算习惯的人类来说,才会有难度差异感觉。我们采用这样计算方法来进行难度评价的标准:
- 解法的最后一步如果是通过整数乘法计算得到24,难度认为最低,定为1
- 但如果在最后一步的乘法,参与了小数,一般人是难以想到的,难度定为5
- 最后一步是加法有时就会多想一会,加法的难度是2
- 最后一步是减法(排除减0这种情况),难度为3
- 最后一步是除法(排除除1这种情况),那就可以说是很难了,难度为4
这个难度判断逻辑未必严谨,但实际得到的分析结果,大致符合实际计算观感。 算法同时返回最简单的计算方法。
def anaHard(suit, target):
solvs= tryCalc3(suit, target)
hard= 100
sol= ''
for can,solv in solvs:
if not can:
continue
lines= solv.split(',')
if '-' in lines[2]:
nums= lines[2].split('-')
if isSame(nums[1],0):
lev= 1
else:
lev= 3
elif '+' in lines[2]:
lev= 2
elif '*' in lines[2]:
nums= lines[2].split('*')
if isFloat(nums[0]) or isFloat(nums[1]):
lev= 5
else:
lev= 1
else:
nums= lines[2].split('/')
if isSame(nums[1],1):
lev= 1
else:
lev= 4
if lev< hard:
hard= lev
sol= solv
if hard==100:
hard= 0
return hard, sol
现在,让我们来看看全部1820套牌组的难度情况。
cc= 0
for x in ret:
Solve= anaHard(x, 24)
if Solve[0]> 3:
print(f'牌组{x},难度{Solve[0]},最简单的解法:{Solve[1]}')
cc+=1
从结果看,无解的458组,这个早已有结论;
难度为1的 956组
难度为2的 290组
难度为3的 86组
难度为4的 20组
难度达到5的,仅10组
系统认为难度达到4级或5级的,一共只有30组。这里将他们列举出来,有兴趣和自信的朋友可以尝试手工求解。不过,当你知道它们的难度是4级5级,其实是一个巨大的提示,反而容易想出来。所以,如果你把这些题目出给你的朋友们,大概是最有趣的。
牌组[1, 2, 7, 7],难度4
牌组[1, 3, 4, 6],难度4
牌组[1, 4, 5, 6],难度4
牌组[1, 5, 5, 5],难度5
牌组[1, 5, 11, 11],难度4
牌组[1, 6, 6, 8],难度4
牌组[1, 6, 11, 13],难度4
牌组[1, 7, 13, 13],难度4
牌组[1, 8, 12, 12],难度4
牌组[2, 2, 11, 11],难度5
牌组[2, 2, 13, 13],难度5
牌组[2, 3, 5, 12],难度4
牌组[2, 4, 10, 10],难度5
牌组[2, 5, 5, 10],难度5
牌组[2, 7, 7, 10],难度5
牌组[3, 3, 7, 7],难度5
牌组[3, 3, 8, 8],难度4
牌组[3, 5, 7, 13],难度4
牌组[3, 6, 6, 11],难度4
牌组[3, 8, 8, 10],难度4
牌组[4, 4, 7, 7],难度5
牌组[4, 4, 10, 10],难度4
牌组[4, 8, 8, 11],难度4
牌组[4, 8, 8, 13],难度4
牌组[5, 5, 7, 11],难度5
牌组[5, 7, 7, 11],难度5
牌组[5, 10, 10, 11],难度4
牌组[5, 10, 10, 13],难度4
牌组[6, 11, 12, 12],难度4
牌组[6, 12, 12, 13],难度4