1. 程式人生 > >四國軍棋引擎開發(9)子力概率判斷分析

四國軍棋引擎開發(9)子力概率判斷分析

本文分為2部分,第1部分繼續深入分析子力的概率問題,第2部分記錄下剛剛碰到的一個非常棘手的bug,解決這個bug後,目前這個版本基本上沒有什麼明顯的bug,可以作為版本為2.0。如果全部著法都搜尋的話,1秒最多搜4層,軍棋每步可行的走法太多,搜尋已經很難優化了,接下來的主要優化在局面評估和區域性搜尋,目前2.0版本的測試結果如下:

引擎A vs 引擎B 戰績(勝:負:和)
1.1 vs 1.0 8:2:0
1.2 vs 1.1 8:2:0
1.2 vs 1.0 10:0:0
2.0 vs 1.2 8:1:1
2.0 vs 1.1 8:0:2

 

1.概率分析

子力的概率判斷在四國軍棋中起著非常重要的作用,之前已經對這方面做過分析,現在除錯後發現之前的分析還是太粗糙了,這次將會做的更加精細。這次概率優化主要針對地雷和炸彈的擺放,按照軍棋的規則,炸彈不能放第一排,地雷只能放最後2排,為了說清這個問題,現在構造一個簡化的場景,如下圖

現在有2個炸彈和2個地雷,規定炸彈不能放第一排,地雷不能放最後一排,假設a4沒被碰過,a9被碰過,那麼a4不是炸彈的概率是多少?

因為a4沒被碰過,所以a4可能是炸彈,這9個子裡有5個子可能是炸彈,所以概率p=(5-2)/5=0.6,這是錯誤的做法,因為沒有考慮炸彈不能放第一排這個條件。

我們發現a4在第2排,a4不可能是地雷,所以正確的概率應該是p(a4不是炸)=1-p(a4是炸彈),那麼a4是炸彈的概率是多少呢,現在用nBomb表示炸彈的概率,nLand表示地雷的個數,nMayBomb表示可能的炸彈個數,nMayLand表示可能的地雷個數,現在nBomb=2,由於a9被撞過所以不是炸彈,所以nMayBomb=5,所以a4是炸彈的概率為p=nBomb/nMayBomb = 2/5=0.4

上面的演算法仍然存在問題,雖然a4~a8這5個子都可能是炸彈,但是這裡面可能混雜著地雷,比如a7、a8是地雷,a9是大子,這時概率是2/3,所以正確的做法是分母為所有可能是炸彈和地雷的棋減去地雷的個數即6-2,現在設nLand=2,nMayLand=3,nMayBombLand為既可能是炸彈也可能是地雷的數量,這個值可由軟體檢測出來,現在設為2(即a7和a8),所以有

p(a4是炸)=nBomb/(nMayLand+nMayBomb-nMayBombLand-nLand)=2/(5+3-2-2)=2/4=0.5,當然a9可能是地雷也可能不是地雷,這裡是一個平均估算的概率。

現在考慮a7不是炸彈和地雷的概率,由於a7在最後一排且沒有撞過所以地雷和炸彈都有可能,a7不是地雷的概率是1-p(地雷)=1-2/3=1/3,在此基礎上再計算不是炸彈的概率就得到結果

p = (1-p(炸彈))*(1-p(地雷))=(1-0.5)(1-2/3)=1/6

這是2種比較困難的情況,當然還有許多其他情況,可以按照類似的方式算出。在實際程式碼中要比上述場景繁瑣的多,有非常多的細節需要考慮,這些都需要不斷的除錯來解決,這裡就不詳細介紹了。

2.Bug除錯記錄

接下來分析一個bug的解決過程,因為這個bug是隨機出現的,不能復現,而且裡面的過程有點複雜,所以在這裡記錄一下。bug是這樣的,當2個引擎對弈時,其中一個會出現記憶體崩潰現象,奔潰的引擎使用AlphaBeta1函式搜尋,列印資訊如下

