中國象棋程式設計(五)置換表
宣告:本程式設計參考象棋巫師原始碼(開發工具dephi 11,建議用delphi 10.3以上版本)。
這一章主要介紹置換表。本章目標:
- 實現置換表;
- 採用置換表走法、殺手走法等多種啟發方式。
5.1置換表
沒有置換表,就稱不上是完整的計算機博弈程式。在搜尋過程中,某個搜尋結果可能會出現這麼多次,這浪費了很多時間。為避免重複搜尋,儲存搜尋結果的表,就是置換表。由於雜湊表的讀寫速度很快,通常置換表就由雜湊表來實現。
置換表非常簡單,以局面的Zobrist Key mod HASH_SIZE作為索引值。每個置換表項儲存的內容無非就是:A.深度,B.標誌,C.分值,D.最佳走法,E. Zobrist Lock
{置換表結構} type HashItem=record ucDepth, ucFlag:Byte;//深度、標誌 svl:SmallInt; //分值 wmv, wReserved:Word; //最佳走法 dwLock0, dwLock1:Cardinal; //Zobrist檢驗碼 end;
置換表的處理函式也很傳統——一個ProbeHash和一個RecordHash就足夠了。
先說RecordHash,即便採用深度優先的替換策略,RecordHash也非常簡單,在判斷深度後,將Hash表項中的每個值填上就是了。
再看看ProbeHash
(1)檢查局面所對應的置換表項,如果Zobrist Lock校驗碼匹配,那麼我們就認為命中(Hit)了;
(2)是否能直接利用置換表中的結果,取決於兩個因素:A.深度是否達到要求,B.非PV節點還需要考慮邊界。
第二種情況是最好的(完全利用),ProbeHash返回一個非-MATE_VALUE的值,這樣就能不對該節點進行展開了。
如果僅僅符合第一種情況,那麼該置換表項的資訊仍舊是有意義的——它的最佳走法給了我們一定的啟發(部分利用)。
5.2殺棋分數調整
增加了置換表以後,殺棋分數要進行調整——置換表中的分值不能是距離根節點的殺棋分值,而是距離當前(
(1)對於RecordHash:置換表項記錄的殺棋步數=實際殺棋步數-置換表項距離根節點的步數;
(2)對於ProbeHash:實際殺棋步數=置換表項記錄的殺棋步數+置換表項距離根節點的步數。
5.3殺手(Killer)走法
殺手走法就是兄弟節點中產生Beta截斷的走法。根據國際象棋的經驗,殺手走法產生截斷的可能性極大。很顯然,兄弟節點中的走法未必在當前節點下能走,所以在嘗試殺手走法以前先要對它進行走法合理性的判斷。在第二章裡寫過CanMove(走法是否合理)這個函式,這裡它將大顯身手。如果殺手走法確實產生截斷了,那麼後面耗時更多的GenerateMove (生成所有走法)就可以不用執行了。
如何儲存和獲取“兄弟節點中產生截斷的走法”呢?我們可以把這個問題簡單化——距離根節點步數(nDistance)同樣多的節點,彼此都稱為“兄弟”節點,換句話說,親兄弟、堂表兄弟以及關係更疏遠的兄弟都稱為“兄弟”。
我們可以把距離根節點的步數(nDistance)作為索引值,構造一個殺手走法表。每個殺手走法表項存有兩個殺手走法,走法一比走法二優先:存一個走法時,走法二被走法一替換,走法一被新走法替換;取走法時,先取走法一,後取走法二。
5.4優化走法順序
利用各種資訊渠道(如置換表、殺手走法、歷史表等)來優化走法順序的手段稱為“啟發”。第五章以前,我們只用歷史表作啟發,但從這個版本開始,我們採用了多種啟發方式:
(1)如果置換表中有過該局面的資料,但無法完全利用,那麼多數情況下它是淺一層搜尋中產生截斷的走法,我們可以首先嚐試它;
(2)然後是兩個殺手走法(如果其中某個殺手走法與置換表走法一樣,那麼可以跳過);
(3)然後生成全部走法,按歷史表排序,再依次搜尋(可以排除置換表走法和兩個殺手走法)。
這樣,我們就可以構造一個狀態機,來描述走法順序的若干階段:
首先要定義走法結構:
{走法排序結構} type SortStruct=record mvHash, mvKiller1, mvKiller2:Integer; // 置換表走法和兩個殺手走法 nPhase, nIndex, nGenMoves:Integer; // 當前階段,當前採用第幾個走法,總共有幾個走法 mvs:TArray<Integer>; // 所有的走法 procedure Init(mvHash_:Integer);// 初始化,設定置換表走法和兩個殺手走法 function Next:Integer; // 得到下一個走法 end;
其中Next方法就是狀態機的實現:
function SortStruct.Next:Integer; var mv:Integer; Comparer: IComparer<Integer>; s,d:TPoint; begin // "nPhase"表示著法啟發的若干階段,依次為: // 0. 置換表著法啟發,完成後立即進入下一階段; if nPhase=PHASE_HASH then begin nPhase:= PHASE_KILLER_1; if (mvHash<>0) then Exit(mvHash); end; // 1. 殺手著法啟發(第一個殺手著法),完成後立即進入下一階段; if nPhase=PHASE_KILLER_1 then begin nPhase:= PHASE_KILLER_2; s:=GetSrc(mvKiller1);d:=GetDest(mvKiller1); if (mvKiller1<> mvHash)and(mvKiller1<>0) and pcMove.CanMove(s,d) then Exit(mvKiller1); end; // 2. 殺手著法啟發(第二個殺手著法),完成後立即進入下一階段; if nPhase=PHASE_KILLER_2 then begin nPhase:= PHASE_GEN_MOVES; s:=GetSrc(mvKiller2);d:=GetDest(mvKiller2); if (mvKiller2 <>mvHash)and(mvKiller2<>0)and pcMove.canMove(s,d) then Exit(mvKiller2); end; // 3. 生成所有著法,完成後立即進入下一階段; if nPhase=PHASE_GEN_MOVES then begin nPhase:= PHASE_REST; mvs:= pcMove.GenerateMoves; nGenMoves:=Length(mvs); Comparer := TComparer<Integer>.Construct(CompareHistory); TArray.Sort<Integer>(mvs,Comparer); nIndex:= 0; end; // 4. 對剩餘著法做歷史表啟發; if nPhase=PHASE_REST then begin while (nIndex < nGenMoves) do begin mv:= mvs[nIndex]; Inc(nIndex); if (mv<>mvHash)and(mv<>mvKiller1)and(mv<>mvKiller2) then Exit(mv); end; end; // 5. 沒有著法了,返回零。 Result:=0; end;
提取置換表和儲存置換表的程式碼如下:
// 提取置換表項 function ProbeHash(vlAlpha,vlBeta,nDepth:Integer;var mv:Integer):Integer; var bMate:Boolean; // 殺棋標誌:如果是殺棋,那麼不需要滿足深度條件 hsh:HashItem; begin // 查詢置換表分為以下幾步: // 1.獲取當前局面的置換表表項 hsh:= Search.HashTable[pcMove.zobr.dwKey and (HASH_SIZE - 1)]; // 2.判斷置換表中的zobristLock校驗碼與當前局面是否一致 if (hsh.dwLock0 <> pcMove.zobr.dwLock0) or(hsh.dwLock1 <> pcMove.zobr.dwLock1) then begin mv:= 0; Exit(-MATE_VALUE); end; mv:= hsh.wmv; bMate:= False; //3.如果是殺棋,返回與深度相關的殺棋分數。如果是長將或者和棋,返回-MATE_VALUE。 if hsh.svl > WIN_VALUE then begin hsh.svl:=hsh.svl - pcMove.nDistance; bMate:= True; end else if hsh.svl < -WIN_VALUE then begin hsh.svl:=hsh.svl+ pcMove.nDistance; bMate:= True; end; //4.如果置換表中節點的搜尋深度小於當前節點,查詢失敗 if (hsh.ucDepth>= nDepth)or(bMate) then begin // 5.遇到一個beta節點,只能說明當前節點的值不小於hash.vl。 // 如果正好hash.vl >= vlBeta,說明當前節點會產生beta階段。否則,置換表查詢失敗,需要重新對該局面進行搜尋。 if hsh.ucFlag=HASH_BETA then begin if hsh.svl >=vlBeta then Exit(hsh.svl) else Exit(-MATE_VALUE); end // 6.遇到一個alpha節點,只能說明當前節點的值不大於hash.vl。 // 如果正好hash.vl <= vlAlpha,說明當前節點又是一個alpha節點,並且值不大於hash.vl。否則,置換表查詢失敗,需要重新對該局面進行搜尋。 else if hsh.ucFlag=HASH_ALPHA then begin if hsh.svl <= vlAlpha then Exit(hsh.svl) else Exit(-MATE_VALUE); end; Exit(hsh.svl); end; Result:=-MATE_VALUE; end; // 儲存置換表項 procedure RecordHash(nFlag,vl,nDepth,mv:Integer); var hsh:HashItem; begin hsh:= Search.HashTable[pcMove.zobr.dwKey and (HASH_SIZE - 1)];// 獲取當前局面的置換表表項 if hsh.ucDepth > nDepth then Exit; // 深度優先覆蓋原則 hsh.ucFlag:= nFlag; // 節點型別 hsh.ucDepth:= nDepth; // 搜尋深度 // 如果是殺棋,需要將分值轉換為與深度無關的分值。如果是長將或者和棋,又沒有最佳走法,就不記入置換表。 if vl > WIN_VALUE then begin hsh.svl:= vl + pcMove.nDistance; end else if vl < -WIN_VALUE then begin hsh.svl:= vl - pcMove.nDistance; end else hsh.svl:= vl; hsh.wmv:= mv; hsh.dwLock0:= pcMove.zobr.dwLock0; hsh.dwLock1:= pcMove.zobr.dwLock1; Search.HashTable[pcMove.zobr.dwKey and (HASH_SIZE - 1)]:= hsh; end;
SearchFull函式中生成所有走法,並逐一走這些走法被Next方法所替代:
{超出邊界(Fail-Soft)的Alpha-Beta搜尋過程} function SearchFull(vlAlpha,vlBeta,nDepth:Integer;bNoNull:Boolean=False):Integer; var vl, vlBest,mvBest,mvHash,nHashFlag,mv:Integer; s,d:TPoint; Sort:SortStruct; begin // 一個Alpha-Beta完全搜尋分為以下幾個階段 // 1. 到達水平線,則呼叫靜態搜尋(注意:由於空步裁剪,深度可能小於零) if pcMove.nDistance>0 then begin if (nDepth <= 0)then Exit(SearchQuiesc(vlAlpha, vlBeta)); // 1-1. 檢查重複局面(注意:不要在根節點檢查,否則就沒有走法了) vl:= pcMove.RepStatus; if vl <> 0 then Exit(pcMove.RepValue(vl)); // 1-2. 到達極限深度就返回局面評價 if pcMove.nDistance = LIMIT_DEPTH then Exit(pcMove.Evaluate); // 1-3. 嘗試置換表裁剪,並得到置換表走法 vl:= ProbeHash(vlAlpha, vlBeta, nDepth, mvHash); if vl > -MATE_VALUE then Exit(vl); // 1-3. 嘗試空步裁剪(根節點的Beta值是"MATE_VALUE",所以不可能發生空步裁剪) if (not bNoNull)and(pcMove.InCheck=False)and pcMove.NullOkay then begin pcMove.NullMove; vl:= -SearchFull(-vlBeta, 1 - vlBeta, nDepth - NULL_DEPTH - 1, True);//NO_NULL=True pcMove.UndoNullMove; if vl >= vlBeta then Exit(vl); end; end else mvHash:=0; // 2. 初始化最佳值和最佳走法 nHashFlag:= HASH_ALPHA; vlBest:= -MATE_VALUE; // 這樣可以知道,是否一個走法都沒走過(殺棋) mvBest:=0; // 這樣可以知道,是否搜尋到了Beta走法或PV走法,以便儲存到歷史表 // 3. 初始化走法排序結構 Sort.Init(mvHash); // 4. 逐一走這些走法,並進行遞迴 with pcMove do while True do begin mv:=Sort.Next; if mv=0 then Break; s:=GetSrc(mv);d:=GetDest(mv); if MakeMove(s,d) then begin vl:= -SearchFull(-vlBeta, -vlAlpha, nDepth+InCheck.ToInteger-1); // 將軍延伸(如果局面處於被將軍的狀態,或者只有一種回棋,多向下搜尋一層) // 將軍延伸或者只有一種走法也要延伸 UndoMakeMove; // 5. 進行Alpha-Beta大小判斷和截斷 if (vl > vlBest) then // 找到最佳值(但不能確定是Alpha、PV還是Beta走法) begin vlBest:= vl; // "vlBest"就是目前要返回的最佳值,可能超出Alpha-Beta邊界 if (vl >= vlBeta) then // 找到一個Beta走法 begin nHashFlag:= HASH_BETA;// 節點型別 mvBest:= mv; // Beta走法要儲存到歷史表 break; // Beta截斷 end; if (vl > vlAlpha) then // 找到一個PV走法 begin nHashFlag:= HASH_PV; // 節點型別 mvBest := mv; // PV走法要儲存到歷史表 vlAlpha:= vl; // 縮小Alpha-Beta邊界 end; end; end; end; // 5. 所有走法都搜尋完了,把最佳走法(不能是Alpha走法)儲存到歷史表,返回最佳值 if vlBest =-MATE_VALUE then Exit(pcMove.nDistance - MATE_VALUE); // 如果是殺棋,就根據殺棋步數給出評價 // 記錄到置換表 RecordHash(nHashFlag, vlBest, nDepth, mvBest); if mvBest<>0 then begin // 如果不是Alpha走法,就將最佳走法儲存到歷史表 SetBestMove(mvBest, nDepth); if pcMove.nDistance = 0 then // 搜尋根節點時,總是有一個最佳走法(因為全視窗搜尋不會超出邊界),將這個走法儲存下來 Search.mvResult:=mvBest; end; Result:=vlBest; end;
以上程式未經充分測試,發現問題請及時反饋。
下一章將實現以下目標:
- 實現開局庫;
- 實現PVS(主要變例搜尋);
- 把根節點的搜尋單獨處理,增加搜尋的隨機性;
- 克服由長將引起的置換表的不穩定性。
其他未說明的內容請參閱原始碼註釋,如有問題,敬請指出。
本章節原始碼百度雲盤:
提取碼:1234