mirror of
https://github.moeyy.xyz/https://github.com/trekhleb/javascript-algorithms.git
synced 2024-11-10 11:09:43 +08:00
Merge b2edea0ce3
into ca3d16dcce
This commit is contained in:
commit
e463a1f68f
38
src/algorithms/ai/GameNode.js
Normal file
38
src/algorithms/ai/GameNode.js
Normal file
@ -0,0 +1,38 @@
|
||||
class GameNode {
|
||||
/**
|
||||
* @returns {boolean} - If it is the opponent's turn
|
||||
*/
|
||||
isOpponentPlaying() {
|
||||
throw new Error('GameNode::isOpponentPlaying must be implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} - If the game is terminated in the current state
|
||||
*/
|
||||
isTerminalState() {
|
||||
throw new Error('GameNode::isTerminalState must be implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Object} - Get the move which caused this game state
|
||||
*/
|
||||
getMove() {
|
||||
throw new Error('GameNode::getMove must be implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array.<GameNode>} - Possible next states of the game tree
|
||||
*/
|
||||
computeNextStates() {
|
||||
throw new Error('GameNode::computeNextStates must be implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Number} - Score of current state
|
||||
*/
|
||||
evaluateState() {
|
||||
throw new Error('GameNode::evaluateState must be implemented');
|
||||
}
|
||||
}
|
||||
|
||||
export default GameNode;
|
8
src/algorithms/ai/Player.js
Normal file
8
src/algorithms/ai/Player.js
Normal file
@ -0,0 +1,8 @@
|
||||
export default class Player {
|
||||
/**
|
||||
* @param {GameNode} node One node of the game tree. Description of a game state
|
||||
*/
|
||||
findBestMove() {
|
||||
throw new Error('Player::findBestMove must be implemented');
|
||||
}
|
||||
}
|
152
src/algorithms/ai/TicTacToeGameNode.js
Normal file
152
src/algorithms/ai/TicTacToeGameNode.js
Normal file
@ -0,0 +1,152 @@
|
||||
import GameNode from './GameNode';
|
||||
|
||||
export const playerMark = 'x';
|
||||
export const opponentMark = 'o';
|
||||
class TicTacToeGameNode extends GameNode {
|
||||
/**
|
||||
*
|
||||
* @param {Array.<Array.<string>>} board Board of a Tic-Tac-Toe game.
|
||||
* @param {string} currentPlayer Current player mark: 'x' or 'o'
|
||||
*/
|
||||
constructor(board, currentPlayer) {
|
||||
super();
|
||||
|
||||
if (!TicTacToeGameNode.isBoardValid(board)) throw new Error('Invalid board object!');
|
||||
if (currentPlayer !== 'x' && currentPlayer !== 'o') throw new Error('Invalid player: \'x\' or \'o\'');
|
||||
|
||||
this.board = board;
|
||||
this.currentPlayer = currentPlayer;
|
||||
this.move = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a board object is valid: 3*3 matrix of 'x', 'o' or '_'
|
||||
* @param {Array.<Array.<string>>} board Board of a Tic-Tac-Toe game.
|
||||
* @returns {boolean} - If the board is valid.
|
||||
*/
|
||||
static isBoardValid(board) {
|
||||
let validCount = 0;
|
||||
if (Array.isArray(board) && board.length === 3) {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const row = board[i];
|
||||
if (Array.isArray(row) && row.length === 3) {
|
||||
for (let j = 0; j < 3; j += 1) {
|
||||
if (row[j] === 'x' || row[j] === 'o' || row[j] === '_') {
|
||||
validCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return validCount === 9;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} - If it is the opponent's turn
|
||||
*/
|
||||
isOpponentPlaying() {
|
||||
return this.currentPlayer === opponentMark;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} - If the game is terminated
|
||||
*/
|
||||
isTerminalState() {
|
||||
if (!this.hasMoreMoves() || this.checkWin() !== null) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} - If there are empty grids on the board
|
||||
*/
|
||||
hasMoreMoves() {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
for (let j = 0; j < 3; j += 1) {
|
||||
if (this.board[i][j] === '_') return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Object} - Get the move which caused this game state
|
||||
*/
|
||||
getMove() {
|
||||
return this.move;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {string} - If the game is end with three consecutive mark on the board.
|
||||
* Return the player mark of winning ('x' or 'o'), otherwise return null.
|
||||
*/
|
||||
checkWin() {
|
||||
const { board } = this;
|
||||
// Checking for rows
|
||||
for (let row = 0; row < 3; row += 1) {
|
||||
if (board[row][0] === board[row][1] && board[row][1] === board[row][2]) {
|
||||
if (board[row][0] === playerMark) return playerMark;
|
||||
if (board[row][0] === opponentMark) return opponentMark;
|
||||
}
|
||||
}
|
||||
|
||||
// Checking for columns
|
||||
for (let col = 0; col < 3; col += 1) {
|
||||
if (board[0][col] === board[1][col] && board[1][col] === board[2][col]) {
|
||||
if (board[0][col] === playerMark) return playerMark;
|
||||
if (board[0][col] === opponentMark) return opponentMark;
|
||||
}
|
||||
}
|
||||
|
||||
// Checking for diagonals
|
||||
if (board[0][0] === board[1][1] && board[1][1] === board[2][2]) {
|
||||
if (board[0][0] === playerMark) return playerMark;
|
||||
if (board[0][0] === opponentMark) return opponentMark;
|
||||
}
|
||||
|
||||
if (board[0][2] === board[1][1] && board[1][1] === board[2][0]) {
|
||||
if (board[0][2] === playerMark) return playerMark;
|
||||
if (board[0][2] === opponentMark) return opponentMark;
|
||||
}
|
||||
|
||||
// Otherwise none of the players have won, return null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Number} - Score of current state
|
||||
*/
|
||||
evaluate() {
|
||||
const win = this.checkWin();
|
||||
if (win === playerMark) return 10;
|
||||
if (win === opponentMark) return -10;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array.<GameNode>} - Possible next states of the game tree
|
||||
*/
|
||||
computeNextStates() {
|
||||
if (this.isTerminalState()) return [];
|
||||
|
||||
const nextPlayerMark = this.currentPlayer === playerMark ? opponentMark : playerMark;
|
||||
const nextStates = [];
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
for (let j = 0; j < 3; j += 1) {
|
||||
if (this.board[i][j] === '_') {
|
||||
const newBoard = JSON.parse(JSON.stringify(this.board)); // Deep clone the board array
|
||||
newBoard[i][j] = this.currentPlayer; // Make the move
|
||||
|
||||
const newNode = new TicTacToeGameNode(newBoard, nextPlayerMark);
|
||||
newNode.move = [i, j]; // Record the move
|
||||
nextStates.push(newNode); // Add the new state to result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nextStates;
|
||||
}
|
||||
}
|
||||
|
||||
export default TicTacToeGameNode;
|
82
src/algorithms/ai/minimax/Minimax.js
Normal file
82
src/algorithms/ai/minimax/Minimax.js
Normal file
@ -0,0 +1,82 @@
|
||||
import Player from '../Player';
|
||||
|
||||
class MinimaxPlayer extends Player {
|
||||
/**
|
||||
* Find the best move to perform in the current game state.
|
||||
* @param {GameNode} node - Description of game state
|
||||
*/
|
||||
findBestMove(node, depth = 5) {
|
||||
// Default depth set low
|
||||
|
||||
let [_, best_node] = this.minimax(node, depth);
|
||||
|
||||
// If current state is terminal or depth == 0, then no move is possible
|
||||
if (best_node === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return best_node.getMove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the heuristic value of current state.
|
||||
* @param {GameNode} node - Description of game state
|
||||
* @returns
|
||||
*/
|
||||
heuristic(node) {
|
||||
return node.evaluate();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {GameNode} node - Description of game state
|
||||
* @param {Number} depth - Limit to the search depth
|
||||
* @returns {[Number, GameNode]} - Best score the player can reach under this
|
||||
* state, and the resulting game state
|
||||
*/
|
||||
minimax(node, depth) {
|
||||
// Check whether search depth is reached
|
||||
// or if the state is terminal
|
||||
if (depth === 0 || node.isTerminalState()) {
|
||||
return [this.heuristic(node), null];
|
||||
}
|
||||
|
||||
// One player is maximizing and the other play is minimizing
|
||||
// Their optimal values are initialized to numbers which will be replaced
|
||||
// as soon as any valid node is visited
|
||||
let optimal = node.isOpponentPlaying() ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
|
||||
let optimal_node = null
|
||||
|
||||
// Make a list of possible next states
|
||||
const nextNodes = node.computeNextStates();
|
||||
|
||||
// Iterate over all possible next states
|
||||
// Choose the optimal score, from the perspective of current player
|
||||
for (let i = 0; i < nextNodes.length; i += 1) {
|
||||
const nextNode = nextNodes[i];
|
||||
|
||||
// Recursively call minimax to find score of the next state
|
||||
const [score, _] = this.minimax(nextNode, depth - 1);
|
||||
|
||||
// In this code, the "opponent" is the minimizing player
|
||||
// And the "player" is the maximizing player
|
||||
if (node.isOpponentPlaying()) {
|
||||
// optimal is set to min(score, optimal)
|
||||
if (score < optimal) {
|
||||
optimal = score;
|
||||
optimal_node = nextNode;
|
||||
}
|
||||
} else {
|
||||
// optimal is set to max(score, optimal)
|
||||
if (score > optimal) {
|
||||
optimal = score;
|
||||
optimal_node = nextNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Return optimal score and resulting game state
|
||||
return [optimal, optimal_node];
|
||||
}
|
||||
}
|
||||
|
||||
export default MinimaxPlayer;
|
43
src/algorithms/ai/minimax/README.md
Normal file
43
src/algorithms/ai/minimax/README.md
Normal file
@ -0,0 +1,43 @@
|
||||
## The Minimax Algorithm
|
||||
|
||||
The minimax algorithm is a recursive algorithm used to make intelligent decisions in a zero-sum game. In this case it is implemented for a two-player game, but is possible to implement for a game with more players. The algorithm looks ahead into possible future states of the game, and assuming that each of the two players seek to maximize their own score. The algorithm attempts to maximize the guaranteed gain of a move. In other words it tries to maximize the minimum gain of a move or minimize the maximum loss of a move.
|
||||
|
||||
The algorithm simulates a decision tree of different moves by the two players. At the bottom of the decision tree the state of the game is evaluated and assigned a score reflecting how good the game state is for some player. The algorithm works depth first, which in practice means that any game that takes many turns to finish, needs to use a depth limit in order for the minimax to terminate reasonably fast or at all.
|
||||
|
||||
A small example below shows a value associated with each game state in a decision tree. In the example, there are two moves possible in each state. As the topmost player seeks to maximize the score, the value 10 is chosen at the bottom of the tree.
|
||||
|
||||
```
|
||||
maximizing: 7
|
||||
/ \
|
||||
minimizing: 3 7
|
||||
/ \ / \
|
||||
maximizing: 3 5 7 10
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Pseudo code for the algorithm
|
||||
```
|
||||
function minimax(node, depth, maximizingPlayer) is
|
||||
if depth = 0 or node is a terminal node then
|
||||
return the heuristic value of node
|
||||
if maximizingPlayer then
|
||||
value := −∞
|
||||
for each child of node do
|
||||
value := max(value, minimax(child, depth − 1, FALSE))
|
||||
return value
|
||||
else (* minimizing player *)
|
||||
value := +∞
|
||||
for each child of node do
|
||||
value := min(value, minimax(child, depth − 1, TRUE))
|
||||
return value
|
||||
```
|
||||
|
||||
The initial call to the method will be
|
||||
```
|
||||
minimax(origin, depth, TRUE)
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Wikipedia](https://en.wikipedia.org/wiki/Minimax)
|
64
src/algorithms/ai/minimax/__test__/Minimax.test.js
Normal file
64
src/algorithms/ai/minimax/__test__/Minimax.test.js
Normal file
@ -0,0 +1,64 @@
|
||||
import TicTacToeGameNode, { opponentMark, playerMark } from '../../TicTacToeGameNode';
|
||||
import Minimax from '../Minimax';
|
||||
|
||||
describe('Minimax', () => {
|
||||
it('player makes right decision under a normal state', () => {
|
||||
const board = [
|
||||
['x', 'o', 'x'],
|
||||
['o', 'o', 'x'],
|
||||
['_', '_', '_'],
|
||||
];
|
||||
const initialNode = new TicTacToeGameNode(board, playerMark);
|
||||
const minimax = new Minimax();
|
||||
|
||||
expect(minimax.findBestMove(initialNode)).toStrictEqual([2, 2]);
|
||||
});
|
||||
|
||||
it('opponent makes right decision under a normal state', () => {
|
||||
const board = [
|
||||
['x', 'o', 'x'],
|
||||
['o', 'o', 'x'],
|
||||
['_', '_', '_'],
|
||||
];
|
||||
const initialNode = new TicTacToeGameNode(board, opponentMark);
|
||||
const minimax = new Minimax();
|
||||
|
||||
expect(minimax.findBestMove(initialNode)).toStrictEqual([2, 1]);
|
||||
});
|
||||
|
||||
it('a finished game finds no moves', () => {
|
||||
const board = [
|
||||
['x', 'o', 'x'],
|
||||
['o', 'o', 'x'],
|
||||
['_', 'o', '_'],
|
||||
];
|
||||
const initialNode = new TicTacToeGameNode(board, playerMark);
|
||||
const minimax = new Minimax();
|
||||
|
||||
expect(minimax.findBestMove(initialNode)).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('a game far from a terminating state finds moves', () => {
|
||||
const board = [
|
||||
['_', '_', '_'],
|
||||
['_', '_', '_'],
|
||||
['_', '_', '_'],
|
||||
];
|
||||
const initialNode = new TicTacToeGameNode(board, opponentMark);
|
||||
const minimax = new Minimax();
|
||||
|
||||
expect(minimax.findBestMove(initialNode)).not.toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('a full game board finds no moves', () => {
|
||||
const board = [
|
||||
['x', 'o', 'o'],
|
||||
['o', 'x', 'x'],
|
||||
['x', 'x', 'o'],
|
||||
];
|
||||
const initialNode = new TicTacToeGameNode(board, opponentMark);
|
||||
const minimax = new Minimax();
|
||||
|
||||
expect(minimax.findBestMove(initialNode)).toStrictEqual(null);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user