......
search1 num 6302
gen num 6655
key num 0 0
time 0
best 10 11 12 10
gen time 0
gen0 time 47070
depth 3 value -118
best cnt 3 val -120 per 16
06 04 04 06 03 02 07 00 
00 00 
NULL
alpha1: -8 depth 3
best cnt 2 val -28 per 241
06 05 06 0B 02 00 00 00 
00 00 
best cnt 2 val -200 per 14
06 05 06 0B 04 00 00 00 
00 00 
alpha1: -38 depth 2
move
best cnt 1 val -10000 per 256
0A 0B 0C 0A 02 00 00 00 
00 00 
depth 1 val -10000 per 256
0A 0B 0C 0A 02 00 00 00 
00 00 
depth 2 val -10000 per 241
06 05 06 0B 02 00 00 00 
00 00 
depth 2 val -10000 per 14
06 05 06 0B 04 00 00 00 
00 00 
depth 3 val -10000 per 16
06 04 04 06 03 02 07 00 
00 00 
depth 4 val -10000 per 128
0D 0A 0C 0A 02 00 00 00 
00 00 
depth 4 val -10000 per 64
0D 0A 0C 0A 03 04 00 00 
09 10 
end
      0 [test3] test3 2184 cygwin_exception::open_stackdumpfile: Dumping stack t
race to test3.exe.stackdump

在搜尋第4層的時候出現崩潰,當我把這個覆盤儲存下來再呼叫引擎去分析這個局面時不再出現崩潰,觀察列印資訊,在move後value的值就變為了異常的-10000,這個值是作為α 的負無窮大,現在情況是輪到對方下棋,引擎正在分析,此時對方行棋後,引擎收到行棋指令,會列印move,並把pJunqi->move置1來結束分析。

接下去要做的事就是分析val為什麼會是-10000,pJunqi->move置1會導致TimeOut(pJunqi)函式返回1,

    	if( TimeOut(pJunqi) )
    	{
            pData->cut = 1;
            break;
    	}

搜尋過程中會遇到很多迴圈,pData->cut是結束迴圈的標誌,"alpha1: -38 depth 2"這行資訊是在SearchBestMove()函式中列印,所以下面程式碼中

search_data.mxVal = SearchBestMove(pJunqi,aBestMove,cnt,alpha,beta,&search_data.pBest,depth,1);

search_data.mxVal的值一開始在第2層的時候是-38,這是正常的,最後為什麼會被改為10000,從而導致第一層的分數為-10000,所以只能是通過呼叫SearchMoveList繼續遞迴第3、4層的時候得到分數10000

        SearchMoveList(pJunqi,pSrc,0,&search_data);
        val = search_data.mxVal;

search_data.mxVal的修改只能是在SearchAlphaBeta()函式中進行,search_data是作為每一層共享的區域性結構變數,每一層都有一個search_data結構體,它們是不同的,search_data傳入SearchAlphaBeta()後為pData指標

val = CallAlphaBeta1(pJunqi,depth-1,alpha,beta,pData->iDir);
        if( val>pData->mxVal )
        {
            pData->mxVal = val;
            ...
        }

從上面的程式碼可以看到,第2層的分數為10000,只能是val為10000,所以第3層的分數為-10000(這裡上一層是下一層分數的負值),但是第4層的分數為直接評估局面後的分數不可能是10000,這樣第3層的val不為-10000,而search_data.mxVal的初始值為-10000,必然會被更改,那為什麼沒有被更改呢,其實順著這個思路往下走,就能得到答案,而我當時思路比較混亂,想不到那麼深,第一選擇是把bug重現出來,通過偵錯程式來分析。

既然在"alpha1: -38 depth 2"這行資訊出問題,那麼我可以在這行列印後新增程式碼pJunqi->move=1把bug重現出來,但是還是沒有重現出來,列印的資訊裡並沒有出現“best cnt 1 val -10000 per 256”,也沒有出現崩潰。這說明pJunqi->move置1的時機選擇不對,需要再稍微讓程式碼執行一段時間,在某個結點置1才能重現,這種情況讓重現變得比較困難。

再仔細想一下,pJunqi->move影響的是TimeOut函式,TimeOut會結束搜尋,為什麼一開始就結束搜尋不會有問題,只有過一段時間才有問題呢,想不明白,先看看在"alpha1: -38 depth 2"和“best cnt 1 val -10000 per 256”這2處程式碼中間呼叫了幾個TimeOut,在開始的地方新增如下程式碼

    if( cnt==2 && search_data.mxVal==-38 )
    {
        pJunqi->debugFlag = 1;
        pJunqi->debugCnt = 0;
    }

