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


theme: fancy

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

回顾

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

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

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

极大极小值搜索算法

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

我要表达的是什么?

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

current_position.png

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

player_important_role.png

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

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

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

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

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

analogy-game.png

你应该为 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 的时候,你要简单考虑所有你可能下子的棋盘位置,然后选择一个最适合你的位置下子。

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

analogy-game.png

要分析的下一步应该是:

next_child01.png
next_child02.png
next_child03.png
next_child04.png

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

改善这点很容易,我们目前寻找的是深度 1 或者 1ply(层通常用来表示深度 — 五层表示深度五)。我们可以通过查看两层,使得我们的人工智能更强大,这就包括你的移动和机器的回应。这就要解释 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

本文正在参加「金石计划 . 瓜分6万现金大奖」

© 版权声明
THE END
喜欢就支持一下吧
点赞8 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容