Min-Max对抗搜索

· · 个人记录

一、最小-最大搜索

从浅显的地方开始

在国际象棋里,双方棋手都知道每个棋子在哪里,他们轮流走并且可以走任何合理的着法。下棋的目的就是将死对方,或者避免被将死,或者有时争取和棋是最好的选择。  

国际象棋程序通过使用“搜索”函数来寻找着法。搜索函数获得棋局信息,然后寻找对于程序一方来说最好的着法。  

一个浅显的搜索函数用“树状搜索”(Tree-Searching)来实现。一个国际象棋棋局通常可以看作一个很大的n叉树(“n叉树”意思是树的每个结点有任意多个分枝通向其他结点),棋盘上目前的局面就是“根局面”(Root Position)或“根结点”(Root Node)。从根局面走一步棋,局面就到达根局面的“分枝”(Branch),这些局面称为“后续局面”(Successor Position)或“后续结点”(Successor Nodes)。每个后续局面后面还有一系列分枝,每个分枝就是这个局面的一个合理的着法。  

国际象棋的树非常庞大(通常每个局面有35个分枝),又非常深。  

每盘棋局都是一棵巨大的n叉树,如果能通过树状搜索找到棋局中对双方来说都最好的着法就好了。这个浅显的算法在这里称为“最小-最大搜索”(Min-max Search)。  

用最小-最大搜索来解诸如井字棋的简单棋局是可行的(即完全了解每一种变化)。井字棋的博弈树既不烦琐也不深,所以整个树可以遍历,棋局的所有变化都可以知道,任何局面都可以保证找到一步最佳着法。  

数学上用这种方法处理国际象棋也是可以的,但是目前和不久的将来用计算机去实现,却是不可行的。即便如此,我们仍然可以用基于最小-最大搜索的程序来下国际象棋。相比最小-最大地搜索整个树,在一个给定的局面下搜索前几步则是可能的。由于叶子结点的局面没能搜索出杀棋或和棋,所以要用一个称为“评价”(Evaluate)的启发函数给这些局面赋值。尽管程序设计师希望这些值能够通过知识来得到,但它们确实都是猜的。 

基于最小-最大的评价函数   

我不打算在这里谈很多关于评价函数的细节。这里我只说明它是怎样确定的,在以后的章节中会详细展开。评价函数首先应该返回局面的准确值,在没办法得到准确值的情况下,如果可能的话启发值也可以。

它可以由两种方法来决定:   (1) 如果黑方被将死了,那么评价函数返回一个充分大的正数;如果白方被将死了,那么返回一个充分大的负数;如果棋局是和棋(例如某一方逼和,或者双方都只有王),那么返回一个常数,通常是零或接近零。如果不是棋局结束局面,那么它返回一个启发值。我将不详细介绍这个启发值是如何确定的,但是我有把握说子力平衡是首先要考虑的(如果白方盘面上多子的话,这个值就大),而其他位置上的考虑(兵型、王的安全性、重要的子力等等)也需要加上。如果白方是赢棋或者很有希望赢,那么启发函数通常会返回正数;如果黑方是赢棋或者很有希望赢,那么返回负数;如果棋局是均势或者是和棋,那么返回在零左右的数值。  

(2) 这个函数的工作原理跟第一个一样,只是如果当前局面要走子的一方优势,那么它返回正数,反之是负数。 

最小-最大搜索是如何运作的   

最小-最大搜索是一对几乎一样的函数,或者说两个逻辑上重复的函数。我写了很少的代码,用一个更好的函数来完成同一件事,但是写出来时却收到一些意见,因此我首先写出纯粹的(不完美的)最小-最大函数,代码如下:

int MinMax(int depth) { 
    if(SideToMove()==WHITE){ // 白方是“最大”者
        return Max(depth);
    } 
    else{// 黑方是“最小”者
        return Min(depth); 
    }
} 
int Max(int depth){
    int best=-INFINITY;
    if(depth<=0){return Evaluate();}
    GenerateLegalMoves();
    while (MovesLeft()){
        MakeNextMove();
        val=Min(depth - 1);
        UnmakeMove();
        if(val > best){best = val;}
    }
    return best;
}
int Min(int depth){
    int best=INFINITY;// 注意这里不同于“最大”算法
    if(depth<=0) {return Evaluate();}
    GenerateLegalMoves();
    while (MovesLeft()){
        MakeNextMove();
        val=Max(depth - 1);  
        UnmakeMove();
        if(val<best){best = val;}// 注意这里不同于“最大”算法      
    } 
    return best;
} 

上面的代码可以这样调用: 

val=MinMax(5);

这样可以返回当前局面的评价,它是向前看5步的结果。  

这里的“评价”函数用的是我上面所说第一种定义,它总是返回对于白方来说的局面。  

我简要描述一下这个函数是如何运作的。假设根局面(棋盘上当前局面)是白方走,那么调用的是“Max”函数,它产生白方所有合理着法。在每个后续局面中,调用的是“Min”函数,它对局面作出评价并返回。由于现在是白走,因此白方需要让评价尽可能地大,能得到最大值的那个着法被认为是最好的,因此返回这个着法的评价。  

“Min”函数正好相反,当黑方走时调用“Min”函数,而黑方需要尽可能地小,因此选择能得到最小值的那个着法。  

这两个函数是互相递归的,即它们互相调用,直到达到所需要的深度为止。当函数到达最底层时,它们就返回“Evaluate”函数的值。  

如果在深度为1时调用“MinMax”函数,那么“Evaluate”函数在走完每个合理着法之后就调用,选择一个能达到最佳值的那个着法导致的局面。如果层数大于1,那么另一方有权选择局面,并找一个最好的。  

以上内容应该不难理解,但是代码很长,下面有个更好的办法。 

负值最大函数

负值最大只是对最小-最大的优化,“评价”函数返回我所说的第二种定义,对于当前结点上要走的一方,占优的情况返回正值,其他结点也是对于要走的一方而言的。这个值返回后要加上负号,因为返回以后就是对另一方而言了。代码如下:

int NegaMax(int depth){ 
    int best=-INFINITY; 
    if (depth<=0){return Evaluate(); } 
    GenerateLegalMoves(); 
    while(MovesLeft()) {  
        MakeNextMove();  
        val=-NegaMax(depth-1); // 注意这里有个负号。 
        UnmakeMove();  
        if (val>best){best val;} 
    } 
    return best;
}   

在这个函数里,当走子一方改变时就要对返回值取负值,以反映当前局面评价的更改。就根结点是白先走的情况,如果没有剩下的层数,那么“评价”返回的值是就白方而言的,如果有剩下的层数,就产生后续局面,函数对这些局面逐一做递归,每个次递归都得到就黑方而言的评价,黑方走得越好值就越大。当评价值返回时,它们被取负数,变成就白方而言的评价。  

该函数在遍历时结点的顺序同“最小-最大”搜索的函数是一样的,产生的返回值也一样。它的代码更短,同时减少了移植代码时出错的可能,代码维护起来也比较方便。 

二、Alpha-Beta搜索

最小-最大的问题

Alpha-Beta 同“最小-最大”非常相似,事实上只多了一条额外的语句。最小最大运行时要检查整个博弈树,然后尽可能选择最好的线路。这是非常好理解的,但效率非常低。每次搜索更深一层时,树的大小就呈指数式增长。  

通常一个国际象棋局面都有35个左右的合理着法,所以用最小-最大搜索来搜索一层深度,就有35个局面要检查,如果用这个函数来搜索两层,就有352个局面要搜索。这就已经上千了,看上去还不怎样,但是数字增长得非常迅速,例如六层的搜索就接近是二十亿,而十层的搜索就超过两千万亿了。  

要想通过检查搜索树的前面几层,并且在叶子结点上用启发式的评价,那么做尽可能深的搜索是很重要的。最小-最大搜索无法做到很深的搜索,因为有效的分枝因子实在太大了。

