python例項:解決經典撲克牌遊戲 -- 四張牌湊24點 (一)

Lindy_Z發表於2021-01-01

  Hey! Hope you are having a great day so far!

  今天想和大家討論的是一道我從這學期cs的期末考試得到靈感的題:Get 24 Poker Game。說到 Get 24 Poker Game,也就是我們通常說的湊24點,大家可能都比較熟悉。但是因為這個遊戲有很多變種,我們還是來簡單說一下游戲的規則。老規矩,上Wikipedia:

The 24 Game is an arithmetical card game in which the objective is to find a way to manipulate four integers so that the end result is 24.

  簡單來說,這個遊戲需要玩家快速用隨機抽出的四張牌上的整數通過常見的加減乘除計算來湊出24點來取得勝利(一定要用全4張,不可以棄牌)。不同於我們通常玩的鬥地主,釣魚等等撲克遊戲,湊24點更考驗玩家的數學反應能力(less fun but somewhat interesting:))。在這裡稍稍跑一下題,因為可能會有人好奇,為什麼恰恰選了24這個數字。其實答案很直白,因為這個數字最容易被湊出來:24是擁有八個除數的最小整數(1, 2, 3, 4, 6, 8, 12, and 24),使用這個數字不僅僅把遊戲變得更加簡單(畢竟可能沒有人想花一個小時在用撲克牌湊數上),還把遊戲作廢(目標數字湊不出來)的機率降到最小。

  好了,希望大家對這個卡牌遊戲已經有一些基礎的認知了,但是在正式開始編這個遊戲之前,我想先從這學期cs期末考試的一道題入手:

  It is possible to combine the numbers 1, 5, 6, 7 with arithemtic operations to get 21 as follows: 6/(1-5/7).

Part I: 

  Write a function that takes in a list of three numbers and a target number, and returns a string that contains an expression that uses all the numbers in the list once, and results in the target. Assume that the task is possible without using parentheses.

  For example, get_target_noparens([3, 1, 2], 7) can return "2*3+1" or "1+2*3" (either output would be fine).

 

Part II:

  Now, write the function get_target which returns a string that contains an expression that uses all the numbers in the list once, and results in the target. The expression can contain parentheses. Assume that the task is possible.

  For example, get_target([1, 5, 6, 7], 21) can return "6/(1-5/7)" , this will return all permutation of the list of number.

 

  這道題有兩部分,第一部分的大意是寫出一個函式,讓其能夠以列表的形式接收三個數字並試圖用加減乘除的方法來湊出目標點數(注意,這裡不包括括號操作)。第二部分相對於前一部分難度更高一點,要求寫出函式,讓其能夠接受任意數量的數字,並可通過對其進行加減乘除(這裡包括括號)的操作來湊出目標點數。另外,在做這道題的時候不需要考慮備選數字沒有辦法湊出目標點數的情況。

  我們不難看出,第二題其實和我們想要做出的卡牌遊戲的解法基本上是完全重合,甚至更加簡單。只需把函式的目標數字設為24,而且限制前面列表的數字數量為4,就能夠做出這個遊戲的解法。但第一題給考生了一個基礎的思路,也就是說第一題給第二題做了很好的鋪墊,能夠讓人更快的找到正確的邏輯,完成解題過程。所以只要解決這兩道題,我們就可以很流暢的找出湊24點的最終解法(多做一道賺一道啊!)。

 

  話不多說,我們開始!(完整程式碼在文末)


   

  通過讀題,我們可以發現第一題和第二題最主要的差別在以下兩點上:

  1. 第一題只需要考慮三個數字,兩個符號的排列組合,也就是說在改變運算子號時,我們只需考慮4選1,或者4選2的情況。而第二題卻需要完全不同的思路,要在不確定總數字量的情況下,在每兩個數字間考慮到所有排列可能。
  2. 第二題需要考慮到一個,或者多個括號的演算法,在這種情況下,我們不能直接計算結果,因為沒有辦法確定括號的個數和位置。

  記住以上兩點:)如果我們能在做第一小題時找到關於第二小題的啟發,會事半功倍的!

 

  第一小題我們可以從運算式子的結構入手。正常寫出的話,一個有三個運算數字的式子會長這個樣子:

$1+2+3$

結構一目瞭然,三個數字,兩個運算子號。如果想生成所有排列組合的可能性的話,我們可以用巢狀for迴圈很容易的用程式碼實現如下:

