Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/others/minimax.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
(function (exports) {
'use strict';
/* eslint max-params: 0 */

/**
* Minimax (sometimes MinMax, MM[1] or saddle point[2]) is a decision rule used in artificial intelligence,
* decision theory, game theory, statistics, and philosophy for minimizing the possible loss for a worst case (maximum loss) scenario.
* Optimized with alpha-beta pruning.
* {@link https://en.wikipedia.org/wiki/Minimax}
* {@link https://en.wikipedia.org/wiki/Alpha%E2%80%93beta_pruning}
*
* @public
* @module others/minimax
*
* @example
*
* var miniMax =
* require('path-to-algorithms/src/others/minimax').minimax;
* var result = minimax(
* [1, 2, 3],
* true,
* 5,
* -Infinity,
* Infinity,
* state => ({ move: 0, state: [2, 3, 4] }),
* state => state[1] < 3,
* state => state[1]
* );
*
* @param {*} state Current game state
* @param {Boolean} maximize Defines if the result should be maximized or minimized
* @param {Number} depth Defines the maximum depth search
* @param {Number} alpha Maximum score that the minimizing player is assured
* @param {Number} beta Minimum score that the maximizing player is assured
* @param {Function} getPossibleNextStatesFn Function which returns all possible next moves with states .
* @param {Function} isGameOverFn Function which returns if game is over.
* @param {Function} getScoreFn Function which returns score.
* @return {{score: Number, move: *}} which contains the minimum coins from the given
* list, required for the change.
*/
function minimax(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some parameters which don't change over time:

  • getPossibleNextStatesFn
  • isGameOverFn
  • getScoreFn

We can have a higher-order (builder) function, which accepts them and returns a new function, which does the rest:

const minimaxBuilder = (getPossibleNextStatesFn, isGameOverFn, getScoreFn) => {
  const minimax = (state, maximize, depth, alpha, beta) => {
    // ...
  };
  return minimax;
};

This way we'll change the public API a bit:

const minimax = minimaxBuilder(...);
const result = minimax(...);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great idea! Fixed.

state,
maximize,
depth,
alpha,
beta,
getPossibleNextStatesFn,
isGameOverFn,
getScoreFn
) {
if (depth === 0 || isGameOverFn(state)) {
const score = getScoreFn(state);
return {score, move: null};
}

const possibleMoveResults = getPossibleNextStatesFn(state);

if (maximize) {

let maxResult = {score: -Infinity, move: null};

for (const next of possibleMoveResults) {
const result = minimax(
next.state,
false,
depth - 1,
alpha,
beta,
getPossibleNextStatesFn,
isGameOverFn,
getScoreFn
);

if (result.score > maxResult.score) {
maxResult = {score: result.score, move: next.move};
}

alpha = Math.max(alpha, result.score);

if (alpha >= beta) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used for an optimization so we stop the loop earlier?

Copy link
Owner

@mgechev mgechev Apr 25, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we stop when alpha === beta can't we miss later cases when alpha is larger than beta, which will lead to a better outcome?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right, it's an optimization called "Alpha-beta pruning".
That's not the case. If alpha is larger then beta, then we know that this whole leaf won't be chosen anyway. I couldn't explain it better than 20 seconds of this video.

break;
}
}

return maxResult;
} else {
let minResult = {score: Infinity, move: null};

for (const next of possibleMoveResults) {
const result = minimax(
next.state,
true,
depth - 1,
alpha,
beta,
getPossibleNextStatesFn,
isGameOverFn,
getScoreFn
);

if (result.score < minResult.score) {
minResult = {score: result.score, move: next.move};
}

beta = Math.min(beta, result.score);

if (beta <= alpha) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

break;
}
}

return minResult;
}
}


