Tic-Tac-Toe minimax AI 在游戏结束后无法正常工作递归问题,它们在哪里?

问题描述

我设法为我的井字游戏获得了一个非常简单的 AI,但仅限于最后一步,当它是两个之间的明显选择时——我可以让算法在一个剩余的空间中放置一个标记,看看是否有一个赢家,如果没有,清除板子,把它放在另一个空间,看看是否有赢家,然后返回正确的选择。

但是,当我尝试进行递归时,有些地方无法正常工作。你能看一下并告诉我它在哪里崩溃吗?我小心翼翼地通过了,这对我来说似乎是正确的。它也很慢,所以我一定是在某个地方搞砸了。


function bestaimove() {
  var smartAIArray = listemptySpaces();
  let bestscore = -100000
  var move;
  for (var i = 0; i < smartAIArray.length; i++) {
    let smartAIpicked = smartAIArray[i];
    origBoard[smartAIpicked].classList.add(TWO_CLASS);
    origBoard[smartAIpicked].innerHTML = TWO_CLASS;
      let score = minimax(origBoard)
      origBoard[smartAIpicked].classList.remove(TWO_CLASS);
      origBoard[smartAIpicked].innerHTML = "";
  if (score > bestscore) {
    bestscore = score;
    move = smartAIpicked;
    console.log(move)
  } 
}
origBoard[move].classList.add(TWO_CLASS);
origBoard[move].innerHTML = TWO_CLASS;
}





function minimax() {
  if (playerhasWon() &&  playerOneTurn) {
    return -10;
  } else if (playerhasWon() && !playerOneTurn) {
    return 10;
  } else if (emptySpaceRemains() == false) {
    return 0;
  }
  swapTurns()

// the recursive part from here down is the problem

  if (!playerOneTurn) {
    let bestscore = -100000; 
    var smartAIArray = listemptySpaces();
    for (var i = 0; i < smartAIArray.length; i++) {
      let smartAIpicked = smartAIArray[i];
      origBoard[smartAIpicked].classList.add(TWO_CLASS);
      origBoard[smartAIpicked].innerHTML = TWO_CLASS;
        let score = minimax(origBoard)
        origBoard[smartAIpicked].classList.remove(TWO_CLASS);
        origBoard[smartAIpicked].innerHTML = "";
        if (score > bestscore) {
          bestscore = score}
      }
    return bestscore;
    }
   else {


    let bestscore = 100000; 
    var smartAIArray = listemptySpaces();
    for (var i = 0; i < smartAIArray.length; i++) {
      let smartAIpicked = smartAIArray[i];
      origBoard[smartAIpicked].classList.add(ONE_CLASS);
      origBoard[smartAIpicked].innerHTML = ONE_CLASS;
        let score = minimax(origBoard)
      origBoard[smartAIpicked].classList.remove(ONE_CLASS);
      origBoard[smartAIpicked].innerHTML = "";
      if (score < bestscore) {
        bestscore = score}
      }
    return bestscore;
  }
}

如果我只是将代码限制为 bestaimove 和计算 if 中的分数的 minimax() 语句,它的行为。之后的一切都是一场噩梦,尽管它基本上是 bestaimove()

代码的嵌套版本
  • bestaimove() 中循环遍历称为 smartAIArray 的空格;
  • 更改 origBoard 以在 [i] 空格中放置标记
  • 通过 minimax() 评估分数;
  • 如果没有分数,运行swapTurns(),然后转向else的{​​{1}}部分;
  • 再次转到 listemptySpaces()。运行另一个 for 循环。再次在 [i] 空格中放置一个标记
  • 再次通过minimax评估分数;
  • 如果没有分数,运行minimax();
  • 然后从 swapTurns()if (!playerOneTurn) 部分开始;
  • 重复,直到获得分数;
  • 该分数被放在 bestaimove 中;
  • 用于测试点的标记被移除;
  • 将评估下一个空白空间;
  • 得分最高的空白区域是标记所在的位置
  • 相应的空间将被称为 minimax 并代表 AI 移动的位置

我错过了什么?谢谢!

解决方法

概述

我对此很感兴趣。我最初的编程经验之一是在 TRS-80 上用旧行号 BASIC 编写井字游戏。 (是的,我已经那么老了!)我想我在此后的四十年里从未尝试过写一篇。具有递归、一流函数等功能的现代语言使这个项目比 13 岁的我更容易。

