FireMonkey3D之中国象棋程序(三)初步搜索算法

FireMonkey3D之中国象棋程序(三)初步搜索算法

声明:本程序设计参考象棋巫师源码(开发工具dephi 11,建议用delphi 10.3以上版本)。

这一章计划初步实现搜索算法,前两章基本上按照我自己对中国象棋的理解来设计程序,从这章开始参照象棋巫师算法。

3.1、局面评价  

中国象棋共有7种棋子:将(帅)、士、相、马、车、炮、兵,局面评价中最关键的因素是每种棋子的价值,子力价值是跟它的绝对位置相关的。比如兵(卒),过河前基本上没有什么威胁,子力价值就很低,过河后分数大涨,越接近九宫分数就越高,九宫中心甚至接近一个马或炮的值。再比如马,在卧槽的位置和在挂角的位置对将(帅)的影响非常大,在此位置子力评分很高。如此一来,每个兵种就都会有一个与绝对位置相关的价值,因此我们定义一个三维常量数组:vlPc:array [0..6,0..9,0..8] of Byte(从“象眼”中照搬过来的),这个子力价值表水平左右对称,以红方为基准,黑方使用时只须颠倒纵向数据即可。

我们在TPieceMove内定义两个常量:vlRed, vlBlack:Integer;   用来记录红、黑双方的子力价值;定义Evaluate函数来评价局面分,即红黑双方的子力价值差,先走棋再加3分。我们可以每次走棋后,全盘搜索每个棋子的位置来计算局面分,但是这样做太浪费时间了,因为根本没有必要每次都把棋盘扫描一遍。我们定义了两个函数来实现每步走棋计算分值:调用 AddPiece (放一枚棋子) DelPiece (取走一枚棋子),可以趁这个机会更新 Evaluate这样的局面评价函数已经足够了。

3.2、极大极小搜索算法

以上均为准备工作。现在我们先从最简单的搜索算法学起:极大极小搜索算法。这个算法这样首先这样评价局面:

  如果黑方被将死了,那么评价函数返回一个充分大的正数;如果红方被将死了,那么返回一个充分大的负数。如果红方是赢棋或者很有希望赢,那么函数通常会返回正数;如果黑方是赢棋或者很有希望赢,那么返回负数;如果棋局是均势或者是和棋,那么返回在零左右的数值。

     按照搜索算法的思路,我们先定义常量:MATE_VALUE = 10000; 最高分值,即将死的分值。极大搜索时,初始化始最优值为负无穷,即—MATE_VALUE;极小搜索时,为正MATE_VALUE这样定义的好处是可以确定没有走过任何棋。

  简要描述一下这个函数是如何运作的。假设根局面(棋盘上当前局面)是红方走,先生成红方所有合理走法,逐一走这些走法,调用“maxSearch”函数,生成黑方所有合理着法。在每个后续局面中,调用的是“MinSearch”函数,它对局面作出评价并返回。由于现在是红方走,因此红方需要让评价尽可能地大,能得到最大值的那个着法被认为是最好的,因此返回这个着法的评价。“minSearch”函数正好相反,当黑方走时调用“Min”函数,而黑方需要尽可能地小,因此选择能得到最小值的那个着法。这两个函数是互相递归的,即它们互相调用,直到达到所需要的深度为止。当函数到达最底层时,它们就返回“Evaluate”函数的值。如果在深度为1时调用“MinMax”函数,那么“Evaluate”函数在走完每个合理着法之后就调用,选择一个能达到最佳值的那个着法导致的局面。如果层数大于1,那么另一方有权选择局面,并找一个最好的。

  举个例子,电脑为A,人类为B,A在走棋之前需要思考,A走了某一步后,看看B有哪些走法,B又不傻,所以B肯定是要选择让A得分最少的走法走棋,而A会选择在所有走法中B认为得分最少的走法中分值最高的走法,也就是说A的走法取决于B,反之亦然。听起来大概会比较抽象比较绕吧,试着多读几遍,多理解理解。
通过极大极小搜索算法,电脑就可以初步自动回应走棋了。极大极小搜索算法可以合成负极大搜索,搜索过程如下图:

红色数字是当前节点的分值,蓝色数字是父节点对子节点返回值取负后的值。

搜索进行到C1、C2、C3时,要做评估,得到的估值是12、15、13。由于此时轮到红方走棋,表示红方在三个局面分别有12、15、13的优势。在极大极小搜索中,要在B1求这三个局面估值的最小值。现在我们可以这样来看,黑方在C1、C2、C3的优势分别为-12、-15、-13。黑方显然在B1点需要对三个局面做一个选择,选择的目标当然是优势最大化。可以这样表示:max(-12, -15, -13);

