四国军棋引擎开发(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函数。

3.源代码

https://github.com/pfysw/JunQi

猜你喜欢

转载自blog.csdn.net/pfysw/article/details/83045724