Facebook Pixel

877. Stone Game

Problem Description

Alice and Bob are playing a strategic game with stones. The game setup consists of an even number of piles arranged in a row, where each pile contains a positive integer number of stones represented by piles[i].

The game rules are:

  • Players take turns, with Alice going first
  • On each turn, a player must take an entire pile of stones from either the beginning or the end of the row
  • Players continue taking piles until no piles remain
  • The winner is the player who collects the most stones in total
  • Since the total number of stones is odd, there cannot be a tie

Both players play optimally, meaning they always make the best possible move to maximize their chances of winning.

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

For example, if piles = [5, 3, 4, 5]:

  • Alice could take 5 from the beginning, leaving [3, 4, 5]
  • Bob then takes from either end
  • The game continues with both players making optimal choices
  • The function should return whether Alice ends up with more stones than Bob

The solution uses dynamic programming with memoization to calculate the maximum advantage Alice can achieve. The dfs(i, j) function computes the maximum score difference (current player's score minus opponent's score) when choosing optimally from piles between indices i and j. At each state, the player chooses between taking the left pile (piles[i]) or the right pile (piles[j]), considering that the opponent will also play optimally in subsequent turns.

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

Intuition

When facing this problem, we need to think about how to model optimal play for both players. The key insight is that each player wants to maximize their own score while minimizing their opponent's score.

Let's think about what happens when a player faces a subarray of piles from index i to j. The player has two choices:

  1. Take the left pile (piles[i]) and leave piles from i+1 to j for the opponent
  2. Take the right pile (piles[j]) and leave piles from i to j-1 for the opponent

Here's the crucial observation: instead of tracking both players' scores separately, we can track the score difference between the current player and their opponent. This simplifies our state representation significantly.

When we take piles[i], we gain piles[i] points, but then our opponent plays optimally on the remaining piles [i+1, j]. If we denote the optimal score difference for the remaining subproblem as dfs(i+1, j), then our opponent will achieve that difference. From our perspective, this means we get piles[i] - dfs(i+1, j).

Why the subtraction? Because dfs(i+1, j) represents how much better the player (now our opponent) does compared to their opponent (us) in that subgame. So we subtract it from our gain.

Similarly, if we take piles[j], our score difference becomes piles[j] - dfs(i, j-1).

Since we play optimally, we choose the option that gives us the maximum score difference: dfs(i, j) = max(piles[i] - dfs(i+1, j), piles[j] - dfs(i, j-1))

The base case occurs when i > j, meaning no piles are left, so the score difference is 0.

Finally, Alice wins if dfs(0, len(piles)-1) > 0, meaning she can achieve a positive score difference when playing optimally from the beginning.

Learn more about Math and Dynamic Programming patterns.

Solution Approach

The solution implements a top-down dynamic programming approach with memoization using Python's @cache decorator.

Implementation Details:

  1. Main Function Structure:

    • The stoneGame function takes the list of piles as input
    • It defines an inner recursive function dfs(i, j) that calculates the maximum score difference for the current player
  2. Recursive Function dfs(i, j):

    • Parameters: i and j represent the left and right boundaries of the remaining piles
    • Base Case: When i > j, no piles remain, so return 0
    • Recursive Case: Calculate the maximum of two choices:
      • Take left pile: piles[i] - dfs(i + 1, j)
      • Take right pile: piles[j] - dfs(i, j - 1)
  3. Memoization:

    • The @cache decorator automatically memoizes the results of dfs(i, j)
    • This prevents redundant calculations for the same subproblems
    • Without memoization, the time complexity would be exponential; with it, we achieve O(n²) time complexity
  4. State Space:

    • There are O(n²) possible states where n is the number of piles
    • Each state (i, j) represents a unique subarray of piles
  5. Final Decision:

    • Call dfs(0, len(piles) - 1) to get Alice's maximum advantage when starting with all piles
    • Return True if this value is positive (Alice wins), otherwise False

Time and Space Complexity:

  • Time Complexity: O(n²) where n is the number of piles, as we compute each state (i, j) at most once
  • Space Complexity: O(n²) for the memoization cache plus O(n) for the recursion stack

The beauty of this approach is that it naturally handles the alternating turns by recursively computing score differences, eliminating the need to track whose turn it is or maintain separate scores for each player.

