Minimax 和 Alpha-beta 剪枝算法簡介,以及以此實現的井字棋遊戲(Tic-tac-toe)

前段時間用 React 寫了個2048 遊戲來練練手,準備用來回顧下 React 相關的各類技術,以及試驗一下新技術。在寫這個2048的過程當中,我考慮是否能夠在其中加入一個 AI 算法來自動進行遊戲,因而我找到了這篇文章:2048-AI程序算法分析,文中介紹了 minimax 算法和 alpha-beta 剪枝算法。因而我決定先學習下這兩種算法,並以此寫了這個 tic-tac-toe 遊戲:tic-tac-toe-js代碼見此處)。本文將說明如何用 JavaScript 來簡單地實現算法,並將其運用到 tic-tac-toe 遊戲中。html

Minimax 算法簡介

我以爲要解釋 minimax 算法的原理,須要用示意圖來解釋更清晰,如下的幾篇文章都對原理說的足夠清楚。react

  1. 2048-AI程序算法分析
  2. Tic Tac Toe: Understanding the Minimax Algorithm
  3. An Exhaustive Explanation of Minimax, a Staple AI Algorithm

其中後面的兩篇文章都是以 tic-tac-toe 遊戲爲例,並用 Ruby 實現。ios

以棋類遊戲爲例來講明 minimax 算法,每個棋盤的狀態都會對應一個分數。雙方將會輪流下棋。輪到我方下子時,我會選擇分數最高的狀態;而對方會選擇對我最不利的狀態。能夠這麼認爲,每次我都須要從對手給我選擇的最差(min)局面中選出最好(max)的一個,這就是這個算法名稱 minimax 的意義。git

minimax tree

(圖片來自於 http://web.cs.ucla.edu/~rosen...github

咱們接下來會解決這樣一個問題,如上圖所示,正方形的節點對應於個人決策,圓形的節點是對手的決策。雙方輪流選擇一個分支,個人目標是讓最後選出的數字儘量大,對方的目標是讓這個數字儘量小。web

Minimax 算法的實現

爲了簡單起見,對於這個特定的問題,我用了一個嵌套的數組來表示狀態樹。算法

const dataTree = [
    [
        [
            [3, 17], [2, 12]
        ],
        [
            [15], [25, 0]
        ]
    ],
    [
        [
            [2, 5], [3]
        ],
        [
            [2, 14]
        ]
    ]
];

圖中的節點分爲兩種類型:canvas

  1. Max 節點:圖中的正方形節點,對應於個人回合,它會選取全部子節點中的最大值做爲自身的值
  2. 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 均是一個數組。app

接下來考慮如何給每一個節點打分,可能會出現這樣的幾種狀況:

  1. 最底層的節點,直接返回自己的數字
  2. 中間層的 max 節點,返回子節點中的最大分數
  3. 中間層的 min 節點,返回子節點中的最小分數

爲方便描述,咱們按照由上到下、由左到右的順序給圖中節點進行標號。節點1是 max 節點,從節點2和節點3中選擇較大值;而對於節點2來講,須要從節點4,5中選取較小值。很顯然,咱們這裏要用遞歸的方法來實現,當搜索到最底層的節點時,遞歸過程開始返回。

minimax tree mark

如下是打分函數 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 剪枝算法簡介

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 遊戲中。

完整的 alpha-beta 剪枝算法代碼

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?, 還有很大的改進空間。


本文原連接

相關文章
相關標籤/搜索