exports.minimax = minimax;

})(typeof window === 'undefined' ? module.exports : window);
4 changes: 2 additions & 2 deletions test/data-structures/avl-tree.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,8 @@ describe('AVL Tree', function () {
avlTree.insert(6);
avlTree.remove(32);
avlTree.remove(11);
avlTree.remove(25);
avlTree.remove(25);

// depth 1
expect(avlTree._root.value).toBe(37);
expect(avlTree._root._height).toBe(4);
Expand Down
170 changes: 170 additions & 0 deletions test/others/minimax.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
var minimax = require('../../src/others/minimax.js').minimax;

describe('Minimax with tic tac toe', function () {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a fantastic example! As you suggested in your comment, it's leaning a bit towards an integration test.

I think, having a simpler game is fine as well. This way we won't need that much logic to setup our tests and they'll be less error prone. Consider a game where each next step is a binary decision, and the leafs of the binary tree determine which player is the winner.

I really like this example, so I'd love to keep it, and also add another test group which tests the minimax example with a simpler binary game.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm glad you like it!
Sounds reasonable. I'm not sure if I follow the idea of the simpler game. What do you mean by the leafs of the binary tree determine which player is the winner? Should I create some binary tree with one "winning leaf"?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't have to be a real game, it can be any game. My proposal is for something like this:

     o
   /    \
  o      o
 /  \   /  \
1   -1 1   -1

If we get 1 player "A" wins, otherwise, if we get -1 player "B" wins. It's pretty much pre-defined, depends on who would start first. It'll be easier to reason about IMO.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I got it. Indeed, it's much simpler.

'use strict';

let game = ticTacToe();

function getAllNextStates(state) {
const possibleMoves = game.emptyCells(state);

return possibleMoves.map(move => ({
move,
state: game.nextState(state, move),
}));
}

function minimaxFor(player, state) {
const getScoreFn = player === 'x'
? state => game.getScore(state).x - game.getScore(state).o
: state => game.getScore(state).o - game.getScore(state).x;

return minimax(
state,
true,
5,
-Infinity,
Infinity,
getAllNextStates,
state => game.isGameOver(state),
getScoreFn
);
}

it('should be defined', function () {
expect(minimax).toBeDefined();
});

it('should win versus dumb agent as first player', function () {
let state = game.newState('x');

while (!game.isGameOver(state)) {
if (state.turn === 'x') {
state = game.nextState(state, minimaxFor(state.turn, state).move);
} else {
const move = game.emptyCells(state)[0];
state = game.nextState(state, move);
}
}

expect(game.isGameOver(state)).toBe(true);
expect(game.getScore(state)).toEqual({x: 1, o: 0});
});

it('should win versus dumb agent as second player', function () {
let state = game.newState('x');

while (!game.isGameOver(state)) {
if (state.turn === 'o') {
state = game.nextState(state, minimaxFor(state.turn, state).move);
} else {
const move = game.emptyCells(state)[0];
state = game.nextState(state, move);
}
}

expect(game.isGameOver(state)).toBe(true);
expect(game.getScore(state)).toEqual({x: 0, o: 1});
});


it('should be a tie for two minimax agents', function () {
let state = game.newState('x');

while (!game.isGameOver(state)) {
state = game.nextState(state, minimaxFor(state.turn, state).move);
}
expect(game.isGameOver(state)).toBe(true);
expect(game.getScore(state)).toEqual({x: 0, o: 0});
});
});

function ticTacToe() {
'use strict';

function newState(turn) {
return {
board: [[0, 0, 0],
[0, 0, 0],
[0, 0, 0]],
turn
};
}

function emptyCells(state) {
const result = [];
state.board.forEach((row, y) => {
row.forEach((cell, x) => {
if (cell === 0) {
result.push({x, y})
}
});
});

return result;
}

function getWinner(state) {
const winVariants = [
[{x: 0, y: 0}, {x: 0, y: 1}, {x: 0, y: 2}],
[{x: 1, y: 0}, {x: 1, y: 1}, {x: 1, y: 2}],
[{x: 2, y: 0}, {x: 2, y: 1}, {x: 2, y: 2}],

[{x: 0, y: 0}, {x: 1, y: 0}, {x: 2, y: 0}],
[{x: 0, y: 1}, {x: 1, y: 1}, {x: 2, y: 1}],
[{x: 0, y: 2}, {x: 1, y: 0}, {x: 2, y: 2}],

[{x: 0, y: 0}, {x: 1, y: 1}, {x: 2, y: 2}],
[{x: 2, y: 0}, {x: 1, y: 1}, {x: 2, y: 0}],
];

for (const variant of winVariants) {
const combo = variant.map(cell => state.board[cell.y][cell.x]).join('');
if (combo === 'xxx') {
return 'x';
} else if (combo === 'ooo') {
return 'o';
}
}

return null;
}

function allFieldsMarked(state) {
return state.board.every(row => row.every(cell => cell !== 0));
}

function isGameOver(state) {
return allFieldsMarked(state) || getWinner(state) !== null;
}

function getScore(state) {
if (getWinner(state) === 'x') {
return {x: 1, o: 0};
} else if (getWinner(state) === 'o') {
return {x: 0, o: 1};
}

return {x: 0, o: 0};
}

function nextState(state, move) {
const board = state.board;
return {
board: [
...board.slice(0, move.y),
Copy link
Owner

@mgechev mgechev Apr 25, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit hard to read. Something like this for updating the board should be easier to understand:

const newBoard = state.board.map(row => row.slice());
newBoard[move.x][move.y] = state.turn;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clever! Fixed.

[...board[move.y].slice(0, move.x), state.turn, ...board[move.y].slice(move.x + 1)],
...board.slice(move.y + 1)
],
turn: state.turn === 'x' ? 'o' : 'x',
};
}

return {
newState,
getScore,
nextState,
isGameOver,
emptyCells,
}
}