带有 JavaScript 的井字游戏:带有 Minimax 算法的 AI 玩家
Tic-Tac-Toe with JavaScript: AI Player with Minimax Algorithm | Ali Alaa - Front-end Web Developerhttps://alialaa.com/blog/tic-tac-toe-js-minimax
在上一部分中,我们为井字游戏棋盘创建了一个 JavaScript 类。让我们在这部分学习如何在 JavaScript 中实现极小极大算法,以便为计算机玩家获得最佳移动。给定一些假设和深度(要计算的转数),我们将计算每个可能的移动的启发式值。
这是 3 部分系列的第二部分。您可以在下面找到其他部分的列表:
- 第 1 部分:构建井字游戏棋盘
- 第 2 部分:使用 Minimax 算法的 AI 玩家
- 第 3 部分:构建用户界面
要创建 AI 玩家,我们需要模仿人类在玩井字游戏时的想法。在现实生活中,人类会考虑每一步的所有可能后果。这就是极小极大算法派上用场的地方。minimax 算法是一种决策规则,用于确定游戏中的最佳可能移动,其中所有可能的移动都可以预见,如井字游戏或国际象棋。
井字游戏中的极小极大算法
为了在两人游戏中应用极小极大算法,我们将假设 X 是最大化玩家,O 是最小化玩家。最大化玩家将尝试最大化他的分数,或者换句话说,选择具有最高价值的移动。最小化玩家将尝试最小化最大化玩家的价值,从而选择具有最小值的移动。
为了计算上述值,我们需要做出一些假设。我们将这些值称为启发式值。在井字游戏中,我们有 3 种可能性:
- 棋盘状态为平局:我们将给这个棋盘赋值为 0;
- X 在棋盘状态下获胜:我们给这个棋盘赋值 100;
- O 在棋盘状态下获胜:我们将给这个棋盘一个值-100;
带有博弈树的极小极大示例
为了更详细地说明极小极大算法,让我们看一个视觉示例。在下图中,考虑在当前状态下轮到 X 的情况。X 有三种可能的移动方式:
- 级别 1: X 有三个可能的移动,并试图找到最大节点。
- 级别 2:第一步导致 X 直接获胜,因此给予 100 分。
- 级别 2:第二步和第三步将导致轮到 O 的另外两个可能的步。
- 级别 3: O 试图最小化分数,因此它选择具有最小值的节点。
- 级别 3: O 的第一步将导致获胜,第二步将导致平局,因此我们假设 O 将选择第一步并且父节点的值为 -100。第三步和第四步也一样。
- 回到第 1 级,X 现在必须在 100、-100 和 0 之间进行选择。由于 X 是最大化者,它肯定会选择 100,这将导致获胜。
正如您所注意到的,我们递归地传播可能性树,计算每个终端状态的分数,然后返回决定我们将采取的行动。
增加计算的深度
现在想象一种情况,X 可以通过两种可能的方式获胜,但一种方式比另一种方式需要更多的动作。如果我们遵循当前的实现,两个动作都将返回 100 分。然后我们将随机选择动作;但是,如果我们直接选择最短的获胜方式会更好。
为了解决这个问题,我们将从棋盘的分数中减去深度或当前级别,以防玩家是最大化玩家,或者将深度添加到分数以防玩家是最小化玩家。这样,对于最大化的玩家,较短的路径将获得更高的分数,因为从中减去了较低的深度,反之亦然,对于最小化的玩家。
如果有更短的放松方式,这种方式也将有助于以更长的方式输掉。这是添加深度后的视觉示例:
在上面的例子中,X 显然会选择 99 而不是 97,因为这是一种更容易获胜的方式。
JavaScript 实现
现在是时候将这个理论转化为代码了。在我们的classes文件夹中,让我们创建一个名为player.js的新文件。Player 类将使用最大深度参数构造。此参数将用于限制计算机在树中传播的深度。这样我们选择的深度越低,游戏就越容易。除此之外,我们将定义一个新地图。此地图的键将保存某个启发式值,并且该值将保存一个逗号分隔的字符串,用于所有导致该值的移动。这样,对于最大化玩家,我们可以选择最高的键,如果有多个值,移动将是值或随机值。
import Board from "./board.js";export default class Player {constructor(maxDepth = -1) {this.maxDepth = maxDepth;this.nodesMap = new Map();}
}
现在让我们为这个类添加一个名为getBestMove()的方法。如前所述,这将是一个递归函数。该函数将接收一个棋盘实例、一个用于决定玩家是最大化还是最小化的布尔值、一个在计算后运行的回调函数(这将在我们构建 UI 时使用)和当前节点的深度。
在我们的函数内部,每个调用都会有不同的深度,具体取决于我们当前所处的级别。为了在主函数调用(即在最顶层而不是递归调用)执行一些操作,我们将检查深度是否等于零。
如果我们正在计算最顶层节点的值,我们将在函数内部做的第一件事是从先前的计算中清除nodesMap映射。
然后我们将添加递归函数的基础。每个递归函数都必须有一个递归停止的基点或点,否则我们可能会以无限递归结束。在我们的例子中,当达到终端状态或深度达到最大深度时,递归将停止。在这种情况下,我们将返回状态的启发式值:
player.js
getBestMove(board, maximizing = true, callback = () => {}, depth = 0) {//clear nodesMap if the function is called for a new moveif(depth == 0) this.nodesMap.clear();//If the board state is a terminal one, return the heuristic valueif(board.isTerminal() || depth === this.maxDepth ) {if(board.isTerminal().winner === 'x') {return 100 - depth;} else if (board.isTerminal().winner === 'o') {return -100 + depth;}return 0;}
}
现在我们将检查是否轮到最大化玩家,然后使用我们在上一部分中创建的getAvailableMoves()方法遍历所有空单元格。在循环内部,我们将创建一个新棋盘并将符号插入到循环中的当前空单元格中,然后递归调用getBestMove(),但这次使用新棋盘,最小化玩家转弯并增加一个深度。之后,我们将函数的输出与当前的最佳值进行比较,并在需要时对其进行更新。仍然在循环内部,我们检查我们是否在顶层,如果是,我们将值存储在nodesMap中。
在循环之外,我们检查我们是否处于顶层并返回对应于最佳值的单元格的索引,如果多个索引具有最佳值,则返回随机索引。
if (maximizing) {//Initialize best to the lowest possible valuelet best = -100;//Loop through all empty cellsboard.getAvailableMoves().forEach(index => {//Initialize a new board with a copy of our current stateconst child = new Board([...board.state]);//Create a child node by inserting the maximizing symbol x into the current empty cellchild.insert("x", index);//Recursively calling getBestMove this time with the new board and minimizing turn and incrementing the depthconst nodeValue = this.getBestMove(child, false, callback, depth + 1);//Updating best valuebest = Math.max(best, nodeValue);//If it's the main function call, not a recursive one, map each heuristic value with it's moves indicesif (depth == 0) {//Comma separated indices if multiple moves have the same heuristic valueconst moves = this.nodesMap.has(nodeValue)? `${this.nodesMap.get(nodeValue)},${index}`: index;this.nodesMap.set(nodeValue, moves);}});//If it's the main call, return the index of the best move or a random index if multiple indices have the same valueif (depth == 0) {if (typeof this.nodesMap.get(best) == "string") {const arr = this.nodesMap.get(best).split(",");const rand = Math.floor(Math.random() * arr.length);const ret = arr[rand];} else {ret = this.nodesMap.get(best);}//run a callback after calculation and return the indexcallback(ret);return ret;}//If not main call (recursive) return the heuristic value for next calculationreturn best;
}
同样,我们将检查玩家是否正在最小化,并且我们的代码将非常相似,除了插入 o 代替 x 和使用 Math.min 代替 Math.max 等细微变化。这是我们的最后的类:
import Board from "./board.js";export default class Player {constructor(maxDepth = -1) {this.maxDepth = maxDepth;this.nodesMap = new Map();}getBestMove(board, maximizing = true, callback = () => {}, depth = 0) {//clear nodesMap if the function is called for a new moveif (depth == 0) this.nodesMap.clear();//If the board state is a terminal one, return the heuristic valueif (board.isTerminal() || depth === this.maxDepth) {if (board.isTerminal().winner === "x") {return 100 - depth;} else if (board.isTerminal().winner === "o") {return -100 + depth;}return 0;}if (maximizing) {//Initialize best to the lowest possible valuelet best = -100;//Loop through all empty cellsboard.getAvailableMoves().forEach(index => {//Initialize a new board with a copy of our current stateconst child = new Board([...board.state]);//Create a child node by inserting the maximizing symbol x into the current empty cellchild.insert("x", index);//Recursively calling getBestMove this time with the new board and minimizing turn and incrementing the depthconst nodeValue = this.getBestMove(child, false, callback, depth + 1);//Updating best valuebest = Math.max(best, nodeValue);//If it's the main function call, not a recursive one, map each heuristic value with it's moves indicesif (depth == 0) {//Comma separated indices if multiple moves have the same heuristic valueconst moves = this.nodesMap.has(nodeValue)? `${this.nodesMap.get(nodeValue)},${index}`: index;this.nodesMap.set(nodeValue, moves);}});//If it's the main call, return the index of the best move or a random index if multiple indices have the same valueif (depth == 0) {let returnValue;if (typeof this.nodesMap.get(best) == "string") {const arr = this.nodesMap.get(best).split(",");const rand = Math.floor(Math.random() * arr.length);returnValue = arr[rand];} else {returnValue = this.nodesMap.get(best);}//run a callback after calculation and return the indexcallback(returnValue);return returnValue;}//If not main call (recursive) return the heuristic value for next calculationreturn best;}if (!maximizing) {//Initialize best to the highest possible valuelet best = 100;//Loop through all empty cellsboard.getAvailableMoves().forEach(index => {//Initialize a new board with a copy of our current stateconst child = new Board([...board.state]);//Create a child node by inserting the minimizing symbol o into the current empty cellchild.insert("o", index);//Recursively calling getBestMove this time with the new board and maximizing turn and incrementing the depthlet nodeValue = this.getBestMove(child, true, callback, depth + 1);//Updating best valuebest = Math.min(best, nodeValue);//If it's the main function call, not a recursive one, map each heuristic value with it's moves indicesif (depth == 0) {//Comma separated indices if multiple moves have the same heuristic valueconst moves = this.nodesMap.has(nodeValue)? this.nodesMap.get(nodeValue) + "," + index: index;this.nodesMap.set(nodeValue, moves);}});//If it's the main call, return the index of the best move or a random index if multiple indices have the same valueif (depth == 0) {let returnValue;if (typeof this.nodesMap.get(best) == "string") {const arr = this.nodesMap.get(best).split(",");const rand = Math.floor(Math.random() * arr.length);returnValue = arr[rand];} else {returnValue = this.nodesMap.get(best);}//run a callback after calculation and return the indexcallback(returnValue);return returnValue;}//If not main call (recursive) return the heuristic value for next calculationreturn best;}}
}
现在让我们测试一下这个函数,同时看看nodesMap的地图是什么样子的。在script.js中输入:
import Board from "./classes/board.js";
import Player from "./classes/player.js";const board = new Board(["x", "o", "", "", "", "", "o", "", "x"]);
board.printFormattedBoard();
const p = new Player();
console.log(p.getBestMove(board));
console.log(p.nodesMap);
如您所见,单元格 4 被确定为 X 的最佳移动,因为它将导致直接获胜。让我们看一下同一个棋盘,但这次轮到 O 了:
import Board from "./classes/board.js";
import Player from "./classes/player.js";const board = new Board(["x", "o", "", "", "", "", "o", "", "x"]);
board.printFormattedBoard();
const p = new Player();
console.log(p.getBestMove(board, false)); //false for minimizing turn
console.log(p.nodesMap);
显然,4 也是 O 的最佳移动,因为它可以防止损失。
最后,值得一提的是,使用alpha-beta pruning可以提高该算法的性能。如果你有兴趣,你可以看看那个。
在下一个也是最后一部分,我们将为我们的板构建 UI 和交互。
带有 JavaScript 的井字游戏:带有 Minimax 算法的 AI 玩家相关推荐
- 使用 JavaScript 进行井字游戏:创建棋盘类
Tic-Tac-Toe with JavaScript: Creating the Board Class | Ali Alaa - Front-end Web Developerhttps://al ...
- 如何在javascript中解析带有两个小数位的浮点数?
本文翻译自:How to parse float with two decimal places in javascript? I have the following code. 我有以下代码. I ...
- JavaScript对象,方括号和算法
by Dmitri Grabov 德米特里·格拉波夫(Dmitri Grabov) JavaScript对象,方括号和算法 (JavaScript Objects, Square Brackets a ...
- JavaScript实现squareMatrixRotation方阵旋转算法(附完整源码)
JavaScript实现squareMatrixRotation方阵旋转算法(附完整源码) squareMatrixRotation.js完整源代码 squareMatrixRotation.test ...
- JavaScript实现levenshteinDistance字符串编辑距离算法(附完整源码)
JavaScript实现levenshteinDistance字符串编辑距离算法(附完整源码) levenshteinDistance.js完整源代码 # levenshteinDistance.te ...
- JavaScript实现ShellSort希尔排序算法(附完整源码)
JavaScript实现ShellSort希尔排序算法(附完整源码) Comparator.js完整源代码 Sort.js完整源代码 ShellSort.js完整源代码 Comparator.js完整 ...
- JavaScript实现SelectionSort选择排序算法(附完整源码)
JavaScript实现SelectionSort选择排序算法(附完整源码) Comparator.js完整源代码 Sort.js完整源代码 SelectionSort.js完整源代码 Compara ...
- JavaScript实现CountingSort计数排序算法(附完整源码)
JavaScript实现CountingSort计数排序算法(附完整源码) Comparator.js完整源代码 Sort.js完整源代码 CountingSort.js完整源代码 Comparato ...
- JavaScript实现Knapsack problem背包问题算法(附完整源码)
JavaScript实现Knapsack problem背包问题算法(附完整源码) Comparator.js完整源代码 Sort.js完整源代码 MergeSort.js完整源代码 Knapsack ...
最新文章
- Extjs Ext.TreePanel
- 【转帖】SQLServer登录连接失败(error:40-无法打开到SQLServer的连接)的解决方案...
- 在WINDOW 系统下如何用批处理命令生成代码
- 湖南科技大学计算机控制技术,湖南科技大学控制理论与控制工程专业
- ./ . 和#!/bin/bash 辨析Linux如何选择当前执行脚本的shell
- 分享一个不错的表格样式
- Windows函数错误处理
- 小熊的人生回忆(三)
- 领英“顶尖公司”榜单出炉:华为、字节跳动位居前二
- 蓝桥杯 ADV-203 算法提高 8皇后·改(八皇后问题)
- 【论文写作】综述论文的六个写作模版
- 力扣-1791. 找出星型图的中心节点
- python中的time的时间戳_python中的时间time、datetime、str和时间戳
- Atiitt 自我学习法流程 1.预先阶段 1.1.目标搜索 资料搜索 1.2. 1.3.通过关联关键词 抽象 等领域 拓展拓宽体系树 1.4. 2.分析整理阶段 2.1.找出重点 压缩要学会
- 20220527_数据库过程_语句留档
- 香港警方据线报捣破9个非法赌档 共拘捕114人
- C++实现 酒店管理系统
- 如何更电计算机共享名称,电脑网络共享设置
- 计算机标准用户英文名称,标准计算机专业英文简历范文
- 云原生微服务架构实战精讲第三节 示例用户场景分析和领域驱动DDD