python实例:解决经典扑克牌游戏 -- 四张牌凑24点 (二)

时间:2023-09-30 09:15:56

  Hey! 如果你还没有看这篇的上文的话,可以去稍稍瞅一眼,会帮助加速理解这一篇里面涉及到的递归结构哦!(上一篇点这里:《python实例:解决经典扑克牌游戏 -- 四张牌凑24点 (一)》

  如果你已经看完了第一部分的解析,那我们可以来继续上道题的第二部分。

  根据第一部分的分析,第二部分的难点主要在以下两点:

  1. 第一题只需要考虑三个数字,两个符号的排列组合,也就是说在改变运算符号时,我们只需考虑4选1,或者4选2的情况。而第二题却需要完全不同的思路,要在不确定总数字量的情况下,在每两个数字间考虑到所有排列可能。
  2. 第二题需要考虑到一个,或者多个括号的算法,在这种情况下,我们不能直接计算结果,因为没有办法确定括号的个数和位置。

  那么通过重复利用第一部分的代码,我们发现第一个难点已经被攻克了,于是现在我们可以直接分析第二个难点(如何找到括号的所有合理位置)来完整第二部分的代码(完整代码见文末):

  因为输入函数的内容是不确定的(数字不确定,括号的位置也不确定),所以我们很有可能会需要用到recursion的逻辑来解决括号的位置问题(在后文会更加深入的解释为什么需要用到递归)。在开始思考具体解题方法之前,我们首先要明确,括号不能够只包括一个运算数字(至少两个或更多),括号也不需要被放到整个式子的左右。同时,我们也要避免增加不必要的括号,例如1*((3*4)/5),这个情况下(3*4)周围的括号是不必要的。

  好了,知道了这些条件,我们可以从最简单的表达式试着入手(在需要加入括号的情况下,表达式最少需要三位数字),比如:

$1+2\times 3$

  在这个情况下,我们可以怎么加入括号呢?答案是有两种,如下:

$\left( 1+2 \right) \times 3$

$1+\left( 2\times 3 \right) $

  在以上两种情况下,我们不能够继续加入括号,因为如果把括号内的内容算作一整个计算单位(这里的计算单位指的是表达式里的一个元素,等同于单个数字的概念),我们这个表达式就只剩下了两个单位,在这个情况下,在任何位置加入括号都会违反我们之前设定的规则(括号不能只包括一个数字,同时不能包括全部数字)。所以在有三个元素的情况下,这个表达式就有三个排列组合的可能性(算上没有括号的情况)。

  这看上去好像过于简单了,那我们可以尝试一下四位数字的表达式,比如:

$1+2\times 3\div 4$

  我们可以怎么在这个表达式中加入括号呢?比起上面的例子,这个表达式明显所需步骤更多,所以我们来逐步分析:

  当只需要加入一对括号时,我们可以这么加:

$\left( 1+2 \right) \times 3\div 4  \qquad 1+\left( 2\times 3 \right) \div 4  \qquad 1+2\times \left( 3\div 4 \right) $

$\left( 1+2\times 3 \right) \div 4 \qquad 1+\left( 2\times 3\div 4 \right) $

  注意以上我们可以从两个角度加入括号:括号包括两个数字,以及括号包括三个数字。这里括号以从左到右的顺序加入。

  当需要加入两对括号时,我们可以这么加:

$\left( \left( 1+2 \right) \times 3 \right) \div 4  \qquad  \left( 1+2 \right) \times \left( 3\div 4 \right) $

$\left( 1+\left( 2\times 3 \right) \right) \div 4   \qquad  1+\left( \left( 2\times 3 \right) \div 4 \right) $

$\left( 1+2 \right) \times \left( 3\div 4 \right)   \qquad  1+\left( 2\times \left( 3\div 4 \right) \right)  $

  以上我们在加入一对括号的基础上,把已括入的内容算作一个计算单位,再加入第二对括号,括号以从左到右边的顺序加入。注意在这个例子里有重复的表达式,$\left( 1+2 \right) \times \left( 3\div 4 \right) $ 所以实际上我们是有 11 种排列方式(算上没有括号的情况)。

  你看出来规律了么?如果没有的话,我们可以试试把以上过程用树形图表达出来:

python实例:解决经典扑克牌游戏 -- 四张牌凑24点 (二)

  有没有稍稍清晰一点点?我们可以发现每层之间的过渡其实是一样的。从第一层到第二层,我们把一对括号插入序列中所有可能且合理的位置。为了清晰结构,我们现在只把目光放在第二层,这样便可以发现我们其实从2(允许的列表最小长度)开始,开始给相邻的,长度为2的序列凑对(括号只对相邻的数字生效,中间不能分裂)。随后我们开始给相邻的,长度为三的序列凑对。以此类推,如果我们本来的序列长度为5,我们便会从长度2开始凑对,随后用长度3,然后长度4。

  当把括号里的内容看作一个单位,但第二层生成的序列的长度仍旧大于2时,我们便可以就计算单位继续以不同长度来凑对,这也就是第三层的双重括号的来源:我们在每一个分支里,用长度为2的分序列给长度为3的总序列凑对。在以上的结构中,层与层之间的传递十分明显,几乎在跟我们明示,这是一个递归结构!!!

  相比较第一部分(见《python实例:解决经典扑克牌游戏 -- 四张牌凑24点 (一)》)用到的所有递归函数,这个结构会更加复杂,所以我们需要仔细分析代码实现以上递归结构所需的几个重点:

  1. 首先,我们会发现不是所有的分支都能发展到最深的一层,只有满足条件(长度大于2)的分支才能继续发展,否则分支会就此停止。这意味着我们代码中的base case和序列的长度有关。
  2. 其次,虽然每层的 ‘凑对’ 过程是一样的,我们也需要考虑用不同的长度来凑,而层与层之间的序列长度并不相同。所以,因为长度的不确定性,我们同样可以考虑使用递归的手法来实现(这里并不是指只用循环无法实现,而是指用递归会更加自然)
  3. 最后,括号加入的顺序也很重要,因为我们需要从最简单的情况,也就是没有括号的序列开始考虑,然后逐渐考虑一个括号,再到两个括号,因为只有这样才能够实现表达式的最简化。

  那么我们准备好用代码实现了么?还没有,因为我们需要考虑,我们需要用什么形式来表示括号才能够方便我们之后的估值计算呢(在运算复杂度的最差可能性下,在我们找到了所有括号可能时,我们需要对每个可能表达式进行估值操作,所以括号的表达方式越合理,越能提升运算的总体效率)?明显,为了格式的同意,我们可以采用['(','1','+','2',')','+','3']的方式来表达括号,也就是括号要以其他字符一样的格式,字符串,插入到原式当中。但我们可以发现,这个形式在层与层之间的传递上并不是很合理,因为字符串不能够直接给出表达式中的层次,导致我们需要写额外的代码来判断计算单位。而且这在估值操作上也不灵便,因为有多个括号的情况下我们容易混淆相邻的不同括号(这里不是不可行的意思,但是我们很有可能需要使用额外的循环操作来定义表达式内部的计算优先等级)。所以,在这里我们要利用列表的嵌套特点,来用“俄罗斯套娃” 来直接表达括号所在位置。比如,(1+2)+3 可以表达为[['1','+','2'],'+','3']。

话不多说,我们来用代码实现找所有括号可能位置的功能。第一步,我们先写一个能够帮助我们在每层用不同长度 “凑对” 的递归函数,来方便我们递归过程中的调用:

 1 def layer_sort(equation, depth=3): # 默认凑对的长度为3(这里是因为两个数字加一个运算符号有三位数)
2 '''generate all possible brackets combination within a recursive layer'''
3 if depth == len(equation): # 如果凑对长度达到表达式总长,便返回表达式原式
4 return []
5 layer_comb = [] # 初始化一个总列表
6
7 # 我们要从最左边开始定义左括号的位置,然后在相应长度的结束定义右括号的位置
8 # len(equation)-depth+1 为最右边的左括号合理位置+1,是循环的结束位置
9 for i in range(0,len(equation)-depth+1,2): # 间隔为2,因为我们要跳过一个数字和一个符号
10 new_equation = equation[:i]+[equation[i:i+depth]]+equation[i+depth:] # 写出新表达式
11 layer_comb.append(new_equation)
12 for j in layer_sort(equation, depth+2):
13 layer_comb.append(j)
14 return layer_comb

然后我们需要在每层使用这个函数,然后判断函数返回结果是否符合递归条件,如果符合,便递归下去。这一步会完整我们找所有括号表达式可能的函数,代码如下:

 1 def generate_all_brackets(equation):
2 '''get all bracket combination from the the given equation in list form'''
3
4 layer_possibility = layer_sort(equation) # 找到本层可用的表达式
5 all_possibility = layer_possibility[:]
6 for i in layer_possibility:
7 if len(i) > 3: # 检查是否达成递归条件,这一步同时也是这个递归函数的base case
8 next_layer_equ = generate_all_brackets(i)
9 for j in next_layer_equ: # 去重操作
10 if j not in all_possibility:
11 all_possibility.append(j)
12
13 return [equation] + all_possibility # 不要忘了在列表最开始加入原式

  好了,通过以上的函数,我们能够找到所有表达式的排列组合,但因为加入了括号,我们的估值函数也需要变动(基础代码参考上篇,新的估值函数会加入优先考虑括号内计算内容的算法)。在优先算括号内容的情况下,会出现括号内又有括号内包括号的情况,所以我们需要找到方法去找到最内层的计算内容。因为括号位置的不确定性以及层数的不确定性,在这里我们也需要把估值函数更新成递归的结构,完整代码如下:

 1 #################################### 定义所有可能出现的数学操作,包括加减乘除 ####################################
2
3 def division(a,b): # 除法比较特殊,在之后的代码里会考虑到被除数为0的情况
4 return a/b
5 def multiply(a,b):
6 return a*b
7 def add(a,b):
8 return a+b
9 def subtract(a,b):
10 return a-b
11
12 ############################################ 数学表达式处理函数 ##############################################
13
14 def modify_op(equation, op):
15 '''this function modify the given equation by only computing the section with the given operators
16 parameters:
17 equation: a list that represents a given mathematical equation which may or may not contain the
18 given numerical operators. Ex, ['1','+','2'] represents the equation 1+2
19 op: a string that is the given numerical operators'''
20
21 # 这里我们把代表数学计算的字符串和以上定义的操作函数的名字以字典的方式联系并储存起来
22 operators = {'/':division, '*':multiply, '+':add, '-':subtract}
23
24 while op in equation: # 用while循环来确保没有遗漏任何字符
25 i = equation.index(op) # 找到表达式内的第一处需要计算的字符位置
26 if op == '/' and equation[i+1] == '0': # 考虑除法操作的被除数为0的情况
27 return ['']
28 # 把表达式需要计算的部分替换成计算结果
29 if equation[i+1] != '' and equation[i-1] != '':
30 equation[i-1:i+2] = [str(operators[op](float(equation[i-1]), float(equation[i+1])))] # 注意这里调用了前面字典里储存的函数名
31 else:
32 return ['']
33 return equation # 返回结果
34
35 def evaluate(equation):
36 '''updated version of the evaluation function, place the original loop in a recursive structure.'''
37
38 for i in range(len(equation)):
39 if type(equation[i]) == list: # 如果表达式类型为list
40 equation[i] = evaluate(equation[i]) # 满足括号条件,开始递归
41 for op in ['/','*','+','-']:
42 equation = modify_op(equation, op) # 使用helper function
43 return equation[0] # 最后返回最终计算结果

  这里估值函数的主要更新在 evaluate() 函数里,我们检查了列表里的每个元素,如果满足list of list的条件,便用递归传递下去计算括号内的内容,并把这些内容用计算结果来替换,最终返回结果(类似于我们之前找括号位置的函数,只有满足要求的分支才能够进行到下一层)。终于,我们这道题快做完了!最后一个难点是,把列表形式的计算式转换成字符串,这一步看似简单,但却因为不能够确定括号的位置和具体结构而变得复杂。欸?这句话是不是有些似曾相识?对喽!我们还得写最后一个递归函数来转换列表和字符串的格式:)话不多说,编了这么多递归了,我们直接来看代码:

1 def convert_to_str(equation_list):
2 equation = '' # 初始化字符串表达式
3 for i in range(len(equation_list)):
4 if type(equation_list[i]) == list: # 这里是递归条件,如果数据类型为list,我们要把括号内的表达式传到下一层
5 equation += '(' + convert_to_str(equation_list[i]) + ')' # 加入括号
6 else:
7 equation += equation_list[i] # base case, 如果数据类型不是list,那么就普通返回字符串
8 return equation # 返回字符串形式的表达式

  最后,我们把以上的所有代码整合,写出这道题第二部分的解答,代码如下:

  1 #  Write the function get_target which returns a string that contains an expression that uses all the numbers
2 # in the list once, and results in the target. The expression can contain parentheses. Assume that the task
3 # is possible.
4 # For example, get_target([1, 5, 6, 7], 21) can return "6/(1-5/7)". This will return all permutation of the
5 # list of number.
6
7 ############################################# 数学表达式生成函数 #############################################
8
9 def generate_comb_op(n):
10 '''find all combination of Arithmetic operators with n possible spaces for operators'''
11 # 建立base case
12 if n==0:
13 return [] # 当n为0时不返回任何操作符号
14 elif n ==1:
15 return [['+'],['-'],['*'],['/']] # 当n为1时返回基础的四个符号,注意这里需要用到list of list
16 op_list = generate_comb_op(n-1) # 因为之后要加,所以我们这里用到递归的逻辑,来找到最基本的operator_list
17 all_op_list = [] # 新建一个list来准备更新我们加了运算符号后的sublist
18 # 最后我们还是要用循环的逻辑来给我们原来list里的元素加新的符号
19 for i in op_list:
20 for j in ['+','-','*','/']:
21 all_op_list.append(i+[j]) # 这里用了新的list,来确保每个sublist的长度是相等的
22
23 return all_op_list # 最后返回最终结果
24
25
26 def generate_permutated_list(num_list):
27 '''find permuted lists of n given numbers'''
28 # 建立base case
29 if len(num_list) == 0:
30 return [] # 当n为0时不返回任何数字
31 if len(num_list) == 1:
32 return [num_list] # 当n为1时返回所有式子,作为之后首数字的基础
33 list_of_comb = [] # 新建列表来存更新的排列
34 for i in range(len(num_list)):
35 first_num = num_list[i] # 生成首字母
36 for j in generate_permutated_list(num_list[:i] + num_list[i+1:]): # 去除首字母,继续递归
37 list_of_comb.append([first_num] + j) #加入新的list
38
39 return list_of_comb # 最后返回最终结果
40
41
42 #################################### 定义所有可能出现的数学操作,包括加减乘除 ####################################
43
44 def division(a,b): # 除法比较特殊,用了try except来考虑到被除数为0的情况
45 try:
46 return a/b
47 except:
48 return ''
49
50 def multiply(a,b):
51 return a*b
52 def add(a,b):
53 return a+b
54 def subtract(a,b):
55 return a-b
56
57 ############################################ 数学表达式处理函数 ##############################################
58
59 def modify_op(equation, op):
60 '''this function modify the given equation by only computing the section with the given operators
61 parameters:
62 equation: a list that represents a given mathematical equation which may or may not contain the
63 given numerical operators. Ex, ['1','+','2'] represents the equation 1+2
64 op: a string that is the given numerical operators'''
65
66 # 这里我们把代表数学计算的字符串和以上定义的操作函数的名字以字典的方式联系并储存起来
67 operators = {'/':division, '*':multiply, '+':add, '-':subtract}
68
69 while op in equation: # 用while循环来确保没有遗漏任何字符
70 i = equation.index(op) # 找到表达式内的第一处需要计算的字符位置
71 # 把表达式需要计算的部分替换成计算结果
72 if equation[i+1] != '' and equation[i-1] != '':
73 equation[i-1:i+2] = [str(operators[op](float(equation[i-1]), float(equation[i+1])))] # 注意这里调用了前面字典里储存的函数名
74 else:
75 return ['']
76 return equation # 返回结果
77
78 def evaluate(equation):
79 '''updated version of the evaluation function, place the original loop in a recursive structure.'''
80
81 for i in range(len(equation)):
82 if type(equation[i]) == list: # 如果表达式类型为list
83 equation[i] = evaluate(equation[i]) # 满足括号条件,开始递归
84 for op in ['/','*','+','-']:
85 equation = modify_op(equation, op) # 使用helper function
86
87 return equation[0] # 最后返回最终计算结果
88 ############################################# 括号位置生成函数 #############################################
89
90 def layer_sort(equation, depth=3): # 默认凑对的长度为3(这里是因为两个数字加一个运算符号有三位数)
91 '''generate all possible brackets combination within a recursive layer'''
92 if depth == len(equation): # 如果凑对长度达到表达式总长,便返回表达式原式
93 return []
94 layer_comb = [] # 初始化一个总列表
95
96 # 我们要从最左边开始定义左括号的位置,然后在相应长度的结束定义右括号的位置
97 # len(equation)-depth+1 为最右边的左括号合理位置+1,是循环的结束位置
98 for i in range(0,len(equation)-depth+1,2): # 间隔为2,因为我们要跳过一个数字和一个符号
99 new_equation = equation[:i]+[equation[i:i+depth]]+equation[i+depth:] # 写出新表达式
100 layer_comb.append(new_equation)
101 for j in layer_sort(equation, depth+2):
102 layer_comb.append(j)
103 return layer_comb
104
105 def generate_all_brackets(equation):
106 '''get all bracket combination from the the given equation in list form'''
107
108 layer_possibility = layer_sort(equation) # 找到本层可用的表达式
109 all_possibility = layer_possibility[:]
110 for i in layer_possibility:
111 if len(i) > 3: # 检查是否达成递归条件,这一步同时也是这个递归函数的base case
112 next_layer_equ = generate_all_brackets(i)
113 for j in next_layer_equ: # 去重操作
114 if j not in all_possibility:
115 all_possibility.append(j)
116
117 return [equation] + all_possibility # 不要忘了在列表最开始加入原式
118
119 ########################################### 字符串格式转换函数 #############################################
120
121 def convert_to_str(equation_list):
122 equation = '' # 初始化字符串表达式
123 for i in range(len(equation_list)):
124 if type(equation_list[i]) == list: # 这里是递归条件,如果数据类型为list,我们要把括号内的表达式传到下一层
125 equation += '(' + convert_to_str(equation_list[i]) + ')' # 加入括号
126 else:
127 equation += equation_list[i] # base case, 如果数据类型不是list,那么就普通返回字符串
128 return equation # 返回字符串形式的表达式
129
130 ############################################# 最终使用函数 ################################################
131
132 def get_target(num_list, target):
133 op_list = generate_comb_op(len(num_list) - 1) # 找出所有加减乘除的排列组合
134 num_comb = generate_permutated_list(num_list) # 找出所有数字的排列组合
135 # 用for嵌套循环来整合所有表达式可能
136 for each_op_list in op_list:
137 for each_num_list in num_comb:
138 equation, equation_list= [], [] # 初始化基础表达式,以及基础+括号表达式
139 for i in range(len(each_op_list)):
140 equation.extend([str(each_num_list[i]), each_op_list[i]])
141 equation.append(str(each_num_list[-1])) # 组装基础表达式
142 equation_list.append(equation) # 把基础表达式加入基础+括号表达式的列表
143 equation_list.extend(generate_all_brackets(equation)) # 把所有括号表达式加入括号表达式的列表
144 for each_equation in equation_list:
145 equation_str = convert_to_str(each_equation) # 先把列表转化成字符串
146 if evaluate(each_equation) == str(float(target)): # 如果最终结果相等便返回字符串
147 return equation_str

  以上就是第二道题第二部分的全部代码了,我们稍作测试:

  • get_target([1,87,3,10],47) 返回  87-(1+3)*10
  • get_target([1,5,6,7],21) 返回  6/(1-5/7)
  • get_target([1,7,3], 11) 返回  1+7*3

  三个测试都通过,那我们这道题就这么结束了!

  这部分因为需要写的递归函数太多,我只挑了最难的一个来做树形图和结构分析。如果其他的递归函数也需要讲解,麻烦请给我留言,我会根据需求做相应的讲解:)。完事儿,收工!

  等等?我是不是忘了什么?说好的做凑24点游戏的解法呢???

  :)行吧,这就做,实际上以上的函数已经完全解决了这个游戏的核心难题,我们只需要稍加润色,便可以做出一个好用的24点的作弊器啦(理直气壮✧ (≖ ‿ ≖)✧)!在以上的函数基础上,为了让这个作弊器更加的人性化,我用PyQt5做了简单的GUI,加入了四张牌的问询收录,可修改功能,再来一次功能,以及————和电脑玩24点游戏的功能(既然要做就做的全一点嘛。)

  还是买个关子,既然这篇已经不短了,那我们就把24点的游戏制作放到下一篇吧,链接如下:

  还没更,更了这里就会有个链接:)再等会。

  

  Good good study, day day up! 下次再见!

  

  参考资料:

  • https://en.wikipedia.org/wiki/24_Game
  • 例题选自 University of Toronto, ESC180 2020 Final, Question 4 & Question 5