1872. Stone Game VIII
Problem Description
Alice and Bob are playing a stone game where they take turns, with Alice going first.
The game starts with n
stones arranged in a row, where each stone has a value given in the array stones
.
On each player's turn (while there's more than one stone remaining), the player must:
- Choose an integer
x > 1
and remove the leftmostx
stones from the row - Add the sum of the removed stones' values to their score
- Place a new stone on the left side of the row, with a value equal to the sum of the removed stones
The game continues until only one stone remains.
The goal is to find the score difference (Alice's score - Bob's score) when both players play optimally. Alice wants to maximize this difference, while Bob wants to minimize it.
For example, if stones = [7, 90, 5, 1, 100, 10, 10, 2]
:
- A player might take the first 2 stones
[7, 90]
, score 97 points, and place a stone with value 97 on the left - The new row becomes
[97, 5, 1, 100, 10, 10, 2]
- The next player continues from this state
The key insight is that taking x
leftmost stones and replacing them with their sum is equivalent to merging those stones while maintaining the prefix sum. Each move essentially chooses a prefix of length at least 2, scores its sum, and passes the modified array to the opponent.
The solution uses dynamic programming with memoization, where dfs(i)
represents the maximum score difference the current player can achieve when they can choose to take stones starting from index i
or later. The prefix sum array s
helps efficiently calculate the sum of any prefix.
Intuition
The first key observation is that when we take the leftmost x
stones and replace them with their sum, we're essentially merging stones while keeping the total sum unchanged. This means we can think of each move as choosing a prefix of the array to "collapse" into a single stone.
Since we must take at least 2 stones (x > 1
), the minimum valid move is taking the first 2 stones. After any move, the resulting array has the same prefix sums - just with fewer elements. For instance, if we take stones [7, 90, 5]
with sum 102, the new array starts with [102, ...]
, and the prefix sum at position 0 is still 102.
This leads to a crucial insight: instead of tracking the actual stones, we can work with prefix sums. When a player takes the first i+1
stones (indices 0 to i), they score s[i]
points, where s
is the prefix sum array.
Now, let's think about the game from a minimax perspective. Each player wants to maximize their own advantage, which is equivalent to maximizing (their score - opponent's score). When it's my turn, I want to maximize this difference; when it's the opponent's turn, they want to maximize their difference, which means minimizing mine.
Consider the decision at any state: if I can choose to take stones starting from position i
, I have two main options:
- Take the minimum allowed stones (positions
i
andi+1
), scorings[i]
, then pass the remaining game to my opponent - Pass on taking position
i
, letting my opponent potentially take it
If I take stones up to position i
, I score s[i]
points, but then my opponent plays optimally from position i+1
. So my net advantage becomes s[i] - dfs(i+1)
.
If I don't take position i
now, I'm essentially passing the decision to take from position i+1
onwards, giving me advantage dfs(i+1)
.
Therefore, dfs(i) = max(s[i] - dfs(i+1), dfs(i+1))
- I choose whichever option gives me the better score difference.
The base case is when i >= n-1
: there's only one way to end the game (take all remaining stones), giving a score of s[n-1]
.
Starting from dfs(1)
makes sense because Alice must take at least 2 stones on her first move, meaning she effectively chooses whether to take stones starting from index 1 or later.
Learn more about Math, Dynamic Programming and Prefix Sum patterns.
Solution Approach
The solution implements a top-down dynamic programming approach with memoization using Python's @cache
decorator.
Step 1: Calculate Prefix Sums
First, we compute the prefix sum array using accumulate(stones)
:
s = list(accumulate(stones))
This gives us s[i]
= sum of stones[0]
to stones[i]
, which represents the score when taking the first i+1
stones.
Step 2: Define the Recursive Function
We define dfs(i)
to represent the maximum score difference the current player can achieve when they can choose to take stones starting from index i
or later:
@cache
def dfs(i: int) -> int:
if i >= len(stones) - 1:
return s[-1]
return max(dfs(i + 1), s[i] - dfs(i + 1))
Understanding the Recursion:
-
Base Case: When
i >= n - 1
, only one stone remains (or we're at the last stone). The current player must take all remaining stones, scorings[-1]
(the total sum). -
Recursive Case: The current player has two choices:
- Skip taking stones at position
i
: Pass the decision to positioni+1
, getting advantagedfs(i + 1)
- Take stones up to position
i
: Scores[i]
points, then the opponent plays optimally from positioni+1
. The net advantage iss[i] - dfs(i + 1)
(my score minus opponent's best advantage)
- Skip taking stones at position
The player chooses the maximum of these two options.
Step 3: Start the Game
We call dfs(1)
because Alice's first move must take at least 2 stones (indices 0 and 1 at minimum), so she effectively decides whether to take stones starting from index 1 or beyond.
Memoization:
The @cache
decorator automatically memoizes the results of dfs(i)
, preventing redundant calculations. Each state i
is computed only once, reducing the time complexity from exponential to O(n).
Time and Space Complexity:
- Time: O(n) - we compute each state from 1 to n-1 exactly once
- Space: O(n) - for the prefix sum array and 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 stones = [3, 7, 2, 3]
.
Step 1: Calculate prefix sums
s = [3, 10, 12, 15]
s[0] = 3
,s[1] = 10
,s[2] = 12
,s[3] = 15
Step 2: Build the solution bottom-up using our recursive logic
We'll trace through dfs(i)
for each position, starting from the base cases:
Base case: dfs(3)
- Since
i = 3 >= len(stones) - 1
, we returns[-1] = 15
- This means if the game reaches a state where only index 3 can be taken, the current player scores 15
Calculate dfs(2)
- We have two choices:
- Skip position 2: get
dfs(3) = 15
- Take up to position 2: score
s[2] = 12
, opponent getsdfs(3) = 15
, net =12 - 15 = -3
- Skip position 2: get
dfs(2) = max(15, -3) = 15
- The optimal play is to skip and let the opponent take first
Calculate dfs(1)
- We have two choices:
- Skip position 1: get
dfs(2) = 15
- Take up to position 1: score
s[1] = 10
, opponent getsdfs(2) = 15
, net =10 - 15 = -5
- Skip position 1: get
dfs(1) = max(15, -5) = 15
- Again, optimal play is to skip
Game Play Simulation:
Starting with dfs(1) = 15
, let's see how the game actually unfolds:
- Alice starts and
dfs(1) = 15
tells us she should skip taking at position 1 - Since Alice skips position 1, she's effectively choosing to skip position 2 as well (from
dfs(2)
) - Alice ends up taking all 4 stones on her first move:
[3, 7, 2, 3]
- Alice scores 15, Bob scores 0 (game ends after one move)
- Score difference = 15 - 0 = 15 ✓
This matches our calculated result of dfs(1) = 15
.
Alternative scenario: What if Alice took only the first 2 stones?
- Alice takes
[3, 7]
, scores 10, places stone with value 10 - New array:
[10, 2, 3]
- Bob now plays optimally from this state, which corresponds to our
dfs(2)
calculation - Bob would take all remaining stones, scoring 15
- Score difference = 10 - 15 = -5
This confirms why Alice chooses not to take at position 1 (would result in -5 instead of 15).
Solution Implementation
1from typing import List
2from functools import cache
3from itertools import accumulate
4
5class Solution:
6 def stoneGameVIII(self, stones: List[int]) -> int:
7 # Calculate prefix sums for quick range sum calculation
8 # prefix_sums[i] represents sum of stones[0] to stones[i]
9 prefix_sums = list(accumulate(stones))
10
11 @cache
12 def calculate_max_score_difference(current_index: int) -> int:
13 """
14 Calculate the maximum score difference the current player can achieve
15 starting from current_index.
16
17 Args:
18 current_index: The index from which the current player can choose
19
20 Returns:
21 Maximum score difference (current player score - opponent score)
22 """
23 # Base case: if we're at or past the second-to-last stone,
24 # the only option is to take all remaining stones
25 if current_index >= len(stones) - 1:
26 return prefix_sums[-1]
27
28 # The current player has two choices:
29 # Option 1: Skip this position and let the decision move to next index
30 skip_current = calculate_max_score_difference(current_index + 1)
31
32 # Option 2: Take stones from 0 to current_index (score = prefix_sums[current_index])
33 # Then the opponent plays optimally from current_index + 1
34 # Score difference = our score - opponent's best score
35 take_current = prefix_sums[current_index] - calculate_max_score_difference(current_index + 1)
36
37 # Return the maximum score difference we can achieve
38 return max(skip_current, take_current)
39
40 # Start from index 1 (at least 2 stones must be taken initially)
41 return calculate_max_score_difference(1)
42
1class Solution {
2 // Memoization array to store computed results for dynamic programming
3 private Integer[] memo;
4 // Prefix sum array to store cumulative sums of stones
5 private int[] prefixSum;
6 // Total number of stones
7 private int n;
8
9 public int stoneGameVIII(int[] stones) {
10 // Initialize the number of stones
11 n = stones.length;
12
13 // Initialize memoization array for dynamic programming
14 memo = new Integer[n];
15
16 // Convert stones array to prefix sum array
17 // After this, stones[i] represents sum of all stones from index 0 to i
18 for (int i = 1; i < n; i++) {
19 stones[i] += stones[i - 1];
20 }
21
22 // Store the prefix sum array for use in recursion
23 prefixSum = stones;
24
25 // Start the game from index 1 (Alice's first move must take at least 2 stones)
26 return dfs(1);
27 }
28
29 /**
30 * Dynamic programming function to find the maximum score difference
31 * @param currentIndex - the current index where a player can make a move
32 * @return the maximum score difference the current player can achieve
33 */
34 private int dfs(int currentIndex) {
35 // Base case: if we're at the last stone or beyond,
36 // the player must take all remaining stones
37 if (currentIndex >= n - 1) {
38 return prefixSum[currentIndex];
39 }
40
41 // Check if we've already computed this state
42 if (memo[currentIndex] == null) {
43 // The current player has two choices:
44 // 1. Skip this position and let the game continue from the next index
45 // 2. Take all stones from 0 to currentIndex and give turn to opponent
46
47 // Choice 1: Skip current position
48 int skipCurrent = dfs(currentIndex + 1);
49
50 // Choice 2: Take stones up to current index
51 // Score gained is prefixSum[currentIndex] minus opponent's optimal score
52 int takeCurrent = prefixSum[currentIndex] - dfs(currentIndex + 1);
53
54 // Store the maximum of both choices
55 memo[currentIndex] = Math.max(skipCurrent, takeCurrent);
56 }
57
58 return memo[currentIndex];
59 }
60}
61
1class Solution {
2public:
3 int stoneGameVIII(vector<int>& stones) {
4 int n = stones.size();
5
6 // Convert stones array to prefix sum array
7 // stones[i] now represents sum of original stones[0] to stones[i]
8 for (int i = 1; i < n; ++i) {
9 stones[i] += stones[i - 1];
10 }
11
12 // dp[i] represents the maximum score difference the current player can achieve
13 // when starting from index i (meaning stones 0 to i-1 have been taken)
14 int dp[n];
15 memset(dp, -1, sizeof(dp));
16
17 // DFS with memoization to calculate optimal score difference
18 function<int(int)> dfs = [&](int index) -> int {
19 // Base case: if we're at the last stone or beyond,
20 // the current player must take all remaining stones
21 if (index >= n - 1) {
22 return stones[index];
23 }
24
25 // If not calculated yet, compute the optimal choice
26 if (dp[index] == -1) {
27 // Current player has two choices:
28 // 1. Skip this position (pass the turn with same state to opponent)
29 int skipCurrent = dfs(index + 1);
30
31 // 2. Take stones from 0 to index (get stones[index] points)
32 // Then opponent plays optimally from index+1
33 int takeCurrent = stones[index] - dfs(index + 1);
34
35 // Choose the option that maximizes current player's score difference
36 dp[index] = max(skipCurrent, takeCurrent);
37 }
38
39 return dp[index];
40 };
41
42 // Start from index 1 (Alice must take at least 2 stones initially)
43 return dfs(1);
44 }
45};
46
1/**
2 * Solves the Stone Game VIII problem using dynamic programming with memoization.
3 * Two players take turns choosing stones, where each player must take all stones
4 * from the beginning up to a chosen position. The goal is to maximize the score difference.
5 *
6 * @param stones - Array of stone values
7 * @returns The maximum score difference the first player can achieve
8 */
9function stoneGameVIII(stones: number[]): number {
10 const stonesCount: number = stones.length;
11
12 // Memoization array to store computed results for each position
13 // -1 indicates uncomputed state
14 const memo: number[] = Array(stonesCount).fill(-1);
15
16 // Convert stones array to prefix sum array
17 // After this transformation, stones[i] contains the sum of all stones from index 0 to i
18 for (let i = 1; i < stonesCount; ++i) {
19 stones[i] += stones[i - 1];
20 }
21
22 /**
23 * Recursive function with memoization to find the maximum score difference
24 * starting from position currentIndex.
25 *
26 * @param currentIndex - The current position in the game
27 * @returns The maximum score difference achievable from this position
28 */
29 const calculateMaxScoreDifference = (currentIndex: number): number => {
30 // Base case: if we're at the last stone or beyond,
31 // the current player takes all remaining stones
32 if (currentIndex >= stonesCount - 1) {
33 return stones[currentIndex];
34 }
35
36 // Check if result is already computed
37 if (memo[currentIndex] === -1) {
38 // Current player has two choices:
39 // 1. Skip this position and let the game continue from the next position
40 // 2. Take all stones up to current position and let opponent play from next position
41 const skipCurrentPosition: number = calculateMaxScoreDifference(currentIndex + 1);
42 const takeCurrentPosition: number = stones[currentIndex] - calculateMaxScoreDifference(currentIndex + 1);
43
44 // Store the maximum of both choices
45 memo[currentIndex] = Math.max(skipCurrentPosition, takeCurrentPosition);
46 }
47
48 return memo[currentIndex];
49 };
50
51 // Start the game from index 1 (first player must take at least 2 stones)
52 return calculateMaxScoreDifference(1);
53}
54
Time and Space Complexity
The time complexity is O(n)
, where n
is the length of the array stones
. The memoized recursive function dfs
computes the result for each index from 1 to n-1 exactly once due to the @cache
decorator. Each recursive call either returns immediately (base case) or makes one recursive call and performs O(1)
operations. Since there are n-1
possible states (indices 1 through n-1) and each state is computed once, the total time complexity is O(n)
.
The space complexity is O(n)
. This consists of:
O(n)
for the prefix sum arrays
created bylist(accumulate(stones))
O(n)
for the memoization cache storing up ton-1
computed valuesO(n)
for the recursion call stack in the worst case
Therefore, the overall space complexity is O(n)
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Starting from Wrong Index
Pitfall: A common mistake is calling dfs(0)
instead of dfs(1)
, which would incorrectly allow taking just one stone on the first move.
Why it's wrong: The game rules explicitly state that players must choose x > 1
, meaning at least 2 stones must be taken. Starting from index 0 would allow taking just stones[0]
, violating this constraint.
Solution: Always start the recursion from dfs(1)
since the minimum valid move is taking stones at indices 0 and 1.
# Wrong return calculate_max_score_difference(0) # Correct return calculate_max_score_difference(1)
2. Incorrect Base Case Boundary
Pitfall: Using i == len(stones) - 1
instead of i >= len(stones) - 1
in the base case.
Why it's wrong: During recursion, we might call dfs(i + 1)
when i
is already at len(stones) - 1
, resulting in dfs(len(stones))
. Without the >=
check, this would bypass the base case and cause index out of bounds errors or incorrect results.
Solution: Use >=
to handle all cases where no meaningful choice remains:
# Wrong
if current_index == len(stones) - 1:
return prefix_sums[-1]
# Correct
if current_index >= len(stones) - 1:
return prefix_sums[-1]
3. Misunderstanding Score Calculation
Pitfall: Confusing what prefix_sums[i]
represents and incorrectly calculating the score difference.
Why it's wrong: Some might think they need to calculate prefix_sums[i] - prefix_sums[start]
for a range, but since we're always taking from the leftmost position (index 0), the score is simply prefix_sums[i]
.
Solution: Remember that after each move, the stones are "merged" at position 0, so the next player always takes from index 0. The score for taking up to index i
is exactly prefix_sums[i]
.
4. Forgetting Memoization
Pitfall: Implementing the recursive solution without memoization.
Why it's wrong: Without memoization, the same states are recalculated multiple times, leading to exponential time complexity O(2^n) instead of linear O(n).
Solution: Always use @cache
or implement manual memoization:
# Without memoization - TLE for large inputs
def dfs(i: int) -> int:
if i >= len(stones) - 1:
return prefix_sums[-1]
return max(dfs(i + 1), prefix_sums[i] - dfs(i + 1))
# With memoization - O(n) time
@cache
def dfs(i: int) -> int:
if i >= len(stones) - 1:
return prefix_sums[-1]
return max(dfs(i + 1), prefix_sums[i] - dfs(i + 1))
5. Confusion About Player Perspective
Pitfall: Getting confused about whose perspective the score difference represents at each recursive call.
Why it's wrong: In game theory problems, it's crucial to maintain consistency about which player's advantage we're calculating. Mixing perspectives leads to incorrect signs in the score calculation.
Solution: Always think of dfs(i)
as "the maximum advantage the current player can get over their opponent." When the current player scores prefix_sums[i]
and passes to the opponent, the net advantage is prefix_sums[i] - dfs(i + 1)
(my score minus opponent's best advantage from that point).
Consider the classic dynamic programming of longest increasing subsequence:
Find the length of the longest subsequence of a given sequence such that all elements of the subsequence are sorted in increasing order.
For example, the length of LIS for [50, 3, 10, 7, 40, 80]
is 4
and LIS is
[3, 7, 40, 80]
.
What is the recurrence relation?
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!