原文链接 ​​Minimax for Gomoku (Connect Five)​​ -- 作者 ​​Ofek Gila​​

回顾

不知道你是否还记得​​上一篇文章​​,我们使用深度优先搜索算法来解决井字棋游戏,递归所有可能的分支,然后找到最佳的游戏结果。因为我们是自底向上搜索,我们能够判断每一步棋是赢、输或者平局,为每位玩家下出最佳的一步棋。这使得解决方案非常简单,原因如下:

  1. 我们不需要存储或者创建任何类型的游戏树
  2. 我们只需要检测输赢(这在学习其它算法后会更清晰)

然而,它的主要缺陷让它无法用于稍微复杂的游戏 -- 它的复杂度随着分支因素和深度呈几何级别数地递增,这使得在普通电脑上运行四目游戏(Connect Four)需要数年时间。

极大极小值搜索算法

这个问题最基本的解决方法其实就是深度优先算法的另一种形式,这次我们只是搜索到树一定的深度,而不是一直搜索到游戏的结束(即树的底部)。这给 ​​minimax​​ 带来了额外的复杂性,因为需要一个评估函数 ​​Evaluation Function​​ 来评估这个位置的好坏。你可能需要根据自己编写的启发式评估函数的输出返回 ​​0.8​​​, ​​-0.25​​​ 或者 ​​0.001​​​,而不是根据游戏输赢或者平局来返回 ​​1​​​,​​-1​​​ 或者 ​​0​​。

我要表达的是什么?

用下面的井字棋游戏作为例子:

极大极小值算法应用于五子棋_搜索

不管现在轮到谁,​​X​​​ 将会赢下该局。分析函数 ​​analysis function​​​ 应该为 ​​X​​ 返回一个正值。但是,玩家的回合在分析功能中仍然起着很重要的角色。比如:

极大极小值算法应用于五子棋_JavaScript_02

如果不看玩家的回合,局面看起来完全平局,但知道下一步是 ​​X​​​ 开始,很明显 ​​X​​​ 可以获胜。我们的评估函数应该反映这一点,并为 ​​X​​ 提供非常高的正积分,类似于第一个位置的分数。

你应该对如何为五子棋的位置得分有了某种形式的想法。它应该考虑以下因素:

  1. 你在一行中控制了多少组连续的方块
  2. 每组有多长
  3. 轮到谁
  4. 每组包含多少个开口端(例如:如果你控制了两个连续的位置,但是没有开放端,这两个位置就不可能连成五子,因此不应该获得任何分数)

要注意的是,你应该计算所有方向(垂直/水平/对角线)的组合,以便为不同的组合计算一些方块。

我知道这很难形象化,所以我们再次使用井字棋来类比:

极大极小值算法应用于五子棋_分析函数_03

你应该为 ​​X​​ 计算下面集合:

  • 水平
  1. 一个 ​​X​​,且只有一个开端
  2. 两个 ​​X​​,但是没有开端
  • 垂直
  1. 两个 ​​X​​,且只有一个开端
  2. 一个 ​​X​​,且只有一个开端
  • 对角线(左上角到右下角)
  1. 两个 ​​X​​,但是没有开端
  2. 一个 ​​X​​,且有两个开端
  • 对角线(右上角到左下角)
  1. 一个 ​​X​​,但是没有开端
  2. 一个 ​​X​​,且只有一个开端
  3. 一个 ​​X​​,且只有一个开端

对于五子棋来说,我们可以使用类似的方法实现:

function gomokuShapeScore(consecutive, openEnds, currentTurn) {
if (openEnds == 0 && consecutive < 5)
return 0;
switch (consecutive) {
case 4:
switch (openEnds) {
case 1:
if (currentTurn)
return 100000000;
return 50;
case 2:
if (currentTurn)
return 100000000;
return 500000;
}
case 3:
switch (openEnds) {
case 1:
if (currentTurn)
return 7;
return 5;
case 2:
if (currentTurn)
return 10000;
return 50;
}
case 2:
switch (openEnds) {
case 1:
return 2;
case 2:
return 5;
}
case 1:
switch (openEnds) {
case 1:
return 0.5;
case 2:
return 1;
}
default:
return 200000000;
}
}

正如你所看到的,这个函数考虑到了连续数 ​​consecutive​​​,开端数 ​​openEnds​​​ 还有轮到谁下棋 ​​currentTurn​​​。轮到你的时候,给到 ​​4​​​ 子开端的分数值比对手的高很多。还要注意的是,要为没有开端口的情况设置 ​​0​​ 分。这样的权重分其实是根据实战经验选择的。

