“JavaScript中國象棋程序” 這一系列教程將帶你從頭使用JavaScript編寫一個中國象棋程序。這是教程的第5節。
上一節,我們深度優先遍歷了搜索樹,而沒有廣度優先遍歷。本節介紹的Alpha-Beta搜索,會有力提高搜索算法的效率,並體現出深度優先遍歷的優勢。Alpha-Beta搜索非常重要,是后面課程的基礎。本節課程可以分為以下3步學習:
(1)、學習負極大值搜索。這與上節課程是有關聯的,並不難懂。了解負極大值搜索,是為了更容易接受Alpha-Beta搜索。另外,在文件“search - 負極大值搜索.js”中,實現了負極大值搜索。
(2)、理解剪枝的原理,然后學習Alpha-Beta搜索。在文件“search - 一個簡單的Alpha-Beta搜索.js”中,實現了一個簡單的,不超出邊界的Alpha-Beta搜索。
(3)、學習在Alpha-Beta基礎上進行的一些優化,包括超出邊界的Alpha-Beta搜索、殺棋的分數、歷史表啟發。
5.1、負極大值搜索
前面介紹了極大點搜索和極小點搜索,兩種算法的大部分邏輯是一樣的。可以合並這兩種算法,合並后就是負極大值搜索算法,搜索樹如下:
紅色數字是當前節點的分值,藍色數字是父節點對子節點返回值取負后的值。
搜索進行到C1、C2、C3時,要做評估,得到的估值是8、10、15。由於此時輪到紅方走棋,表示紅方在三個局面分別有8、10、15的優勢。在極大極小搜索中,要在B1求這三個局面估值的最小值。現在我們可以這樣來看,黑方在C1、C2、C3的優勢分別為-8、-10、-15。黑方顯然在B1點需要對三個局面做一個選擇,選擇的目標當然是優勢最大化。可以這樣表示:
max(-8, -10, -15)
在極大極小搜索中,B1是黑方走棋,是極小點,應該求最小值。現在由於多加了一個負號,也變成求極大值了,B1點也成了極大點。
黑方在B1、B2、B3分別取得了-8、-5、-10的優勢,對於紅方來講,則在這三個點分別有8、5、10的優勢。所以在A點,輪到紅方走棋,它會在B1、B2、B3中選擇最大值,即:
max(8, 5, 10)
負極大值搜索偽代碼如下:
Search.prototype.negaMaxSearch = function(depth) { 如果depth等於0,調用評估函數並返回分值 var vlBest = 負無窮; // 初始最優值 var mvs = 當前局面全部走法; // 生成當前局面的所有走法 var value = 0; for (var i = 0; i < mvs.length; i ++) { 執行招法mvs[i] value = -this.negaMaxSearch(depth - 1); // 遞歸調用自身,注意有個負號 撤銷招法mvs[i] if (value > vlBest) { // 尋找最大估值 vlBest = value; if (depth == MINMAXDEPTH) { // 如果回到根節點,需要記錄最佳走法 記錄根節點的最佳走法 } } } return vlBest; // 返回當前節點的最優值 }
在極大極小算法中,葉子節點調用評估函數,始終返回紅方優勢。根節點如果紅方走棋,調用極大點算法;否則調用極小點算法。
在負極大值算法中,所有節點都是極大點。評估函數就不能總是返回紅方優勢了,應該返回葉子節點走棋方的優勢。另外,走棋方具有先走棋的權利,我們認為這是一種優勢。為體現這個優勢,局面得分加上ADVANCED_VALUE(也就是3)的分值。
評估函數應該修改為:
Position.prototype.evaluate = function() { var vl = (this.sdPlayer == 0 ? this.vlWhite - this.vlBlack : this.vlBlack - this.vlWhite) + ADVANCED_VALUE; return vl; }
5.2、淺的搜索剪枝
極大極小搜索以及負極大值搜索,都是對整個搜索樹進行完全搜索。實際上,有些沒用的分支是可以裁減掉的,這樣會大大縮短搜索時間。先來看一個極大極小搜索樹(使用負極大值搜索樹,道理是一樣的。不過極大極小搜索樹更直觀,更簡單一點。):
當搜索到B2點時,紅方A點已經通過B1的搜索得到了一個當前最優值8,紅方希望通過B2這條路徑得到更好的值。當搜索到C4時得到一個估值7,也就是B2當前最優值。由於B2是極小點,在搜索B2剩余的子節點時,凡是估值超過7的B2都不會考慮,一旦有值小於7就會取代7成為新的最優值。也就是說,B2的值不超過7。那么對於A點來講,B2就沒有意義,繼續搜索B2的后續子節點也毫無意義。C5、C6不必搜索,而直接返回A點,繼續A點后續子節點的搜索。
C8、C9節點與B2節點類似,也可以進行剪枝。
以C9節點為例,當搜索到C9時,黑方B3點已經通過對C7、C8的搜索,得到了一個最優值10。當搜索到D5節點是,得到一個估值12。由於C9是極大點,因此C9的值不會低於12。對B3點來說,C9毫無意義,不用再搜索C9的后續節點。
我們再來看一下負極大值搜索樹:
紅色數字是當前節點的分值,藍色數字是父節點對子節點返回值取負后的值。
搜索進行到C1、C2、C3時,得到估值分別是8、10、15,也就是說紅方優勢分別是8、10、15。那么,這三點黑方優勢分別是-8、-10、-15。B1點要在C1、C2、C3點做出抉擇,自然會選擇黑方優勢最大的C1點,B1點估值-8。
在B1點,-8是黑方的優勢,那么-8也可以被認為是紅方的劣勢。紅方希望通過對B2的搜索,來減小這一劣勢(也就是得到-9、-10之類,小於-8的估值)。搜索完C4后,B2點得到了一個最優值-7。由於目前所有節點都是極大點,B2的估值不會小於-7。B2點增大了紅方得的劣勢,休想A點選擇B2這條路線,對B2剩余子節點的搜索也就毫無意義。
怎樣實現這一算法呢?我們再回到B2點。只要把A點已知的紅方劣勢,也就是-8,傳遞給B2點,就能實現這一裁剪。A點傳遞給B2點的這個參數,我們叫做Beta;這一搜索算法,我們叫做Beta搜索。偽代碼如下:
Search.prototype.betaSearch = function(depth, beta) {
如果depth等於0,調用評估函數並返回分值
var vlBest = 負無窮; // 初始最優值
var mvs = 當前局面全部走法; // 生成當前局面的所有走法
var value = 0;
for (var i = 0; i < mvs.length; i ++) {
執行招法mvs[i]
value = -this.betaSearch(depth - 1); // 遞歸調用自身,注意有個負號
撤銷招法mvs[i]
if(value > beta) { // 得到一個大於bate的值,終止對當前節點的搜索
return beta;
}
if (value > vlBest) { // 尋找最大估值
vlBest = value;
if (depth == MINMAXDEPTH) { // 如果回到根節點,需要記錄最佳走法
記錄根節點的最佳走法
}
}
}
return vlBest; // 返回當前節點的最優值
}
5.3、深的搜索剪枝
我們來討論更復雜的可能裁剪的情況,
當搜索B2節點時,已知B1節點的估值是8。從B2點向下兩層,有個D1點。D1點搜索完E1點后,得到E1的估值為6。由於D1是極小點,D1的估值不大於6。如果從C5點返回6或者更小,那么我們就可以在B2上做裁剪,因為它有更好的兄弟節點 B1(這里的更好是對A點來說的)。因此,繼續搜索D1的后續節點毫無意義,E2不必搜索。
為了實現深的裁剪,除了傳遞參數Beta,我們還要傳遞一個參數Alpha,這就形成了Alpha-Beta搜索。
5.4、Alpha-Beta搜索
Alpha-Beta搜索與Beta搜索很相似,偽代碼如下:
Search.prototype.alphaBetaSearch = function(vlAlpha_, vlBeta, depth) { 如果depth等於0,調用評估函數並返回分值 var vlAlpha = vlAlpha_; // 初始最優值,不再是負無窮 var mvs = 當前局面全部走法; // 生成當前局面的所有走法 var vl = 0; for (var i = 0; i < mvs.length; i ++) { 執行招法mvs[i] vl = -this.alphaBetaSearch(-vlBeta, -vlAlpha, depth - 1); // 遞歸調用 撤銷招法mvs[i] if(vl >= vlBeta) { // 得到一個大於或等於bate的值,終止對當前節點的搜索 return vlBeta; } if (vl > vlAlpha) { // 尋找最大估值 vlAlpha = vl; if (depth == MINMAXDEPTH) { // 如果回到根節點,需要記錄最佳走法 記錄最佳走法 } } } return vlAlpha; // 返回當前節點的最優值 }
在根節點,Alpha取負無窮,Beta取正無窮。當函數遞歸時,Alpha和Beta不但取負數而且要交換位置。Alpha表示當前搜索節點走棋一方搜索到的最好值,任何比它小的值對當前節點走棋方都沒有意義。Beta表示對手目前的劣勢,這是對手所能承受的最壞結果。Beta值越大,表示對方劣勢越明顯;Beta值越小,表示對方劣勢也越小。在對手看來,他總是希望找到一個對策不比Beta更壞。如果當前節點返回Beta或比Beta更好的值,作為父節點的對方就絕不會選擇這種策略。
5.5、超出邊界Alpha-Beta搜索
圖A 超出邊界的Alpha-Beta搜索
圖B 不超出邊界的Alpha-Beta搜索
以上兩圖,分別描述了“超出邊界的Alpha-Beta搜索”和“不超出邊界的Alpha-Beta搜索”。
括號中的兩個值,是當前節點的初始alpha和beta。
從上面兩圖可以看出,B2點第1種走法得到value = -7,產生剪枝。B2點無論返回-7還是-8都不會影響A點的值。C8的所有走法得到的值均小於8,無論返回3還是8在父節點都會引起剪枝。
不管是哪種搜索方法,最終根節點的搜索結果是一樣的,搜索效率也一樣。不過,將來程序中會引入置換表(后面的課程會介紹這個概念),超出邊界的Alpha-Beta搜索查找置換表時命中率更高,更有優勢。因此,程序中直接使用了超出邊界的Alpha-Beta搜索。
5.6、殺棋的分數
考慮這樣一個搜索樹:
在根節點A點,紅方優勢明顯,即將取得勝利。而在D點和B2點,都是黑方被將死或者困斃的局面。如果D、B2兩節點的估值都是-MATE_VALUE(也就是-10000),那么A點沿這兩個搜索路徑得到的估值都是10000。當估值相同時,我們的程序一般采用前面的走法。但是對紅方來說,明顯第二個路徑更好,一步就能取得勝利。為解決這一問題,可以把輸棋的分值,與搜索層數結合起來:
輸棋分值 = -MATE_VALUE + 搜索層數
例如,A、B1、B2、C、D的搜索層數分別是0、1、1、2、3。D節點估值
-10000 + 3 = -9997
B2節點估值
-10000 + 1 = -9999
在我們的程序中,使用如下函數實現:
Position.prototype.mateValue = function() {
return this.distance - MATE_VALUE;
}
5.7、歷史表啟發(History Heuristic)
Alpha-Beta搜索嚴重依賴於着發的搜索順序。如果總是先去搜索最壞的着法,那么Beta截斷就不會發生,因此該算法就如同極大極小搜索一樣,效率非常低。如果程序總是能挑最好的着法來首先搜索,就能不斷發生Beta截斷,大大提高搜索效率。因此使用Alpha-Beta搜索時,着發順序至關重要。
國際象棋程序的經驗證明,歷史表是很好的走法排序依據。什么是歷史表呢?如果一個着法在不同層很多節點都被搜索過,並且是很好的走法,那么我們就將該走法存入歷史表,並對該走發賦一個值(當然,這個走法被搜索到的次數越多,賦的值就越大)。
什么樣的走法算是比較好的走法呢?我們的程序選擇了以下兩類走法:
1、產生Beta截斷的走法
2、不能產生Beta截斷,但它是所有PV走法(也就是Beta > vl > vlAlpha的走法)中最好的走法。
歷史表historyTable[]是一個大小為4096的數組,遇到一個比較好的走法mv時,給歷史表中的該走法增加一個value值:
historyTable[mv] = historyTable[mv] + value
對於value的取值,我們沿用國際的經驗——深度的平方。也就是說,
value = depth * depth
事實上,我們的程序並沒有按照慣例,使用mv作為歷史表的指標,並不存在historyTable[mv]這樣的寫法。我們使用“棋子類別 + 走法終點位置”作為歷史表的指標。我是這樣理解這一指標的:比如已知的幾個不錯的走法,都是將紅車移動到某一位置。如果遇到一個走法,也是將紅車移動到這一位置,那么我們猜測,這一走法也是個不錯的走法。
獲取歷史表指標的方法如下:
Position.prototype.historyIndex = function(mv) { return ((this.squares[SRC(mv)] - 8) << 8) + DST(mv); }
更新歷史表的方法如下:
Search.prototype.setBestMove = function(mv, depth) {
this.historyTable[this.pos.historyIndex(mv)] += depth * depth;
}
5.8、迭代加深
上一節的極大極小搜索,搜索深度固定為3。這一節使用了效率更高的Alpha-Beta搜索,完全可以增大搜索深度了。深度太淺,無法充分利用電腦性能;深度過深,又要等待很久的時間。那我們干脆不指定搜索深度了,而是指定搜索時間。設置搜索時間為100毫秒,從深度depth=1開始搜索。如果搜索完后還有時間,那就增加深度繼續搜索,偽代碼如下:
for (i = 1; i < 最大深度; i ++) {
alphaBetaSearch(負無窮, 正無窮, i); // 搜索深度為i
if (超過搜索時間) {
break;
}
}
迭代加深不僅充分利用了時間資源,還能充分發揮歷史表的作用——前一層搜索結束后,將在歷史表中積累大量數據,這將有效減少深一層搜索的時間。
5.9、核心代碼說明
本節的代碼可以在 Github 下載,也可以直接clone
git clone -b step-5 https://github.com/Royhoo/write-a-chinesechess-program
Search中新增或修改的主要屬性和方法:
(1)、historyTable[]
該數組是歷史表。
(2)、searchMain(depth, millis)
增加了迭代加深搜索。
(3)、alphaBetaSearch(vlAlpha_, vlBeta, depth)
實現了Alpha-Beta搜索,並且:
1、根據殺棋步數給出殺棋分數。
2、使用歷史表優化搜索效率。
在文件“search.js”中,新增了一個對象MoveSort,用於生成一個局面排序后的走法。