编程实现一个有GUI的24点游戏

24点是指从去除大小王后的52张扑克牌中任取 4 张,通过「加、减、乘、除」四则运算得到 24。是一个历史悠久的趣味小游戏。

《数据化管理》书中在测试数据敏感度章节提到一个细节“每天上下班的路上,盯着公交车外看到的汽车尾部牌照玩24点”,去练运算能力。根据排列组合知识可以算出:在1-10的数字中任选4个,有C(13,4)=715种情况(因为数字可以重复,如[5,5,5,5],故不是直接从10个数中取4个的组合),从1-13中任选4个是C(16,4)=1820种情况,经过大佬门的枚举和推导,只考虑加减乘除,715种情况中,有566种有解,也就是79.16%的概率,而从1~13中选的1820种情况中是1362种情况下能算出24点,概率为74.83%

给定序列算出24点

最近自己也在练24点的计算,需要随机生成4个数的组合,并且在需要有答案,看这题有哪些做法能算出24点,于是就打算用Python来实现生成4个随机数以及求给定序列的24点计算方法。可以选择在4个数之间的3个空格中枚举各种符号的情况,并且考虑括号,还有一种思路是“降数法”:4个数经过一步运算“降维”成3个数,再变成2个数,最后得到1个数,如果得到24说明这种组合成立。后一种需要的判断更少些,于是选择实现这一思路。

代码的大致流程如下:

  • 1),对给定的4个数进行排列,得到A(4,4)=4!=24种排列,对这24种情况执行:

  • 2),前2个数实现第一步计算,合并成1个数,生成一个3个数的新序列;

  • 3),对这3个数做排列,同样前2个做四则运算,3个数合并成2个;

  • 4),最后两个数的排列为[a,b]和[b,a],分别做加减乘除运算,变成一个数;

  • 5),如果最后生成的数是24,则记录这种计算方式;否则继续对下一个排列重复上面2~4。

降数法计算过程

得到一个序列的全排列的递归方法在之前的一个Ann全排列文章有具体讲解,这里不赘述。

最后求24点计算方法的代码如下:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#枚举列表lst的全排列
def perm(lst): #input:list,[1,2,3,4]
n=len(lst)
if n<=1: #终止条件1
return lst
elif n==2:
return [[lst[0],lst[1]],[lst[1],lst[0]]] #终止条件2
kk=[]
for i in range(n):
nlst=lst[0:i]+lst[i+1:] #除lst[i]外的元素
c=perm(nlst) #对子序列进行递归
ss=[]
for j in c:
sw=[lst[i]]
sw.extend(j)
ss.append(sw)
kk.extend(ss) #注意是extend不是append
return kk
def cal24(a): #24点计算
lst=[[i,''] for i in a]
d1=perm(lst) #len==24
ev=['+','-','*','/']
res=[]
for d in d1: #len(d)==4
for e1 in ev: #24*4
if e1=='/' and d[1][0]==0: #被除数为0
continue
r='({0}{1}{2})'.format(d[0][0],e1,d[1][0])
k1=[[eval(r),r],d[2],d[3]] #k1=[eval(),d[2],d[3]] k1.extend(d[2:])
d2=perm(k1) #len(k1)==3 len(d2)==A(3,2)=6
for d3 in d2: #len(d3)==3
for e2 in ev:
if e2=='/' and d3[1][0]==0: #被除数为0
continue
r1='{0}{1}{2}'.format(d3[0][0],e2,d3[1][0])
y0=d3[0][0] if d3[0][1]=='' else d3[0][1]
y1=d3[1][0] if d3[1][1]=='' else d3[1][1]
r2='({0}{1}{2})'.format(y0,e2,y1)
k2=[[eval(r1),r2],d3[2]] # k2.extend(d3[2:])
d4=[[k2[0],k2[1]],[k2[1],k2[0]]]
for d5 in d4:
for e3 in ev:
if e3=='/' and d5[1][0]==0:
continue
k3=eval('{0}{1}{2}'.format(d5[0][0],e3,d5[1][0]))
if abs(k3-24)<1e-6:
y0=d5[0][0] if d5[0][1]=='' else d5[0][1]
y1=d5[1][0] if d5[1][1]=='' else d5[1][1]
rss='({0}{1}{2})'.format(y0,e3,y1)
k4=eval(rss)
if abs(k4-24)<1e-6:
res.append(rss)
return list(set(res)) #初步去重

