我们在解决ICG游戏问题时,引入了 S G SG SG函数作为必胜策略的判断依据。但 S G SG SG函数的局限在于时空复杂度。 S G SG SG搜索固然能保证答案的正确性,但其时空复杂度与状态数正相关。当游戏的状态数很多时, S G SG SG搜索便不再适用。此时应当适当牺牲正确性来保证高效性和可行性。
为在牺牲正确性的同时保证可行性,我们可以引入估值函数 f ( x ) : S → R f(x):S\to R f(x):S→R给出对某一个状态 S S S的评估。
我们规定,若状态 S ′ S' S′可以由状态 S S S转移而来,则 f ( S ′ ) f(S') f(S′)给出了相对于S先手的估值。这个值越大认为 S S S局面(注意不是 S ′ S' S′)越优。特别地,当 S ′ S' S′先手必败时,则对 S S S而言先手必胜,则此时估值可以设置为 + ∞ +\infty +∞。
我们将不同状态之间的转移关系抽象为有向边,便可以从初始状态 S 0 S_0 S0开始建立一棵搜索树,每一个节点代表一个状态。
在ICG游戏中,仅会有一个玩家胜利,即满足零和性。由此我们可以得到下列转移性质:
前两点较易理解。对于第三点理解如下:经过每一轮,先后手会发生改变,因此在搜索树上,根节点等偶数层的点代表先手行动,而奇数层的点代表后手行动。由游戏的零和性,双方都需要找到对自己最有利的策略,即是对对方最不利的策略。而整个搜索树的估值函数是针对于先手而言的,当估值函数越小,则先手越不利。因此在后手行动的奇数层,就需要找到后继中估值函数值最小的状态进行转移,从而尽可能使后手最优。对偶数层同理。故得到上述转移函数。
一个搜索树示例如下:(图源:《极大极小搜索算法 minimax search》by thormas1996,https://blog.csdn.net/thormas1996/article/details/102662222)
可以发现,上述策略选择的正确性与估值函数的选取有着重要联系。在上图中,为使先手获得的利益最大,则应当选取 A − C − H A-C-H A−C−H这一条路径。
容易发现, M i n − M a x Min-Max Min−Max搜索的状态数仍然可能很多,因此需要采用一些剪枝操作,其中较有名的便是 α − β \alpha-\beta α−β剪枝。
α − β \alpha-\beta α−β剪枝的基本思想是将搜索树上的点进行分类,通过对某一个点的可能估值确定上下界从而排除不可能的分支,达到剪枝的目的。
我们将搜索树上的节点分为三类:
$\alpha-\beta $剪枝的针对对象便是B类点。它们的估值并未确定,但由于部分儿子的估值已经确定,所以可以根据这些信息为B类点的估值范围做出限制,并依此排除不可能的分支。
我们定义每一个B类点的估值的取值范围都有下界 α \alpha α和上界 β \beta β。当某一个分支的解必定小于 α \alpha α或大于 β \beta β时,则将这个分支剪除。
根据上述定义,我们需要解决两个问题:
我们先解决第一个问题。先给出结论:B类点的上下界由儿子与父亲共同决定。其中儿子节点会给定具体值来影响B类点的上下界,而父亲节点则是直接让B类点继承其上下界。
先举一个例子方便理解:
初始化所有节点的下界为负无穷,上界为正无穷,在搜索时现在搜索到上图阶段,即是由B号点访问值为3的叶子节点完毕后进行回溯的过程。
此时B号点属于max层,因此它最终的取值必定会大于等于其中某一个儿子的取值,由其第一个儿子可以确定B的取值范围下界为3。
上图反映了B类点上下界确定的第一种方法:由儿子节点确定的返回值来影响B类点的上下界。
上图是B号点在搜索完毕之后,进一步回溯到父亲A号点。
由于A是在min层,因此其最终的取值必定小于等于其某一个儿子的取值,故由B号点的取值可以确定A的取值上界为5。
上图反映的是A号点在访问完B号点之后,即将访问C号点的情况。由于A号点已经初步确定了自己的上下界(上界为5),所以它将会把这个上下界直接赋予到儿子节点C号点上。赋予这个上下界的意义是:C的可能取值如果大于5,则没有意义,因为在C所在的层有值为5的B号点,B号点一定比C号点更优。因此我们可以令C的取值上界为5,从而对C引出的分支中取值大于5的分支进行剪枝。
小结一下:每一个点的上下界的确定有两种影响因素:
对于第一种,如果儿子存在多种取值的话,新确定的取值可能会进一步减小父亲的取值范围。如下图,一开始确定了B号点的取值为5之后,A的取值范围为 ( − ∞ , 5 ] (-\infty,5] (−∞,5],在确定了C号点的取值为3之后,A的取值范围便缩小为 ( − ∞ , 3 ] (-\infty,3] (−∞,3]。
当然,也可能存在新确定的儿子取值与已有的取值区间冲突的情况,这就引出了我们的第二个问题。
上图中,我们确定了C号点的取值范围,接下来遍历C号点的子树的时候,第一个儿子取值为10,超出了C的上界,那么C的取值一定会大于等于10,故C的分支一定是一个无用分支(A一定会选择B号点作为更优的选择),因此我们可以直接停止遍历C的子树,并令其返回一个必定不会成为答案的值(C处于最大层,我们默认返回其上界),从而完成了对C的子树搜索的剪枝。
注意:C位于max层,故若C的子元素的取值小于C的下界,不能直接进行剪枝。
上图中C遍历完自己的第一个儿子之后,取值区间为 [ 3 , 5 ] [3,5] [3,5]。第二个儿子的取值小于下界3,此时不能进行剪枝,因为不能保证C的分支一定是无效分支,可以看到后续的第三个儿子取值为4,恰好是C号点的最优策略,也是A的最优策略。
上述剪枝的效果是十分明显的,尽管在图上未有明显的体现。但如果C的另一个儿子对应的搜索子树十分庞大,用这种剪枝方法便可以避免对这个子树的遍历,从而大大减小了搜索可能状态,提高效率。
注意:如果某一个点成功遍历完了所有的儿子且并未被剪枝,那么返回的时候并不是返回这个点自身的上界或者下界,而是返回其所有儿子节点中的最大值或者最小值。即该点的取值上界不一定是其儿子节点的最大值。(从上图便可以看出,C号点的最大儿子为4,但是上界为5,此时返回C号点的取值应该是4)
从上述的两个问题的解决过程,我们可以总结到 α − β \alpha-\beta α−β剪枝的基本规则:
实现代码如下:
void alpha_beta(int node, bool player, int alpha, int beta)
{
if(!val[node])//如果当前点未确定估值
{
int maxx = -inf, minn = inf;//记录node节点的儿子节点取值的最大值或者最小值
for( int i = 0; i < children[node].length; i++ )
{
int now_val = alpha-beta(childern[node][i], player ^ 1, alpha, beta);//继承上下界
maxx = max(maxx, now_val);
minn = min(minn, now_val);
if(player == 0)//对应的是最大层
{
if(now_val > alpha)
{
alpha = now_val;
}
if(alpha >= beta)
{
return beta;
}
}
else //对应的是最小层
{
if(now_val < beta)
{
beta = now_val;
}
else
{
return alpha;
}
}
}
val[node] = (player == 1 ? minn : maxx);
}
return val[node];
}