1140. Stone Game II
Problem Description
Alice and Bob are playing a game with piles of stones. There are several piles arranged in a row, where each pile contains a positive integer number of stones represented by piles[i]
.
The game follows these rules:
- Players take turns, with Alice going first
- On each turn, a player can take all the stones from the first X piles (starting from the leftmost available pile)
- The value of X must satisfy:
1 <= X <= 2M
- After taking X piles, the value of M is updated:
M = max(M, X)
- Initially,
M = 1
- The game continues until all stones are taken
- The player with the most stones at the end wins
For example, if M = 1
at the start of a turn, the player can take stones from the first 1 or 2 piles (X
can be 1 or 2 since 2M = 2
). If they choose to take 2 piles, then M
becomes max(1, 2) = 2
for the next turn.
Both Alice and Bob play optimally, meaning they always make the move that maximizes their own score. The task is to determine the maximum number of stones Alice can collect when both players play their best strategy.
The solution uses dynamic programming with memoization. The function dfs(i, m)
calculates the maximum stones the current player can get starting from pile index i
with the current M
value being m
. Since both players play optimally, when one player makes a move, they consider that their opponent will also play optimally in response. The maximum stones a player can get is the total remaining stones minus what their opponent can optimally get in the next turn.
Intuition
The key insight is that this is a zero-sum game - what one player gains, the other player loses. Since both players play optimally, we need to think recursively about the best outcome for the current player.
When it's a player's turn, they want to maximize their own score. But since the total number of stones is fixed, maximizing their score is equivalent to minimizing their opponent's score. This leads us to a minimax approach.
Consider what happens when a player makes a move:
- They take
X
piles (where1 <= X <= 2M
) - The opponent then plays from the remaining piles
- The opponent will also play optimally to maximize their own score
So from the current player's perspective, if they take X
piles, their total score will be:
- The stones they take now (sum of first
X
piles) - Plus whatever stones they can get in future turns
But here's the clever part: instead of tracking both players' scores separately, we can think of it as "the maximum stones the current player can get from the remaining piles." When we switch turns, the "current player" changes, but the logic remains the same.
This means if there are stones from index i
to the end, and the current player takes X
piles, they get:
- All remaining stones (
s[n] - s[i]
) - Minus what the opponent can optimally get from the remaining position (
dfs(i + X, new_M)
)
The formula becomes: current_player_score = total_remaining - opponent_best_score
We use prefix sums (s
) to quickly calculate the sum of any range of piles. The base case is when 2M >= remaining_piles
, meaning the current player can take all remaining stones in one turn.
The memoization with @cache
ensures we don't recalculate the same game state multiple times, as the same position (i, m)
can be reached through different sequences of moves.
Learn more about Math, Dynamic Programming and Prefix Sum patterns.
Solution Approach
The solution uses prefix sum combined with memoization search (dynamic programming with caching) to solve this game theory problem.
Step 1: Prefix Sum Preprocessing
First, we build a prefix sum array s
where s[i]
represents the sum of the first i
elements from the piles
array:
s = list(accumulate(piles, initial=0))
This allows us to calculate the sum of any range of piles in O(1) time. For example, s[n] - s[i]
gives us the sum of all stones from index i
to the end.
Step 2: Define the Recursive Function
We define dfs(i, m)
which returns the maximum stones the current player can get when:
- Starting from pile index
i
- The current value of
M
ism
Step 3: Base Case
if m * 2 >= n - i: return s[n] - s[i]
If 2M >= remaining_piles
, the current player can take all remaining stones in one turn. The number of remaining piles is n - i
, so if 2m >= n - i
, we return the sum of all remaining stones.
Step 4: Recursive Case
return max(
s[n] - s[i] - dfs(i + x, max(m, x)) for x in range(1, m << 1 | 1)
)
The current player tries all possible moves where they take x
piles (1 <= x <= 2m
):
range(1, m << 1 | 1)
generates values from 1 to 2m (inclusive). The expressionm << 1 | 1
equals2m + 1
- For each choice of
x
:- The player takes the first
x
piles starting from indexi
- The new position becomes
i + x
for the opponent - The new
M
value becomesmax(m, x)
- The player's score is: total remaining stones minus what the opponent can optimally get
- Formula:
s[n] - s[i] - dfs(i + x, max(m, x))
- The player takes the first
Step 5: Memoization
The @cache
decorator automatically memoizes the results of dfs(i, m)
. This prevents recalculation of the same game state, reducing time complexity from exponential to polynomial.
Step 6: Return the Answer
return dfs(0, 1)
Alice starts first from index 0 with M = 1
, so the answer is dfs(0, 1)
.
The time complexity is O(n³) where n is the number of piles, as there are O(n²) possible states (i, m) and each state requires O(n) time to compute. The space complexity is O(n²) for the memoization cache.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a small example with piles = [2, 7, 9, 4, 4]
.
Initial Setup:
- Build prefix sum array:
s = [0, 2, 9, 18, 22, 26]
- Total stones = 26
- Alice starts with
dfs(0, 1)
(index 0, M=1)
Turn 1 - Alice's turn (i=0, m=1):
-
Alice can take X piles where 1 ≤ X ≤ 2M = 2
-
Option 1: Take 1 pile (X=1)
- Alice gets pile[0] = 2 stones immediately
- New state: i=1, M=max(1,1)=1
- Bob will play optimally from
dfs(1, 1)
- Alice's total = (26-0) - dfs(1,1) = 26 - Bob's best from position 1
-
Option 2: Take 2 piles (X=2)
- Alice gets piles[0,1] = 2+7 = 9 stones immediately
- New state: i=2, M=max(1,2)=2
- Bob will play optimally from
dfs(2, 2)
- Alice's total = (26-0) - dfs(2,2) = 26 - Bob's best from position 2
Let's trace Option 2 deeper:
Turn 2 - Bob's turn (i=2, m=2):
- Remaining stones from index 2: s[5]-s[2] = 26-9 = 17
- Bob can take X piles where 1 ≤ X ≤ 2M = 4
- Since 2M=4 ≥ remaining piles (3), Bob can take all remaining stones!
- Base case triggered: Bob gets all 17 remaining stones
Back to Turn 1:
- Option 2 gives Alice: 26 - 17 = 9 stones
Let's check Option 1:
Turn 2 - Bob's turn (i=1, m=1):
- Remaining stones: s[5]-s[1] = 26-2 = 24
- Bob can take X piles where 1 ≤ X ≤ 2
- Bob evaluates taking 1 pile vs 2 piles
- After calculation (involving more recursive calls), Bob's optimal play gives him 15 stones
Back to Turn 1:
- Option 1 gives Alice: 26 - 15 = 11 stones
Final Decision: Alice chooses Option 1 (take 1 pile) to get 11 stones total, which is better than Option 2 (9 stones).
The key insight: Even though taking more piles initially seems better (9 stones vs 2 stones), it gives Bob a larger M value, allowing him to take more stones on his turn. By taking fewer piles, Alice limits Bob's options and ultimately gets more stones overall.
Solution Implementation
1class Solution:
2 def stoneGameII(self, piles: List[int]) -> int:
3 from functools import cache
4 from itertools import accumulate
5 from typing import List
6
7 @cache
8 def dp(start_index: int, max_take: int) -> int:
9 """
10 Calculate the maximum stones the current player can get starting from start_index
11 with the ability to take up to 2 * max_take piles.
12
13 Args:
14 start_index: Current position in the piles array
15 max_take: Current M value (maximum X from previous turn)
16
17 Returns:
18 Maximum stones the current player can collect from this state
19 """
20 # If we can take all remaining piles, take them all
21 if max_take * 2 >= total_piles - start_index:
22 return prefix_sum[total_piles] - prefix_sum[start_index]
23
24 # Try taking x piles (1 <= x <= 2 * max_take) and find the maximum outcome
25 # The current player's score = total remaining stones - opponent's best score
26 max_stones = 0
27 for num_piles_to_take in range(1, 2 * max_take + 1):
28 # Calculate stones we get: total remaining - what opponent gets optimally
29 stones_obtained = prefix_sum[total_piles] - prefix_sum[start_index] - \
30 dp(start_index + num_piles_to_take,
31 max(max_take, num_piles_to_take))
32 max_stones = max(max_stones, stones_obtained)
33
34 return max_stones
35
36 # Initialize variables
37 total_piles = len(piles)
38 # Create prefix sum array for quick range sum calculation
39 # prefix_sum[i] = sum of piles[0:i]
40 prefix_sum = list(accumulate(piles, initial=0))
41
42 # Start the game from index 0 with M = 1
43 return dp(0, 1)
44
1class Solution {
2 // Prefix sum array to calculate sum of piles from index i to j efficiently
3 private int[] prefixSum;
4 // Memoization table: dp[i][m] = maximum stones the current player can get
5 // starting from index i with M = m
6 private Integer[][] dp;
7 // Total number of piles
8 private int n;
9
10 public int stoneGameII(int[] piles) {
11 n = piles.length;
12
13 // Build prefix sum array for O(1) range sum queries
14 prefixSum = new int[n + 1];
15 for (int i = 0; i < n; i++) {
16 prefixSum[i + 1] = prefixSum[i] + piles[i];
17 }
18
19 // Initialize memoization table
20 dp = new Integer[n][n + 1];
21
22 // Start the game from index 0 with M = 1
23 return dfs(0, 1);
24 }
25
26 /**
27 * Calculate the maximum stones the current player can get
28 * @param index Current starting index in piles array
29 * @param M Current value of M (player can take 1 to 2*M piles)
30 * @return Maximum stones the current player can collect from this state
31 */
32 private int dfs(int index, int M) {
33 // Base case: if we can take all remaining piles, take them all
34 if (2 * M >= n - index) {
35 return prefixSum[n] - prefixSum[index];
36 }
37
38 // Check if this state has been calculated before
39 if (dp[index][M] != null) {
40 return dp[index][M];
41 }
42
43 int maxStones = 0;
44
45 // Try taking X piles where X is from 1 to 2*M
46 for (int X = 1; X <= 2 * M; X++) {
47 // Calculate maximum stones we can get by taking X piles
48 // Total remaining stones - opponent's best result = our result
49 int currentPlayerStones = prefixSum[n] - prefixSum[index] -
50 dfs(index + X, Math.max(M, X));
51 maxStones = Math.max(maxStones, currentPlayerStones);
52 }
53
54 // Store result in memoization table and return
55 dp[index][M] = maxStones;
56 return maxStones;
57 }
58}
59
1class Solution {
2public:
3 int stoneGameII(vector<int>& piles) {
4 int numPiles = piles.size();
5
6 // Create prefix sum array to quickly calculate sum of any range
7 // prefixSum[i] represents sum of piles[0...i-1]
8 vector<int> prefixSum(numPiles + 1, 0);
9 for (int i = 0; i < numPiles; ++i) {
10 prefixSum[i + 1] = prefixSum[i] + piles[i];
11 }
12
13 // Memoization table: dp[i][m] stores the maximum stones Alice can get
14 // starting from index i with parameter M = m
15 vector<vector<int>> dp(numPiles, vector<int>(numPiles + 1, 0));
16
17 // Recursive function with memoization
18 // Returns maximum stones the current player can get starting from index 'index' with parameter 'M'
19 function<int(int, int)> dfs = [&](int index, int M) -> int {
20 // Base case: if we can take all remaining piles
21 // (when 2*M is greater than or equal to remaining piles)
22 if (2 * M >= numPiles - index) {
23 return prefixSum[numPiles] - prefixSum[index];
24 }
25
26 // Return memoized result if already computed
27 if (dp[index][M] != 0) {
28 return dp[index][M];
29 }
30
31 int maxStones = 0;
32
33 // Try taking X piles where X ranges from 1 to 2*M
34 for (int X = 1; X <= 2 * M; ++X) {
35 // Calculate maximum stones we can get by taking X piles
36 // Total remaining stones - stones opponent will get optimally
37 int stonesIfTakeX = prefixSum[numPiles] - prefixSum[index] -
38 dfs(index + X, max(X, M));
39 maxStones = max(maxStones, stonesIfTakeX);
40 }
41
42 // Store and return the result
43 dp[index][M] = maxStones;
44 return maxStones;
45 };
46
47 // Start the game from index 0 with M = 1
48 return dfs(0, 1);
49 }
50};
51
1/**
2 * Stone Game II - Dynamic Programming with Memoization
3 * Alice and Bob play a game with piles of stones. Players take turns, with Alice starting first.
4 * On each turn, a player can take X piles where 1 <= X <= 2M (M is initially 1).
5 * After taking X piles, M becomes max(M, X) for the next player.
6 * The goal is to maximize the number of stones Alice can get.
7 *
8 * @param piles - Array of integers representing the number of stones in each pile
9 * @returns The maximum number of stones Alice can get
10 */
11function stoneGameII(piles: number[]): number {
12 const numPiles: number = piles.length;
13
14 // Memoization table: memo[i][m] stores the maximum stones the current player can get
15 // starting from pile i with M = m
16 const memo: number[][] = Array.from(
17 { length: numPiles },
18 () => new Array(numPiles + 1).fill(0)
19 );
20
21 // Prefix sum array for quick calculation of sum of stones from index i to end
22 // prefixSum[i] = sum of piles[0] to piles[i-1]
23 const prefixSum: number[] = new Array(numPiles + 1).fill(0);
24 for (let i = 0; i < numPiles; i++) {
25 prefixSum[i + 1] = prefixSum[i] + piles[i];
26 }
27
28 /**
29 * DFS with memoization to find the maximum stones the current player can get
30 *
31 * @param startIndex - The starting pile index for the current turn
32 * @param currentM - The current value of M (maximum piles constraint parameter)
33 * @returns Maximum stones the current player can get from this state
34 */
35 const dfs = (startIndex: number, currentM: number): number => {
36 // If we can take all remaining piles (2M >= remaining piles), take them all
37 if (currentM * 2 >= numPiles - startIndex) {
38 return prefixSum[numPiles] - prefixSum[startIndex];
39 }
40
41 // Return memoized result if already computed
42 if (memo[startIndex][currentM]) {
43 return memo[startIndex][currentM];
44 }
45
46 let maxStones: number = 0;
47
48 // Try taking x piles where x ranges from 1 to 2M
49 for (let x = 1; x <= currentM * 2; x++) {
50 // Calculate maximum stones by considering:
51 // Total remaining stones - stones the opponent will get optimally
52 const totalRemaining: number = prefixSum[numPiles] - prefixSum[startIndex];
53 const opponentStones: number = dfs(startIndex + x, Math.max(currentM, x));
54 maxStones = Math.max(maxStones, totalRemaining - opponentStones);
55 }
56
57 // Memoize and return the result
58 memo[startIndex][currentM] = maxStones;
59 return maxStones;
60 };
61
62 // Start the game with Alice at pile 0 with M = 1
63 return dfs(0, 1);
64}
65
Time and Space Complexity
The time complexity is O(n^3)
, and the space complexity is O(n^2)
.
Time Complexity Analysis:
- The function
dfs(i, m)
has two parameters:i
ranges from0
ton-1
, andm
can range from1
to at mostn
. - This gives us
O(n^2)
possible unique states for the memoization cache. - For each state
(i, m)
, we iterate throughx
from1
to2*m
, which in the worst case can be up to2n
iterations (whenm
approachesn
). - Therefore, the total time complexity is
O(n^2) * O(n) = O(n^3)
.
Space Complexity Analysis:
- The
@cache
decorator stores all unique combinations of(i, m)
parameters. - Since
i
can be from0
ton-1
andm
can be from1
to at mostn
, there areO(n^2)
possible states to cache. - The prefix sum array
s
takesO(n)
space. - The recursion depth is at most
O(n)
in the worst case. - The dominant factor is the cache storage, giving us
O(n^2)
space complexity.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Incorrect Understanding of the Game Rules
Pitfall: A common mistake is misunderstanding how the parameter M works. Some might think:
- M is fixed throughout the game
- M only affects the current player
- The update rule
M = max(M, X)
applies globally rather than tracking separately for each game branch
Why it happens: The problem statement's complexity and the interaction between X and M can be confusing. Each recursive branch maintains its own M value based on the choices made in that particular game path.
Solution: Remember that M is passed as a parameter in the recursion and updated locally for each branch. When a player takes X piles, the NEW M value (max(M, X)) is what gets passed to the opponent's turn, not a global M.
2. Off-by-One Errors in Range Calculation
Pitfall: Using incorrect ranges when iterating through possible moves:
# Wrong - doesn't include 2*m
for x in range(1, 2 * m):
# Wrong - starts from 0
for x in range(0, 2 * m + 1):
# Correct
for x in range(1, 2 * m + 1):
Why it happens: The problem states "1 <= X <= 2M", which means both 1 and 2M are valid choices (inclusive range).
Solution: Always use range(1, 2 * m + 1)
to include all valid moves from 1 to 2M inclusive.
3. Forgetting to Check Array Bounds
Pitfall: Not handling the case where taking X piles would exceed the array bounds:
# Wrong - might access invalid indices
def dfs(i, m):
max_score = 0
for x in range(1, 2 * m + 1):
# This could make i + x > n
next_score = dfs(i + x, max(m, x))
# ...
Why it happens: When near the end of the array, 2M might be larger than the remaining piles.
Solution: Either add a bounds check in the loop or handle it in the base case:
# Option 1: Limit the range
for x in range(1, min(2 * m + 1, n - i + 1)):
# Option 2: Handle in base case (as shown in the solution)
if m * 2 >= n - i:
return s[n] - s[i]
4. Incorrectly Calculating Opponent's Optimal Play
Pitfall: Trying to maximize both players' scores or minimize the opponent's score directly:
# Wrong - trying to minimize opponent's score
return min(dfs(i + x, max(m, x)) for x in range(1, 2 * m + 1))
# Wrong - trying to maximize current player's immediate gain
return max(sum(piles[i:i+x]) + dfs(i + x, max(m, x)) for x in range(1, 2 * m + 1))
Why it happens: Misunderstanding the minimax principle in game theory. Since both players play optimally, after the current player's move, the opponent will maximize THEIR score, not help the current player.
Solution: Use the correct formula: The current player's best score = Total remaining stones - Opponent's best score from the resulting position:
return max(s[n] - s[i] - dfs(i + x, max(m, x)) for x in range(1, 2 * m + 1))
5. Not Using Memoization or Using It Incorrectly
Pitfall: Implementing plain recursion without caching, leading to exponential time complexity:
# Wrong - no memoization
def dfs(i, m):
# recursive logic without caching
# Wrong - manual memoization with incorrect key
memo = {}
def dfs(i, m):
if i in memo: # Should use (i, m) as key, not just i
return memo[i]
Why it happens: Underestimating the number of repeated subproblems or incorrectly identifying the state variables.
Solution: Use Python's @cache
decorator or implement manual memoization with the correct state tuple (i, m):
@cache
def dfs(i, m):
# recursive logic
# OR manual memoization
memo = {}
def dfs(i, m):
if (i, m) in memo:
return memo[(i, m)]
# ... calculate result ...
memo[(i, m)] = result
return result
What is the best way of checking if an element exists in a sorted array once in terms of time complexity? Select the best that applies.
Recommended Readings
Math for Technical Interviews How much math do I need to know for technical interviews The short answer is about high school level math Computer science is often associated with math and some universities even place their computer science department under the math faculty However the reality is that you
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
Prefix Sum The prefix sum is an incredibly powerful and straightforward technique Its primary goal is to allow for constant time range sum queries on an array What is Prefix Sum The prefix sum of an array at index i is the sum of all numbers from index 0 to i By
Want a Structured Path to Master System Design Too? Don’t Miss This!