Ready to land your dream job?

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

Start Evaluator

Example Walkthrough

Let's walk through a small example with piles = [5, 3, 4, 5] to illustrate how the dynamic programming solution works.

We'll use dfs(i, j) to represent the maximum score difference (current player's score - opponent's score) when choosing optimally from piles between indices i and j.

Initial call: dfs(0, 3) - Alice plays first with all 4 piles available.

At dfs(0, 3), Alice has two choices:

  • Take left pile (5): Score difference = 5 - dfs(1, 3)
  • Take right pile (5): Score difference = 5 - dfs(0, 2)

Let's evaluate dfs(1, 3) (piles [3, 4, 5]):

  • Take left (3): 3 - dfs(2, 3)
  • Take right (5): 5 - dfs(1, 2)

For dfs(2, 3) (piles [4, 5]):

  • Take left (4): 4 - dfs(3, 3) = 4 - 5 = -1
  • Take right (5): 5 - dfs(2, 2) = 5 - 4 = 1
  • Maximum: 1

For dfs(1, 2) (piles [3, 4]):

  • Take left (3): 3 - dfs(2, 2) = 3 - 4 = -1
  • Take right (4): 4 - dfs(1, 1) = 4 - 3 = 1
  • Maximum: 1

So dfs(1, 3) = max(3 - 1, 5 - 1) = max(2, 4) = 4

Similarly, we can calculate dfs(0, 2) (piles [5, 3, 4]):

  • Take left (5): 5 - dfs(1, 2) = 5 - 1 = 4
  • Take right (4): 4 - dfs(0, 1) = 4 - 2 = 2
  • Maximum: 4

Finally, dfs(0, 3) = max(5 - 4, 5 - 4) = max(1, 1) = 1

Since dfs(0, 3) = 1 > 0, Alice wins the game. The positive value means Alice can secure 1 more stone than Bob when both play optimally.

In this example, one optimal play sequence would be:

  1. Alice takes 5 (left), leaving [3, 4, 5]
  2. Bob takes 5 (right), leaving [3, 4]
  3. Alice takes 4 (right), leaving [3]
  4. Bob takes 3 Final scores: Alice = 9, Bob = 8

Solution Implementation

