Facebook Pixel

1908. Game of Nim 🔒

MediumBit ManipulationBrainteaserArrayMathDynamic ProgrammingGame Theory
Leetcode Link

Problem Description

This is a classic Nim game problem where two players, Alice and Bob, take turns playing a stone removal game.

The game setup:

  • There are n piles of stones represented by an array piles, where piles[i] indicates the number of stones in the i-th pile
  • Alice always goes first
  • On each turn, a player must remove any positive number of stones (at least 1) from exactly one non-empty pile of their choice
  • The player who cannot make a valid move (when all piles are empty) loses the game
  • Both players play optimally, meaning they always make the best possible move to maximize their chance of winning

The goal is to determine whether Alice wins the game. Return true if Alice wins, or false if Bob wins.

For example:

  • If piles = [1, 2, 3], players can remove stones from any pile on their turn
  • If a player faces piles = [0, 0, 0] at the start of their turn, they lose since no moves are available

The solution uses dynamic programming with memoization to explore all possible game states. The dfs function recursively checks whether the current player can win from a given state by trying all possible moves (removing 1 to x stones from each pile) and checking if any move leads to a losing position for the opponent.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

The key insight is that this is a game theory problem where we need to determine winning and losing positions. A position is winning if the current player can force a win, and losing if no matter what move they make, the opponent can force a win.

Think about it backwards - if all piles are empty [0, 0, 0], the current player has already lost because they can't make any move. This is our base case - a losing position.

For any other game state, the current player wins if they can make at least one move that puts their opponent in a losing position. Why? Because if we can force our opponent into a position where they must lose, then we win.

This naturally leads to a recursive approach:

  • For each possible game state, try all valid moves (removing 1 to x stones from each non-empty pile)
  • After making a move, check if the resulting state is a losing position for the opponent
  • If we find even one move that results in a losing position for our opponent, the current state is a winning position for us
  • If all our moves lead to winning positions for our opponent, then our current state is a losing position

The beauty of this approach is that it captures the "optimal play" requirement automatically. Each player will choose the move that guarantees their victory if such a move exists. The recursion explores all possibilities and determines whether the current player has a winning strategy.

Since the same game state can be reached through different sequences of moves, we use memoization (@cache) to avoid recalculating the same positions multiple times. We convert the list to a tuple for hashing since lists aren't hashable in Python.

Learn more about Math and Dynamic Programming patterns.

Solution Approach

The solution implements a recursive depth-first search with memoization to determine if Alice can win from the initial game state.

Implementation Details:

  1. State Representation: The game state is represented as a tuple of pile sizes. We use tuples instead of lists because they're immutable and hashable, allowing us to cache results with @cache.

  2. Recursive Function dfs(st): This function takes a game state st (tuple of pile sizes) and returns True if the current player can win from this state, False otherwise.

  3. Core Algorithm:

    lst = list(st)  # Convert tuple to list for manipulation
    for i, x in enumerate(lst):  # Try each pile
        for j in range(1, x + 1):  # Try removing 1 to x stones
            lst[i] -= j  # Make the move
            if not dfs(tuple(lst)):  # Check opponent's position
                return True  # Found a winning move
            lst[i] += j  # Backtrack
    return False  # No winning move found
  4. Move Exploration: For each pile i with x stones, we try removing j stones where 1 ≤ j ≤ x. This covers all possible valid moves from the current state.

  5. Winning Condition: If any move leads to a state where dfs returns False (opponent loses), then the current player wins and we return True. This implements the principle that a position is winning if there exists at least one move to a losing position for the opponent.

  6. Base Case: When no moves are possible (all piles empty), the loop doesn't execute and the function returns False, indicating the current player loses.

  7. Memoization: The @cache decorator automatically memoizes the results, preventing redundant calculations for previously encountered states.

The initial call dfs(tuple(piles)) determines if Alice (the first player) can win from the starting configuration.

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's trace through a small example with piles = [1, 2] to illustrate how the solution works.

Initial State: Alice starts with [1, 2]

We call dfs((1, 2)) to determine if Alice can win.

Alice's Turn - Exploring from (1, 2):

Alice can make these moves:

  1. Remove 1 stone from pile 0: [1, 2] → [0, 2]
  2. Remove 1 stone from pile 1: [1, 2] → [1, 1]
  3. Remove 2 stones from pile 1: [1, 2] → [1, 0]

For each move, we check if Bob loses from the resulting state:

Move 1: Alice removes 1 from pile 0 → State becomes (0, 2)

  • Call dfs((0, 2)) for Bob's position
  • Bob can remove 1 or 2 from pile 1:
    • Remove 1: [0, 2] → [0, 1], then dfs((0, 1)) for Alice
      • Alice must remove 1: [0, 1] → [0, 0]
      • Bob faces [0, 0] and loses, so dfs((0, 1)) = True
    • Remove 2: [0, 2] → [0, 0], then dfs((0, 0)) for Alice
      • Alice faces [0, 0] and loses, so dfs((0, 0)) = False
  • Since Bob found a move leading to Alice losing, dfs((0, 2)) = True
  • This means Bob wins from (0, 2), so this isn't a good move for Alice

Move 2: Alice removes 1 from pile 1 → State becomes (1, 1)

  • Call dfs((1, 1)) for Bob's position
  • Bob can remove 1 from either pile:
    • Remove from pile 0: [1, 1] → [0, 1], dfs((0, 1)) = True (as calculated above)
    • Remove from pile 1: [1, 1] → [1, 0], dfs((1, 0)) = True (by symmetry)
  • Both moves lead to Alice winning, so dfs((1, 1)) = False
  • Bob loses from (1, 1)! This is a winning move for Alice.

Since Alice found at least one move (removing 1 from pile 1) that puts Bob in a losing position, dfs((1, 2)) returns True.

Result: Alice wins from [1, 2] by removing 1 stone from the second pile, creating the symmetric position [1, 1] where Bob is guaranteed to lose with optimal play.

Solution Implementation