这里还有一个重要的环节错过了 -- 找到所有的集合并将它们传递给集合评分函数。如下面所示,我们考虑应用在水平集合中:

function analyzeHorizontalSetsForBlack(current_turn) {
var score = 0;
var countConsecutive = 0;
var openEnds = 0;

for (var i = 0; i < board.length; i++) {
for (var a = 0; a < board[i].length; a++) {
if (board[i][a] == 'B')
countConsecutive++;
else if (board[i][a] == ' ' && countConsecutive > 0) {
openEnds++;
score += gomokuShapeScore(countConsecutive, openEnds, current_turn == 'black');
countConsecutive = 0;
openEnds = 1;
}
else if (board[i][a] == ' ')
openEnds = 1;
else if (countConsecutive > 0) {
score += gomokuShapeScore(countConsecutive, openEnds, current_turn == 'black');
countConsecutive = 0;
openEnds = 0;
}
else openEnds = 0;
}
if (countConsecutive > 0)
score += gomokuShapeScore(countConsecutive, openEnds, current_turn == 'black');
countConsecutive = 0;
openEnds = 0;
}
return score;
}

当然,这是其中一种可能的实现方式,绝不是最快的一种。另外,它只是分析了黑子和水平的方向 -- 真正情况下应该考虑黑白子和所有的方向。你可以将一个玩家的点数减去或者除以另一个玩家的点数。然而,这个方法仍然需要你汇总所有集合所需的函数类型。

现在,我们可以构建我们的分析函数了,我们仍需要使用 ​​minmax​​ 算法去实现它。正如回顾那样,这个方法类似深度优先搜索,因为我们尝试逐个分支让玩家最大化它们的结果,然而,这里我们只是遍历到一定的深度,而不是遍历到游戏结束,我们使用分析函数来判断位置的优劣。

深度 ​​1​​ 的时候,你要简单考虑所有你可能下子的棋盘位置,然后选择一个最适合你的位置下子。

给定上面相同的位置作为例子:

极大极小值算法应用于五子棋_分析函数_03

要分析的下一步应该是:

极大极小值算法应用于五子棋_分析函数_05

极大极小值算法应用于五子棋_JavaScript_06

极大极小值算法应用于五子棋_JavaScript_07

极大极小值算法应用于五子棋_前端_08

需要注意,在你分析过程中 ,你要假设是对手回合,而不是你的回合!这将使得第三种情况最有利,因为在其它的情况中,​​X​​ 有一个连续的两子和一端空位(因为这也轮不到它们,虽然你的分析函数可以给出很高的分数)。在这个情况下,你已经成功阻止了它们的胜利。

改善这点很容易,我们目前寻找的是深度 ​​1​​​ 或者 ​​1​​​ 层 ​​ply​​(层通常用来表示深度 -- 五层表示深度五)。我们可以通过查看两层,使得我们的人工智能更强大,这就包括你的移动和机器的回应。这就要解释 ​​Minimax​​ 这个名字,当你尝试最大化你的分数时,你的对手正在尝试最小化你的分数 -- 在对手所有最小的回应中,你选择最大值,也就是最适合你的一个位置,然后下该位置的子。你尝试从对手的最小值中获得最大值。当然,增加两层以上是微不足道的,因为你需要做更多相同的事情。

下面是用 ​​javascript​​ 编写的一个很基本的例子:

function bestGomokuMove(bturn, depth) {
var color = bturn ? 'B':'W';
var xBest = -1, yBest = -1;
var bestScore = bturn ? -1000000000:1000000000;
var analysis, response;
var analTurn = depth % 2 === 0 ? bturn:!bturn;
var moves = get_moves();

for (var i = moves.length-1; i > moves.length - aiMoveCheck - 1 && i >= 0; i--) {
board[moves[i][1]][moves[i][2]] = color;
if (depth == 1)
analysis = analyzeGomoku(analTurn);
else {
response = bestGomokuMove(!bturn, depth - 1);
analysis = response[2];
}
board[moves[i][1]][moves[i][2]] = ' ';
if ((analysis > bestScore && bturn) || (analysis < bestScore && !bturn)) {
bestScore = analysis;
xBest = moves[i][1];
yBest = moves[i][2];
}
}

return [xBest, yBest, bestScore];
}

正如你所看到的,在每一层中,玩家都尝试最大化它们的收益,然后最小化对手的收益。你会注意到此算法和​​上一篇文章​​中的深度优先算法很类似。

你可以使用这种极大极小值算法来构建一个相当合理的 ​​AI​​,但是还有很多需要改进的地方。我们在后面的文章再讲。你可以尝试玩下我自己的 ​​Gomoku AI​​。