“JavaScript中國象棋程序” 這一系列教程將帶你從頭使用JavaScript編寫一個中國象棋程序。這是教程的第7節。
這一節主要介紹置換表。正如象棋百科全書網所說的,沒有置換表,就稱不上是完整的計算機博弈程序。
7.1、置換表
上圖所示的搜索樹中,局面A出現了3次,程序也搜索了3次,這浪費了很多時間。何不把A的搜索結果保存在表里,后面再搜索到A時直接查表取值,避免重復搜索呢?保存搜索結果的表,就是置換表。由於哈希表的讀寫速度很快,通常置換表就由哈希表來實現。
7.1.1、哈希表
哈希表是用散列方法存儲的線性表。它以節點的關鍵字K為自變量,通過一個確定的函數關系h,計算出對應的函數值h(K),然后把這個值解釋為節點的存儲地址,將節點存入h(K)所指的存儲位置上。在查找時,根據要查找的關鍵字用同一函數h計算出地址,再到相應的單元里查找要找的節點。函數h(K)稱為散列函數或哈希函數。
如:11個元素的關鍵字分別為18,27,1,20,22,6,10,13,41,15,25。用11個連續存儲空間來存放,選取關鍵字與元素位置間的函數為h(K) = key mod 11。(mod是取余運算)
7.1.2、存儲
在我們的程序中,關鍵字K就是上節已經用到的Zobrist校驗碼。哈希函數同樣是取余運算:
h(K) = Zobrist % TableSize
其中,TableSize是哈希表的長度。
但是這個函數在速度上有個瓶頸,因為“電腦一做除法就成了傻瓜”。因此,TableSize最好是2的整數次冪,這樣就能把“取余運算”轉化為“按位與運算”。比如取TableSize = 16,那么有:
7 mod 16 = 7, 17 mod 16 = 1, 25 mod 16 = 9
轉換為按位與運算就有:
7 & 15 = 7, 17 & 15 = 1, 25 & 15 = 9
也就是說,和15進行與運算,就是對16取余。(可參考這篇文章)
因此,h(K) = Zobrist & (TableSize - 1),其中TableSize是2的整數次冪。
每個哈希表項存儲的內容包括:深度(depth)、節點類型(flag)、分值(vl)、最佳走法(mv)、Zobrist Lock校驗碼。后面會介紹這些字段的作用。
7.1.3、沖突處理
以TableSize = 16為例,
7 mod 16 = 7
23 mod 16 = 7
因此,通過哈希函數算得的位置可能存在沖突。我們處理沖突的方法很簡單——深度優先的替換。如果新節點深度高於舊節點,直接用新節點替換舊結點;否則,保留舊節點不變。
7.1.4、查找
由於Zobrist校驗碼不足以保證局面的唯一性,我們對每個局面都生成一個Zobrist Lock校驗碼(生成方式與Zobrist校驗碼一樣),並保存在哈希表表項中。
查找置換表時,首先根據Zobrist校驗碼計算出哈希表中的地址,然后再比較Zobrist Lock校驗碼是否一致,一致的話才能說明是同一局面。
7.2、節點類型
(a)alpha節點 (b)beta節點 (c)PV節點
如上圖所示,(10,20)是指搜索到C節點時,alpha為10,beta為20。
在圖(a)中,所有子節點的值均小於alpha值10,最后C點取值為9,C點稱為alpha點。
在圖(b)中,節點C通過走法1到達D1取值為24,超過了beta值20,剪去了D2和D3兩個分支,同時C點取值24。24並不是C點的真實值,我們只知道C點的值不比24小。這樣的C點稱為beta節點。
在圖 (c)中,C點的取值為14,它是所有子節點中的最大值,反映了C點的真是情況,所以C點稱為PV節點(Principal Variation)。
如果從哈希表中是一個PV節點,那么當前所搜索節點的值,就是哈希表中存儲的值。
如果哈希表中是個beta節點,值為value。由於beta節點會發生剪枝,value不是一個准確值。只能說明當前搜索節點的值,不小於value。
如果哈希表中是個alpha節點,值為value。對於不超出邊界的Alpha-Beta搜索,value顯然不是個准確值,只能說明當前搜索節點的值不大於value。對於超出邊界的Alpha-Beta搜索,例如上面的圖(a),我個人覺得,9就是節點C的真實情況吧。讓我不解的是,在程序中,使用的正是超出邊界的搜索,卻沒有把alpha節點的值作為准確值來處理。
7.3、殺棋分數調整
在第5節,我們已經考慮了殺棋的分值,把輸棋的分值與搜索層數結合起來:
輸棋分值 = -MATE_VALUE + 搜索層數
贏棋分值 = MATE_VALUE - 搜索層數
現在使用置換表之后,由於相同的局面,可能位於不同的層數,所以不能再把這個調整后的分值存入置換表。我們的做法是,存入置換表時,存儲與層數無關的分值(比如-MATE_VALUE);讀取置換表時,再調整為與層數相關的分值(-MATE_VALUE + 當前搜索層數)。
7.4、殺手走法
第5節我們介紹了歷史表,將一些好的走法(beta節點引發剪枝的走法、PV節點估值最好的走法)保存到歷史表。根據國家象棋的經驗,一個節點好的走法,在它兄弟節點也很可能就是好的走法。但是兄弟節點的走法,在當前節點下未必能走,所以在嘗試殺手走法以前先要對它進行走法合理性的判斷。
如何保存和獲取“兄弟節點中產生截斷的走法”呢?我們可以把這個問題簡單化——距離根節點步數(distance)同樣多的節點,彼此都稱為“兄弟”節點,換句話說,親兄弟、堂表兄弟以及關系更疏遠的兄弟都稱為“兄弟”。
我們可以把距離根節點的步數(distance)作為索引值,構造一個殺手走法表。我們的程序每個殺手走法表項存有兩個殺手走法,走法一比走法二優先:存一個走法時,走法二被走法一替換,走法一被新走法替換;取走法時,先取走法一,后取走法二。
7.5、優化走法順序
利用各種信息渠道(如置換表、殺手走法、歷史表等)來優化走法順序的手段稱為“啟發”。之前我們只用歷史表作啟發,但從這個版本開始,我們采用了多種啟發方式:
1、如果置換表中有過該局面的數據,但無法完全利用,那么多數情況下它是淺一層搜索中產生截斷的走法,我們可以首先嘗試它;
2、然后是兩個殺手走法(如果其中某個殺手走法與置換表走法一樣,那么可以跳過);
3、然后生成全部走法,按歷史表排序,再依次搜索(可以排除置換表走法和兩個殺手走法)。
7.6、核心代碼說明
本節的代碼可以在 Github 下載,也可以直接clone
git clone -b step-7 https://github.com/Royhoo/write-a-chinesechess-program
Search中新增或修改的主要屬性和方法:
(1)、hashMask
hashMask = 哈希表長度 - 1
用於將哈希函數的“取余運算”,轉化為“按位與運算”。
(2)、recordHash(flag, vl, depth, mv)
記錄哈希表
(3)、probeHash(vlAlpha, vlBeta, depth, mv)
查詢哈希表
(4)、setBestMove(mv, depth)
該函數之前只更新歷史表,目前要同時更新殺手走法表。
MoveSort中修改的方法:
(1)、next()
該方法是獲取排序后的一個走法,目前加入置換表走法、殺手走法、歷史表走法三種啟發。