1class Solution:
2    def nimGame(self, piles: List[int]) -> bool:
3        """
4        Determines if the first player can win the Nim game with optimal play.
5      
6        Args:
7            piles: List of integers representing the number of stones in each pile
8          
9        Returns:
10            True if the first player can win, False otherwise
11        """
12        from functools import cache
13      
14        @cache
15        def dfs(state: tuple) -> bool:
16            """
17            Recursively determines if the current player can win from the given state.
18          
19            Args:
20                state: Tuple representing the current number of stones in each pile
21              
22            Returns:
23                True if the current player can win from this state, False otherwise
24            """
25            # Convert immutable tuple to mutable list for manipulation
26            piles_list = list(state)
27          
28            # Try removing stones from each pile
29            for pile_index, stones_in_pile in enumerate(piles_list):
30                # Try removing 1 to all stones from the current pile
31                for stones_to_remove in range(1, stones_in_pile + 1):
32                    # Make the move: remove stones from current pile
33                    piles_list[pile_index] -= stones_to_remove
34                  
35                    # Check if this move leads to a losing state for the opponent
36                    # If opponent loses from the resulting state, current player wins
37                    if not dfs(tuple(piles_list)):
38                        return True
39                  
40                    # Undo the move for next iteration
41                    piles_list[pile_index] += stones_to_remove
42          
43            # No winning move found - current player loses
44            return False
45      
46        # Start the game with the initial piles configuration
47        return dfs(tuple(piles))
48
1class Solution {
2    // Memoization cache to store game states and their outcomes
3    private Map<Integer, Boolean> memo = new HashMap<>();
4  
5    // Powers of 8 for state encoding (base-8 representation)
6    private int[] powersOf8 = new int[8];
7
8    /**
9     * Constructor initializes powers of 8 for efficient state encoding
10     */
11    public Solution() {
12        powersOf8[0] = 1;
13        for (int i = 1; i < 8; ++i) {
14            powersOf8[i] = powersOf8[i - 1] * 8;
15        }
16    }
17
18    /**
19     * Determines if the current player can win the Nim game
20     * @param piles Array representing the number of stones in each pile
21     * @return true if the current player can win, false otherwise
22     */
23    public boolean nimGame(int[] piles) {
24        return canCurrentPlayerWin(piles);
25    }
26
27    /**
28     * Recursive DFS to determine if current player can win from given state
29     * @param piles Current state of piles
30     * @return true if current player can win from this position
31     */
32    private boolean canCurrentPlayerWin(int[] piles) {
33        // Encode current game state as an integer
34        int encodedState = encodeState(piles);
35      
36        // Check if this state has been computed before
37        if (memo.containsKey(encodedState)) {
38            return memo.get(encodedState);
39        }
40      
41        // Try all possible moves: remove j stones from pile i
42        for (int pileIndex = 0; pileIndex < piles.length; ++pileIndex) {
43            for (int stonesToRemove = 1; stonesToRemove <= piles[pileIndex]; ++stonesToRemove) {
44                // Make the move
45                piles[pileIndex] -= stonesToRemove;
46              
47                // If opponent cannot win after our move, we win
48                if (!canCurrentPlayerWin(piles)) {
49                    // Restore the pile state (backtrack)
50                    piles[pileIndex] += stonesToRemove;
51                    // Cache the winning result
52                    memo.put(encodedState, true);
53                    return true;
54                }
55              
56                // Restore the pile state (backtrack)
57                piles[pileIndex] += stonesToRemove;
58            }
59        }
60      
61        // No winning move found, current player loses
62        memo.put(encodedState, false);
63        return false;
64    }
65
66    /**
67     * Encodes pile state into a unique integer using base-8 representation
68     * @param piles Array of pile sizes
69     * @return Unique integer representing the game state
70     */
71    private int encodeState(int[] piles) {
72        int encodedValue = 0;
73        for (int i = 0; i < piles.length; ++i) {
74            // Each pile contributes: piles[i] * (8^i) to the encoded value
75            encodedValue += piles[i] * powersOf8[i];
76        }
77        return encodedValue;
78    }
79}
80
1class Solution {
2public:
3    bool nimGame(vector<int>& piles) {
4        // Memoization table to store computed game states
5        unordered_map<int, int> memo;
6      
7        // Powers of 8 for state encoding (base-8 representation)
8        // Used to create unique hash for each game state
9        int powersOf8[8] = {1};
10        for (int i = 1; i < 8; ++i) {
11            powersOf8[i] = powersOf8[i - 1] * 8;
12        }
13      
14        // Lambda function to encode the current game state into a unique integer
15        // Each pile count is treated as a digit in base-8 number system
16        auto encodeState = [&](vector<int>& piles) {
17            int stateHash = 0;
18            for (int i = 0; i < piles.size(); ++i) {
19                stateHash += piles[i] * powersOf8[i];
20            }
21            return stateHash;
22        };
23      
24        // Recursive DFS function to determine if current player can win
25        // Returns true if current player has a winning strategy
26        function<bool(vector<int>&)> canWin = [&](vector<int>& piles) {
27            // Get the hash representation of current state
28            int currentStateHash = encodeState(piles);
29          
30            // Check if this state has been computed before
31            if (memo.count(currentStateHash)) {
32                return memo[currentStateHash];
33            }
34          
35            // Try all possible moves from current state
36            for (int pileIndex = 0; pileIndex < piles.size(); ++pileIndex) {
37                // Try removing 1 to piles[pileIndex] stones from current pile
38                for (int stonesToRemove = 1; stonesToRemove <= piles[pileIndex]; ++stonesToRemove) {
39                    // Make the move
40                    piles[pileIndex] -= stonesToRemove;
41                  
42                    // If opponent cannot win after our move, we have a winning move
43                    if (!canWin(piles)) {
44                        // Restore the state before returning
45                        piles[pileIndex] += stonesToRemove;
46                        return memo[currentStateHash] = true;
47                    }
48                  
49                    // Restore the state for next iteration
50                    piles[pileIndex] += stonesToRemove;
51                }
52            }
53          
54            // No winning move found, current player loses
55            return memo[currentStateHash] = false;
56        };
57      
58        // Start the game simulation
59        return canWin(piles);
60    }
61};
62
1// Powers of 8 for state encoding (supports up to 8 piles)
2const powersOf8: number[] = Array(8).fill(1);
3for (let i = 1; i < 8; i++) {
4    powersOf8[i] = powersOf8[i - 1] * 8;
5}
6
7/**
8 * Encodes the game state into a unique number
9 * Each pile's count is multiplied by a power of 8 based on its position
10 * @param piles - Array of pile sizes
11 * @returns Encoded state as a number
12 */
13function encodeState(piles: number[]): number {
14    let state = 0;
15    for (let i = 0; i < piles.length; i++) {
16        state += piles[i] * powersOf8[i];
17    }
18    return state;
19}
20
21// Memoization cache for storing computed game states
22const stateCache: Map<number, boolean> = new Map();
23
24/**
25 * Depth-first search to determine if current player can win
26 * @param piles - Current state of piles
27 * @returns true if current player can win, false otherwise
28 */
29function canCurrentPlayerWin(piles: number[]): boolean {
30    // Get encoded state for memoization
31    const currentState = encodeState(piles);
32  
33    // Check if this state has been computed before
34    if (stateCache.has(currentState)) {
35        return stateCache.get(currentState)!;
36    }
37  
38    // Try all possible moves
39    for (let pileIndex = 0; pileIndex < piles.length; pileIndex++) {
40        // Try removing 1 to piles[pileIndex] stones from current pile
41        for (let stonesToRemove = 1; stonesToRemove <= piles[pileIndex]; stonesToRemove++) {
42            // Make the move
43            piles[pileIndex] -= stonesToRemove;
44          
45            // If opponent cannot win after this move, current player wins
46            if (!canCurrentPlayerWin(piles)) {
47                // Restore the pile before returning
48                piles[pileIndex] += stonesToRemove;
49                stateCache.set(currentState, true);
50                return true;
51            }
52          
53            // Restore the pile for next iteration
54            piles[pileIndex] += stonesToRemove;
55        }
56    }
57  
58    // No winning move found, current player loses
59    stateCache.set(currentState, false);
60    return false;
61}
62
63/**
64 * Determines if the first player can win the Nim game
65 * @param piles - Initial configuration of stone piles
66 * @returns true if first player has a winning strategy, false otherwise
67 */
68function nimGame(piles: number[]): boolean {
69    return canCurrentPlayerWin(piles);
70}
71

