Game Theory
1. Minimax Algorithm
| Concept | Description | Purpose | Complexity |
|---|---|---|---|
| Minimax | Maximize your minimum gain, minimize opponent's maximum gain | Optimal play for two-player zero-sum games | O(b^d) where b=branching factor, d=depth |
| Max Player | Tries to maximize the score | Your turn | Choose max of children |
| Min Player | Tries to minimize the score | Opponent's turn | Choose min of children |
| Terminal State | Game over condition with score | Base case for recursion | Win/loss/draw evaluation |
Example: Basic minimax for Tic-Tac-Toe
// Simplified minimax for optimal game play
function minimax(board, depth, isMaximizing) {
// Check terminal state
const score = evaluate(board);
// If game is over, return score
if (score !== null) return score;
// If board is full, it's a tie
if (isBoardFull(board)) return 0;
if (isMaximizing) {
let bestScore = -Infinity;
// Try all possible moves
for (let i = 0; i < 9; i++) {
if (board[i] === '') {
board[i] = 'X'; // Make move
const score = minimax(board, depth + 1, false);
board[i] = ''; // Undo move
bestScore = Math.max(bestScore, score);
}
}
return bestScore;
} else {
let bestScore = Infinity;
for (let i = 0; i < 9; i++) {
if (board[i] === '') {
board[i] = 'O'; // Opponent's move
const score = minimax(board, depth + 1, true);
board[i] = ''; // Undo move
bestScore = Math.min(bestScore, score);
}
}
return bestScore;
}
}
// Evaluate board: +10 for X win, -10 for O win, null for ongoing
function evaluate(board) {
const winPatterns = [
[0,1,2], [3,4,5], [6,7,8], // Rows
[0,3,6], [1,4,7], [2,5,8], // Columns
[0,4,8], [2,4,6] // Diagonals
];
for (const [a, b, c] of winPatterns) {
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
return board[a] === 'X' ? 10 : -10;
}
}
return null;
}
function isBoardFull(board) {
return board.every(cell => cell !== '');
}
// Find best move
function findBestMove(board) {
let bestScore = -Infinity;
let bestMove = -1;
for (let i = 0; i < 9; i++) {
if (board[i] === '') {
board[i] = 'X';
const score = minimax(board, 0, false);
board[i] = '';
if (score > bestScore) {
bestScore = score;
bestMove = i;
}
}
}
return bestMove;
}
Note: Minimax assumes both players play optimally. The algorithm explores the entire game tree, so it's guaranteed to find the best move but can be slow for complex games. Use alpha-beta pruning to optimize.
2. Alpha Beta Pruning
| Parameter | Meaning | Update Rule | Effect |
|---|---|---|---|
| Alpha (α) | Best score maximizer can guarantee | α = max(α, value) | Lower bound for max player |
| Beta (β) | Best score minimizer can guarantee | β = min(β, value) | Upper bound for min player |
| Pruning Condition | α ≥ β | Stop exploring this branch | Remaining moves won't be chosen |
| Optimization | Cuts search tree significantly | Best case: O(b^(d/2)) | Can search twice as deep |
Example: Minimax with alpha-beta pruning
function minimaxAlphaBeta(board, depth, alpha, beta, isMaximizing) {
const score = evaluate(board);
if (score !== null) return score;
if (isBoardFull(board)) return 0;
if (isMaximizing) {
let maxEval = -Infinity;
for (let i = 0; i < 9; i++) {
if (board[i] === '') {
board[i] = 'X';
const eval = minimaxAlphaBeta(board, depth + 1, alpha, beta, false);
board[i] = '';
maxEval = Math.max(maxEval, eval);
alpha = Math.max(alpha, eval);
// Beta cutoff: opponent won't allow this branch
if (beta <= alpha) {
break; // Prune remaining moves
}
}
}
return maxEval;
} else {
let minEval = Infinity;
for (let i = 0; i < 9; i++) {
if (board[i] === '') {
board[i] = 'O';
const eval = minimaxAlphaBeta(board, depth + 1, alpha, beta, true);
board[i] = '';
minEval = Math.min(minEval, eval);
beta = Math.min(beta, eval);
// Alpha cutoff: maximizer won't allow this branch
if (beta <= alpha) {
break; // Prune remaining moves
}
}
}
return minEval;
}
}
// Initial call with alpha = -∞, beta = +∞
function findBestMoveOptimized(board) {
let bestScore = -Infinity;
let bestMove = -1;
let alpha = -Infinity;
const beta = Infinity;
for (let i = 0; i < 9; i++) {
if (board[i] === '') {
board[i] = 'X';
const score = minimaxAlphaBeta(board, 0, alpha, beta, false);
board[i] = '';
if (score > bestScore) {
bestScore = score;
bestMove = i;
}
alpha = Math.max(alpha, bestScore);
}
}
return bestMove;
}
Example: Alpha-beta pruning visualization
// Understanding pruning:
//
// Max (α=-∞, β=+∞)
// / | \
// / | \
// Min Min Min
// / | \ / | \
// 3 5 2 ...
//
// At first Min node:
// - Evaluates 3: β = min(+∞, 3) = 3
// - Evaluates 5: β stays 3 (min doesn't change)
// - Evaluates 2: β stays 3
// - Returns 2 to Max
// - Max: α = max(-∞, 2) = 2
//
// At second Min node:
// - Evaluates first child, gets 1
// - β = min(+∞, 1) = 1
// - Since β(1) <= α(2), PRUNE!
// - Don't need to check remaining children
// - Max already has better option (2 > 1)
function minimaxWithLogging(board, depth, alpha, beta, isMax) {
const score = evaluate(board);
if (score !== null) return score;
if (isBoardFull(board)) return 0;
const indent = ' '.repeat(depth);
console.log(`${indent}${isMax ? 'MAX' : 'MIN'} (α=${alpha}, β=${beta})`);
if (isMax) {
let maxEval = -Infinity;
for (let i = 0; i < 9; i++) {
if (board[i] === '') {
board[i] = 'X';
const eval = minimaxWithLogging(board, depth + 1, alpha, beta, false);
board[i] = '';
maxEval = Math.max(maxEval, eval);
alpha = Math.max(alpha, eval);
if (beta <= alpha) {
console.log(`${indent}PRUNE at α=${alpha}, β=${beta}`);
break;
}
}
}
return maxEval;
} else {
let minEval = Infinity;
for (let i = 0; i < 9; i++) {
if (board[i] === '') {
board[i] = 'O';
const eval = minimaxWithLogging(board, depth + 1, alpha, beta, true);
board[i] = '';
minEval = Math.min(minEval, eval);
beta = Math.min(beta, eval);
if (beta <= alpha) {
console.log(`${indent}PRUNE at α=${alpha}, β=${beta}`);
break;
}
}
}
return minEval;
}
}
3. Nim Game Pattern
| Concept | Description | Winning Condition | Strategy |
|---|---|---|---|
| Nim Game | Remove 1-k items from pile, last to move loses | Leave opponent with multiple of (k+1) | Mathematical pattern |
| Can Win Formula | n % (k + 1) !== 0 |
If n is not multiple of k+1, first player wins | O(1) solution |
| Nim Sum (XOR) | For multiple piles: XOR all pile sizes | XOR = 0 means losing position | Sprague-Grundy theorem |
| Grundy Number | Minimum excludant (mex) of reachable states | Grundy = 0 is losing position | Generalized Nim |
Example: Simple Nim game - remove 1, 2, or 3 stones
// Classic Nim: n stones, can remove 1-3, last to move wins
function canWinNim(n) {
// If n % 4 === 0, you lose (opponent can mirror moves)
// Otherwise, remove stones to leave opponent with multiple of 4
return n % 4 !== 0;
}
// Examples:
// n=1: Take 1, you win -> true
// n=2: Take 2, you win -> true
// n=3: Take 3, you win -> true
// n=4: Any move (1,2,3) leaves opponent with 3,2,1 -> false
// n=5: Take 1, leave 4 for opponent -> true
// n=6: Take 2, leave 4 for opponent -> true
// n=7: Take 3, leave 4 for opponent -> true
// n=8: Any move leaves 7,6,5 for opponent -> false
// Strategy: Leave opponent with multiple of 4
function optimalNimMove(n) {
if (n % 4 === 0) return -1; // No winning move
return n % 4; // Take this many to leave multiple of 4
}
// Generalized: can remove 1 to k stones
function canWinNimGeneral(n, k) {
return n % (k + 1) !== 0;
}
// Multiple piles - Nim sum (XOR)
function canWinMultiPileNim(piles) {
let nimSum = 0;
for (const pile of piles) {
nimSum ^= pile;
}
// If XOR of all piles is 0, current player loses
return nimSum !== 0;
}
// Find winning move in multi-pile Nim
function findWinningMove(piles) {
const nimSum = piles.reduce((xor, pile) => xor ^ pile, 0);
if (nimSum === 0) return null; // No winning move
// Find a pile where removing stones reduces nim sum to 0
for (let i = 0; i < piles.length; i++) {
const targetSize = piles[i] ^ nimSum;
if (targetSize < piles[i]) {
return { pile: i, remove: piles[i] - targetSize };
}
}
}
Note: The Nim game pattern applies to many variations. Key insight: positions where n % (k+1) === 0 are losing positions because any move you make can be mirrored by opponent to return to a multiple of k+1.
4. Stone Game DP
| Problem Type | DP State | Transition | Time |
|---|---|---|---|
| Take First/Last | dp[i][j] = max score for piles[i..j] |
Max of taking first or last | O(n²) |
| Optimal Substructure | Best play in subarray | Assume opponent plays optimally | Minimax DP |
| Score Difference | Your score - opponent score | Maximize difference | Common pattern |
| Even-Odd Parity | Some problems have parity tricks | First player wins if total is odd | O(1) for special cases |
Example: Stone game - take first or last
function stoneGame(piles) {
const n = piles.length;
// dp[i][j] = max stones you can get from piles[i..j]
const dp = Array.from({ length: n }, () => Array(n).fill(0));
// Base case: single pile
for (let i = 0; i < n; i++) {
dp[i][i] = piles[i];
}
// Fill diagonal by diagonal (length 2, 3, ..., n)
for (let len = 2; len <= n; len++) {
for (let i = 0; i <= n - len; i++) {
const j = i + len - 1;
// Calculate total stones in range [i, j]
let total = 0;
for (let k = i; k <= j; k++) {
total += piles[k];
}
// Take first: piles[i] + (total - piles[i] - dp[i+1][j])
// Take last: piles[j] + (total - piles[j] - dp[i][j-1])
const takeFirst = total - dp[i + 1][j];
const takeLast = total - dp[i][j - 1];
dp[i][j] = Math.max(takeFirst, takeLast);
}
}
const total = piles.reduce((sum, pile) => sum + pile, 0);
// You get dp[0][n-1], opponent gets rest
return dp[0][n - 1] > total - dp[0][n - 1];
}
// Optimized: Stone game with even number of piles
// First player always wins!
function stoneGameOptimized(piles) {
// Parity trick: group piles into even and odd indices
// First player can always take all even or all odd indices
// One group sum is guaranteed > half total
return true;
}
// Explanation of why first player wins:
// Piles: [5, 3, 4, 5]
// Even indices (0, 2): 5 + 4 = 9
// Odd indices (1, 3): 3 + 5 = 8
// First player can force taking all even or all odd
// Take from end that starts the better group
Example: Stone game with prefix sum optimization
function stoneGameDP(piles) {
const n = piles.length;
// Prefix sum for O(1) range sum
const prefix = [0];
for (const pile of piles) {
prefix.push(prefix[prefix.length - 1] + pile);
}
// Get sum of piles[i..j]
const rangeSum = (i, j) => prefix[j + 1] - prefix[i];
// dp[i][j] = maximum stones from piles[i..j]
const dp = Array.from({ length: n }, () => Array(n).fill(0));
for (let i = 0; i < n; i++) {
dp[i][i] = piles[i];
}
for (let len = 2; len <= n; len++) {
for (let i = 0; i <= n - len; i++) {
const j = i + len - 1;
const total = rangeSum(i, j);
// If you take piles[i], opponent plays optimally on [i+1, j]
// You get: piles[i] + remaining stones after opponent's optimal play
const takeFirst = total - dp[i + 1][j];
// If you take piles[j], opponent plays optimally on [i, j-1]
const takeLast = total - dp[i][j - 1];
dp[i][j] = Math.max(takeFirst, takeLast);
}
}
return dp[0][n - 1];
}
// Memoization approach
function stoneGameMemo(piles) {
const memo = new Map();
function dp(i, j, total) {
if (i === j) return piles[i];
const key = `${i},${j}`;
if (memo.has(key)) return memo.get(key);
const takeFirst = total - dp(i + 1, j, total - piles[i]);
const takeLast = total - dp(i, j - 1, total - piles[j]);
const result = Math.max(takeFirst, takeLast);
memo.set(key, result);
return result;
}
const total = piles.reduce((a, b) => a + b, 0);
return dp(0, piles.length - 1, total);
}
5. Predict the Winner
| Approach | State | Strategy | Complexity |
|---|---|---|---|
| Minimax DP | dp[i][j] = score difference |
Maximize your advantage | O(n²) time, O(n²) space |
| Score Difference | Your score - opponent's score | Win if difference ≥ 0 | Simplifies logic |
| 1D DP Optimization | Rolling array, use current row only | Space optimization | O(n) space |
| Memoization | Top-down with cache | Easier to understand | Same complexity, cleaner code |
Example: Predict winner with DP
function predictTheWinner(nums) {
const n = nums.length;
// dp[i][j] = max score difference (you - opponent) for nums[i..j]
const dp = Array.from({ length: n }, () => Array(n).fill(0));
// Base case: single element, you take it
for (let i = 0; i < n; i++) {
dp[i][i] = nums[i];
}
// Fill for all lengths
for (let len = 2; len <= n; len++) {
for (let i = 0; i <= n - len; i++) {
const j = i + len - 1;
// Take nums[i]: you get +nums[i], opponent gets -dp[i+1][j]
const takeFirst = nums[i] - dp[i + 1][j];
// Take nums[j]: you get +nums[j], opponent gets -dp[i][j-1]
const takeLast = nums[j] - dp[i][j - 1];
dp[i][j] = Math.max(takeFirst, takeLast);
}
}
// Player 1 wins if score difference >= 0
return dp[0][n - 1] >= 0;
}
// Example: nums = [1, 5, 2]
//
// dp[0][0] = 1, dp[1][1] = 5, dp[2][2] = 2
//
// dp[0][1]: Take 1 -> 1 - dp[1][1] = 1 - 5 = -4
// Take 5 -> 5 - dp[0][0] = 5 - 1 = 4
// dp[0][1] = 4
//
// dp[1][2]: Take 5 -> 5 - dp[2][2] = 5 - 2 = 3
// Take 2 -> 2 - dp[1][1] = 2 - 5 = -3
// dp[1][2] = 3
//
// dp[0][2]: Take 1 -> 1 - dp[1][2] = 1 - 3 = -2
// Take 2 -> 2 - dp[0][1] = 2 - 4 = -2
// dp[0][2] = -2
//
// Result: -2 < 0, so false (Player 2 wins)
Example: Space-optimized and memoization
// 1D DP optimization - O(n) space
function predictTheWinnerOptimized(nums) {
const n = nums.length;
let dp = [...nums]; // Previous row
for (let len = 2; len <= n; len++) {
const newDp = Array(n).fill(0);
for (let i = 0; i <= n - len; i++) {
const j = i + len - 1;
const takeFirst = nums[i] - dp[i + 1];
const takeLast = nums[j] - dp[i];
newDp[i] = Math.max(takeFirst, takeLast);
}
dp = newDp;
}
return dp[0] >= 0;
}
// Memoization approach - cleaner logic
function predictTheWinnerMemo(nums) {
const memo = new Map();
function maxDiff(i, j) {
if (i === j) return nums[i];
const key = `${i},${j}`;
if (memo.has(key)) return memo.get(key);
// Take first: you gain nums[i], lose opponent's best play
const takeFirst = nums[i] - maxDiff(i + 1, j);
// Take last: you gain nums[j], lose opponent's best play
const takeLast = nums[j] - maxDiff(i, j - 1);
const result = Math.max(takeFirst, takeLast);
memo.set(key, result);
return result;
}
return maxDiff(0, nums.length - 1) >= 0;
}
// Parity optimization for even-length arrays
function predictTheWinnerParity(nums) {
// If even length, player 1 can always force a win
// by choosing to always take from even or odd positions
if (nums.length % 2 === 0) return true;
// For odd length, need full DP
return predictTheWinnerMemo(nums);
}
Warning: In game theory DP, track score difference, not absolute scores. When you take a value, your difference increases by that value, but opponent's optimal play on remaining elements decreases your total difference. Formula: `dp[i][j] = max(nums[i] - dp[i+1][j], nums[j] - dp[i][j-1])`.
Summary: Game Theory Patterns
- Minimax Principle: Maximize your minimum gain, minimize opponent's maximum gain - assumes both players play optimally
- Max Player: Choose max of all possible moves - your turn trying to maximize score
- Min Player: Choose min of all possible moves - opponent's turn trying to minimize your score
- Alpha-Beta Pruning: α = best max can do, β = best min can do - prune when α ≥ β
- Pruning Benefit: Reduces O(b^d) to O(b^(d/2)) in best case - allows searching twice as deep
- Alpha Cutoff: If min node finds β ≤ α, stop - max already has better option elsewhere
- Beta Cutoff: If max node finds α ≥ β, stop - min already has better option elsewhere
- Nim Game Pattern: n % (k+1) !== 0 means first player wins - leave opponent with multiple of k+1
- Nim Sum (XOR): For multiple piles, XOR all sizes - XOR = 0 is losing position (Sprague-Grundy)
- Grundy Number: Mex (minimum excludant) of reachable states - Grundy = 0 is losing position
- Stone Game DP: dp[i][j] = max stones from piles[i..j] - transition: max(total - dp[i+1][j], total - dp[i][j-1])
- Score Difference: Track (your score - opponent score) instead of absolute - simplifies win condition to ≥ 0
- Predict Winner: dp[i][j] = max difference - transition: max(nums[i] - dp[i+1][j], nums[j] - dp[i][j-1])
- Parity Trick: Even-length arrays: first player wins by controlling even/odd indices - O(1) solution
- Space Optimization: 2D DP can often reduce to 1D - use rolling array for O(n) space
- Memoization: Top-down often cleaner than bottom-up for game theory - cache (i, j) states