五子棋AI算法三 Alpha-Beta 剪枝算法

剪枝是必须的

上一篇讲了极大极小值搜索,其实单纯的极大极小值搜索算法并没有实际意义。

可以做一个简单的计算,平均一步考虑 50 种可能性的话,思考到第四层,那么搜索的节点数就是 50^4 = 6250000,在我的酷睿I7的电脑上一秒钟能计算的节点不超过 5W 个,那么 625W 个节点需要的时间在 100 秒以上。电脑一步思考 100秒肯定是不能接受的,实际上最好一步能控制在 5 秒 以内。

顺便说一下层数的问题,首先思考层数必须是偶数。因为奇数节点是AI,偶数节点是玩家,如果AI下一个子不考虑玩家防守一下,那么这个估分明显是有问题的。
然后,至少需要进行4层思考,如果连4四层都考虑不到,那就是只看眼前利益,那么棋力会非常非常弱。 如果能进行6层思考基本可以达到随便赢普通玩家的水平了(普通玩家是指没有专门研究过五子棋的玩家,棋力大约是4层的水平)。

Alpha Beta 剪枝原理

Alpha Beta 剪枝算法的基本依据是:棋手不会做出对自己不利的选择。依据这个前提,如果一个节点明显是不利于自己的节点,那么就可以直接剪掉这个节点。

前面讲到过,AI会在MAX层选择最大节点,而玩家会在MIN层选择最小节点。那么如下两种情况就是分别对双方不利的选择:

  1. 在MAX层,假设当前层已经搜索到一个最大值 X, 如果发现下一个节点的下一层(也就是MIN层)会产生一个比X还小的值,那么就直接剪掉此节点。

解释一下,也就是在MAX层的时候会把当前层已经搜索到的最大值X存起来,如果下一个节点的下一层会产生一个比X还小的值Y,那么之前说过玩家总是会选择最小值的。也就是说这个节点玩家的分数不会超过Y,那么这个节点显然没有必要进行计算了。

通俗点来讲就是,AI发现这一步是对玩家更有利的,那么当然不会走这一步。

  1. 在MIN层,假设当前层已经搜索到一个最小值 Y, 如果发现下一个节点的下一层(也就是MIN层)会产生一个比Y还大的值,那么就直接剪掉此节点。

这个是一样的道理,如果玩家走了一步棋发现其实对AI更有利,玩家必定不会走这一步。

下面图解说明,懒得画图,直接用wiki上的图:

alpha-beta

如上图所示,在第二层,也就是MIN层,当计算到第三个节点的时候,已知前面有一个3和一个6,也就是最小值为3。 在计算第三个节点的时候,发现它的第一个孩子的结果是5,因为它的孩子是MAX节点,而MAX节点是会选择最大值的,那么此节点的值不会比5小,因此此节点的后序孩子就没有必要计算了,因为这个节点不可能小于5,而同一层已经有一个值为3的节点了。

其实这个图里面第三层分数为7的节点也是不需要计算的。

这是 MAX 节点的剪枝,MIN节点的剪枝也是同样的道理,就不再讲了。 Alpha Beta 剪枝的 Alpha 和 Beta 分别指的是MAX 和 MIN节点。

代码实现

虽然原理说了很多,但是其实代码的实现特别简单。

对max和min函数都增加一个 alphabeta 参数。在 max 函数中如果发现一个子节点的值大于 alpha,则不再计算后序节点,此为 Alpha 剪枝。在 min 函数中如果发现一个子节点的值小于 beta,则不再计算后序节点,此为 Beta剪枝。

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
var min = function(board, deep, alpha, beta) {
var v = evaluate(board);
total ++;
if(deep <= 0 || win(board)) {
return v;
}

var best = MAX;
var points = gen(board, deep);

for(var i=0;i<points.length;i++) {
var p = points[i];
board[p[0]][p[1]] = R.hum;
var v = max(board, deep-1, best < alpha ? best : alpha, beta);
board[p[0]][p[1]] = R.empty;
if(v < best ) {
best = v;
}
if(v < beta) { //AB剪枝
ABcut ++;
break;
}
}
return best ;
}


var max = function(board, deep, alpha, beta) {
var v = evaluate(board);
total ++;
if(deep <= 0 || win(board)) {
return v;
}

var best = MIN;
var points = gen(board, deep);

for(var i=0;i<points.length;i++) {
var p = points[i];
board[p[0]][p[1]] = R.com;
var v = min(board, deep-1, alpha, best > beta ? best : beta);
board[p[0]][p[1]] = R.empty;
if(v > best) {
best = v;
}
if(v > alpha) { //AB 剪枝
ABcut ++;
break;
}
}
return best;
}

优化效果

按照wiki上的说法,优化效果应该达到 1/2 次方,也就是能优化到 50^2 = 2500 左右,实际我测试的时候并没有这么理想。不过节点数也不到之前的十分之一,平均大约每一步计算 50W 个节点,需要时间在10秒左右。相比之前的600W节点已经有了极大的提升。

不过即使经过了Alpha Beta 剪枝,思考层数也只能达到四层,也就是一个不怎么会玩五子棋的普通玩家的水平。而且每增加一层,所需要的时间或者说计算的节点数量是指数级增加的。所以目前的代码想计算到第六层是很困难的。

我们的时间复杂度是一个指数函数 M^N,其中底数M是每一层节点的子节点数,N 是思考的层数。我们的剪枝算法能剪掉很多不用的分支,相当于减少了 N,那么下一步我们需要减少 M,如果能把 M 减少一半,那么四层平均思考的时间能降低到 0.5^4 = 0.06 倍,也就是能从10秒降低到1秒以内。

而这个M是怎么来的? 其实 M 就是函数 gen 返回的那些可选的空位。其实gen函数有很大的优化空间,而这个优化后的gen函数其实就是启发式搜索函数