1 def get_all_comb(num_list):
2     '''find all arithmatic combination using the given 3 numbers'''
3 
4     for op_1 in ['+','-','*','/']: # 用第一個for loop來考慮所有第一個位置的運算子號的可能性
5         for op_2 in ['+','-','*','/']: # 用第二個for loop來考慮所有第二個位置的運算子號的可能性
6             comb = str(num_list[0])+op_1+str(num_list[1])+op_2+str(num_list[2]) # 組裝運算式
7             print(comb) # 列印運算式

  這段程式碼的輸出結果為 ‘1+2+3’,‘1+2-3’,‘1+2*3’...等等十六個不重複的運算式。但是我們還要考慮到所有數字的排列組合的情況,注意在以上的例子裡,所有運算的數字是沒有變化的,但數字位置的變化在多數情況下會對運算結果造成影響,也就是說在我們改變運算子號的同時,我們也要考慮到所有數字的排列情況(這裡指permutation)。

  同樣,我們也可以用以上相似的巢狀迴圈邏輯來用程式碼實現:

1 def get_num_comb(num_list):
2     '''find all combination possibilities of the given 3 numbers'''
3 
4     all_comb = [] # 準備收集所有排列組合
5     for i in range(3): # 三個巢狀for迴圈分別對應在num_list裡的序數
6         for j in range(3):
7             for k in range(3):
8                 if i != j and i != k and j != k: # 確定沒有重複元素
9                     print([num_list[i], num_list[j], num_list[k]]) #列印最終結果

  但是我們可以通過以上兩段程式碼發現,在這裡用for loop來分別實現符號和數字的排列組合雖然是可行的(同理我們也可以用類似的for loop結構來),但卻無法延伸到這道題的侷限外,也就是說,這個思路僅限於這道題的Part 1,如果要做第二部分的話,我們需要重新寫這部分的函式(這也是這兩道題的第一個主要差別:數字數量的不確定性)。

  為了使第一部分的解法可以延伸到第二題,我們需要換個思路。很自然的,為了解決數字數量的不確定問題,我們不能夠再使用for loop這種需要定量條件的方法,而是使用遞迴(recursion)。

  以上我們討論到的兩個問題,運算子號以及運算數字的排列組合,可以被分別寫作兩個遞迴函式。比起普通迴圈,遞迴函式的結構更加複雜。為了減少碼程式碼時出現不必要的概念不清的錯誤,我們可以針對每個遞迴畫出樹形圖來作結構分析。

  我們先來看運算子號的遞迴規律,如果我們有三個需要考慮的運算位置的話,樹形圖便如下圖:

  通過觀察,我們可以看到第一層有4個分支,第二層有16個,第三層有64個。不難看出,這個遞迴規律的複雜度是隨著遞迴的深度而以二次增長,所以可以用Big-Oh Notation表達成 O(4^n),n為運算子號的個數。(關於運算複雜度和常見algorithm會有後續文章跟進,在這裡不做過多解釋)

根據以上基礎結構,我們可以用程式碼來寫出生成運算子號的遞迴函式,如下:

 1 def generate_comb_op(n):
 2     '''find all combination of Arithmetic operators with n possible spaces for operators'''
 3     # 建立base case
 4     if n==0:
 5         return [] # 當n為0時不返回任何操作符號
 6     elif n ==1:
 7         return [['+'],['-'],['*'],['/']] # 當n為1時返回基礎的四個符號,注意這裡需要用到list of list
 8     op_list = generate_comb_op(n-1) # 因為之後要加,所以我們這裡用到遞迴的邏輯,來找到最基本的operator_list
 9     all_op_list = [] # 新建一個list來準備更新我們加了運算子號後的sublist
10     # 最後我們還是要用迴圈的邏輯來給我們原來list裡的元素加新的符號
11     for i in op_list:
12         for j in ['+','-','*','/']:
13             all_op_list.append(i+[j]) # 這裡用了新的list,來確保每個sublist的長度是相等的
14 
15     return all_op_list # 最後返回最終結果

  如果再次檢查運算複雜度,我們不難看出這個函式的複雜度符合我們的預測,為O(4^n)。

  好了,我們再來看數字的排列方法。如果想要找到固定數量的數字的所有排列方式,我們需要用到permutation的邏輯:找到所有排列(長度為n)的第一個元素,然後根據每個元素找到剩餘數字的第一個元素(剩餘數字排列長度為n-1),以此類推,直到最後只剩餘一個數字。我們來看一下這個遞迴思路的樹狀圖(此樹狀圖用了長度為三的list為例):

  遞迴的第一層有三個元素,第二層有3*2=6個元素,第三層有3*2*1=6個元素,我們可以看出這個邏輯的複雜度為 O(n!), n為需要排列組合數字的個數。