下面尝试为一个简单的棋盘编写一个合理的极小极大函数,例如表示为 ['1','X','3','4','5','O','7','8','9'] 和表示为 'X''O' 的玩家。我们的想法是,该模型可以轻松用作您选择显示和与游戏交互的任何用户界面的基础。

设计

我们使用了一些在简单数据类型上运行的小型——甚至很小的——单一用途的函数。所有输出数据都是不可变的。没有一个是全球性的。我们只是将必要的项目传递给每个函数,并返回结果而不是修改全局对象。此代码根本不涉及用户界面。一个完整的系统将在此处的功能之上分层。

代码

const randomChoice = (xs) =>
  xs [Math .floor (Math .random () * xs .length)]

const move = (player,square,board) => 
  board .map (x => x === square ? player : x)

const availableMoves = (board) => 
  board .filter (x => /\d/ .test (x))

const checkWin = ((wins) => (player,board) =>
  wins .some (squares => squares .every (square => board [square - 1] == player))
) ([[1,2,3],[4,5,6],[7,8,9],[1,4,7],[2,8],[3,6,7]])

const score = (board) =>
  checkWin ('X',board) ? 10 : checkWin ('O',board) ? -10 : 0

const flipPlayer = (player) =>
  player == 'X' ? 'O' : 'X'

const compare = (test) => (best,{move,score}) =>
  test (score,best [0] .score) 
    ? [{move,score}] 
  : score == best [0] .score 
    ? [...best,score}] 
    : best

const bestMoves = (player,options) => 
  options .reduce (
    compare (player === 'X' ? (a,b) => a > b : (a,b) => a < b),[{move: null,score: player == 'X' ? -Infinity : Infinity}]
  )

const chooseBest = (player,options) => 
  randomChoice (bestMoves (player,options))

const minimax = (player,board,avail = availableMoves (board),val = score(board)) =>
  avail .length === 0 || val !== 0
    ? {move: null,score: val}
    : chooseBest (
        player,avail .map (square => ({
          move: square,score: minimax (flipPlayer (player),move (player,board)) .score
        }))
      )

const parseBoard = (str) => 
  str .split ('\n') .filter (Boolean) .map (s=> s .trim ()) .flatMap (x => x .split (' '))

const results = minimax ('X',parseBoard (`
  1 X 3
  4 5 O
  7 8 9
`)) //=> randomly chooses between {move: 3,score: 10} and {move: 5,score: 10}

console .log (results)

实用功能

第一个函数是一个通用的效用函数:

  • randomChoice 随机选择数组中的一个元素。我们将在下面使用它使 AI 玩家更有趣,从导致相同结果的所有移动中随机选择。

辅助函数

接下来的几个函数是通用的井字棋函数,它们既需要编写极小极大函数,也可能用于玩游戏。它们中的任何一个都没有错误检查。那仍然需要分层。

  • move 为棋盘上的当前玩家执行给定的移动,返回一个全新的棋盘。这对设计很重要。我们从不改变我们的输入,只创建它的新版本。

    例如,如果 board 看起来像这样:

      1 X 3
      4 5 O
      7 8 9
    

    然后 move('X',board) 将返回新板

      1 X 3
      4 5 O
      X 8 9
    

    保持旧板完好无损。

  • availableMoves 只需找到棋盘表示中的数字方块。在上一步之后,availableMoves (board) 将返回 ['1','9']

  • checkWin 接受一个玩家和一个棋盘,并报告该玩家是否已经获胜。它通过简单地在井字棋盘上测试八种可能的胜利(三个水平,三个垂直和两个对角线)来实现这一点。

  • score 返回游戏分数,使用 +10 代表“X”,-10 代表 O,以及0 否则。我们首先检查 X,如果有人创建了一个棋盘上的两个玩家都赢了,这将导致 X 赢。

  • flipPlayer 仅在给定 'O' 时返回 'X',反之亦然。

测试功能

最后的函数是

  • parseBoard,它只是在测试中使用,以便于查看电路板结构。它将布局的字符串转换为我们的数组模型。也就是说,它变成

      1 X 3
      4 5 O
      7 8 9
    

    进入['1','9']

其余函数专门用于构建 minimax,但在我们讨论它们之前,这里有两个函数,我们可能希望方便地开发和测试系统的其余部分。上面不需要它们:

const display = (board) => [
  board .slice (0,3) .join (' '),board .slice (3,6) .join (' '),board .slice (6,9) .join (' ')
] .join ('\n')

const emptyBoard = () =>
  '123456789' .split ('')
  • display 将棋盘变成合理的输出,逆转 parseBoard 的作用。也就是说,display (['1','9'] 会产生

      1 X 3
      4 5 O
      X 8 9
    
  • emptyBoard 只是生成一个可用于开始游戏的空白板。

核心功能

其中的核心是 minimax,它接受一个玩家和一个棋盘,并返回一个 {move,score} 对象,该对象表示所选的移动以及它通过以下最佳选择获得的分数每个玩家。如果首先找到棋盘上的当前分数和可用动作列表。如果没有棋步,或者棋盘上已经赢了,它只会返回 null 棋步和当前分数。否则,它会找到可用的移动,并为每个移动计算当前玩家移动到该方格后对方玩家的最小最大结果。从那些得分最高的人中随机选择一个并返回。最后一个由 chooseBest 处理,它是 bestMoves 的一个小包装,只需调用 bestMoves 来查找顶部移动和randomChoices 选择一个。

bestMoves 可能是这里最复杂的块。它是移动列表上的折叠 (.reduce(...)),使用由 compare 生成的归约函数和具有 null 移动的初始值和正或负的无限分数,取决于玩家。我们传递给 compare 的测试函数也基于我们正在检查的玩家。 compare 检查每个候选值是否优于、等于或低于我们当前的值列表,并分别返回仅包含候选值的列表、添加了候选值的当前列表或仅返回当前列表。

如果不清楚发生了什么,这是对我原始版本的 bestMoves 的重构 - 删除重复 - 内联了 compare 等效项。它看起来像这样:

const bestMoves = (player,options) => 
  options .reduce (
    (best,score}) => 
      player == 'X' 
        ? (score > best [0] .score ? [{move,score}] : score == best [0] .score ? [...best,score}] : best)
        : (score < best [0] .score ? [{move,score}] : best),score: player == 'X' ? -Infinity : Infinity}]
  )

性能

在您的版本中,您发现它在空板上表现不佳。几乎可以肯定这是由于所涉及的 DOM 操作。如果您在每次测试中都更改 HTML 板,那肯定会减慢速度。

在这里,我们只操作单字符串数组。在我对中级笔记本电脑的测试中,这将在 600 - 800 毫秒内解决空板问题。上面的示例中,已经填充了两个正方形所需的时间不到十分之一秒。 (通常 60 - 70 毫秒。)

课程

  1. 将问题分解为更小的部分会使代码更易于理解。随您进行测试更容易,并且更容易隔离问题。请注意,在解决此处的 minimax 问题时,我使用了十个自定义函数,其中只有一个 randomChoice,这对我们井字棋系统之外的任何事物都有好处。但是 moveavailableMovescheckWinscoreflipPlayer 都可能在代码库的其他地方使用。

    因为每个函数都做一个小的专用工作,所以它们很容易测试。但他们甚至没有真正添加代码。整个代码段的大小与您的 minimax 版本相同。

  2. 将数据模型与其显示方式分开可使代码更加健壮。数据模型不必很复杂。这里我们只使用了一个包含 9 个单字符串的数组来表示棋盘,并使用 'X''O' 来表示玩家。我们最复杂的模型项是调用 minimax 和对象的结果,该对象具有从板中提取的 move 属性和数字 score 属性。在使用这样的模型时,我们可以轻松编写简单的函数来操作它们。在此之上编写一个用户界面会很简单。

  3. 对于不可变数据有很多话要说。无需跟踪我们所做的更改并在正确的时间撤消它们,因此了解我们系统的状态要简单得多。甚至我们的递归调用也应该足够清楚。

  4. 使用内置的数组方法可以使代码更具声明性。 for 循环几乎是不必要的,在这样的代码中,所有函数都没有副作用,它们只会分散注意力。请注意,我们在 .map 中使用了 move,在 minimax 中使用了 .filteravailableMoves.some 中的 >.everycheckWin.reduce 中的 bestMoves,以及在 parseBoard 中,我们使用了三种这样的方法,.filter.map.flatMap强>。在每种情况下,这些都可以用 for 循环重写。但在任何情况下,这都会使代码膨胀并使更难理解。