四國軍棋引擎開發(7)概率分析與搜尋優化
1.概率分析
四國軍棋屬於不完全資訊博弈,我們是看不到敵方的棋子,但是可以通過棋子間的碰撞來判斷敵方的子力分佈情況和棋子大小的概率。
當棋子產生碰撞後,可能的判決結果有吃子、打兌、撞死3種結果,有時還會附加是否亮軍旗的資訊,之前的處理只是簡單的把所有情況取平均值,這是不對的,因為某些情況雖然存在,但是概率特別小,如果取平均值就會對著法評分的準確性造成很大的影響,所以更好的方式應該是對每一種情況生成一個概率與對應的分數相乘最後得到一個期望分數。
概率是一個0~1的小數值,如果用float變數會嚴重影響速度,所以把實際概率乘以256得到一個最後的結果p<<8,在最後計算期望分數時再除以256,即sum>>8。
至於概率的計算真的是非常繁瑣的一件事,程式碼的實現全部在以下3個函式裡
- GetEatPercent
- GetBombPercent
- GetKilledPercent
所有的結果都劃分為吃子、打兌、撞死3種情況,最後這3個概率相加應該是1,考慮到除法的誤差,最後相加的結果乘以256應該在250~256左右。
在計算概率之前先要收集2個資訊,在AdjustMaxType裡計算:
- aLiveTypeSum [14] 表示大於某個級別的並且還活著的明棋的總數,即敵方吃過子的棋
- aLiveTypeAll[14] 表示大於某個級別的並且還活著的明棋總數再加上所有大於該級別的暗棋包括被暗吃的
計算的時候,考慮的情況非常多,這裡只舉個例子簡要說明一下,比如我方37吃掉對方一個暗子,那麼這個概率是多少呢?首先要計算這個子的所有可能情況,這個子肯定大於等於工兵,而且不是明棋,也可能是地雷或炸彈,總數應該是
num = (aLiveTypeAll[GONGB]-aLiveTypeSum[GONGB])+nBomb+nLand;
根據之前的一些碰撞情況,我們應該知道這個棋子的最大可能,例如如果已經確定其他子是40、39,那麼這個子最大的可能也就38,所以要把大於38的子排除,這裡假設mxDstType是38(列舉變數是SHIZH),得到的分母是num-mxNum。
mxNum = (aLiveTypeAll[mxDstType-1]-aLiveTypeSum[mxDstType-1]);
再來計算分子,由於37必須要吃的動,那麼這個子必須要小於37,先來計算大於等於37的數量nSrc,這裡src是37
nSrc = aLiveTypeAll[src]-aLiveTypeSum[src]+nBomb+nLand;
於是就得到了分子num-nSrc。考慮到敵方可能最大的子都比37小,那麼這個時候是必然可以吃掉的,概率是100%,不能算出來大於100%,所以還要再處理一下
nSrc = (nSrc>mxNum)?nSrc:mxNum;
最後的結果就是
percent = ((num-nSrc)<<8)/(num-mxNum);
其他需要考慮的情況非常多非常繁瑣,不再一一細說。如果是碰到大本營,就要考慮是否是軍旗,這種情況要單獨處理,由於出現的頻率比較低,所以大概設定了一個合理的概率,並沒有嚴格計算。
2.搜尋優化
之前在生成著法的時候,效率太低,需要遍歷129個棋盤位置,每個位置都要執行IsEnableMove()函式,在IsEnableMove()函式中又要搜尋整個棋盤來判斷著法是否合法。現在改進後每個棋子只搜尋一次,和路徑生成的函式類似,遞迴搜尋相鄰或鐵路上直通的棋子,加入到著法連結串列裡並做上標記,如果下次再遇到直接跳過,實現在SearchMovePath()函式裡。
每一次生成著法時生成的數量非常巨大,很多都是類似重複的或者是廢招,這些著法就不用向下繼續遞迴搜尋了。那麼如何判斷呢?現在有2步棋可以選擇,下完後,敵方行棋所產生的碰撞效果不一樣,那麼就認為這2步棋的效果不同,否則認為這2步棋是相同的,只搜尋其中一步棋即可。
如下圖,排長進營和營長進營的效果是一樣的,因為並不改變敵方對棋子的碰撞。 而如果司令上擡一步和上面的走法產生的效果就不一樣了,因為已經改變了地方棋子與我方棋子碰撞的可能性,如下圖所示,38可以和37直接接觸,而在上面的局面中是無法直接接觸的。
在程式中我們通過把所以如黃色箭頭這樣的接觸全部異或起來得到一個key值並加入到hash表中,每一次搜尋時先查詢hash表中有每一key值,如果key值已經有了說明之前已經搜尋過了就不用再繼續搜尋,如果還沒有key值,那麼把key值加入到hash表中。
每一次碰撞用4個位元組表示即原棋子的dir和index、目標棋子的dir和index,用異或是為了滿足結合律,異或的先後次序並不影響最後的結果
u8 val[4];
val[0] = pSrc->iDir;
val[1] = pSrc->pLineup->index;
val[2] = pDst->iDir;
val[3] = pDst->pLineup->index;
pJunqi->iKey ^= *((int*)val);
如果有2對這樣的碰撞,其中一對與另一對的val中,其他相同,只是pSrc->iDir與pDst->iDir交換,最後得到的key值是相同的,因為這2對是不同的碰撞卻產生的相同的key值,這不是我們所希望的,做如下修改就可以避免這種情況
val[0] = pSrc->pLineup->index<<pSrc->iDir;
val[1] = pSrc->pLineup->index;
val[2] = pDst->pLineup->index<<pDst->iDir;
val[3] = pDst->pLineup->index;
pJunqi->iKey ^= *((int*)val);
總的步驟是,在GenerateMoveList生成著法連結串列後,每移動一步後,通過GetHashKey產生一個key值,最後通過CheckMoveHash檢查key值是否存在決定是否繼續搜尋,hash表的查詢和插入屬於基礎演算法,這裡就不介紹了。
MakeNextMove(pJunqi,&p->move);
iKey = GetHashKey(pJunqi);
if( CheckMoveHash(&paHash,iKey) &&
IsNotSameMove(p) )
{
if( p->move.result>MOVE )
{
while( !memcmp( &p->move, &p->pNext->move, 4) )
{
if( p->pNext->isHead ) break;
p = p->pNext;
}
}
//把局面撤回到上一步
UnMakeMove(pJunqi,&p->move);
goto continue_search;
}
else
{
val = CallAlphaBeta(pJunqi,depth-1,alpha,beta,iDir);
//把局面撤回到上一步
UnMakeMove(pJunqi,&p->move);
}
另外每一次搜尋時GenerateMoveList都會生成全部著法,不管有沒有剪枝都會生成全部著法,之前考慮到層數深度較深時這裡也是一個很大的消耗,所以重寫一個AlphaBeta1函式,不先生成全部著法,每產生一步就往下搜尋,產生剪枝後剩下的著法就不用生成了。但是後來發現,增加了GetHashKey函式後,GetHashKey的呼叫次數要數十倍多於GenerateMoveLis的呼叫,所以著法生成的時間已經微不足道了,修改後效能的提升也十分有限。由於改動較大,相關的程式碼放在了search1.c裡,不影響原來AlphaBeta函式。