1140. Stone Game II
Problem Description
Alice and Bob are playing a game with a row of piles of stones, where each pile has a positive number of stones, given by piles[i]
. The goal is to end with the most stones. Alice starts the game and the players alternate turns. Initially, the variable M = 1
.
On each turn, the current player can take all the stones from the first X
piles, where 1 <= X <= 2M
. After choosing the number of piles to take the stones from, the player updates M
to be the maximum of M
and the number X
chosen.
The game ends when all stones have been taken. The objective is to maximize the number of stones Alice can obtain if both players play optimally.
Intuition
To find the solution, we need to consider the game from a dynamic programming perspective, specifically using a minimax approach with memorization to handle the overlapping subproblems.
Since Alice aims to maximize the number of stones she can get, while Bob aims to minimize that same value, we can define a recursive function that returns the maximum number of stones a player can achieve from the current state, defined by the index i
of the first available pile and the value of M
.
The stopping condition for our recursive function dfs(i, m)
is when there are 2M
or more piles remaining starting from index i
. In this case, the current player can take all remaining stones. Otherwise, the player iterates over all valid choices of X
(from 1 to 2M
), recursively calling dfs(i + x, max(m, x))
which represents the new state after the opponent has played their turn.
The function then chooses the maximum value from these recursive calls, which will represent the optimal decision for the current player. To optimize and avoid recalculating the same states, we use the @cache
decorator from the functools
module, which memorizes the results of the function calls with the given arguments.
We also utilize the accumulate
function from the itertools
module to create a prefix sum array, s
, which allows us to quickly calculate the sum of stones from any range of piles during the recursive calls. The final answer for the maximum number of stones Alice can get will be the return value of dfs(0, 1)
, which is the starting state of the game.
Learn more about Math, Dynamic Programming and Prefix Sum patterns.
Solution Approach
The given Python solution uses a depth-first search (DFS) recursive strategy with memoization. The implementation comprises several key parts:
-
Memoization: The
@cache
decorator fromfunctools
is used to automatically store the results of expensive function calls and return the cached result when the same inputs occur again. This optimization reduces the computational complexity by preventing redundant calculations of subproblems. -
Depth-First Search (DFS) Recursive Function: The
dfs(i, m)
function is the heart of the solution. Here,i
represents the current index starting from which the stones can be taken, andm
is the maximum number of piles that can be chosen based on the previous move. -
Prefix Sum Array: The
accumulate
function from theitertools
module creates a prefix sum arrays
. It allows for constant-time computation of the sum of stones in any subarray ofpiles
. The initial0
is added to the sums to make sure that the sum from the start is easily accessible. -
Minimax Algorithm: Given that Alice wants to maximize her stones and Bob wants to minimize Alice's stones, the algorithm looks at each possible choice of
X
(from 1 to2M
) that the players can make. For each choice, it recursively calculates what would be the best score Alice can get if she picksX
piles and then the opponent plays optimally after her. The result of this recursive call is subtracted from the total number of stones fromi
to the end (s[n] - s[i]
) to get the sequence of optimal choices for Alice. -
Base Case: If we can take all remaining piles (
m * 2 >= n - i
), we simply take all the remaining stones (s[n] - s[i]
) without further recursion because this is the maximum we can get from this point. -
Calculation and Return: In the
dfs
function, we use a generator expression inside themax
function to iterate over all possibleX
within the1 to 2M
range and calculate the corresponding score Alice would get if the optimal choice forX
is made.
By calling dfs(0,1)
we start the DFS recursion from the beginning of the piles and with an initial M
of 1, as per the game's rules. The return value of this call provides the answer to the problem, which is the maximum number of stones Alice can get if both she and Bob play optimally.
Ready to land your dream job?
Unlock your dream job with a 2-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's illustrate the solution approach with an example. Suppose the piles
array is [2, 7, 9, 4]
, representing four piles with 2, 7, 9, and 4 stones each.
We apply the depth-first search (DFS) recursive function dfs
with memoization to compute the maximum number of stones Alice can obtain.
-
Initial Call: Start with
dfs(0, 1)
with an index of0
andM
of1
. -
Prefix Sum Array: First, we construct a prefix sum array
s
:[0, 2, 9, 18, 22]
. This represents the total stones one can get by taking piles up to that index. E.g., taking the first three piles yieldss[3] - s[0]
which equals18
. -
Possible Choices: Now, Alice can take from 1 to
2 * M
piles. SinceM
is 1, Alice can take 1 or 2 piles. -
Alice's Turn:
- If Alice takes 1 pile, she gets 2 stones. Now Bob will use
dfs(1, max(1, 1)) = dfs(1, 1)
on the remaining[7, 9, 4]
piles. - If Alice takes 2 piles (1 to
2M
), she gets 9 stones (2 + 7
). Now Bob will usedfs(2, max(1, 2)) = dfs(2, 2)
on the remaining[9, 4]
piles.
- If Alice takes 1 pile, she gets 2 stones. Now Bob will use
-
Bob's Turn: Bob plays optimally to give Alice the minimum number of remaining stones.
- From
dfs(1, 1)
: Bob follows the same logic and has the option to take 1 or 2 piles. If he takes 1 pile, Alice is left withdfs(2, 1)
and if he takes 2 piles, Alice is left withdfs(3, 2)
. - From
dfs(2, 2)
: Bob has the option to take 1, 2, 3, or 4 piles. However, only the options taking 1 or 2 piles are valid as2M
= 4 and there are only two piles left. Both lead to Alice not being able to pick any more piles, as Bob would clear the remaining stones.
- From
-
Subproblem Results: The return values of these recursive calls are cached, preventing redundant calculations. Alice's choice will be the one that maximizes her stones considering optimal plays by Bob.
-
Calculation:
dfs(0, 1)
is the maximum of:2 + (Total stones - 2 - dfs(1, 1))
9 + (Total stones - 9 - dfs(2, 2))
- The recursive calls continue breaking down subsequent turns in the same manner until the game ends.
-
Base Case:
- When
dfs(2, 2)
is called,M
is 2 and there are only 2 piles left, so Alice can take all the remaining stones, returnings[4] - s[2]
=22 - 18
= 4. - When
dfs(3, 2)
is called,M
is 2 and there is only 1 pile left, so Alice can take all the remaining stones, returnings[4] - s[3]
=22 - 18
= 4.
- When
By the end of these calculations, we deduce that Alice's optimal strategy yields the most stones based on the initial call dfs(0, 1)
. This result is the maximum number of stones Alice can collect if both players play optimally. In this case, Alice's best initial move is to take the first two piles, and she will end up with 9 stones.
Solution Implementation
1from functools import lru_cache
2from itertools import accumulate
3from typing import List
4
5class Solution:
6 def stoneGameII(self, piles: List[int]) -> int:
7
8 @lru_cache(maxsize=None)
9 def dfs(current_index, max_take):
10 # If we can take all remaining piles, return the sum of those piles.
11 if max_take * 2 >= total_piles - current_index:
12 return prefix_sums[total_piles] - prefix_sums[current_index]
13
14 # Try every possible number of stones we can take, and choose the option
15 # that maximizes our stones.
16 return max(
17 prefix_sums[total_piles] - prefix_sums[current_index] -
18 dfs(current_index + x, max(max_take, x))
19 for x in range(1, 2 * max_take + 1)
20 )
21
22 total_piles = len(piles) # The total number of piles.
23 prefix_sums = list(accumulate(piles, initial=0)) # Prefix sum array to calculate the sum efficiently.
24
25 # Start the game from the first pile, with the initial maximum of 1 stone to take.
26 return dfs(0, 1)
27
1class Solution {
2 // s will hold the prefix sum of the piles array
3 private int[] prefixSum;
4 // f is a memoization table where f[i][m] will store the result of dfs(i, m)
5 private Integer[][] memo;
6 // n is the total number of piles
7 private int n;
8
9 public int stoneGameII(int[] piles) {
10 n = piles.length;
11 prefixSum = new int[n + 1];
12 memo = new Integer[n][n + 1];
13 // Calculate prefix sums for the piles array for easy range sum queries
14 for (int i = 0; i < n; ++i) {
15 prefixSum[i + 1] = prefixSum[i] + piles[i];
16 }
17 // Start the game with the first pile (index 0) and initial 'M' value of 1
18 return dfs(0, 1);
19 }
20
21 private int dfs(int i, int m) {
22 // If the next player can take all remaining piles, return the sum of those piles
23 if (m * 2 >= n - i) {
24 return prefixSum[n] - prefixSum[i];
25 }
26 // If we have already computed this state, return the stored value
27 if (memo[i][m] != null) {
28 return memo[i][m];
29 }
30 int result = 0;
31 // Try all possible x moves from the current position
32 for (int x = 1; x <= m * 2; ++x) {
33 // Choose the move that maximizes the current player's score
34 result = Math.max(result, prefixSum[n] - prefixSum[i] - dfs(i + x, Math.max(m, x)));
35 }
36 // Store the result in the memoization table before returning
37 memo[i][m] = result;
38 return result;
39 }
40}
41
1class Solution {
2public:
3 // Function to play the stone game and determine the maximum number of stones the player can get.
4 int stoneGameII(vector<int>& piles) {
5 int n = piles.size();
6
7 // Prefix sum array 's' to efficiently calculate the sum of stones in 'piles' from any index.
8 vector<int> prefixSum(n + 1, 0);
9 for (int i = 0; i < n; ++i) {
10 prefixSum[i + 1] = prefixSum[i] + piles[i];
11 }
12
13 // Memoization array 'dp', initialized to 0. Stores the results of subproblems.
14 vector<vector<int>> dp(n, vector<int>(n + 1, 0));
15
16 // Recursive lambda function to perform a depth-first search (DFS).
17 // It uses memoization to store results of subproblems.
18 // 'i' denotes the current index and 'm' the current M value.
19 function<int(int, int)> dfs = [&](int i, int m) -> int {
20 // If we're able to take all remaining stones, we'll do so.
21 if (m * 2 >= n - i) {
22 return prefixSum[n] - prefixSum[i];
23 }
24 // If we've already computed this subproblem, return the stored result.
25 if (dp[i][m] != 0) {
26 return dp[i][m];
27 }
28
29 int best = 0; // Keeping track of the best score we can achieve from this state.
30
31 // Try taking x stones where x is from 1 to the maximum number of stones we can take.
32 for (int x = 1; x <= 2 * m; ++x) {
33 best = max(best, prefixSum[n] - prefixSum[i] - dfs(i + x, max(x, m)));
34 }
35
36 // Store the computed result in 'dp' before returning.
37 dp[i][m] = best;
38 return best;
39 };
40
41 // Initial call to the DFS function starting from index 0 and M value 1.
42 return dfs(0, 1);
43 }
44};
45
1function stoneGameII(piles: number[]): number {
2 const pileCount = piles.length;
3
4 // f is a memoization table where f[i][m] represents the maximum number of stones
5 // a player can get when starting from the i-th pile with M = m
6 const memoTable = Array.from({ length: pileCount }, () => new Array(pileCount + 1).fill(0));
7
8 // s is a prefix sum array where s[i] represents the total number of stones
9 // in the first i piles
10 const prefixSum = new Array(pileCount + 1).fill(0);
11 for (let i = 0; i < pileCount; ++i) {
12 prefixSum[i + 1] = prefixSum[i] + piles[i];
13 }
14
15 // Define depth-first search function to determine the maximum stones
16 // that can be taken starting from the i-th pile with m as the current M value
17 const dfs = (currentIndex: number, currentM: number): number => {
18 // If the current player can take all remaining piles, return the sum directly
19 if (currentM * 2 >= pileCount - currentIndex) {
20 return prefixSum[pileCount] - prefixSum[currentIndex];
21 }
22
23 // Use memoization to avoid re-calculating states
24 if (memoTable[currentIndex][currentM]) {
25 return memoTable[currentIndex][currentM];
26 }
27
28 let maxStones = 0;
29 // Try taking x piles where x is from 1 to M*2 and maximize the stones
30 for (let x = 1; x <= currentM * 2; ++x) {
31 maxStones = Math.max(maxStones, prefixSum[pileCount] - prefixSum[currentIndex] - dfs(currentIndex + x, Math.max(currentM, x)));
32 }
33 // Store and return the result in memo table
34 memoTable[currentIndex][currentM] = maxStones;
35 return maxStones;
36 };
37
38 return dfs(0, 1);
39}
40
Time and Space Complexity
The time complexity of the code is O(n^3)
. This is because in the function dfs(i, m)
, we iterate up to 2m - 1
times where m
can go up to n/2
in the worst case. For each iteration, we have a recursive call, which can lead to a maximum of n
levels of recursion (since i
can range from 0
to n - 1
), and the update of m
in the recursive calls could happen n
times as well. Since we are memoizing the results of the recursive calls, each state (i
, m
) is only computed once. The number of unique states for i
is n
and for m
is also up to n
, this results in n^2
unique states. Thus the overall time complexity is O(n * n * n)
which simplifies to O(n^3)
.
The space complexity of the function is O(n^2)
. The memoization cache dfs(i, m)
will store at most n * n
states, since both i
and m
range in [0, n]
. The list s
that stores the accumulated sums introduces another O(n)
space, but since it grows linearly with the input, it doesn't affect the overall O(n^2)
space complexity dominated by the memoization cache.
Learn more about how to find time and space complexity quickly using problem constraints.
Which technique can we use to find the middle of a linked list?
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!