我们拿几个实例来进行测试,输入结果如下:

几个实例的结果

这种实现还是有些粗暴,没有很好地进行各种情况的去重,例如2×7+6+4和2×7+4+6是一种情况,对交换律和括号的去重实现可以参考 如何不重复地枚举 24 点算式?(上) - 王赟 Maigo

给24点小程序加上GUI

基于上面写的代码我们可以求任意4个数算24的所有情况,加上随机数生成平时就不缺24点的练习了,为了更好用,我们再加上GUI。为了兼容性,这里选择用内置的tkinter去实现GUI。

整体流程如下:

导入tk库,创建主窗体->添加控件->处理交互->进入主事件循环

交互的逻辑还是“降数法”的思路。

整体的界面如下图:

盘面及交互效果

代码比较长,主要分为了生成各种按钮并设置坐标放在合适的位置,编写按钮按下的回调函数两个部分。部分代码如下:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
root=tk.Tk()
root.geometry('280x320+400+100') #大小和位置 widthxheight+x+y
root.title('cal 24')
ctv=tk.StringVar(root,'')
btnUs=tk.IntVar(root,0)
cur=[]
result=[]
if result==[]:
for _ in range(4):
cur.append(random.randint(0,10))
cur.append('') #对应各个按钮当前值
scur=cur.copy() #重来 用
stk=[['',''],'',['',''],''] #操作符点击
itv=tk.StringVar(root,'---')
infov=tk.Label(root,textvariable=itv) #显示信息用
infov.place(x=170,y=5,width=120,height=20)

stk[3]=tk.Button(root,text='').cget("background") #默认按钮背景色 linux: #d9d9d9 win:SystemButtonFace
#回调函数
def btnClick(btn,bt=''): #btn:按下的按钮 bt:所按下按钮的标识,主要是数值键用
global cur,stk,scur,result
ith=itv.get()
btnus=btnUs.get()
uop=[i for i in range(15)] #[0,14]
opw=['+','-','*','/']
if btn=='--':return
if btn in uop: #按的是数值类型的键
btnn=cur[bt-1]
itv.set('{0}'.format(btnn))
if stk[0][0]=='': #第一次按到数值键
stk[0]=[btnn,bt] #or stk[0][0]=btnn;stk[0][1]=bt
elif stk[1]=='':#没有按过符号键
if stk[0][0] !='':#如两次点到数值键
stk[0]=[btnn,bt]
elif stk[1]!='': #关键 完成了 a+b的输入
stk[2]=[btnn,bt]
btnus+=1 #在这个if条件下会合并两个按钮为一个,用掉一个按钮
vss='{0}{1}{2}'.format(stk[0][0],stk[1],stk[2][0]) #a+b