Permutation的邏輯比運算子號的排列稍稍複雜,但是我們可以用類似的遞迴結構來解決不同的問題,程式碼如下:

 1 def generate_permutated_list(num_list):
 2     '''find permuted lists of n given numbers'''
 3     # 建立base case
 4     if len(num_list) == 0:
 5         return [] # 當n為0時不返回任何數字
 6     if len(num_list) == 1:
 7         return [num_list] # 當n為1時返回所有式子,作為之後首數字的基礎
 8     list_of_comb = [] # 新建列表來存更新的排列
 9     for i in range(len(num_list)):
10         first_num = num_list[i] # 生成首字母
11         for j in generate_permutated_list(num_list[:i] + num_list[i+1:]): # 去除首字母,繼續遞迴
12             list_of_comb.append([first_num] + j) #加入新的list
13             
14     return list_of_comb # 最後返回最終結果

  分別生成數學操作符號以及所有數字的排列組合後,我們要把兩個組合整合起來,以此生成所有的排列可能性。因為這裡我們不用考慮排列組合數列的不確定性的問題(每個排列的長度,以及每組數學操作符號的長度維持不變),我們可以用迴圈的思維來生成所有數學表示式(所有數字和數學操作符號的組合)。但是生成所有數學表示式還不能完整的解決這個問題,因為我們不僅僅要生成所有的數學表示式,還要把表示式估值並和最終的目標數字進行比較。所以在組合最終的函式之前,我們需要先寫一個估值函式來方便之後的使用。

  估值函式的難點在於數學操作符號的處理,因為在數學表示式裡這些運算子號都是以字串的形式表達,例如 ‘+’,‘-’,所以無法當作正常運算子號放到程式碼中來操作。所以在這個情況,我們要重新賦予這些字串它們象徵的含義,程式碼如下:

 1 def modify_op(equation, op):
 2     '''this function modify the given equation by only computing the section with the given operators
 3     parameters:
 4         equation: a list that represents a given mathematical equation which may or may not contain the 
 5                 given numerical operators. Ex, ['1','+','2'] represents the equation 1+2
 6         op: a string that is the given numerical operators'''
 7     
 8     # 這裡我們把代表數學計算的字串和以上定義的操作函式的名字以字典的方式聯絡並儲存起來
 9     operators = {'/':division, '*':multiply, '+':add, '-':subtract}
