Hey! 如果你還沒有看這篇的上文的話,可以去稍稍瞅一眼,會幫助加速理解這一篇裡面涉及到的遞迴結構哦!(上一篇點這裡:《python例項:解決經典撲克牌遊戲 -- 四張牌湊24點 (一)》)
如果你已經看完了第一部分的解析,那我們可以來繼續上道題的第二部分。
根據第一部分的分析,第二部分的難點主要在以下兩點:
- 第一題只需要考慮三個數字,兩個符號的排列組合,也就是說在改變運算子號時,我們只需考慮4選1,或者4選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 種排列方式(算上沒有括號的情況)。
你看出來規律了麼?如果沒有的話,我們可以試試把以上過程用樹形圖表達出來:
有沒有稍稍清晰一點點?我們可以發現每層之間的過渡其實是一樣的。從第一層到第二層,我們把一對括號插入序列中所有可能且合理的位置。為了清晰結構,我們現在只把目光放在第二層,這樣便可以發現我們其實從2(允許的列表最小長度)開始,開始給相鄰的,長度為2的序列湊對(括號只對相鄰的數字生效,中間不能分裂)。隨後我們開始給相鄰的,長度為三的序列湊對。以此類推,如果我們本來的序列長度為5,我們便會從長度2開始湊對,隨後用長度3,然後長度4。
當把括號裡的內容看作一個單位,但第二層生成的序列的長度仍舊大於2時,我們便可以就計算單位繼續以不同長度來湊對,這也就是第三層的雙重括號的來源:我們在每一個分支裡,用長度為2的分序列給長度為3的總序列湊對。在以上的結構中,層與層之間的傳遞十分明顯,幾乎在跟我們明示,這是一個遞迴結構!!!
相比較第一部分(見《python例項:解決經典撲克牌遊戲 -- 四張牌湊24點 (一)》)用到的所有遞迴函式,這個結構會更加複雜,所以我們需要仔細分析程式碼實現以上遞迴結構所需的幾個重點:
- 首先,我們會發現不是所有的分支都能發展到最深的一層,只有滿足條件(長度大於2)的分支才能繼續發展,否則分支會就此停止。這意味著我們程式碼中的base case和序列的長度有關。
- 其次,雖然每層的 ‘湊對’ 過程是一樣的,我們也需要考慮用不同的長度來湊,而層與層之間的序列長度並不相同。所以,因為長度的不確定性,我們同樣可以考慮使用遞迴的手法來實現(這裡並不是指只用迴圈無法實現,而是指用遞迴會更加自然)
- 最後,括號加入的順序也很重要,因為我們需要從最簡單的情況,也就是沒有括號的序列開始考慮,然後逐漸考慮一個括號,再到兩個括號,因為只有這樣才能夠實現表示式的最簡化。
那麼我們準備好用程式碼實現了麼?還沒有,因為我們需要考慮,我們需要用什麼形式來表示括號才能夠方便我們之後的估值計算呢(在運算複雜度的最差可能性下,在我們找到了所有括號可能時,我們需要對每個可能表示式進行估值操作,所以括號的表達方式越合理,越能提升運算的總體效率)?明顯,為了格式的同意,我們可以採用['(','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 try: 5 return a/b 6 except: 7 return '' 8 9 def multiply(a,b): 10 return a*b 11 def add(a,b): 12 return a+b 13 def subtract(a,b): 14 return a-b 15 16 ############################################ 數學表示式處理函式 ############################################## 17 18 def modify_op(equation, op): 19 '''this function modify the given equation by only computing the section with the given operators 20 parameters: 21 equation: a list that represents a given mathematical equation which may or may not contain the 22 given numerical operators. Ex, ['1','+','2'] represents the equation 1+2 23 op: a string that is the given numerical operators''' 24 25 # 這裡我們把代表數學計算的字串和以上定義的操作函式的名字以字典的方式聯絡並儲存起來 26 operators = {'/':division, '*':multiply, '+':add, '-':subtract} 27 28 while op in equation: # 用while迴圈來確保沒有遺漏任何字元 29 i = equation.index(op) # 找到表示式內的第一處需要計算的字元位置 30 # 把表示式需要計算的部分替換成計算結果 31 if equation[i+1] != '' and equation[i-1] != '': 32 equation[i-1:i+2] = [str(operators[op](float(equation[i-1]), float(equation[i+1])))] # 注意這裡呼叫了前面字典裡儲存的函式名 33 else: 34 return [''] 35 return equation # 返回結果 36 37 def evaluate(equation): 38 '''updated version of the evaluation function, place the original loop in a recursive structure.''' 39 40 for i in range(len(equation)): 41 if type(equation[i]) == list: # 如果表示式型別為list 42 equation[i] = evaluate(equation[i]) # 滿足括號條件,開始遞迴 43 for op in ['/','*','+','-']: 44 equation = modify_op(equation, op) # 使用helper function 45 46 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