cur[4]='({0})'.format(vss)
#暂时不好区分是cur[4],stk[1],stk[2][0] 还是 stk[0][0],stk[1],cur[4]
v=eval(vss)
itv.set(vss)
ccv=float("%.3f" %v)
if abs(v-ccv)<1e-6: setVBtnval(v,bt)
else: setVBtnval(ccv,bt)
setVBtnCol('#808080',stk[0][1]) #“失效”一个按钮
setVBtnval('--',stk[0][1])
stk[0]=[v,bt]
stk[1]='' #置空后两步操作,第一步更新为v的值,以方便实现a*b+c (a+b)*c
stk[2]=['','']
if abs(v-24)<1e-6:
if btnus==3: #用掉三个,结果正确,到达endgame
messagebox.showinfo(str(scur[:4]),'恭喜你计算正确!')
elif btn in opw: #操作符,更新stk[1]
if stk[0][0]=='':
itv.set('操作符前没有数值')
return #无效 操作符前没有数值
elif stk[1] in opw: #覆盖上一步点的操作符
stk[1]=btn
elif stk[1]=='': #当前循环还没有输入过运算符
stk[1]=btn
elif btn=='C': #清空操作重来
itv.set('--')
cur=scur.copy()
updateVBtn(cur) #更新数值按钮上的值
resetVBtnColor(stk[3]) #重设按钮的背景色
stk=resetStk(stk) #重设stk的值
btnus=0 #按钮使用数重设为0
elif btn=='Next': #下一题
ch=[]
for i in range(150):
ch=[]
for _ in range(4):
ch.append(random.randint(0,10))
result=cal24(ch)
if result!=[]:
if len(result)>9: #只取前10个答案
result=result[:9]
break
if ch==[]:
for i in range(4):
cur[i]=random.randint(0,10)
else:
for i in range(4):
cur[i]=ch[i]
cur[4]=''
updateVBtn(cur)
resetVBtnColor(stk[3])
stk=resetStk(stk)
scur=cur.copy()
itv.set('--')
btnus=0
btnUs.set(btnus)

def showAnswer(): #用消息框展示当前题目的答案
global result,cur
rss='\n'.join([str(i) for i in result])
messagebox.showinfo(str(cur),rss)

btn1=tk.Button(root,text=str(cur[0]),command=lambda x=cur[0]:btnClick(x,1))
btn1.place(x=0,y=10,width=90,height=90)
btn2=tk.Button(root,text=str(cur[1]),command=lambda x=cur[1]:btnClick(x,2))
btn2.place(x=90,y=10,width=90,height=90)
btn3=tk.Button(root,text=str(cur[2]),command=lambda x=cur[2]:btnClick(x,3))
btn3.place(x=0,y=100,width=90,height=90)
btn4=tk.Button(root,text=str(cur[3]),command=lambda x=cur[3]:btnClick(x,4))
btn4.place(x=90,y=100,width=90,height=90)

btn5=tk.Button(root,text='+',command=lambda :btnClick('+'))
btn5.place(x=0,y=200,width=40,height=20)
#……
btnClear=tk.Button(root,text='重来',command=lambda :btnClick('C'))
btnClear.place(x=0,y=250,width=60,height=20)
# ……
root.mainloop()

运行效果如下:

运行效果示例

代码改一下可以变成命令行下的交互版本:

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

def cmdcal24():
import random
print('欢迎使用命令行版24点训练器!\n## 说明')
q=''
cur,res=[],[]
while q!='q':
if res==[]:
res,cur=getOne()
q=input('当前题目:{0}\n输入您的答案:'.format(str(cur)))
elif q=='a':
print(res)
res,cur=getOne()
q=input('当前题目:{0}\n输入您的答案:'.format(str(cur)))
else:
try:
c=re.compile(r'\d+').findall(q)
if len(c)!=4:
q=input('式子有问题,请检查后重新输入\n')
else:
cr=[str(i) for i in cur]
if cmptlst(c,cr):
c=eval(q)
if abs(c-24)<1e-6:
print('计算正确!')
res,cur=getOne()
q=input('当前题目:{0}\n输入您的答案:'.format(str(cur)))
except Exception as e:
print(e)
q=input('输入您的答案:'.format(str(cur)))

命令行下运行

换个环境,Ubuntu下的效果:

Ubuntu下运行效果

最后GUI版的脚本可以导出为exe文件,其他人也可以方便的使用,通过pyindatller可以快速打包py脚本为exe文件。

GUI运行效果

Python打包为exe普遍文件会比较大(还是C#在这方面更有优势),我这边导出的结果是8.3MB,可以接受,用内置库的好处。写小型程序用tkinter是够用的。

公众号蛰虫始航后台回复 24点可下载文中代码。