前段時間用 React 寫了個2048 遊戲來練練手,準備用來回顧下 React 相關的各類技術,以及試驗一下新技術。在寫這個2048的過程當中,我考慮是否能夠在其中加入一個 AI 算法來自動進行遊戲,因而我找到了這篇文章:2048-AI程序算法分析,文中介紹了 minimax 算法和 alpha-beta 剪枝算法。因而我決定先學習下這兩種算法,並以此寫了這個 tic-tac-toe 遊戲:tic-tac-toe-js(代碼見此處)。本文將說明如何用 JavaScript 來簡單地實現算法,並將其運用到 tic-tac-toe 遊戲中。html
我以爲要解釋 minimax 算法的原理,須要用示意圖來解釋更清晰,如下的幾篇文章都對原理說的足夠清楚。react
其中後面的兩篇文章都是以 tic-tac-toe 遊戲爲例,並用 Ruby 實現。ios
以棋類遊戲爲例來講明 minimax 算法,每個棋盤的狀態都會對應一個分數。雙方將會輪流下棋。輪到我方下子時,我會選擇分數最高的狀態;而對方會選擇對我最不利的狀態。能夠這麼認爲,每次我都須要從對手給我選擇的最差(min)局面中選出最好(max)的一個,這就是這個算法名稱 minimax 的意義。git
(圖片來自於 http://web.cs.ucla.edu/~rosen...)github
咱們接下來會解決這樣一個問題,如上圖所示,正方形的節點對應於個人決策,圓形的節點是對手的決策。雙方輪流選擇一個分支,個人目標是讓最後選出的數字儘量大,對方的目標是讓這個數字儘量小。web
爲了簡單起見,對於這個特定的問題,我用了一個嵌套的數組來表示狀態樹。算法
const dataTree = [ [ [ [3, 17], [2, 12] ], [ [15], [25, 0] ] ], [ [ [2, 5], [3] ], [ [2, 14] ] ] ];
圖中的節點分爲兩種類型:canvas
先定義一個 Node
類,constructor
以下:數組
constructor(data, type, depth) { this.data = data; this.type = type; // 區分此節點的種類是 max 或 min this.depth = depth; }
根節點的 depth
爲0,如下的每一層 depth
依次加一。最底層的節點 depth
爲4,其 data
是寫在圖中的數字,其它層節點的 data
均是一個數組。app
接下來考慮如何給每一個節點打分,可能會出現這樣的幾種狀況:
爲方便描述,咱們按照由上到下、由左到右的順序給圖中節點進行標號。節點1是 max 節點,從節點2和節點3中選擇較大值;而對於節點2來講,須要從節點4,5中選取較小值。很顯然,咱們這裏要用遞歸的方法來實現,當搜索到最底層的節點時,遞歸過程開始返回。
如下是打分函數 score
的具體代碼:
score() { // 到達了最大深度後,此時的 data 是數組最內層的數字 if (this.depth >= 4) { return this.data; } // 對於 max 節點,返回的是子節點中的最大值 if (this.type === 'max') { let maxScore = -1000; for (let i = 0; i < this.data.length; i++) { const d = this.data[i]; // 生成新的節點,子節點的 type 會和父節點不一樣 const childNode = new Node(d, changeType(this.type), this.depth + 1); // 遞歸獲取其分數 const childScore = childNode.score(); if (childScore > maxScore) { maxScore = childScore; } } return maxScore; } // 對於 min 節點,返回的是子節點中的最小值 else if (this.type === 'min') { // 與上方代碼類似,省略部分代碼 } }
完整的 minimax 算法代碼
Alpha-beta 剪枝算法能夠認爲是 minimax 算法的一種改進,在實際的問題中,須要搜索的狀態數量將會很是龐大,利用 alpha-beta 剪枝算法能夠去除一些沒必要要的搜索。
關於 alpha-beta 算法的具體解釋能夠看這篇文章 Minimax with Alpha Beta Pruning。咱們在前文中考慮的那張圖就來自這篇文章,以後咱們會用 alpha-beta 剪枝算法來改進以前的解決方案。
剪枝算法中主要有這麼些概念:
每個節點都會由 alpha 和 beta 兩個值來肯定一個範圍 [alpha, beta],alpha 值表明的是下界,beta 表明的是上界。每搜索一個子節點,都會按規則對範圍進行修正。
Max 節點能夠修改 alpha 值,min 節點修改 beta 值。
若是出現了 beta <= alpha 的狀況,則不用搜索更多的子樹了,未搜索的這部分子樹將被忽略,這個操做就被稱做剪枝(pruning)。
接下來我會盡可能說明爲何剪枝這個操做是合理的,省略了一部分節點爲何不會對結果產生影響。用原圖中以4號節點(第三層的第一個節點)爲根節點的子樹來舉例,方便描述這裏將他們用 A - G 的字母來從新標記。
從 B 節點看起,B 是 min 節點,須要在 D 和 E 中尋找較小值,所以 B 取值爲3,同時 B 的 beta 值也設置爲 3。假設 B 還有更多值大於3的子節點,但由於已經出現了 D 這個最小值,因此不會對 B 產生影響,即這裏的 beta = 3 肯定了一個上界。
.A 是 max 節點,須要在 B 和 C 中找到較大值,由於子樹 B 已經搜索完畢,B 的值肯定爲 3,因此 A 的值至少爲 3,這樣肯定了 A 的下界 alpha = 3。在搜索 C 子樹以前,咱們但願 C 的值大於3,這樣纔會對 A 的下界 alpha 產生影響。因而 C 從 A 這裏得到了下界 alpha = 3 這個限制條件。
.C 是 min 節點,要從 F 和 G 裏找出較小值。F 的值爲2,因此 C 的值必定小於等於 2,更新 C 的上界 beta = 2。此時 C 的 alpha = 3, beta = 2,這是一個空區間,也就是說即便繼續考慮 C 的其它子節點, 也不可能讓 C 的值大於 3,因此咱們沒必要再考慮 G 節點。G 節點就是被剪枝的節點。
重複這樣的過程,會有更多的節點由於剪枝操做被忽略,從而對 minimax 算法進行了優化。
接下來討論如何修改前面實現的 minimax 算法,使其變爲 alpha-beta 剪枝算法。
第一步在 constructor 中加入兩個新屬性,alpha、beta。
constructor(data, type, depth, alpha, beta) { this.data = data; this.type = type; // 區分此節點的種類是 max 或 min this.depth = depth; this.alpha = alpha || -Infinity; this.beta = beta || Infinity; }
而後每次都搜索會視狀況更新 alpha, beta 的值,如下的代碼片斷來自於搜索 max 節點的過程:
// alphabeta.js 中的 score() 函數 for (let i = 0; i < this.data.length; i++) { // ... if (childScore > maxScore) { maxScore = childScore; // 相對於 minimax 算法,alpha-beta 剪枝算法在這裏增長了一個更新 alpha 值的操做 this.alpha = maxScore; } // 若是知足了退出的條件,咱們不須要繼續搜索更多的節點了,退出循環 if (this.alpha >= this.beta) { break; }
相對應的是在 min 節點中,咱們更新的將是 beta 值。好了,只須要作這麼些簡單的改變,就將 minimax 算法改變成了 alpha-beta 剪枝算法了。
最後看看如何將算法應用到 tic-tac-toe 遊戲中。
完整的 alpha-beta 剪枝算法代碼
Tic-tac-toe,即井字棋遊戲,規則是在雙方輪流在 3x3 的棋盤上的任意位置下子,率先將三子連成一線的一方獲勝。
這就是一個很是適合用 minimax 來解決的問題,即便在不考慮對稱的狀況,全部的遊戲狀態也只有 9! = 362880 種,相比於其它棋類遊戲天文數字般的狀態數量已經不多了,於是很適合做爲算法的示例。
我在代碼中將棋盤的狀態用一個長度爲9的數組來表示,而後利用 canvas 繪製出一個簡易的棋盤,下子的過程就是修改數組的對應位置而後重繪畫面。
如今咱們已經有了現成的 minimax 和 alpha-beta 剪枝算法,只要加上一點兒細節就能完成這個遊戲了?。
先來定義一個 GameState
類,其中保存了遊戲的狀態,對應於以前分析過程當中的節點,其 constructor
以下:
constructor(board, player, depth, alpha, beta) { this.board = board; // player 是用字符 X 和 O 來標記當前由誰下子,以此來判斷當前是 max 仍是 min 節點 this.playerTurn = player; this.depth = depth; // 保存分數最高或最低的狀態,用於肯定下一步的棋盤狀態 this.choosenState = null; this.alpha = alpha || -Infinity; this.beta = beta || Infinity; }
爲進行遊戲,首先須要一個 checkFinish
函數,檢查遊戲是否結束,結束時返回勝利者信息。搜索的過程是在 getScore
函數中完成的,每次搜索先檢查遊戲是否結束,平局返回零分,咱們的算法是站在 AI 的角度來考慮的,所以 AI 勝利時返回10分,AI 失利時返回-10分。
// alphabeta.js 中的 getScore() 方法 const winner = this.checkFinish(); if (winner) { if (winner === 'draw') return 0; if (winner === aiToken) return 10; return -10; }
接着是對 max 和 min 節點的分類處理:
// alphabeta.js 中的 getScore() 方法 // 得到全部可能的位置,利用 shuffle 加入隨機性 const availablePos = _.shuffle(this.getAvailablePos()); // 對於 max 節點,返回的是子節點中的最大值 if (this.playerTurn === aiToken) { let maxScore = -1000; let maxIndex = 0; for (let i = 0; i < availablePos.length; i++) { const pos = availablePos[i]; // 在給定的位置下子,生成一個新的棋盤 const newBoard = this.generateNewBoard(pos, this.playerTurn); // 生成一個新的節點 const childState = new GameState(newBoard, changeTurn(this.playerTurn), this.depth + 1, this.alpha, this.beta); // 這裏開始遞歸調用 getScore() 函數 const childScore = childState.getScore(); if (childScore > maxScore) { maxScore = childScore; maxIndex = i; // 這裏保存產生了最大的分數的節點,以後會被用於進行下一步 this.choosenState = childState; this.alpha = maxScore; } if (this.alpha >= this.beta) { break; } } return maxScore; } // min 節點的處理與上面相似 // ...
完整代碼見 alphabeta.js
這樣就簡單地介紹了 minimax 算法和 alpha-beta 算法,並分別給出了一個簡單的實現,而後在 tic-tac-toe 遊戲中應用了算法。
文章中所提到的全部代碼可見此項目:Tic-tac-toe-js。其中的 algorithms
文件夾中是兩種算法的簡單實現,src
文件中是遊戲的代碼。
文章開頭說到了這篇文章起源於寫2048遊戲項目的過程當中,以後我將 minimax 算法應用到了2048遊戲的 AI 中,不過對於局面的評估函數尚不完善,如今 AI 只能勉強合成1024?, 還有很大的改進空間。