在极大极小搜索中,B1是黑方走棋,是极小点,应该求最小值。现在由于多加了一个负号,也变成求极大值了,B1点也成了极大点。

黑方在B1、B2、B3分别取得了-12、-5、-14的优势,对于红方来讲,则在这三个点分别有12、5、14的优势。所以在A点,轮到红方走棋,它会在B1、B2、B3中选择最大值,即:max(12, 5, 14),这里看出A的选择了吗?

代码如下:

function negaMaxSearch (depth:Integer):Integer;
var
  vlBest,i,value:Integer;
  mvs:TArray<TMoves>;
  s,d:TPoint;
  id:Byte;
begin
  // 深度为0,调用评估函数并返回分值
  if depth = 0 then
    Exit(pcMove.evaluate);
  vlBest:=-MATE_VALUE;		// 初始最优值为负无穷
  mvs:= pcmove.generateMoves;	// 生成当前局面的所有走法
  value:= 0;
  for i:= 0 to Length(mvs)-1 do
  begin
    s:=mvs[i].src;
    d:=mvs[i].dest;
    if not pcMove.makeMove(s,d,id) then Continue;
    value:=-negaMaxSearch(depth - 1);
    PcMove.undoMakeMove(s,d,id);
    if value > vlBest then
      vlBest:= value;
    if depth = MINMAXDEPTH then
      Search.mvResult:= mvs[i];
  end;
  Result:=vlBest;// 返回当前节点的最优值
end;

这个函数有一个常量:MINMAXDEPTH=3,调用时,参数depth必须也等于3。通过测试,我们会发现这个搜索算法运行时要检查整个博弈树,然后尽可能选择最好的线路,但因为分枝因子太大导致效率非常低,无法做到很深的搜索,搜索5层基本卡死。

3.3、Alpha-Beta搜索

  这是本章的重点,要把这个算法搞明白得费一番功夫。Alpha-Beta搜索好处在于裁剪了不必要的分枝因子。简单来讲就是这个搜索算法就是在负极大搜索算法的基础上加上范围[Alpha-Beta],在这个范围内的算法可以考虑,同时不断缩小Alpha的范围,超过Beta就要截断。最开始,Alpha、Beta的值也是“负MATE_VALUE”和“正MATE_VALUE”。当函数递归时,AlphaBeta不但取负数而且位置交换了,这样缩小了搜索范围,从而减少搜索数量。

  • 算法思路:这个算法思想是在搜索中传递两个值,第一个值是Alpha,即搜索到的最好值,任何比它更小的值就没用了,因为策略就是任何小于或等于Alpha的值都要舍弃。第二个值是Beta,即对于对手来说最坏的值。对中国象棋来说,这是对手不能承受的最坏的结果,因为对手达到这个值就等于输棋。我们知道在对手看来,他总是要找到一个对策比Beta好的着法,走棋的一方没有机会使用这种策略。在搜索着法时,每个搜索过的着法都返回跟AlphaBeta有关的值,它们之间的关系非常重要,或许意味着搜索可以停止并返回。如果某个着法的结果小于或等于Alpha,那么它就是很差的着法,可以抛弃。因为在这个策略中,局面对走棋的一方来说是以Alpha为评价的。如果某个着法的结果大于或等于Beta,那么整个结点就作废了,因为对手不希望走到这个局面,而它有别的着法可以避免到达这个局面。因此如果我们找到的评价大于或等于Beta,就证明了这个结点是不会发生的,剩下的合理着法没有必要再搜索。如果某个着法的结果大于Alpha但小于Beta,那么这个着法就是走棋一方可以考虑走的,除非以后有所变化。因此Alpha会不断增加以反映新的情况。有时候可能一个合理着法也不超过Alpha,这在实战中是经常发生的,此时这种局面是不予考虑的,为了避免这样的局面,我们必须在博弈树的上一个层局面选择另外一个着法。Alpha-Beta搜索裁剪因子如下图演示:

 

  从图示上看,Alpha剪掉比自己的小的分枝,Beta减掉比自己大的分枝,关键点在于Alpha-Beta搜索必须进行排序。

  • 算法实现:我们根据以上思路写出一个Alpha-Beta搜索函数,伪代码(以下来自象棋巫师):
function AlphaBeta(var vlAlpha, vlBeta, nDepth:integer):Integer;
var
  vl:integer;
begin
 if nDepth=0 then
  Exit(局面评价函数);
 生成全部走法;
 排序全部走法;
 for (每个生成的走法) do 
  begin
  走这个走法;
  vl =:-AlphaBeta(-vlBeta, -vlAlpha, nDepth - 1);
  撤消这个走法; 
  if vl >= vlBet then
   Exit(vlBeta);
  if vl > vlAlpha then
   vlAlpha = vl;
  end;
 Result:= vlAlpha;