Time and Space Complexity

Time Complexity: O(2^(n*m)) where n is the number of piles and m is the maximum number of stones in any pile.

The recursive function explores all possible game states. From each state, we try removing 1 to x stones from each pile where x is the current number of stones in that pile. In the worst case:

  • Each pile can have values from 0 to its initial value
  • The total number of possible states is bounded by the product (piles[0]+1) * (piles[1]+1) * ... * (piles[n-1]+1)
  • For each state, we iterate through all piles and all possible moves, which takes O(n*m) time
  • Due to memoization with @cache, each unique state is computed only once
  • Therefore, the time complexity is O(n*m*Π(piles[i]+1)) which simplifies to O(n*m*(m+1)^n) in the worst case when all piles have m stones

Space Complexity: O((m+1)^n) where n is the number of piles and m is the maximum number of stones in any pile.

The space is dominated by:

  • The memoization cache which stores all possible game states: O((m+1)^n)
  • The recursion call stack depth which can be at most O(n*m) in the worst case when we remove stones one by one
  • Since (m+1)^n grows much faster than n*m, the overall space complexity is O((m+1)^n)

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Exponential Time Complexity Without Memoization

The most critical pitfall is forgetting to use memoization (@cache decorator). Without it, the recursive solution has exponential time complexity because the same game states are recalculated multiple times.

Problem Example:

# BAD: Without memoization
def dfs(state):
    piles_list = list(state)
    for pile_index, stones_in_pile in enumerate(piles_list):
        for stones_to_remove in range(1, stones_in_pile + 1):
            piles_list[pile_index] -= stones_to_remove
            if not dfs(tuple(piles_list)):  # Recalculates same states
                return True
            piles_list[pile_index] += stones_to_remove
    return False

Solution: Always use @cache or @lru_cache to memoize results:

from functools import cache

@cache  # Critical for performance
def dfs(state):
    # ... rest of the code

2. Using Mutable Data Structures as Cache Keys

Attempting to cache with lists instead of tuples will cause a TypeError because lists are unhashable and cannot be used as dictionary keys for memoization.

Problem Example:

@cache
def dfs(state):
    # ERROR: Can't pass a list to a cached function
    return dfs(state)  # If state is a list, this fails

# Calling with: dfs(piles)  # Where piles is a list

Solution: Always convert to tuple before passing to cached function:

return dfs(tuple(piles))  # Convert list to tuple

3. Incorrect State Restoration During Backtracking

Failing to properly restore the state after trying a move can corrupt the game state for subsequent iterations.

Problem Example:

def dfs(state):
    piles_list = list(state)
    for pile_index, stones_in_pile in enumerate(piles_list):
        for stones_to_remove in range(1, stones_in_pile + 1):
            piles_list[pile_index] -= stones_to_remove
            if not dfs(tuple(piles_list)):
                return True
            # FORGOT TO RESTORE: piles_list[pile_index] += stones_to_remove
    return False

Solution: Always restore the state after recursive call:

piles_list[pile_index] -= stones_to_remove
if not dfs(tuple(piles_list)):
    return True
piles_list[pile_index] += stones_to_remove  # Restore state

4. Inefficient State Representation

For large pile values, the solution might hit memory limits or recursion depth limits. Consider that for piles like [1000000, 1000000], the number of possible states is enormous.

Alternative Approach for Standard Nim: If this is the standard Nim game, you can use the mathematical solution with XOR:

def nimGame(self, piles: List[int]) -> bool:
    # Standard Nim game solution: XOR all pile sizes
    xor_sum = 0
    for pile in piles:
        xor_sum ^= pile
    return xor_sum != 0  # First player wins if XOR is non-zero

5. Not Handling Edge Cases

The code doesn't explicitly handle edge cases like empty input or piles with zero stones.

Problem Example:

# These cases might not be handled properly:
piles = []  # Empty game
piles = [0, 0, 0]  # All piles already empty
piles = [0, 5, 0]  # Some piles empty

Solution: Add validation at the beginning:

def nimGame(self, piles: List[int]) -> bool:
    if not piles or all(p == 0 for p in piles):
        return False  # No moves available, first player loses
  
    # Continue with normal solution...
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which algorithm is best for finding the shortest distance between two points in an unweighted graph?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More