本文分为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就可以了。
接下来分析出现崩溃的问题,既然可以复现,通过之前文章介绍的的方法:
可以迅速定位到是在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;即可