五子棋AI优化:α-β剪枝
我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第7篇文章,点击查看活动详情
本文首发于我的个人博客:xeblog.cn/articles/76
前言
回顾上文,我们介绍了 极大极小值搜索
算法的实现原理,使得我们的 AI
可以进行更深层次的思考,棋力突飞猛进。但此时,我们又发现了一个新的问题,由于 博弈树
的分支太多,计算量太过庞大,从而使得 AI
的响应时间变长,原本思考一步的 AI
在毫秒之间就能计算出最佳落子点,现在思考两步的 AI
需要花费好几秒的时间才能找出最佳走步。棋力虽然是提升了很多,但面对这么长的耗时,我们能否做一些优化呢?
有些人可能就会说了,你特么标题不都写了吗?还问我?
所以,本文将介绍一种优化算法:α-β剪枝
。
Alpha-beta剪枝是一种搜索算法,用以减少极小化极大算法(Minimax算法)搜索树的节点数。这是一种对抗性搜索算法,主要应用于机器游玩的二人游戏(如井字棋、象棋、围棋)。当算法评估出某策略的后续走法比之前策略的还差时,就会停止计算该策略的后续发展。该算法和极小化极大算法所得结论相同,但剪去了不影响最终决定的分枝。—— 百度百科
α-β剪枝
算法实现原理
这个算法的实现原理并不难,甚至还特别简单。在讲解算法之前,我们先搞清楚一个问题:下图中,博弈树 A1
分支的深度优先遍历(后序遍历)的过程是怎样的?
只要搞清楚这个问题,后面讲解算法的时候就不会被弄糊涂啦。
上图描绘出了 A1
访问左子树 B1
的过程。标了数字的节点表示的是处理的顺序,优先处理最深层的节点,然后将深层节点的处理结果返回给上一层的节点,如果这一层还有节点,则继续处理,直至所有节点都处理完毕。
我们回顾一下之前 A1
分支 极大极小值搜索
的过程:
B5 ~ B12
节点是 AI
和对手交替落子第四步后所形成的局面,局面对应的评分都标在了节点的下方。我们都知道,B5 ~ B12
的分支都是 Min
分支,Min
分支会将得分最低的节点返回给上层的 Max
分支,所以 A3 ~ A6
分支的得分都是对手给的最低分。接下来,轮到 A3 ~ A6
分支做选择,Max
分支会将得分最高的节点返回给上层的 Min
分支,也就是说 B1 ~ B2
分支的得分是从 A3 ~ A6
分支里挑选出来的最高分,然后 B1 ~ B2
分支又会将最小的得分返回给 A1
。
在 极大极小值搜索
的过程中,其实有些分支是可以不用访问的,比如上图中 A4
不用访问 B8
分支,B2
不用访问 A6
分支,为什么呢?
我们下面利用 α-β剪枝
算法来解释为什么有些节点不用访问。
定义变量:Max
分支挑选出来的最高分记为 α
;Min
分支挑选出来的最低分记为 β
。初始时, α
值为无穷小 -∞
,β
值为无穷大 +∞
。
从 A1
分支开始,将 α
和 β
变量向下传递,如图所示
我们先从 Max
分支 A3
看起,A3
下一步会访问 Min
分支的 B5
节点,并且将 α
和 β
变量向下传递到 B5
,如图所示
此时,B5
计算出了局面评分为 4
,将这个评分和 β
进行比较,如果评分 < β
就更新 β
的值为当前评分。因为 4 < +∞
,所以当前的 β
值被更新为了 4
,如图所示
继续访问 B6
节点。此时,B6
节点评分为 2
,与当前 β
值进行比较, 因为 2 < 4
满足条件,所以更新 β
值为 2
,如图所示
B5 ~ B6
节点都访问完毕后,要将当前的 β
的值返回给上一层的 Max
分支 A3
,A3
此时需要做一步操作:如果 β > α
,则更新 α
的值为 β
,如图所示
此时,A3
分支的 α
值为 2
,β
值为 +∞
,A3
分支结束,进入到 A4
分支,并将 α
和 β
变量传递,如图所示
A4
下一步会访问 Min
分支的 B7
节点,并且将 α
和 β
变量向下传递到 B7
,如图所示
此时,B7
局面评分为 1
,因为 1 < +∞
,所以当前 β
更新为 1
。此时,α
值为 2
,β
值为 1
。
这时候,我们要做一步操作:如果 α >= β
则结束当前层次的遍历。这就叫做 剪枝
。
因为 α(2) >= β(1)
,所以我们可以跳过B8
节点的访问。
这是为什么呢?
原因很简单,因为 α
储存着当前最大分值,β
储存着当前最小分值,在 Min
分支的时候,一直都在搜索小于 β
值的节点,找到之后会更新 β
值,并返回给 Max
分支,如果 α
此时的值就比 β
要大,那你返回给我的 β
值肯定也是会小于我当前的 α
值的,你给我我也不会要,因为我要的是比 α
大的值才对。这里说的可能会有点难以理解,我们可以这么理解:
抢劫犯已经从张三的口袋中拿到了1000元现金,张三此时却想要拿《Java从入门到入土》这本破书给他换,你说抢劫犯他能同意吗?
就是这个意思,我手中已经有好东西了,你却想拿一个不值钱的东西给我换?我肯定是不答应的。B5 ~ B6
分支给到 A3
的分数为 2
,B7 ~ B8
分支给到 A4
的分数为 1
,A4
的分数小于 A3
,已经没有选择他的必要了,这个结果在 B7
节点的时候就已经能确定了,所以 B7
之后的节点都不用再去访问了,我们可以剪掉那些不用访问的分支。最后 A4
节点将当前的 α
值返回给了 B1
分支,如图所示
我们再来看 B2
分支,B2
分支将当前的 α
、β
值向下传递到 B9
,如图所示
B9
评分为 4
,由于 4 > 2
,不满足 β
的更新条件,接着访问 B10
,同样也不满足更新条件,最后 β
值还是 2
,将该值返回给 A5
,因为 2 > -∞
,所以需要将当前的 α
值更新为 2
,此时 α(2) >= β(2)
条件成立,又可以跳过后面的节点了,A6
分支可以剪掉。
最后,A5
将当前的 α
返回给 B2
,B2
将当前的 β
返回给 A1
,所以A1
最终的得分为 2
。
有一个地方大家可能还会有疑问:为什么 Min
分支返回给 Max
分支 β
时,Max
分支不是更新 β
而是更新 α
?
回看一下,我们上文对 α
和 β
的定义:
Max
分支挑选出来的最高分记为α
;Min
分支挑选出来的最低分记为β
。初始时,α
值为无穷小-∞
,β
值为无穷大+∞
。
Min
分支返回给 Max
分支的最小分值其实就是当前 Max
分支的分值,所以需要更新的是 α
值,同样的,Max
分支会选择最大的一个 α
值返回给 Min
分支,这个 α
值也就是当前 Min
分支的分值,所以需要更新的是 β
值。我们只需要记住一点:α
值的来源是 β
,β
值的来源是 α
(除了叶子结点,因为叶子结点的值来源于评估函数)。
代码实现
我们再接着上次的代码,稍微修改下之前写的 minimax
方法,新增两个输入参数:alpha
和 beta
,代码改动其实不是很大。
/**
* 极大极小值搜索、AlphaBeta剪枝
*
* @param type 当前走棋方 0.根节点表示AI走棋 1.AI 2.玩家
* @param depth 搜索深度
* @param alpha 极大值
* @param beta 极小值
* @return
*/
private int minimax(int type, int depth, int alpha, int beta) {
// 是否是根节点
boolean isRoot = type == 0;
if (isRoot) {
// 根节点是AI走棋
type = this.ai;
}
// 当前是否是AI走棋
boolean isAI = type == this.ai;
// 到达叶子结点
if (depth == 0) {
/**
* 评估每棵博弈树的叶子结点的局势
* 比如:depth=2时,表示从AI开始走两步棋之后的局势评估,AI(走第一步) -> 玩家(走第二步),然后对局势进行评估
* 注意:局势评估是以AI角度进行的,分值越大对AI越有利,对玩家越不利
*/
return evaluateAll();
}
for (int i = 0; i < this.cols; i++) {
if (alpha >= beta) {
/*
AlphaBeta剪枝
解释:
AI当前最大分数为:alpha 搜索区间 (alpha, +∞]
对手当前最小分数为:beta 搜索区间 [-∞, beta)
因为对手要选择分数小于beta的分支,AI要从对手给的分支里面选最大的分支,这个最大的分支要和当前的分支(alpha)做比较,
现在alpha都比beta大了,下面搜索给出的分支也都是小于alpha的,所以搜索下去没有意义,剪掉提高搜索效率。
*/
break;
}
for (int j = 0; j < this.rows; j++) {
if (this.chessData[i][j] != 0) {
// 该处已有棋子,跳过
continue;
}
/* 模拟 AI -> 玩家 交替落子 */
Point p = new Point(i, j, type);
// 落子
putChess(p);
// 递归生成博弈树,并评估叶子结点的局势
int score = minimax(3 - type, depth - 1, alpha, beta);
// 撤销落子
revokeChess(p);
if (isAI) {
// AI要选对自己最有利的节点(分最高的)
if (score > alpha) {
// 最高值被刷新,更新alpha值
alpha = score;
if (isRoot) {
// 根节点处更新AI最好的棋位
this.bestPoint = p;
}
}
} else {
// 对手要选对AI最不利的节点(分最低的)
if (score < beta) {
// 最低值被刷新,更新beta值
beta = score;
}
}
if (alpha >= beta) {
// 剪枝
break;
}
}
}
return isAI ? alpha : beta;
}
调整入口方法 getPoint
,minimax
方法传入 α
和 β
,α
值初始为 -INFINITY
,β
值初始为 INFINITY
。
@Override
public Point getPoint(int[][] chessData, Point point, boolean started) {
initChessData(chessData);
this.ai = 3 - point.type;
this.bestPoint = null;
this.attack = 2;
if (started) {
// AI先下,首子天元
int centerX = this.cols / 2;
int centerY = this.rows / 2;
return new Point(centerX, centerY, this.ai);
}
// 基于极大极小值搜索获取最佳棋位
minimax(0, 2, -INFINITY, INFINITY);
return this.bestPoint;
}
经过优化后,思考两步棋的 AI
耗时基本控制在 1s
内。
α-β剪枝
算法对节点价值的顺序要求很高,如果价值高的节点都排在后面,那这个算法的优化效果不会太好,所以后续还需要对节点进行价值排序,以便将 α-β剪枝
的效果发挥到极致。
转载自:https://juejin.cn/post/7145080191001231397