四國軍棋引擎開發(6)alpha-beta剪枝演算法
在講alpha-beta剪枝演算法之前先要了解最大最小演算法,在棋類遊戲中,給每一個局面打一個分數,輪到自己下時會選擇有利於自己的下法,即選擇局面分數高的,而對手會選擇更加不利於自己的局面,即分數最低的。如下圖所示,max結點會選擇分數最高的子結點作為分值,而min結點會選擇分數低的,最後得到根結點的分數。
在上圖中為了確定最後根結點的值,我們要查詢所有葉子結點的值,事實上沒有必要查詢所有葉子的值,在不影響根結點最後結果的情況下減掉一些分枝,這就是alpha-beta剪枝演算法,如下圖所示,我們再搜尋第2個最小結點時,得到了第一個孩子結點的值3,這時可以判斷這結點的值不會大於3,而3小於4,對於根結點來說需要的是最大值,既然已經知道這個結點的值不會比4大,那麼繼續搜尋下去也就沒有意義了,所以產生了剪枝。
如果根結點是min結點,那麼也是類似的,在已經確認該結點的值不能產生更小的值就不必往下繼續搜素
在演算法實現時,會輸入一個alpha和beta引數,分別是正無窮和負無窮,取最大值時如果超過alpha的值就會更新alpha的值,小於alpha取alpha(取更小的值沒意義,會在上面一層產生截斷),但超過beta就會產生截斷,相反取最小值時比beta小則更新beta,但如果小於alpha則產生截斷
這裡程式中需要對max和min結點分別處理,現在改一下評分的標準,分數始終是對下棋方而言的,當結點搜尋完畢後返回時取負分,那麼分數就變成對另一方而言了,這時不管是max結點還是min結點我們始終取的是負分最大值,如下圖所示
假設每個局面有b種下法,總共有n層,則沒剪枝時要搜尋種,那麼最佳剪枝後會搜尋多少局面呢。首先根結點下的每個孩子結點都不能剪枝,根結點的第一個孩子和根結點類似,下面的孩子都不能剪枝,第2個孩子結點下的孩子可能出現剪枝,最佳情況只剩1個孩子,接下來孫子結點不能剪枝,這樣每增加2層才擴大了b倍,這樣大概估算總共約有種。
在四國軍棋中會出2打1的情形,所以下步是對家走的時候不能返回負值,另外由於軍棋是暗棋,同一個著法會有不同的判定情形,這些不同的判定最後返回時要歸為同一種並取平均值,另外無棋可走、跳過、投降這些情形目前還沒仔細考慮,最後實現程式碼如下,不知道為什麼一發表程式碼格式全亂了
int AlphaBeta(
Junqi *pJunqi,
int depth,
int alpha,
int beta)
{
MoveList *pHead;
MoveList *p;
MoveResultData *pData;
MoveResultData *pBest = NULL;
int val;
int sum = 0;
int k = 0;
static int cnt = 0;
int iDir = pJunqi->eTurn;
cnt++;
//遍歷到最後一層時計算局面分值
if( depth==0 )
{
val = EvalSituation(pJunqi);
pJunqi->test_num++;
//EvalSituation是針對引擎評價的,所以對方的分值應取負值
if( iDir%2!=ENGINE_DIR%2 )
{
val = -val;
}
cnt--;
return val;
}
//生成著法列表
pHead = GenerateMoveList(pJunqi, iDir);
if( pHead!=NULL )
{
pBest = &pHead->move;
}
//無棋可走時直接跳到下一層
else
{
pJunqi->eTurn = iDir;
ChessTurn(pJunqi);
if( iDir%2==pJunqi->eTurn%2 )
{
//下家陣亡輪到對家走
val = AlphaBeta(pJunqi,depth-1,alpha,beta);
}
else
{
val = -AlphaBeta(pJunqi,depth-1,-beta,-alpha);
}
}
//遍歷每一個著法
for(p=pHead; pHead!=NULL; p=p->pNext)
{
pJunqi->eTurn = iDir;
//模擬著法產生後的局面
MakeNextMove(pJunqi,&p->move);
assert(pJunqi->pEngine->pPos!=NULL);
if( iDir%2==pJunqi->eTurn%2 )
{
//下家陣亡輪到對家走
val = AlphaBeta(pJunqi,depth-1,alpha,beta);
}
else
{
val = -AlphaBeta(pJunqi,depth-1,-beta,-alpha);
}
//把局面撤回到上一步
assert(pJunqi->pEngine->pPos!=NULL);
UnMakeMove(pJunqi,&p->move);
sum += val;
k++;
//著法相同但是判決結果不同,取平均值
val = sum/k;
if( !p->pNext->isHead )
{
pData = &p->pNext->move;
//下一個著法
if( memcmp(&p->move, pData, 4) )
{
sum = 0;
k = 0;
}
}
//產生截斷
if( val>=beta )
{
alpha = beta;
break;
}
//更新alpha值
if( val>alpha )
{
pBest = &p->move;
alpha = val;
}
if( p->pNext->isHead )
{
break;
}
//時間結束或收到go指令結束搜尋
if( TimeOut(pJunqi) )
{
break;
}
}
cnt--;
if( 0==cnt )
{
cnt = 0;
SetBestMove(pJunqi,pBest);
}
ClearMoveList(pHead);
return alpha;
}
由於我們並不知道應該搜尋多少層,所以從第一層開始一層層往下迭代加深,上一層搜尋的時間對下一層來說基本上可以忽略不計,所以不必擔心多出來的時間消耗
eTurn = pJunqi->eTurn;
for(int i=1; ;i++)
{
pJunqi->eTurn = eTurn;
pthread_mutex_lock(&pJunqi->mutex);
pJunqi->bSearch = 1;
value = AlphaBeta(pJunqi,i,-INFINITY,INFINITY);
pJunqi->bSearch = 0;
pthread_mutex_unlock(&pJunqi->mutex);
if( TimeOut(pJunqi) )
{
break;
}
if( eTurn%2!=ENGINE_DIR%2 )
{
value = -value;
}
}
這是alpha-beta剪枝演算法的初步實現,很遺憾現在只能搜尋4層,經過除錯發現時間主要耗在了GenerateMoveList()函式裡面,這個函式的實現的確有很大的問題,需要進一步優化,MakeNextMove()和EvalSituation()也有一定的影響,但關係不是很大。另外一個局面下的搜尋步數太多了,達到了75步之多,這是之前把每個暗棋當工兵處理的原因,事實上只有一個暗棋當工兵處理就夠了。另外剪枝效率沒有達到最大這也是一個原因,後續可以考慮加入期望視窗或主要變例搜尋的演算法改進。把上述因素遮蔽掉後,分數值設成一個固定值以達到最佳剪枝效果,把每個局面的搜尋步數固定在30步,這時候可以搜尋到10~11層,因為現在是單執行緒,而我用象棋巫師測了一下,也是單執行緒,大概有12~13層,還少了2層,想了一下原因,這裡象棋是每2步一個回合,所以在搜尋中可能出現大量重複局面用置換表避免重複搜尋,另外還有對稱局面也不用重複搜尋,而軍棋中每4步一個回合,至少要8步才能得到重複局面,所以加了置換表對重複局面的處理,改善的效果也不會很大。而象棋名手是4核執行,能搜尋到20多層,雖然計算效率提高了4倍,也不至於能夠增加那麼多層,所以應該在完全搜尋的最佳剪枝情形下還增加了一些其他剪枝的考慮。