口袋的例子

幸运的是我们有办法来减小分枝因子,这个办法非常可靠,实际上这样做绝对没有坏处,纯粹是个有益的办法。这个方法是建立在一个思想上的,如果你已经有一个不太坏的选择了,那么当你要作别的选择并知道它不会更好时,你没有必要确切地知道它有多坏。有了最好的选择,任何不比它更好的选择就是足够坏的,因此你可以撇开它而不需要完全了解它。只要你能证明它不比最好的选择更好,你就可以完全抛弃它。  

你可能仍旧不明白,那么我就举个例子。比如你的死敌面前有很多口袋,他和你打赌赌输了,因此他必须从中给你一样东西,而挑选规则却非常奇怪:  

每个口袋里有几件物品,你能取其中的一件,你来挑这件物品所在的口袋,而他来挑这个口袋里的物品。你要赶紧挑出口袋并离开,因为你不愿意一直做在那里翻口袋而让你的死敌盯着你。  

假设你一次只能找一只口袋,在找口袋时一次只能从里面摸出一样东西。  

很显然,当你挑出口袋时,你的死敌会把口袋里最糟糕的物品给你,因此你的目标是挑出“诸多最糟的物品当中是最好的”那个口袋。  

你很容易把最小-最大原理运用到这个问题上。你是最大一方棋手,你将挑出最好的口袋。而你的死敌是最小一方棋手,他将挑出最好的口袋里尽可能差的物品。运用最小-最大原理,你需要做的就是挑一个有“最好的最差的”物品的口袋。  

假设你可以估计口袋里每个物品的准确价值的话,最小-最大原理可以让你作出正确的选择。我们讨论的话题中,准确评价并不重要,因为它同最小-最大或Alpha-Beta的工作原理没有关系。现在我们假设你可以正确地评价物品。 

最小-最大原理刚才讨论过,它的问题是效率太低。你必须看每个口袋里的每件物品,这就需要花很多时间。  

那么怎样才能做得比最小-最大更高效呢?  

我们从第一个口袋开始,看每一件物品,并对口袋作出评价。比方说口袋里有一只花生黄油三明治和一辆新汽车的钥匙。你知道三明治更糟,因此如果你挑了这只口袋就会得到三明治。事实上只要我们假设对手也会跟我们一样正确评价物品,那么口袋里的汽车钥匙就是无关紧要的了。  

现在你开始翻第二个口袋,这次你采取的方案就和最小-最大方案不同了。你每次看一件物品,并跟你能得到的最好的那件物品(三明治)去比较。只要物品比三明治更好,那么你就按照最小-最大方案来办——去找最糟的,或许最糟的要比三明治更好,那么你就可以挑这个口袋,它比装有三明治的那个口袋好。  

比方这个口袋里的第一件物品是一张20美元的钞票,它比三明治好。如果包里其他东西都没比这个更糟了,那么如果你选了这个口袋,它就是对手必须给你的物品,这个口袋就成了你的选择。  

这个口袋里的下一件物品是六合装的流行唱片。你认为它比三明治好,但比20美元差,那么这个口袋仍旧可以选择。再下一件物品是一条烂鱼,这回比三明治差了。于是你就说“不谢了”,把口袋放回去,不再考虑它了。  

无论口袋里还有什么东西,或许还有另一辆汽车的钥匙,也没有用了,因为你会得到那条烂鱼。或许还有比烂鱼更糟的东西(那么你看着办吧)。无论如何烂鱼已经够糟的了,而你知道挑那个有三明治的口袋肯定会更好。

算法

Alpha-Beta就是这么工作的,并且只能用递归来实现。稍后我们再来谈最小一方的策略,我希望这样可以更明白些。  

这个思想是在搜索中传递两个值,第一个值是Alpha,即搜索到的最好值,任何比它更小的值就没用了,因为策略就是知道Alpha的值,任何小于或等于Alpha的值都不会有所提高。  