10     
11     while op in equation: # 用while迴圈來確保沒有遺漏任何字元
12         i = equation.index(op) # 找到表示式內的第一處需要計算的字元位置
13         if op == '/' and equation[i+1] == '0': # 考慮除法操作的被除數為0的情況
14             return ['']
15         # 把表示式需要計算的部分替換成計算結果
16         equation[i-1:i+2] = [str(operators[op](float(equation[i-1]), float(equation[i+1])))] # 注意這裡呼叫了前面字典裡儲存的函式名
17     return equation # 返回結果
18 
19 def evaluate(equation):
20     '''return the evaluated result in float for the equation'''
21 
22     for op in ['/','*','+','-']: # 這裡需要注意標點順序,除在最先,因為需要考慮特殊情況,乘其次,然後才是加減
23         equation = modify_op(equation, op) # 使用helper function
24     return equation[0] # 最後返回最終計算結果

  這裡我們需要注意,這個估值函式能夠接收表示式的形式為list,而list裡的每項也必須要用字串的形式來表達。

  最後,我們只要按照之前提到的思路,整合表示式,並用以上估值函式來計算表示式的值,就可以完成這道題。在給出完整程式碼之前,我們再來最後複習一下這道題的解題思路:

  1. 找出所有加減乘除的排列組合
  2. 找出所有數字的排列組合
  3. 整合所有表示式可能
  4. 用估值函式計算表示式
  5. 對比表示式答案和目標數
  6. 返回符合要求的表示式

  好了,那我們來看一下完整程式碼:

  1 #  Write a function that takes in a list of three numbers and a target number, and
  2 #  returns a string that contains an expression that uses all the numbers
  3 #  in the list once, and results in the target. Assume that the task is possible
  4 #  without using parentheses.
  5 #
  6 #  For example, get_target_noparens([3, 1, 2], 7) can return "2*3+1" or "1+2*3"
  7 #  (either output would be fine).
  8 
  9 ############################################# 數學表示式生成函式 #############################################
 10 
 11 def generate_comb_op(n):
 12     '''find all combination of Arithmetic operators with n possible spaces for operators'''
 13     # 建立base case
 14     if n==0:
 15         return [] # 當n為0時不返回任何操作符號
 16     elif n ==1:
 17         return [['+'],['-'],['*'],['/']] # 當n為1時返回基礎的四個符號,注意這裡需要用到list of list
 18     op_list = generate_comb_op(n-1) # 因為之後要加,所以我們這裡用到遞迴的邏輯,來找到最基本的operator_list
 19     all_op_list = [] # 新建一個list來準備更新我們加了運算子號後的sublist
 20     # 最後我們還是要用迴圈的邏輯來給我們原來list裡的元素加新的符號
 21     for i in op_list:
 22         for j in ['+','-','*','/']:
 23             all_op_list.append(i+[j]) # 這裡用了新的list,來確保每個sublist的長度是相等的
 24 
 25     return all_op_list # 最後返回最終結果
 26 
 27 
 28 def generate_permutated_list(num_list):
 29     '''find permuted lists of n given numbers'''
 30     # 建立base case
 31     if len(num_list) == 0:
 32         return [] # 當n為0時不返回任何數字
 33     if len(num_list) == 1:
 34         return [num_list] # 當n為1時返回所有式子,作為之後首數字的基礎
 35     list_of_comb = [] # 新建列表來存更新的排列
 36     for i in range(len(num_list)):
 37         first_num = num_list[i] # 生成首字母
 38         for j in generate_permutated_list(num_list[:i] + num_list[i+1:]): # 去除首字母,繼續遞迴
 39             list_of_comb.append([first_num] + j) #加入新的list
 40 
 41     return list_of_comb # 最後返回最終結果
 42 
 43 
 44 #################################### 定義所有可能出現的數學操作,包括加減乘除 ####################################
 45 
 46 def division(a,b): # 除法比較特殊,在之後的程式碼裡會考慮到被除數為0的情況
 47     return a/b
 48 def multiply(a,b):
 49     return a*b
 50 def add(a,b):
 51     return a+b
 52 def subtract(a,b):
 53     return a-b
 54 
 55 ############################################ 數學表示式處理函式 ##############################################
 56 
 57 def modify_op(equation, op):
 58     '''this function modify the given equation by only computing the section with the given operators
 59     parameters:
 60         equation: a list that represents a given mathematical equation which may or may not contain the 
 61                 given numerical operators. Ex, ['1','+','2'] represents the equation 1+2
 62         op: a string that is the given numerical operators'''
 63     
 64     # 這裡我們把代表數學計算的字串和以上定義的操作函式的名字以字典的方式聯絡並儲存起來
 65     operators = {'/':division, '*':multiply, '+':add, '-':subtract}
 66     
 67     while op in equation: # 用while迴圈來確保沒有遺漏任何字元
 68         i = equation.index(op) # 找到表示式內的第一處需要計算的字元位置
 69         if op == '/' and equation[i+1] == '0': # 考慮除法操作的被除數為0的情況
 70             return ['']
 71         # 把表示式需要計算的部分替換成計算結果
 72         equation[i-1:i+2] = [str(operators[op](float(equation[i-1]), float(equation[i+1])))] # 注意這裡呼叫了前面字典裡儲存的函式名
 73     return equation # 返回結果
 74 
 75 def evaluate(equation):
 76     '''return the evaluated result in float for the equation'''
 77 
 78     for op in ['/','*','+','-']: # 這裡需要注意標點順序,除在最先,因為需要考慮特殊情況,乘其次,然後才是加減
 79         equation = modify_op(equation, op) # 使用helper function
 80     return equation[0] # 最後返回最終計算結果
 81 
 82 ############################################# 最終使用函式 ###############################################
 83 
 84 def get_target_noparens(num_list, target):
 85     op_list = generate_comb_op(len(num_list)-1) # 找出所有加減乘除的排列組合
 86     num_comb = generate_permutated_list(num_list) # 找出所有數字的排列組合
 87     # 用for巢狀迴圈來整合所有表示式可能
 88     for each_op_list in op_list: 
 89         for each_num_list in num_comb:
 90             equation = [] # 用list初始化表示式
 91             equation_str = '' # 用string表達算式
 92             for i in range(len(each_op_list)):
 93                 equation.extend([str(each_num_list[i]), each_op_list[i]])
 94                 equation_str += str(each_num_list[i]) + each_op_list[i]
 95             equation.append(str(each_num_list[-1]))
 96             equation_str += str(each_num_list[-1])
 97             
 98             result = evaluate(equation) # 表示式估值,這裡要用list的形式
 99             if float(result) == float(target):
100                 return equation_str # 用字串返回算式

  我們稍作測試:

  • get_target_noparens([1,2,3], 6)返回 1+2+3
  • get_target_noparens([1,2,3], 4.5)返回 1+3/2
  • get_target_noparens([23,1,3], 68)返回 23*3-1

  三個測試都成功通過,那麼我們的第一題就解完了。

  我們這裡對第一題的解法同樣能夠應用到第二題的基礎部分,因為篇幅原因,第二題的解題方式會放到下一篇講解,連結如下:

  https://www.cnblogs.com/Lin-z/p/14219620.html

  

  那我們今天就到這裡,新年快樂!

 

 

參考資料:

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

 

相關文章