前段時間用 React 寫了個2048 遊戲來練練手,準備用來回顧下 React 相關的各種技術,以及試驗一下新技術。在寫這個2048的過程中,我考慮是否可以在其中加入一個 AI 演算法來自動進行遊戲,於是我找到了這篇文章:2048-AI程式演算法分析,文中介紹了 minimax 演算法和 alpha-beta 剪枝演算法。於是我決定先學習下這兩種演算法,並以此寫了這個 tic-tac-toe 遊戲:tic-tac-toe-js(程式碼見此處)。本文將說明如何用 JavaScript 來簡單地實現演算法,並將其運用到 tic-tac-toe 遊戲中。
Minimax 演算法簡介
我覺得要解釋 minimax 演算法的原理,需要用示意圖來解釋更清晰,以下的幾篇文章都對原理說的足夠清楚。
其中後面的兩篇文章都是以 tic-tac-toe 遊戲為例,並用 Ruby 實現。
以棋類遊戲為例來說明 minimax 演算法,每一個棋盤的狀態都會對應一個分數。雙方將會輪流下棋。輪到我方下子時,我會選擇分數最高的狀態;而對方會選擇對我最不利的狀態。可以這麼認為,每次我都需要從對手給我選擇的最差(min)局面中選出最好(max)的一個,這就是這個演算法名稱 minimax 的意義。
(圖片來自於 http://web.cs.ucla.edu/~rosen/161/notes/alphabeta.html)我們接下來會解決這樣一個問題,如上圖所示,正方形的節點對應於我的決策,圓形的節點是對手的決策。雙方輪流選擇一個分支,我的目標是讓最後選出的數字儘可能大,對方的目標是讓這個數字儘可能小。
Minimax 演算法的實現
為了簡單起見,對於這個特定的問題,我用了一個巢狀的陣列來表示狀態樹。
const dataTree = [
[
[
[3, 17], [2, 12]
],
[
[15], [25, 0]
]
],
[
[
[2, 5], [3]
],
[
[2, 14]
]
]
];
複製程式碼
圖中的節點分為兩種型別:
- Max 節點:圖中的正方形節點,對應於我的回合,它會選取所有子節點中的最大值作為自身的值
- Min 節點:圖中的圓形節點,對應於對手的回合,它會選取所有子節點中的最小值作為自身的值
先定義一個 Node
類,constructor
如下:
constructor(data, type, depth) {
this.data = data;
this.type = type; // 區分此節點的種類是 max 或 min
this.depth = depth;
}
複製程式碼
根節點的 depth
為0,以下的每一層 depth
依次加一。最底層的節點 depth
為4,其 data
是寫在圖中的數字,其它層節點的 data
均是一個陣列。
接下來考慮如何給每個節點打分,可能會出現這樣的幾種情況:
- 最底層的節點,直接返回本身的數字
- 中間層的 max 節點,返回子節點中的最大分數
- 中間層的 min 節點,返回子節點中的最小分數
為方便描述,我們按照由上到下、由左到右的順序給圖中節點進行標號。節點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') {
// 與上方程式碼相似,省略部分程式碼
}
}
複製程式碼
Alpha-beta 剪枝演算法簡介
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 演算法進行了優化。
Alpha-beta 剪枝演算法的實現
接下來討論如何修改前面實現的 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 遊戲中。
Tic-tac-toe 遊戲中的應用
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?, 還有很大的改進空間。