对于棋类软件的搜索算法经前人的努力已形成了较为成熟的Alpha-Beta搜索算法以及其它一些辅助增强算法。所以小生在自己的程序中直接借鉴了Alpha-Beta搜索算法并辅以了历史启发。对此两者王小春的《PC 游戏编程(人机博弈)》和ElephantBoard的主页 http://www.elephantbase.net/上都有非常详细的介绍,所以这里我只简单介绍一下算法的主体思想:
首先对于棋类游戏存在一棵“博弈树”——树的每一个结点代表棋盘上的一个局面,对每一个局面(结点)根据不同的走法又产生不同的局面(生出新的结点),如此不断直到再无可选择的走法(棋局结束)。
现在假定甲乙两方下棋,甲胜的局面是一个极大值(一个正数),那么乙胜的局面则是一个极小值(极大值的负值),和棋的局面则是零值或是接近零的值。如此,则轮到甲走棋时他会选择生成结点的值最大的走法,相反轮到乙走棋时他会选择生成结点的值最小的走法(当然,搜索会向下推导多步后选择一个分值对当前下棋方有利的着法)。这就是“最大-最小”的基本思想。这样搜索函数可以通过搜索以当前局面为根结点、限定层数以内的整棵树来获得一个最佳的着法。不幸的是,博弈树相当庞大(它会成指数增长),因而搜索整棵树是一件相当费时的工作。
然而,下棋是一个你来我往的交替进行并且相互“较劲”的过程,由于每一方都会尽可能将局面导向对自己有利而对对方不利的形势。所以有些“暂时”看来很不错的局面由于可能会产生很糟糕的局面因而根本没有考虑的价值。所以当你看到某个局面有可能产生很糟糕的局面时(确切地说这里的“很糟糕”是与之前所分析的情况相比较而言的),你应当立刻停止对其剩余子结点的分析——不要对它再报任何幻想了,如果你选择了它,则你必将得到那个很糟糕的局面,甚至更糟……这样一来便可以很大程度上减少搜索的工作量,提高搜索效率。这称为“树的剪裁”。为了便于大家理解,下面我援引ElephantBoard的主页http://www.elephantbase.net/上所翻译的《Alpha-Beta搜索》中的一个“口袋的例子”,原文作者是Bruce Moreland (brucemo@seanet.com)。
口袋的例子:
比如你的死敌面前有很多口袋,他和你打赌赌输了,因此他必须从中给你一样东西,而挑选规则却非常奇怪:
每个口袋里有几件物品,你能取其中的一件,你来挑这件物品所在的口袋,而他来挑这个口袋里的物品。你要赶紧挑出口袋并离开,因为你不愿意一直做在那里翻口袋而让你的死敌盯着你。
假设你一次只能找一只口袋,在找口袋时一次只能从里面摸出一样东西。
很显然,当你挑出口袋时,你的死敌会把口袋里最糟糕的物品给你,因此你的目标是挑出“诸多最糟的物品当中是最好的”那个口袋。
你很容易把最小-最大原理运用到这个问题上。你是最大一方棋手,你将挑出最好的口袋。而你的死敌是最小一方棋手,他将挑出最好的口袋里尽可能差的物品。运用最小-最大原理,你需要做的就是挑一个有“最好的最差的”物品的口袋。
假设你可以估计口袋里每个物品的准确价值的话,最小-最大原理可以让你作出正确的选择。我们讨论的话题中,准确评价并不重要,因为它同最小-最大或Alpha-Beta的工作原理没有关系。现在我们假设你可以正确地评价物品。
最小-最大原理刚才讨论过,它的问题是效率太低。你必须看每个口袋里的每件物品,这就需要花很多时间。
那么怎样才能做得比最小-最大更高效呢?
我们从第一个口袋开始,看每一件物品,并对口袋作出评价。比方说口袋里有一只花生黄油三明治和一辆新汽车的钥匙。你知道三明治更糟,因此如果你挑了这只口袋就会得到三明治。事实上只要我们假设对手也会跟我们一样正确评价物品,那么口袋里的汽车钥匙就是无关紧要的了。
现在你开始翻第二个口袋,这次你采取的方案就和最小-最大方案不同了。你每次看一件物品,并跟你能得到的最好的那件物品(三明治)去比较。只要物品比三明治更好,那么你就按照最小-最大方案来办——去找最糟的,或许最糟的要比三明治更好,那么你就可以挑这个口袋,它比装有三明治的那个口袋好。
比方这个口袋里的第一件物品是一张20美元的钞票,它比三明治好。如果包里其他东西都没比这个更糟了,那么如果你选了这个口袋,它就是对手必须给你的物品,这个口袋就成了你的选择。
这个口袋里的下一件物品是六合装的流行唱片。你认为它比三明治好,但比20美元差,那么这个口袋仍旧可以选择。再下一件物品是一条烂鱼,这回比三明治差了。于是你就说“不谢了”,把口袋放回去,不再考虑它了。
无论口袋里还有什么东西,或许还有另一辆汽车的钥匙,也没有用了,因为你会得到那条烂鱼。或许还有比烂鱼更糟的东西(那么你看着办吧)。无论如何烂鱼已经够糟的了,而你知道挑那个有三明治的口袋肯定会更好。
“最大-最小”的思想再加上“对树的剪裁”就是Alpha-Beta搜索算法的核心。
下面就是CChessSearch.h的代码实现,其中涉及的“历史启发”和“着法排序”的具体实现会在下篇文章中给出。
// CChessSearch.h #include "HistoryHeuristic.h" #include "SortMove.h" #include "CChessEvaluate.h" /////////////////// Data Define /////////////////////////////////////////////// int nMaxSearchDepth; // 最大搜索深度 CCHESSMOVE cmBestMove; // 存储最佳走法 /////////////////// Function Prototype //////////////////////////////////////// // 通过AlphaBeta搜索+历史启发搜索得到一部最佳着法并执行之 CCHESSMOVE SearchAGoodMove(); // AlphaBeta搜索+历史启发,nDepth为搜索深度, // alpha初始为极小值,beta初始为极大值 int AlphaBeta_HH( int nDepth, int alpha, int beta ); // 判断游戏是否结束,若结束则根据当前下棋方返回相应的极值,否则返回0 // 当前下棋方胜则返回极大值,当前下棋方败则返回极小值(下棋方追求极大值) int IsGameOver( int fWhoseTurn ); // 执行着法,返回ptTo位置的棋子状况。即若吃掉子返回被吃掉的子,没有吃子则返回0 BYTE DoMove( CCHESSMOVE * move ); // 撤销执行着法,恢复原位置的棋子状况。nCChessID为原ptTo位置的棋子状况 void UndoMove( CCHESSMOVE * move, BYTE nCChessID ); ////////////////// Programmer-Defined Function //////////////////////////////// CCHESSMOVE SearchAGoodMove() { ResetHistoryTable(); int i; i = AlphaBeta_HH( nMaxSearchDepth, -MaxValue, MaxValue ); DoMove( &cmBestMove ); return cmBestMove; } int AlphaBeta_HH( int nDepth, int alpha, int beta ) { int nScore; int nCount; BYTE nCChessID; int i; i = IsGameOver( ( nMaxSearchDepth - nDepth ) % 2 ); if( i != 0 ) // 游戏结束 return i; if( nDepth == 0 ) // 叶子节点 return Eveluate( ( nMaxSearchDepth - nDepth ) % 2 ); nCount = GenerateMove( ( nMaxSearchDepth - nDepth ) % 2 , nDepth ); for( i = 0; i < nCount; i ++ ) // 取历史表得分 { MoveList[nDepth][i].nScore = GetHistoryScore( & MoveList[nDepth][i] ); } MergeSort( MoveList[nDepth], nCount ); // 对着法进行降序排序 int iBestmove = -1; for( i = 0; i < nCount; i ++ ) { nCChessID = DoMove( & MoveList[nDepth][i] ); // 执行着法(生成新节点) nScore = - AlphaBeta_HH( nDepth - 1, -beta, -alpha );//递归调用AlphaBeta_HH UndoMove( & MoveList[nDepth][i], nCChessID ); // 撤销执行(删除节点) if( nScore > alpha ) { alpha = nScore; // 保留最大值 if( nDepth == nMaxSearchDepth ) cmBestMove = MoveList[nDepth][i]; iBestmove = i; // 保存最佳走法的序号 } if( alpha >= beta ) { iBestmove = i; // 保存最佳走法的序号 break; } } if( iBestmove != -1 ) EnterHistoryScore( & MoveList[nDepth][iBestmove], nDepth ); //记录历史得分 return alpha; } int IsGameOver( int fWhoseTurn ) { int x, y ; if( fWhoseTurn == RED ) // 轮到红方下棋,只可能是红帅已经被吃 { // 红方九宫 for( x = 3; x <= 5; x ++ ) for( y = 0; y <= 2; y ++ ) { if( CChessBoard[x][y] == RED_K ) { return 0; // 红帅没被吃,则说明游戏尚未结束 } } return -MaxValue ; // 返回失败极值(已验证应为 -MaxValue ) } else // 轮到黑方下棋,只可能是黑将已经被吃 { // 黑方九宫 for( x = 3; x <= 5; x ++ ) for( y = 9; y >= 7; y -- ) { if( CChessBoard[x][y] == BLACK_K ) { return 0; // 黑将没被吃,则说明游戏尚未结束 } } return -MaxValue ; // 返回失败极值(已验证应为 -MaxValue ) } } BYTE DoMove( CCHESSMOVE * move ) { BYTE nCChessID; //保留目标位置的棋子状况 nCChessID = CChessBoard[ move->ptTo.x ][ move->ptTo.y ] ; //移动子到目标位置 CChessBoard[ move->ptTo.x ][ move->ptTo.y ] = CChessBoard[ move->ptFrom.x ][ move->ptFrom.y ] ; CChessBoard[ move->ptFrom.x ][ move->ptFrom.y ] = 0 ; return nCChessID; } void UndoMove( CCHESSMOVE * move, BYTE nCChessID ) { //将子移动回原处 CChessBoard[ move->ptFrom.x ][ move->ptFrom.y ] = CChessBoard[ move->ptTo.x ][ move->ptTo.y ] ; //恢复目标位置的子 CChessBoard[ move->ptTo.x ][ move->ptTo.y ] = nCChessID ; } // end of CChessSearch.h