在讲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层,则没剪枝时要搜索
b
n
b^{n}
bn种,那么最佳剪枝后会搜索多少局面呢。首先根结点下的每个孩子结点都不能剪枝,根结点的第一个孩子和根结点类似,下面的孩子都不能剪枝,第2个孩子结点下的孩子可能出现剪枝,最佳情况只剩1个孩子,接下来孙子结点不能剪枝,这样每增加2层才扩大了b倍,这样大概估算总共约有
2
b
n
/
2
2b^{n/2}
2bn/2种。
在四国军棋中会出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倍,也不至于能够增加那么多层,所以应该在完全搜索的最佳剪枝情形下还增加了一些其他剪枝的考虑。
下一步将对剪枝算法进行优化,看看能不能提升到12层。源代码如下:
https://github.com/pfysw/JunQi
参考文章
http://www.xqbase.com/computer/search_minimax.htm
http://www.xqbase.com/computer/search_alphabeta.htm
http://www.xqbase.com/computer/search_iterative.htm
http://www.xqbase.com/computer/search_hashing.htm