用python对算24游戏做一个全面分析

有了Python语言,编程者可以对算24游戏相关内容做一个比较彻底的分析。为什么必须python语言,其它语言不行么?当然也可以,只是python让很多算法可以比较简单地实现,让研究过程更有乐趣。

问题1:扑克牌玩法的算24游戏有多少种不同的组合

问题转化为一个组合问题:从1~13,抽取4个数字的组合,抽取可以重复。转化为苹果和盘子的语境就是:从13种不同的苹果中任意抽取,放在4个相同的盘子里,盘子不允许为空。
解:写一个函数,传两个参数:

  1. 供选择的列表choose
  2. 抽取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个小算法:三个个辅助算法(任性两个数字、所有可能的计算、剩下的数字),和最后可解性判断的算法。

辅助算法一:任选两个数字
这是传统的组合问题,在苹果盘子语境下是:不同的苹果,相同的盘子,盘子不可为空。
把题目扩展一下:写一个函数,传两个参数:

  1. 供选择的列表choose
  2. 抽取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
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

圣手书生肖让

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值