AI實現的基本思路-極大極小值搜尋演算法
五子棋看起來有各種各樣的走法,而實際上把每一步的走法展開,就是一顆巨大的博弈樹。在這個樹中,從根節點為0開始,奇數層表示電腦可能的走法,偶數層表示玩家可能的走法。
假設電腦先手,那麼第一層就是電腦的所有可能的走法,第二層就是玩家的所有可能走法,以此類推。
我們假設平均每一步有50種可能的走法,那麼從根節點開始,往下面每一層的節點數量是上一層的 50被,假設我們進行4層思考,也就是電腦和玩家各走兩步,那麼這顆博弈樹的最後一層的節點數為 50^4 = 625W 個。
先不考慮這麼多個節點需要多久能算出來。有了對博弈樹的基本認識,我們就可以用遞迴來遍曆這一棵樹。
那麼我們如何才能知道哪一個分支的走法是最優的,我們就需要一個評估函數能對當前整個局勢作出評估,返回一個分數。我們規定對電腦越有利,分數越大,對玩家越有利,分數越小,分數的起點是0。
我們遍曆這顆博弈樹的時候就很明顯知道該如何選擇分支了: 電腦走棋的層我們稱為 MAX層,這一層電腦要保證自己利益最大化,那麼就需要選分最高的節點。 玩家走棋的層我們稱為MIN層,這一層玩家要保證自己的利益最大化,那麼就會選分最低的節點。
這也就是極大極小值搜尋演算法的名稱由來。直接盜一個圖說明這個問題:
此圖中甲是電腦,乙是玩家,那麼在甲層的時候,總是選其中值最大的節點,乙層的時候,總是選其中最小的節點。
而每一個節點的分數,都是由子節點決定的,因此我們對博弈樹只能進行深度優先搜尋而無法進行廣度優先搜尋。深度優先搜尋用遞迴非常容易實現,然後主要工作其實是完成一個評估函數,這個函數需要對當前局勢給出一個比較準確的評分。
這篇部落格講的五子棋演算法已經全部用JS實現並且是開源的,源碼地址: https://github.com/lihongxun945/gobang ,預覽地址: http://gobang.light7.cn/
可以clone這個倉庫,參考其中js目錄下的源碼。因為代碼量不小,後面要講的只是關鍵區段的代碼實現,不會貼完整的代碼。而且關鍵代碼可能會省略部分實現,UI因為跟AI演算法無關,也不會講,請自行參考源碼。
我們下面來分部實現其中幾個關鍵的部分。 極大極小值搜尋
五子棋是一個15*15的棋盤,因為棋盤大小不會變動,所以目前來看用 15*15 的二維數組來儲存,效果是最好的。
極大極小值的搜尋比較簡單,就是一個DFS,直接上代碼,看不懂的可以
var maxmin = function(board, deep) { var best = MIN; var points = gen(board, deep); //這個函數的作用是產生待選的列表,就是可以下子的空位。後面會講這個方法。 for(var i=0;i<points.length;i++) { var p = points[i]; board[p[0]][p[1]] = R.com; //嘗試下一個子 var v = min(board, deep-1); //找最大值 //如果跟之前的一個好,則把當前位子加入待選位子 if(v == best) { bestPoints.push(p); } //找到一個更好的分,就把以前存的位子全部清除 if(v > best) { best = v; bestPoints = []; bestPoints.push(p); } board[p[0]][p[1]] = R.empty; //記得把嘗試下的子移除 } var result = bestPoints[Math.floor(bestPoints.length * Math.random())]; //在分數最高的幾個位置中隨機播放一個 return result;}var min = function(board, deep) { var v = evaluate(board); //重點來了,評價函數,這個函數返回的是對當前局勢的估分。 if(deep <= 0 || win(board)) { return v; } var best = MAX; var points = gen(board, deep); for(var i=0;i<points.length;i++) { var p = points[i]; board[p[0]][p[1]] = R.hum; var v = max(board, deep-1); board[p[0]][p[1]] = R.empty; if(v < best ) { best = v; } } return best ;}var max = function(board, deep) { var v = evaluate(board); if(deep <= 0 || win(board)) { return v; } var best = MIN; var points = gen(board, deep); for(var i=0;i<points.length;i++) { var p = points[i]; board[p[0]][p[1]] = R.com; var v = min(board, deep-1); board[p[0]][p[1]] = R.empty; if(v > best) { best = v; } } return best;}
源碼裡面是有 Alpha Beta剪枝的,不過剪枝代碼其實就幾行,可以直接看源碼,在 js/max-min.js 裡面。 評估函數
在源碼中 js/evaluate.js 就是評估函數,它接受一個二維數組,返回一個整型數。
我們對五子棋的評分是簡單的把棋盤上的各種連子的分值加起來得到的,對各種連子的基本評分規則如下: 成五,100000 活四, 10000 活三 1000 活二 100 活一 10
如果一側被封死但是另一側沒有,則評分降一個檔次,也就是死四和活三是相同的分 死四, 1000 死三 100 死二 10
score.js 中是對各種情況估值的定義。
按照這個規則把棋盤上電腦的所有棋子打分,之和即為電腦的單方面得分 scoreComputer,然後對玩家的棋子同樣打分 得到 scoreHuman。
scoreComputer - scoreHuman 即為當前局勢的總分數。
那麼如何找出所有的連子呢,目前我的方式是先把二維的棋盤按照橫豎撇捺四個方向變成N個一維數組,這個過程稱為 flat。flat.js 是一個專門實現這個功能的模組。
然後我們就可以對所有的一維數組進行得分計算。
評分函數代碼領比較大,請自行參考 evalute.js
請注意,這裡我們說的評估函數是對整個棋局的評估,後面講啟發學習法搜尋的時候還會有一個啟發學習法評估函數是對某一個位置的評估。這兩個評估函數是不同的 generator函數
generator顧名思義,就是在每一步產生所有可以落子的點。並不是所有的空位我們又要搜尋,很多位置明顯不合適的我們可以直接排除。
這個函數非常重要,是最佳化效能的關鍵。不過目前我們只做一個最簡單的實現。就是把所有周圍有鄰居的空位認為是合適的位子。
有鄰居的定義是:想個兩步以內至少有一個不為空白的點即可。比如 b[7,7] 有一個子,那麼 b[6,7]是他的鄰居,b[5,7] 也是,但是 b[4,7] 就不是,因為相隔了三步。
var gen = function(board, deep) { var neighbors = []; var nextNeighbors = []; for(var i=0;i<board.length;i++) { for(var j=0;j<board[i].length;j++) { if(board[i][j] == R.empty) { if(hasNeighbor(board, [i, j], 1, 1)) { //必須是有鄰居的才行 neighbors.push([i, j]); } else if(deep >= 2 && hasNeighbor(board, [i, j], 2, 2)) { nextNeighbors.push([i, j]); } } } } return neighbors.concat(nextNeighbors)}
這裡做了一個簡單的最佳化,就是按照鄰居的遠近做了一個排序,為什麼要排序。後面講AB剪枝的時候會講到。排序對AB剪枝的效率起了決定性作用。
另外一些簡單的函數,沒有列出,直接去看源碼就行了
下面我們將講一個重點: Alpha Beta 剪枝演算法。