在TimeOut中新增如下程式碼

	if( pJunqi->debugFlag )
	{
	    pJunqi->debugCnt++;
	    log_a("debugCnt %d",pJunqi->debugCnt);
	}

這時列印後發現pJunqi->debugCnt有3000多次,把程式碼改成如下

	if( pJunqi->debugFlag )
	{
	    pJunqi->debugCnt++;
	    if( pJunqi->debugCnt>100 )
	    {
	        pJunqi->bMove = 1;
	    }
	}

這時後bug終於復現了,而且是每次都出現,接下來就容易多了,用pJunqi->debugTest記錄遞迴層數,列印如下資訊

log_a("debugCnt %d %d",pJunqi->debugCnt,pJunqi->debugTest);

pJunqi->debugTest的值在96之前都是3和4,第96次為2,所以把條件設為pJunqi->debugCnt>95,通過單步執行就可以知道在val = CallAlphaBeta(pJunqi,depth-1,alpha,beta,iDir);得到val的值後有時並不會立即去更新search_data.mxVal,因為發生碰撞後有3種情況,需要算平均值,所以執行goto continue_search;跳過mxVal的更新繼續下一次搜尋,而TimeOut剛好在continue_search後面,而這時TimeOut返回1,直接跳出迴圈,導致mxVal沒被更新,停留在-10000,也就是說第3層剛出現TimeOut時,正在搜尋的棋剛好是碰撞時才會復現這個問題。解決的方法很簡單,因為SearchAlphaBeta只是搜尋單步棋的所有可能,所以在TimeOut跳出迴圈使沒有意義的,把break去掉,只要保留pData->cut=1就可以了。

接下來分析出現崩潰的問題,既然可以復現,通過之前文章介紹的的方法:

C語言除錯記憶體訪問出錯而引起的程式崩潰問題

可以迅速定位到是在SetBestMove函式裡pResult的值為0,導致訪問非法的鄰接表pJunqi->aBoard[p1.x][p1.y].pAdjList,這是傳入的search_data.pBest值為0導致的,pBest是一個指標,其地址有2個來源,一個是搜尋最佳變例的aBestMove[0].pHead->result[4]這是一個數組,存放四種行棋的結果,一個是正常搜尋時每層的最佳著法,存放在 pJunqi->pMoveList裡,不管哪種情況都不會出現記憶體被提前釋放,那麼接下來思考的是search_data.pBest的值在哪裡被修改了,反覆看程式碼,只有在以下地方被修改

        if( val>pData->mxVal )
        {
            pData->mxVal = val;
            if( aBestMove[cnt-1].mxPerFlag1 )
            {
                UpdateBestMove(aBestMove,p,depth,cnt,isHashVal);
                pData->pBest = &p->move;
                if( cnt==1 )
                {
                    PrintBestMove(aBestMove,alpha,depth);
                }
            }
            //更新alpha值
            if( val>alpha )
            {            
                pData->alpha = val;
            }
        }

然而除錯時發現這裡並未被修改,然後通過列印資訊反覆確定修改的地方,定位在了下面的程式碼

        if( val>=beta )
        {
            if( -INFINITY==pData->mxVal && aBestMove[cnt-1].mxPerFlag1 )
            {
                UpdateBestMove(aBestMove,p,depth,cnt,isHashVal);
                if( cnt==1 )
                 {
                     PrintBestMove(aBestMove,alpha,depth);
                 }
            }
            pData->mxVal = val;
            pData->cut = 1;

            break;
        }

也就是在UpdateBestMove處被修改了,這下終於明白了,search_data.pBest雖然沒被修改,但存放的是aBestMove[0].pHead->result[4]陣列中的其中一個地址,在更新最佳變例時,這個值被修改了,但是search_data.pBest指向的地址卻沒有被修改,所以更新後這個地址存放的值可能是空值。那麼beta是10000,為什麼這個條件會進來的,通過上面的分析可知道,在第2層時,遇到碰撞的著法會導致search_data.mxVal未被更新,停留在初始值-10000,從而返回到第一層為10000。最後的解決辦法很簡單,在 UpdateBestMove下面加一句pData->pBest = &p->move;即可

3.原始碼

https://github.com/pfysw/JunQi