第二个值是Beta,即对于对手来说最坏的值。这是对手所能承受的最坏的结果,因为我们知道在对手看来,他总是会找到一个对策不比Beta更坏的。如果搜索过程中返回Beta或比Beta更好的值,那就够好的了,走棋的一方就没有机会使用这种策略了。  

在搜索着法时,每个搜索过的着法都返回跟Alpha和Beta有关的值,它们之间的关系非常重要,或许意味着搜索可以停止并返回。  

如果某个着法的结果小于或等于Alpha,那么它就是很差的着法,因此可以抛弃。因为我前面说过,在这个策略中,局面对走棋的一方来说是以Alpha为评价的。  

如果某个着法的结果大于或等于Beta,那么整个结点就作废了,因为对手不希望走到这个局面,而它有别的着法可以避免到达这个局面。因此如果我们找到的评价大于或等于Beta,就证明了这个结点是不会发生的,因此剩下的合理着法没有必要再搜索。  

如果某个着法的结果大于Alpha但小于Beta,那么这个着法就是走棋一方可以考虑走的,除非以后有所变化。因此Alpha会不断增加以反映新的情况。有时候可能一个合理着法也不超过Alpha,这在实战中是经常发生的,此时这种局面是不予考虑的,因此为了避免这样的局面,我们必须在博弈树的上一个层局面选择另外一个着法。  

在第二个口袋里找到烂鱼就相当于超过了Beta,如果口袋里没有烂鱼,那么考虑六盒装流行唱片的口袋会比三明治的口袋好,这就相当于超过了Alpha(在上一层)。算法如下,醒目的部分是在最小-最大算法上改过的:

int AlphaBeta(int depth,int alpha,int beta){
    if(depth==0) return Evaluate();
    GenerateLegalMoves(); 
    while(MovesLeft()){  
        MakeNextMove();  
        val=-AlphaBeta(depth-1,-beta,-alpha);  
        UnmakeMove();  
        if (val>=beta) return beta;//醒目
        if (val>alpha) alpha=val;
    }
    return alpha;
}

把醒目的部分去掉,剩下的就是最小-最大函数。可以看出现在的算法没有太多的改变。  

这个函数需要传递的参数有:需要搜索的深度,负无穷大即Alpha,以及正无穷大即Beta:val = AlphaBeta(5, -INFINITY, INFINITY);   

这样就完成了5层的搜索。我在写最小-最大函数时,用了一个诀窍来避免用了“Min”还用“Max”函数。在那个算法中,我从递归中返回时简单地对返回值取了负数。这样就使函数值在每一次递归中改变评价的角度,以反映双方棋手的交替着子,并且它们的目标是对立的。  

在Alpha-Beta函数中我们做了同样的处理。唯一使算法感到复杂的是,Alpha和Beta是不断互换的。当函数递归时,Alpha和Beta不但取负数而且位置交换了,这就使得情况比口袋的例子复杂,但是可以证明它只是比最小-最大算法更好而已。  

最终出现的情况是,在搜索树的很多地方,Beta是很容易超过的,因此很多工作都免去了。 

可能的弱点

这个算法严重依赖于着法的寻找顺序。如果你总是先去搜索最坏的着法,那么Beta截断就不会发生,因此该算法就如同最小-最大一样,效率非常低。该算法最终会找遍整个博弈树,就像最小-最大算法一样。  

如果程序总是能挑最好的着法来首先搜索,那么数学上有效分枝因子就接近于实际分枝因子的平方根。这是Alpha-Beta算法可能达到的最好的情况。  

由于国际象棋的分枝因子在35左右,这就意味着Alpha-Beta算法能使国际象棋搜索树的分枝因子变成6。  

这是很大的改进,在搜索结点数一样的情况下,可以使你的搜索深度达到原来的两倍。这就是为什么使用Alpha-Beta搜索时,着法顺序至关重要的原因。

搬运自