end;

但是,这样的程序根本走不出棋来,因为它返回的是一个分数而不是一个走法。另外,我们还发现几个问题:  

(1) 排序的依据是什么?  

(2) 是不是每个生成的走法都可以走?  

(3) 如果什么走法都走不出来,那么返回vlAlpha合理吗?   

针对以上几个问题,我们对程序做如下改进:  

(0) 如果函数在根节点处被调用,就把最佳走法作为电脑要走的棋;  

(1) 国际象棋程序的经验证明,历史表是很好的走法排序依据;  

(2) 由于我们的走法生成器并没有考虑自杀(被将军)的情况,因此走完一步后要检查是否被将军了,被将军时应立即退回来;  

(3) 如果没有走出过任何走法,说明当前局面是杀棋或困毙局面,应该返回杀棋的分数。  

下面是改进过的程序,改进的地方已标出:

function AlphaBeta(vlAlpha,vlBeta,nDepth:Integer):Integer;
var
  vl:integer;
begin
 if nDepth = 0 then
  Exit(局面评价函数);
 生成全部走法;
 按历史表排序全部走法;{--添加---}
 for (每个生成的走法) do
  begin
  走这个走法;
  if (被将军) then {--添加---}
   撤消这个走法    {--添加---}
    else           {--添加---}
	begin
   int vl:= -AlphaBeta(-vlBeta, -vlAlpha, nDepth - 1);
   撤消这个走法; 
   if vl >= vlBeta then 
      begin
    将这个走法记录到历史表中;{--添加---}
    Exit(vlBeta);
   end;
   if vl > vlAlpha then 
      begin
    设置最佳走法;{--添加---}
    vlAlpha = vl;
   end;
  end;
 end;
 if (没有走过任何走法) then {--添加---}
  Exit(杀棋的分数);        {--添加---}
 将最佳走法记录到历史表中;    {--添加---}
 if (根节点) then           {--添加---}
  最佳走法就是电脑要走的棋;   {--添加---}
 Resutl:= vlAlpha;
end;
  • 杀棋的分数:遇到将死或困毙的局面时,应该返回 nDistance - MATE_VALUE,这样程序就能找到最短的搜索路线。nDistance 是当前节点距离根节点的步数,每走一个走法,nDistance 就增加1,每撤消一个走法,nDistance 就减少1。如果程序中使用了置换表,这个问题将变得更加复杂,我们以后再讨论。
  • 历史表:国际象棋程序的经验证明,历史表是很好的走法排序依据。那么,什么样的走法要记录到历史表中去呢?象棋小巫师选择了以下两类走法:  

   A. 产生Beta截断的走法;  

  B. 不能产生Beta截断,但它是所有PV走法(vl > vlAlpha)中最好的走法。

Delphi实现算法需要这样:  

  • 首先,我们必须建立一个走法历史表,象棋巫师里把走法定义为一个16位无符号整数(Word),结构如下(每个XY各占4位):
Dest Src
Y X Y X

 我们之前的代码也必须做出调整,将生成所有走法的函数更改为返回整数数组,以兼容象棋巫师的算法。定义走法历史表:nHistoryTable:array [Word] of Integer; 象棋巫师的历史表是一个大小为65536的数组,正好能将走法的数值(mv)作为指标,因此根据走法取得历史表的值非常容易,即nHistoryTable[mv]。那么,一个走法记录到历史表,究竟该给 nHistoryTable 中的这个元素加多少分的值呢?沿用国际的经验——深度的平方。所以,更新历史表的代码非常简单: Inc(nHistoryTable[mv], nDepth * nDepth);

  •  其次,对这个历史表按进行排序nDepth的平方从大到小排序,采用了快速排序法,Delphi泛型自带快速排序法,代码如下:
uses System.Generics.Collections,System.Generics.Defaults;//泛型排序必须引用单元
function CompareHistory(const mv1,mv2:Integer):Integer;//定义从大到小的排序函数,mv即为走法
begin
  Result:=Search.nHistoryTable[mv2] -Search.nHistoryTable[mv1];//按深度的平方进行比较
end;
function SortMVS:TArray<Integer>;
var
  Comparer: IComparer<Integer>;
begin
  MVS:=pcMove.GenerateMoves;
  Comparer := TComparer<Integer>.Construct(CompareHistory);
  TArray.Sort<Integer>(MVS,Comparer);
  Result:=MVS;
end;
  •  最后,实现Alpha-Beta搜索算法: 
{超出边界(Fail-Soft)的Alpha-Beta搜索过程}
function SearchFull(vlAlpha,vlBeta,nDepth:Integer):Integer;
var
  i,vl, vlBest:Integer;
  pc:Byte;
  MVS:TArray<Integer>;
  Comparer: IComparer<Integer>;
  s,d:TPoint;
  mvBest:Integer;
