问题描述
我设法为我的井字游戏获得了一个非常简单的 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 毫秒。)
课程
-
将问题分解为更小的部分会使代码更易于理解。随您进行测试更容易,并且更容易隔离问题。请注意,在解决此处的
minimax
问题时,我使用了十个自定义函数,其中只有一个randomChoice
,这对我们井字棋系统之外的任何事物都有好处。但是move
、availableMoves
、checkWin
、score
和flipPlayer
都可能在代码库的其他地方使用。因为每个函数都做一个小的专用工作,所以它们很容易测试。但他们甚至没有真正添加代码。整个代码段的大小与您的
minimax
版本相同。 -
将数据模型与其显示方式分开可使代码更加健壮。数据模型不必很复杂。这里我们只使用了一个包含 9 个单字符串的数组来表示棋盘,并使用
'X'
或'O'
来表示玩家。我们最复杂的模型项是调用minimax
和对象的结果,该对象具有从板中提取的move
属性和数字score
属性。在使用这样的模型时,我们可以轻松编写简单的函数来操作它们。在此之上编写一个用户界面会很简单。 -
对于不可变数据有很多话要说。无需跟踪我们所做的更改并在正确的时间撤消它们,因此了解我们系统的状态要简单得多。甚至我们的递归调用也应该足够清楚。
-
使用内置的数组方法可以使代码更具声明性。
for
循环几乎是不必要的,在这样的代码中,所有函数都没有副作用,它们只会分散注意力。请注意,我们在.map
中使用了move
,在minimax
中使用了.filter
、availableMoves
、.some 中的 >.every
和checkWin
,.reduce
中的bestMoves
,以及在parseBoard
中,我们使用了三种这样的方法,.filter
、.map
和.flatMap
强>。在每种情况下,这些都可以用for
循环重写。但在任何情况下,这都会使代码膨胀并使更难理解。