1class Solution:
2    def stoneGame(self, piles: List[int]) -> bool:
3        """
4        Determine if Alice can win the stone game.
5      
6        Alice and Bob take turns picking stones from either end of the array.
7        Alice goes first. Both play optimally.
8      
9        Args:
10            piles: List of integers representing stone piles
11          
12        Returns:
13            True if Alice wins (gets more stones than Bob), False otherwise
14        """
15        from functools import cache
16      
17        @cache
18        def calculate_score_difference(left: int, right: int) -> int:
19            """
20            Calculate the maximum score difference the current player can achieve.
21          
22            The score difference represents how many more stones the current player
23            can get compared to their opponent when both play optimally.
24          
25            Args:
26                left: Left boundary index of remaining piles
27                right: Right boundary index of remaining piles
28              
29            Returns:
30                Maximum score difference achievable by current player
31            """
32            # Base case: no piles left to pick
33            if left > right:
34                return 0
35          
36            # Current player chooses optimally between:
37            # 1. Taking left pile: gain piles[left], opponent gets the difference from remaining
38            # 2. Taking right pile: gain piles[right], opponent gets the difference from remaining
39            take_left = piles[left] - calculate_score_difference(left + 1, right)
40            take_right = piles[right] - calculate_score_difference(left, right - 1)
41          
42            # Return the maximum score difference achievable
43            return max(take_left, take_right)
44      
45        # Alice wins if her score difference is positive
46        return calculate_score_difference(0, len(piles) - 1) > 0
47
1class Solution {
2    // Array to store the stone piles
3    private int[] piles;
4    // Memoization table for dynamic programming
5    // dp[i][j] represents the maximum score difference the current player can achieve
6    // when choosing from piles[i] to piles[j]
7    private int[][] dp;
8
9    /**
10     * Determines if the first player can win the stone game.
11     * 
12     * @param piles Array of stone piles where each element represents the number of stones
13     * @return true if the first player can win, false otherwise
14     */
15    public boolean stoneGame(int[] piles) {
16        this.piles = piles;
17        int n = piles.length;
18        dp = new int[n][n];
19      
20        // First player wins if their score difference is positive
21        return dfs(0, n - 1) > 0;
22    }
23
24    /**
25     * Recursive function with memoization to calculate the maximum score difference
26     * the current player can achieve.
27     * 
28     * @param left Left boundary index of remaining piles
29     * @param right Right boundary index of remaining piles
30     * @return Maximum score difference the current player can achieve
31     */
32    private int dfs(int left, int right) {
33        // Base case: no piles left to choose from
34        if (left > right) {
35            return 0;
36        }
37      
38        // Return memoized result if already calculated
39        if (dp[left][right] != 0) {
40            return dp[left][right];
41        }
42      
43        // Current player chooses the pile that maximizes their score difference
44        // If choosing left pile: gain piles[left] and opponent plays optimally on remaining piles
45        // If choosing right pile: gain piles[right] and opponent plays optimally on remaining piles
46        int chooseLeft = piles[left] - dfs(left + 1, right);
47        int chooseRight = piles[right] - dfs(left, right - 1);
48      
49        // Store and return the maximum score difference
50        dp[left][right] = Math.max(chooseLeft, chooseRight);
51        return dp[left][right];
52    }
53}
54
1class Solution {
2public:
3    bool stoneGame(vector<int>& piles) {
4        int n = piles.size();
5      
6        // dp[i][j] represents the maximum score difference (current player - opponent)
7        // when playing optimally on piles from index i to j
8        int dp[n][n];
9        memset(dp, 0, sizeof(dp));
10      
11        // Recursive function with memoization to calculate the maximum score difference
12        // Parameters: i - left boundary index, j - right boundary index
13        // Returns: maximum score difference the current player can achieve
14        auto calculateMaxDifference = [&](this auto&& calculateMaxDifference, int i, int j) -> int {
15            // Base case: no piles left to choose
16            if (i > j) {
17                return 0;
18            }
19          
20            // Return memoized result if already calculated
21            if (dp[i][j] != 0) {
22                return dp[i][j];
23            }
24          
25            // Current player has two choices:
26            // 1. Take the left pile (piles[i]) and subtract opponent's best score from remaining piles
27            // 2. Take the right pile (piles[j]) and subtract opponent's best score from remaining piles
28            int takeLeft = piles[i] - calculateMaxDifference(i + 1, j);
29            int takeRight = piles[j] - calculateMaxDifference(i, j - 1);
30          
31            // Store and return the maximum score difference
32            return dp[i][j] = max(takeLeft, takeRight);
33        };
34      
35        // Alice wins if her score difference is positive
36        return calculateMaxDifference(0, n - 1) > 0;
37    }
38};
39
1/**
2 * Determines if the first player can win the stone game
3 * @param piles - Array of stone piles where each element represents the number of stones in that pile
4 * @returns true if the first player can win, false otherwise
5 */
6function stoneGame(piles: number[]): boolean {
7    const pileCount: number = piles.length;
8  
9    // Memoization table to store computed results
10    // memo[i][j] represents the maximum score difference the current player can achieve
11    // when choosing from piles[i] to piles[j]
12    const memo: number[][] = new Array(pileCount)
13        .fill(0)
14        .map(() => new Array(pileCount).fill(0));
15  
16    /**
17     * Recursively calculates the maximum score difference for the current player
18     * @param left - Left boundary index of available piles
19     * @param right - Right boundary index of available piles
20     * @returns Maximum score difference the current player can achieve
21     */
22    const calculateMaxScoreDifference = (left: number, right: number): number => {
23        // Base case: no piles left to choose from
24        if (left > right) {
25            return 0;
26        }
27      
28        // Check if result is already computed (memoization)
29        if (memo[left][right] === 0) {
30            // Choose left pile: gain piles[left] stones, 
31            // then subtract opponent's best score from remaining piles
32            const chooseLeft: number = piles[left] - calculateMaxScoreDifference(left + 1, right);
33          
34            // Choose right pile: gain piles[right] stones,
35            // then subtract opponent's best score from remaining piles
36            const chooseRight: number = piles[right] - calculateMaxScoreDifference(left, right - 1);
37          
38            // Store the maximum score difference in memo table
39            memo[left][right] = Math.max(chooseLeft, chooseRight);
40        }
41      
42        return memo[left][right];
43    };
44  
45    // First player wins if their score difference is positive
46    return calculateMaxScoreDifference(0, pileCount - 1) > 0;
47}
48