begin
  // 一个Alpha-Beta完全搜索分为以下几个阶段
  // 1. 到达水平线,则返回局面评价值
  if nDepth = 0 then
    Exit(pcMove.Evaluate);
  // 2. 初始化最佳值和最佳走法
  vlBest:= -MATE_VALUE; // 这样可以知道,是否一个走法都没走过(杀棋)
  mvBest:=0;            // 这样可以知道,是否搜索到了Beta走法或PV走法,以便保存到历史表
   // 3. 生成全部走法,并根据历史表排序
  MVS:=pcMove.GenerateMoves;
  Comparer := TComparer<Integer>.Construct(CompareHistory);
  TArray.Sort<Integer>(MVS,Comparer);
  // 4. 逐一走这些走法,并进行递归
  for i:= 0 to  High(MVS) do
  begin
    s:=GetSrc(MVS[i]);
    d:=GetDest(MVS[i]);
    if pcMove.MakeMove(s,d, pc) then{新增函数,在移动棋子的基础上,添加了判断被将军和nDistance++}
    begin
      vl:= -SearchFull(-vlBeta, -vlAlpha, nDepth - 1);
      pcMove.UndoMakeMove(s,d, pc);
      // 5. 进行Alpha-Beta大小判断和截断
      if vl > vlBest then    // 找到最佳值(但不能确定是Alpha、PV还是Beta走法)
      begin
        vlBest:= vl;        // "vlBest"就是目前要返回的最佳值,可能超出Alpha-Beta边界
        if vl >= vlBeta then  // 找到一个Beta走法
        begin
          mvBest:=MVS[i];  // Beta走法要保存到历史表
          break;            // Beta截断
        end;
        if vl > vlAlpha then // 找到一个PV走法,即vlBate>vl>vlAlpha
        begin
          mvBest:=MVS[i];  // PV走法要保存到历史表
          vlAlpha:= vl;     // 缩小Alpha-Beta边界
          if pcMove.nDistance = 0 then // 搜索根节点时,总是有一个最佳走法(因为全窗口搜索不会超出边界),将这个走法保存下来
            Search.mvResult:=mvBest;
        end;
      end;
    end;
  end;
  // 5. 所有走法都搜索完了,把最佳走法(不能是Alpha走法)保存到历史表,返回最佳值
  if  vlBest =-MATE_VALUE then
    Exit(pcMove.nDistance - MATE_VALUE); // 如果是杀棋,就根据杀棋步数给出评价
  if mvBest>0 then
    Inc(Search.nHistoryTable[mvBest], nDepth * nDepth);// 如果不是Alpha走法,就将最佳走法保存到历史表
  Result:=vlBest;
end; 

 以上代码即实现了Alpha-Beta搜索算法,电脑开始有一点点智能,可以走一些合理的棋了。

3.4、核心代码改动说明:

这一章有较多的改动,有必要说明一下。

TPieceMove中新增或修改的主要属性和方法:

(1)vlRed属性:红方所有棋子的价值

(2)vlBlack属性:黑方所有棋子的价值

(3)addPiece、DelPiece方法:此方法增加了一项功能,就是在增减棋子时,更新vlRed和vlBlack。

(4)MakeMove方法:改方法做了一些改进。如果移动棋子后,发现老将被对方攻击,也就是说这步棋是去送死的,那么就要撤销对棋子的移动,并返回false。

(5)UndoMakeMove方法:撤销对棋子的移动。

新增csSearch单元:

(1)负极大搜索算法negaMaxSearch。

(2)Alpha-Beta搜索算法:SearchFull(vlAlpha,vlBeta,nDepth:Integer):Integer;

csCommn单元中新增内容:

(1)vlPc三维数组常量:棋子在棋盘每个位置的子力价值。

(2)定义了一些常量:

  ADVANCED_VALUE = 3; // 先行权分值
  MATE_VALUE = 10000; // 最高分值,即将死的分值
  WIN_VALUE = MATE_VALUE - 200; // 搜索出胜负的分值界限,超出此值就说明已经搜索出杀棋了
  MINMAXDEPTH=3;//用于负极大搜索算法的搜索层次

(2)定义TSearch记录,其成员有:

  mvResult://这是搜索算法找到的最佳走法,随后电脑就会执行这步棋。

   nHistoryTable:array [Word] of Integer;  //历史表

(3)去掉TMoves走法记录

Chess_Unit单元:

(1)添加悔棋功能

(2)添加电脑响应走棋

其他未说明的函数请参阅源码注释。

本章节源码百度云盘:

链接:中国象棋程序设计(三)制定规则

提取码:1234