ai的思考过程是怎样的?自然就是遍历所有的可能,找出相对最好的一种着法。我们首先要实现这个功能,之后再优化算法,使得效率更高。
本文介绍的算法:
极小-极大值搜索
负极大值函数
Alpha-Beta剪枝算法
渴望算法
极小-极大值搜索:
轮到ai下棋时,它首先会思考全部可能的着法。然后还要思考每一种着法下玩家全部可能的着法,然后思考玩家全部可能的着法下ai全部可能的着法,如此类推直到达到设定的搜索深度。然后对此时的棋局进行评分。然后根据评分选择合适着法。
但要注意:并非是最终哪个棋局评分最高ai就要选择对应路线的那步着法,因为是ai、玩家交替下棋的。我们让ai假定在玩家层玩家会选择对自己最有利即对ai最不利的着法(即评分最低)。如此从对棋局评分那层再层层倒推,最终得到ai这步最好的着法。
如下图表示一个搜索深度为4的搜索,每一个节点代表一个局面。每一个节点由上一个节点下完一步后产生。其中圆节点代表是ai下棋,ai的不同着法产生多个方节点。方节点又因玩家的不同着法产生多个圆节点。叶节点则不再下,而是进行评分。除叶节点外的节点不直接评分,而是通过子节点得到评分。(当然,实际的节点数远多于图示)
对于下图的第3层,由于是玩家层,玩家会选择对ai最不利的着法。其子节点是由玩家不同着法产生的局面,所以玩家选择最低评分的子节点对应的着法。而方节点的评分便是其子节点中评分最低的那个节点的评分。
同样,ai会选择其子节点中评分最高的节点对应的着法。其评分也是其最高评分的子节点对应的评分。
通过反复最小、最大值的选择,最终得到顶层圆节点对应的着法,即ai此次选择的着法。
代码:
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;
}
使用Max(4)可以调用以上方法进行深度为4的搜索。
负极大值函数:
不难发现最大值函数和最小值函数高度相仿,只是初始值相反和比较时使用大于号和小于号。所以这二个函数完全能合并成一个函数。只是在递归时的返回值取负。(在使用负极大值函数和之后介绍的算法时,对叶节点评分时需要考虑这是玩家层还是ai层,同样的棋局对玩家来说和对ai来说评分是相反的)
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剪枝算法:
假设你要从对方多个口袋中挑选一件物品拿走。规则是你挑选一个口袋,对方从该口袋中选择一样给你的物品。你希望得到尽量好的物品,对方希望给尽量差的。也就是你要挑选最合适的口袋,对方挑选出其中最差的。
当查看第一个口袋时,有价值5、8、10的物品。当查看第2个口袋时,看到一件价值3的物品,低于价值5。那么第二个口袋余下物品就没有查看价值了。
如下图。对于右边玩家方节点,只有值大于2才可能取代根节点的2。但子节点已经有一个1,由于玩家节点会选择最小评分的子节点作为自身评分,自身最大只可能为1。所以余下节点已经没有计算的必要。
Alpha-Beta剪枝用于裁剪搜索树中没有意义的不需要搜索的树枝,以提高运算速度。假设α为下界,β为上界,对于α ≤ N ≤ β:
若 α ≤ β 则N有解。(该节点还有继续计算的价值)
若 α > β 则N无解。(停止该节点剩余内容的计算,也不会再更新α、β值)
α-β初始为负无穷到正无穷,算法过程中不断的缩小α-β范围。Max层增加本节点的α,Min层减小本节点的β。
一个节点的初始α、β值来自于父节点。父节点如果还没有相应值,子节点的相应值则是初始的正负无穷。
对于ai圆节点(Max层),希望挑选子节点中的最大节点。所以会更新最小值α(因为每得到一个符合条件的更大的值时,以后的新值必须更大才会被选择)。新的最小值α来自于已经计算的子节点(中的最大值)。但最大不能大于于父节点的β。
对于玩家方节点(Min层),希望挑选子节点中的最小节点。所以会更新最大值β。新的最大值β来自于已经计算的子节点(中的最小值)。但最小不能小于父节点的α。
极大极小算法的剪枝算法:
int Max(int depth, int alpha, int beta)
{
int best = -INFINITY;//横线表示原极大极小值算法有,这里删除
if (depth <= 0)
{
return Evaluate();
}
GenerateLegalMoves();
while (MovesLeft())
{
MakeNextMove();
val = Min(depth - 1, int alpha, int beta);
UnmakeMove();
if (val > beta)
{
return beta;
}
if (val > alpha)
{
alpha = val;
}
}
return best;
}
int Min(int depth, int alpha, int beta)
{
int best = INFINITY;
if (depth <= 0)
{
return Evaluate();
}
GenerateLegalMoves();
while (MovesLeft())
{
MakeNextMove();
val = Max(depth - 1);
UnmakeMove();
if (val < alpha)
{
return alpha;
}
if (val < beta)
{
beta = val;
}
}
return best;
}
负值最大函数的剪枝算法:
搜索中传递两个值,第一个值是Alpha,即该节点目前搜索子节点得到的最好值,体现在if (val > alpha) {alpha = val;}
第二个值是beta,来自父节点,如果某个着法的结果大于或等于Beta,那么该结点就作废了(不会被父节点选择,不用再继续计算),体现在:if (val >= beta) {return beta;}
代码:
int AlphaBeta(int depth, int alpha, int beta)
{
if (depth == 0)
{
return Evaluate();
}
GenerateLegalMoves();
while (MovesLeft())
{
MakeNextMove();
val = -AlphaBeta(depth - 1, -beta, -alpha); //Alpha和Beta是不断互换的。当函数递归时,Alpha和Beta不但取负数而且位置交换了
UnmakeMove();
if (val >= beta)
{
return beta;
}
if (val > alpha)
{
alpha = val;
}
}
return alpha;
}
渴望算法:
对Alpha-Beta剪枝算法,剪掉的枝较多时则效率较高,当剪掉的枝较少时则效率较低。如何能剪掉更多的枝呢?渴望算法就是对Alpha-Beta剪枝算法的进一步优化。
渴望算法:
在Alpha-Beta剪枝算法中,我们不断的缩小α- β的范围,最终得到需要的值。如果一开始就知道最终的值,就可以大大提高剪枝的效率。当然这是不可能的。但我们可以设定一个不太大的范围,并使这个范围有较大概率包含最终值。这样我们可以根据这个范围进行剪枝从而大大提高效率。
步骤:
1:进行一次深度为N-1的搜索(N为需要的搜索深度),得到结果X
2: 得到值可能的范围(X-w/2,X+w/2),w为设定的窗口宽度。
3:根据范围进行剪枝搜索,并对值超出范围的情况进行处理。
下例中的current的作用:普通的Alpha-Beta剪枝算法是先判断是否发生了Alpha>Beta,如果发生了就进行剪枝并且此时不会更新Alpha、Beta的值。而渴望算法则是在剪枝前先用current记录下了Alpha。如果最终该着法被选中,但current却超过了选定的范围,则证明选定的范围不合理,需要选一个新范围。
代码:
void AspirationSearch()
{
int X;//n-1层的最佳得分
int current;//用于判断当前所选区域是否合适
X = FAlphaBeta(N - 1, -INFINITY, INFINITY);
current = FAlphaBeta(N, X - 50, X + 50);//这里窗口范围设置为了100,可根据自己需要定
if (current < X - 50)//如果current小于窗口最小值,设(-INFINITY, X - 50)为新范围
{
FAlphaBeta(searchDepth, -INFINITY, X - 50);
}
else if (current > X + 50)//如果current大于窗口最大值,设(X + 50, INFINITY)为新范围
{
FAlphaBeta(searchDepth, X + 50, INFINITY);
}
}
int FAlphaBeta(int depth, int alpha, int beta)
{
int current = -INFINITY;//从负无穷变大,记录最大值。用于和假定的窗口比较,判断当前所选区域是否合适
if (depth <= 0)
{
return Eveluate();//达到搜索深度时进行评分,这个例子忽略了未达搜索深度将帅就死亡的情况
}
GenerateLegalMoves();//得到该棋局所有可能下法
while (MovesLeft())//遍历全部着法
{
val = -FAlphaBeta(depth - 1, -beta, -alpha);
if (val > current)
{
current = val;
if (val > alpha)
{
alpha = val;
if (depth == searchDepth)
{
bestMove = gameManager.movingOfPiece.moveList[depth, i];
}
}
if (alpha >= beta)
{
break;
}
}
}
return current;
}