2048 遊戲是什麼?
2048 遊戲如下圖所示,它由一個 4*4 共 16 個方塊組成。玩家可以通過「上下左右」四個方向操縱方塊滑動,滑動時兩個相鄰且數值相同的方塊會合並,新的方塊,數值為兩者之和。當遊戲裡任意方塊的數值達到 2048,即為勝利。
我們將使用「蒙特卡洛方法」來打造 2048 AI。
蒙特卡洛方法是什麼?
有很多問題,數學公式很複雜,甚至短時間內找不到數學公式。比如下面的不規則形狀的面積。
我們可以通過一種「統計模擬」手段,在實踐上得到上述不規則形狀面積的近似值。做法就是:1)在正方形裡生成許多位置隨機的點;2)統計在不規則圖形內的點的數量;3)計算步驟2得到的數量跟總數的比值;4)用正方形的面積乘以步驟三得到的比值,就是不規則形狀面積的近似值。
上述做法,就是一個典型的蒙特卡洛方法。當我們生成的隨機點數量足夠大時,我們得到的近似值跟理論計算值就越發接近,誤差越發小。如下圖所示,求正方形裡的扇形面積的蒙特卡洛方法的模擬過程:
上面兩幅圖,只是蒙特卡洛方法的兩個應用而已。事實上,蒙特卡洛方法的適用範圍很廣,任何可模擬和統計的比例分佈,都可以使用蒙特卡洛方法來模擬。比如檢測硬幣構造上是否足夠均衡。
理論上,拋硬幣的正反面概率是一樣的,各50%。然而,實際工藝上,做不到絕對均勻,總有偏差。要想知道這個偏差,是偏向正面,還是偏向反面,可以使用蒙特卡洛方法。不斷地拋硬幣,然後統計正反面所佔的比例,當拋硬幣的次數是無限大時,這個比例就反映了硬幣的均勻性。現實中,我們做不到無限次拋硬幣,所以只能在某個誤差範圍內,得到硬幣的均勻性評估。
總而言之,蒙特卡洛方法,在實踐上給予我們這種便利:我們可以用模擬和統計,代替數學公式的運算過程,得到跟理論值相近的解。
蒙特卡洛方法和 2048 遊戲
我們可以把蒙特卡洛方法,應用在 2048 遊戲上。
對於 2048 遊戲的任意狀態,都有「上下左右」四個方向可以選擇;雖然有時往某個方向走了以後,不會改變盤面的狀態,但也是遊戲支援的走法,並不會被判輸,所以也是一個可選項。
這「上下左右」,哪個方向好,哪個方向壞,它們各自的勝率是多少?我們都不知道,但我們知道,客觀上它們是有一種分佈存在的。把它們四個的勝率加起來,必定等於 100%。
可以把這個「上下左右」想象成一個四面骰子,而且是不均勻的四面骰子;或者把它們想象成一個正方向被分成四塊,而且是不均等的四塊。我們有「2048 公式」可以套用嗎?我們能直接計算出每一個方向的勝率面積佔比嗎?我不是數學家,我沒有找到,但我知道蒙特卡洛方法,可以估測出近似解。所以來試試吧。
蒙特卡洛方法的極端情形,等價於暴力窮舉,把四個方向,以及四個方向之後的四個方向,以及四個方向之後的四個方向的四個方向,每一個排列組合都走一遍,知道輸或者贏;然後統計一下走「上下左右」時每個的勝利次數,跟總次數相除,就得到勝率了。
暴力窮舉太粗暴?沒關係。模擬 400 次,可能準確率就達到 90% 呢,剩下的無限次,或許只是把 90% 的準確率提到到 100% 罷了。
蒙特卡洛方法程式碼
按照蒙特卡洛方法的描述。
第一步,先寫一個類,有 run 方法,run 方法接受一個引數 iterations,表示模擬多少次,simulate 方法就是模擬。
模擬完畢之後,getBestAction 獲取分數最高的那個 action 動作。
simulate 方法怎麼寫呢?就是不斷地隨機選一個方向,走到死。board.getActions 方法要在勝利或者失敗時,返回空陣列,表示玩家在遊戲裡沒有任何有效動作可以做了。這樣 while 死迴圈就可以得到釋放。
board.doAction 應該是讓遊戲進入下一個狀態。如果遊戲步驟是無限的,那麼我們需要控制一下一次模擬的時間長短,或者 doAction 的次數,對於 2048 等非無限步驟遊戲來說,這一步倒可以省略。
模擬時,需要 board.clone 複製一個,避免影響到當前遊戲的狀態。如果我們拿不到遊戲模擬器,蒙特卡洛方法就沒有那麼方便地派上用場。
path 陣列變數,記錄了我們這次模擬的 action 序列。
當我們一次模擬走到死之後,就把當前第一個 action 和本次模擬的結果(勝負01或者得分 score),存到統計表裡累計。為什麼是第一個action?因為我們的目的就是找到當前遊戲的下一步動作,所以模擬的第一步動作,對應的就是我們實際上要做的下一部動作。
最後一個方法 updateStatistic,就是我們更新統計表了。它的實現也很簡單,就是判斷一下這個動作是否已經存在,存在就累計,不存在就建立。
不知道你是否注意到,我們的程式碼裡,並沒有 2048 限定的內容,而是在操作一個 board,以及 clone, getActions, doAction, getResult 等高度抽象的方法?
沒錯,我們剛才實現的蒙特卡洛方法,不是為 2048 定製的,它可以使用在不同的棋盤遊戲、視訊遊戲或者跟步驟序列相關的遊戲裡。只要寫一個介面卡,把遊戲狀態和動作匯出到 clone, getActions, doAction, getResult 等介面即可。
2048 遊戲適配蒙特卡洛方法
只需要很簡短的幾行程式碼,就可以提供讓 2048 board 例項的方法,適配我們所實現的「蒙特卡洛方法類」。
在 getActions 裡,判斷 2048 board 當前是否勝利(hasWon)或者失敗(hasLost),如果是,就返回空陣列,如果不是,就返回 [0, 1, 2, 3] 陣列表示「上下左右」。
getResult 返回結果就是,先記錄模擬前的分數 board.score 為 startScore,在模擬後,getResult 時,把當前的 board.score - startScore,就得到本次模擬的掙到的實際分數。
doAction 方法裡簡單地呼叫 board.move 移動方向。為什麼要抽象成 doAction,而非 doMove 呢?因為有些遊戲的動作,不侷限於移動啊,所以 move 太具體了,action 更抽象,可以表示更多可能的動作。
寫完介面卡之後,就可以輸出一個方法 getBestAction,只要把當前 2048 board 輸入進來,就用蒙特卡洛方法模擬 400 次,然後返回統計上得分最高的那個 action,作為下一個 action。
每走一步都跑一下蒙特卡洛方法,雖然重複走了很多次,但沒關係,只要效能跟得上,重複就重複吧,重複帶來更多的模擬次數,也意味著更準確擬合了理論上的面積分布。
如果 400 次模擬,準確度不夠,可以增加到 800 次, 2000 次,總有一個數量級,可以達到滿意的結果。
勝利的結果
下圖是在我機器上模擬後,成功抵達 2048 的截圖。你也可以在自己機器上看一下這個過程。當然,最好你可以動手實現一下蒙特卡洛方法的演算法,加固印象。
請關注我的微信公眾號。有機會,我們再介紹基於蒙特卡洛方法的「蒙特卡洛樹搜尋(MCTS)」,它其實是蒙特卡洛方法在程式設計上的結構優化,本質還是蒙特卡洛方法。