Time and Space Complexity

Time Complexity: O(n²)

The solution uses dynamic programming with memoization. The dfs function explores all possible game states defined by the range [i, j]. There are n possible values for i (from 0 to n-1) and n possible values for j (from 0 to n-1). However, only states where i ≤ j are valid, which gives us approximately n²/2 unique states. Each state is computed only once due to the @cache decorator, and each computation takes O(1) time (just comparing two recursive calls). Therefore, the overall time complexity is O(n²).

Space Complexity: O(n²)

The space complexity consists of two components:

  1. Memoization cache: The @cache decorator stores the results of all unique (i, j) pairs where i ≤ j. This requires O(n²) space to store approximately n²/2 states.
  2. Recursion call stack: In the worst case, the recursion depth can go up to n levels (when we process from one end of the array to the other). This adds O(n) to the space complexity.

Since O(n²) + O(n) = O(n²), the overall space complexity is O(n²).

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

Common Pitfalls

1. Incorrect Score Calculation Logic

Pitfall: A common mistake is trying to track both players' scores separately or incorrectly handling the alternating turns. Developers often write something like:

# INCORRECT approach
def dfs(i, j, is_alice_turn):
    if i > j:
        return 0
  
    if is_alice_turn:
        # Trying to maximize Alice's score
        return max(piles[i] + dfs(i+1, j, False), 
                  piles[j] + dfs(i, j-1, False))
    else:
        # Trying to minimize Alice's score (Bob's turn)
        return min(dfs(i+1, j, True), 
                  dfs(i, j-1, True))

Why it's wrong: This approach fails because it doesn't properly account for both players' scores. You'd need to return and track both scores, making the logic more complex.

Solution: Use the score difference approach where each recursive call returns current_player_score - opponent_score. This elegantly handles alternating turns:

def dfs(i, j):
    if i > j:
        return 0
    # The subtraction naturally handles the alternation
    return max(piles[i] - dfs(i+1, j), 
              piles[j] - dfs(i, j-1))

2. Missing or Incorrect Memoization

Pitfall: Forgetting to add memoization or implementing it incorrectly:

# INCORRECT - No memoization
def stoneGame(self, piles):
    def dfs(i, j):
        if i > j:
            return 0
        return max(piles[i] - dfs(i+1, j), 
                  piles[j] - dfs(i, j-1))
    return dfs(0, len(piles)-1) > 0

Why it's wrong: Without memoization, the time complexity becomes exponential O(2^n) because the same subproblems are calculated multiple times.

Solution: Always use memoization for overlapping subproblems:

from functools import cache

@cache  # or use lru_cache(None)
def dfs(i, j):
    # recursive logic here

3. Mathematical Insight Oversight

Pitfall: Not recognizing that Alice can always win when there's an even number of piles.

Mathematical Proof: With an even number of piles, Alice can always force a win by choosing either all odd-indexed or all even-indexed piles. She can determine which set has more stones at the start and force that outcome.

Optimal Solution: For this specific problem, you could simply return True:

def stoneGame(self, piles):
    return True

However, the dynamic programming solution is more general and works even if the rules were modified (e.g., odd number of piles or different winning conditions).

4. Index Boundary Errors

Pitfall: Incorrect base case or index handling:

# INCORRECT - Wrong base case
def dfs(i, j):
    if i == j:  # Wrong! Should be i > j
        return piles[i]  # This would double-count the last pile

Solution: The correct base case is if i > j: return 0 because when left index exceeds right index, there are no piles left to pick.

5. Forgetting to Clear Cache Between Test Cases

Pitfall: When testing multiple instances, the cache from previous test cases might interfere:

# Potential issue in testing environment
cache = {}
def dfs(i, j):
    if (i, j) in cache:
        return cache[(i, j)]
    # ... recursive logic

Solution: Either use function-local memoization with @cache decorator (which creates a new cache for each function instance) or explicitly clear the cache between test cases if using a global cache.

Discover Your Strengths and Weaknesses: Take Our 3-Minute Quiz to Tailor Your Study Plan:

Which type of traversal does breadth first search do?


Recommended Readings

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

Load More