Stone Game III
HardPrompt
Alice and Bob continue their games with piles of stones. There are several stones arranged in a row, and each stone has an associated value which is an integer given in the array stoneValue.
Alice and Bob take turns, with Alice starting first. On each player's turn, that player can take 1, 2, or 3 stones from the first remaining stones in the row.
The score of each player is the sum of the values of the stones taken. The score of each player is 0 initially.
The objective of the game is to end with the highest score, and the winner is the player with the highest score and there could be a tie. The game continues until all the stones have been taken.
Assume Alice and Bob play optimally.
Return "Alice" if Alice will win, "Bob" if Bob will win, or "Tie" if they will end the game with the same score.
Example 1:
Input: stoneValue = [1,2,3,7]
Output: "Bob"
Explanation: Alice will always lose. Her best move will be to take three piles and the score become 6. Now the score of Bob is 7 and Bob wins.Example 2:
Input: stoneValue = [1,2,3,-9]
Output: "Alice"
Explanation: Alice must choose all the three piles at the first move to win and leave Bob with negative score.
If Alice chooses one pile her score will be 1 and the next move Bob's score becomes 5. In the next move, Alice will take the pile with value = -9 and lose.
If Alice chooses two piles her score will be 3 and the next move Bob's score becomes 3. In the next move, Alice will take the pile with value = -9 and also lose.
Remember that both play optimally so here Alice will choose the scenario that makes her win.Example 3:
Input: stoneValue = [1,2,3,6]
Output: "Tie"
Explanation: Alice cannot win this game. She can end the game in a draw if she decided to choose all the first three piles, otherwise she will lose.
Constraints:
1 <= stoneValue.length <= 5 * 104-1000 <= stoneValue[i] <= 1000
Approaches
3 approaches with complexity analysis and trade-offs.
This approach directly translates the game's rules into a recursive solution. The problem has optimal substructure and overlapping subproblems, which are characteristics of problems solvable by recursion. We define a function that calculates the maximum score difference a player can achieve from a given starting stone. Since both players play optimally, they will always make the move that maximizes their final score relative to their opponent.
Algorithm
- Define a recursive function
solve(i)that returns the maximum score difference the current player can achieve starting from indexi. - Base Case: If
iis out of bounds (i.e.,i >= n), it means there are no stones left, so the difference is 0. Return 0. - Recursive Step: The current player at index
ihas three choices:- Take 1 stone: The player's score increases by
stoneValue[i]. The turn passes to the opponent, who will play optimally from indexi+1. The opponent's score difference from that point will besolve(i+1). Therefore, the current player's net score difference for this choice isstoneValue[i] - solve(i+1). - Take 2 stones: Net score difference is
(stoneValue[i] + stoneValue[i+1]) - solve(i+2). - Take 3 stones: Net score difference is
(stoneValue[i] + stoneValue[i+1] + stoneValue[i+2]) - solve(i+3).
- Take 1 stone: The player's score increases by
- The player will choose the option that maximizes their score difference. So,
solve(i)is the maximum of the valid choices. - The initial call is
solve(0). The final result is determined by the sign ofsolve(0).
Walkthrough
The core idea is to model the game as a recursive function. Let's define solve(i) as the maximum score difference the current player can get over the opponent from the stones stoneValue[i:]. When it's a player's turn at index i, they can take k stones, where k can be 1, 2, or 3. If they take k stones, their score for this turn is the sum of values of these k stones. The game then proceeds from index i+k, but now it's the opponent's turn. The opponent, playing optimally, will achieve a score difference of solve(i+k) from the remaining stones. From the current player's perspective, this is a loss of solve(i+k). Thus, the total score difference for the current player, if they take k stones, is (sum of k stones) - solve(i+k). The player will choose the k that maximizes this value. The final answer depends on solve(0): if it's positive, Alice wins; if negative, Bob wins; if zero, it's a tie.
class Solution { public String stoneGameIII(int[] stoneValue) { int diff = solve(stoneValue, 0); if (diff > 0) { return "Alice"; } else if (diff < 0) { return "Bob"; } else { return "Tie"; } } private int solve(int[] stoneValue, int i) { int n = stoneValue.length; if (i >= n) { return 0; } int maxDiff = Integer.MIN_VALUE; int currentSum = 0; // Iterate through the 3 possible moves (take 1, 2, or 3 stones) for (int k = 0; k < 3 && i + k < n; k++) { currentSum += stoneValue[i + k]; maxDiff = Math.max(maxDiff, currentSum - solve(stoneValue, i + k + 1)); } return maxDiff; }}Complexity
Time
O(3^n), where n is the number of stones. For each state, the function branches into three recursive calls, leading to an exponential number of computations.
Space
O(n), where n is the number of stones. This is due to the maximum depth of the recursion stack.
Trade-offs
Pros
Simple to conceptualize and implement based on the problem's recursive nature.
Cons
Extremely inefficient due to a large number of redundant computations for the same subproblems.
Will result in a 'Time Limit Exceeded' (TLE) error for the given constraints.
The brute-force approach is slow because it repeatedly solves the same subproblems. We can significantly optimize this by using dynamic programming. This can be implemented using a top-down approach with memoization or a bottom-up iterative approach. Both share the same core logic and complexity.
Top-Down with Memoization: We use a helper array, memo, to store the result of solve(i) after computing it for the first time. Subsequent calls for the same i will fetch the result from memo in O(1) time, avoiding redundant calculations.
Bottom-Up: This approach avoids recursion by solving subproblems iteratively. We use a dp array where dp[i] stores the solution for the subproblem starting at index i. We fill the table from the end (dp[n-1]) to the beginning (dp[0]), since the solution for dp[i] depends on solutions for dp[i+1], dp[i+2], and dp[i+3].
Algorithm
- The recursive structure is the same as the brute-force approach. The key difference is the use of a memoization table (e.g., an array
memo) to store the results of subproblems. - Initialize the
memoarray with a sentinel value (likenullorInteger.MIN_VALUE) to indicate that a state has not been computed. - In the recursive function
solve(i), first check ifmemo[i]contains a pre-computed result. If it does, return it immediately. - If not, compute the result as in the brute-force approach by exploring the 1, 2, or 3 stone choices.
- Before returning the computed maximum difference, store it in
memo[i]. - This ensures that each subproblem
solve(i)is computed only once.
Walkthrough
Top-Down with Memoization
This approach enhances the recursive solution by caching results. An array memo of size n is used. memo[i] will store the result of solve(i). When solve(i) is called, it first checks memo[i]. If a value exists, it's returned. Otherwise, the value is computed, stored in memo[i], and then returned. This pruning of the recursion tree reduces the time complexity dramatically.
// Top-Down with Memoizationclass Solution { public String stoneGameIII(int[] stoneValue) { int n = stoneValue.length; Integer[] memo = new Integer[n]; int diff = solve(stoneValue, 0, memo); if (diff > 0) { return "Alice"; } else if (diff < 0) { return "Bob"; } else { return "Tie"; } } private int solve(int[] stoneValue, int i, Integer[] memo) { int n = stoneValue.length; if (i >= n) { return 0; } if (memo[i] != null) { return memo[i]; } int maxDiff = Integer.MIN_VALUE; int currentSum = 0; for (int k = 0; k < 3 && i + k < n; k++) { currentSum += stoneValue[i + k]; maxDiff = Math.max(maxDiff, currentSum - solve(stoneValue, i + k + 1, memo)); } return memo[i] = maxDiff; }}Bottom-Up DP
This iterative approach builds the solution from the base cases. We create a dp array of size n+k (where k=3, for easier boundary handling). dp[i] will store the maximum difference from index i. We iterate from i = n-1 down to 0, calculating dp[i] based on the already computed values dp[i+1], dp[i+2], and dp[i+3]. The final answer is dp[0].
// Bottom-Up DPclass Solution { public String stoneGameIII(int[] stoneValue) { int n = stoneValue.length; int[] dp = new int[n + 3]; // dp[n], dp[n+1], dp[n+2] will be 0 by default for (int i = n - 1; i >= 0; i--) { dp[i] = Integer.MIN_VALUE; int currentSum = 0; for (int k = 0; k < 3 && i + k < n; k++) { currentSum += stoneValue[i + k]; dp[i] = Math.max(dp[i], currentSum - dp[i + k + 1]); } } int diff = dp[0]; if (diff > 0) { return "Alice"; } else if (diff < 0) { return "Bob"; } else { return "Tie"; } }}Complexity
Time
O(n). Each state `dp[i]` is computed once, and each computation takes constant time (a loop of size 3).
Space
O(n). The top-down approach uses O(n) for the memoization table and O(n) for the recursion stack. The bottom-up approach uses O(n) for the DP table.
Trade-offs
Pros
Efficient enough to pass the given constraints.
Guarantees that each subproblem is solved only once.
Cons
Uses O(n) extra space for the memoization table and recursion stack.
For extremely large n (not the case here), it could lead to a stack overflow error.
This approach further optimizes the bottom-up DP solution by reducing its space complexity. By analyzing the recurrence relation, we notice that to compute the DP value for the current state i, we only need the results of the next three states (i+1, i+2, i+3). This allows us to discard older DP values that are no longer needed, reducing the space requirement from a full array of size n to just a few variables.
Algorithm
- Observe the DP transition:
dp[i]depends only ondp[i+1],dp[i+2], anddp[i+3]. - This means we don't need to store the entire DP table. We only need to keep track of the last 3 computed values.
- We can use a small, constant-size array (e.g., of size 4) to store these values, using modular arithmetic (
i % 4) to cycle through the indices. - We iterate from
i = n-1down to0as in the bottom-up approach. - In each step, we calculate
dp[i % 4]using the values atdp[(i+1) % 4],dp[(i+2) % 4], anddp[(i+3) % 4]. - After the loop finishes, the result for
dp[0]will be stored atdp[0 % 4], which isdp[0].
Walkthrough
Instead of an O(n) DP array, we can use an array of size 4. Let's call it dp. dp[i % 4] will store the result for the subproblem starting at index i. As we iterate i from n-1 down to 0, the values for dp[i+1], dp[i+2], and dp[i+3] will be available in our small dp array at indices (i+1)%4, (i+2)%4, and (i+3)%4 respectively. This is because when we computed dp[i+1], we used dp[i+2], dp[i+3], dp[i+4], and so on. The required values are always within the last few computed results. This 'sliding window' of necessary DP states allows for a constant space solution.
class Solution { public String stoneGameIII(int[] stoneValue) { int n = stoneValue.length; // We only need to store the last 3 dp values, so an array of size 4 is sufficient. // dp[i % 4] will store the result for the subproblem starting at index i. // The extra space handles indices i+1, i+2, i+3 gracefully. int[] dp = new int[4]; for (int i = n - 1; i >= 0; i--) { // Initialize with a very small value dp[i % 4] = Integer.MIN_VALUE; // Option 1: Take 1 stone int take1 = stoneValue[i] - dp[(i + 1) % 4]; dp[i % 4] = Math.max(dp[i % 4], take1); // Option 2: Take 2 stones if (i + 1 < n) { int take2 = stoneValue[i] + stoneValue[i + 1] - dp[(i + 2) % 4]; dp[i % 4] = Math.max(dp[i % 4], take2); } // Option 3: Take 3 stones if (i + 2 < n) { int take3 = stoneValue[i] + stoneValue[i + 1] + stoneValue[i + 2] - dp[(i + 3) % 4]; dp[i % 4] = Math.max(dp[i % 4], take3); } } int diff = dp[0]; if (diff > 0) { return "Alice"; } else if (diff < 0) { return "Bob"; } else { return "Tie"; } }}Complexity
Time
O(n), as we still need to iterate through the entire array once.
Space
O(1), as we only use a fixed-size array (or a few variables) regardless of the input size.
Trade-offs
Pros
Most optimal solution with linear time and constant space complexity.
Cons
The logic with modular arithmetic can be slightly more complex to reason about compared to the straightforward DP table.
Solutions
Solution
class Solution {private int[] stoneValue;private Integer[] f;private int n;public String stoneGameIII(int[] stoneValue) { n = stoneValue.length; f = new Integer[n]; this.stoneValue = stoneValue; int ans = dfs(0); if (ans == 0) { return "Tie"; } return ans > 0 ? "Alice" : "Bob"; }private int dfs(int i) { if (i >= n) { return 0; } if (f[i] != null) { return f[i]; } int ans = -(1 << 30); int s = 0; for (int j = 0; j < 3 && i + j < n; ++j) { s += stoneValue[i + j]; ans = Math.max(ans, s - dfs(i + j + 1)); } return f[i] = ans; }}class Solution {public: string stoneGameIII(vector<int> &stoneValue) { int n = stoneValue.size(); int f[n]; memset(f, 0x3f, sizeof(f)); function<int(int)> dfs = [&](int i) -> int { if (i >= n) { return 0; } if (f[i] != 0x3f3f3f3f) { return f[i]; } int ans = -(1 << 30), s = 0; for (int j = 0; j < 3 && i + j < n; ++j) { s += stoneValue[i + j]; ans = max(ans, s - dfs(i + j + 1)); } return f[i] = ans; }; int ans = dfs(0); if (ans == 0) { return "Tie"; } return ans > 0 ? "Alice" : "Bob"; }};class Solution: def stoneGameIII(self, stoneValue: List[int]) -> str: @ cache def dfs(i: int) -> int: if i >= n: return 0 ans, s = - inf, 0 for j in range(3): if i + j >= n: break s += stoneValue[i + j] ans = max(ans, s - dfs(i + j + 1)) return ans n = len(stoneValue) ans = dfs(0) if ans == 0: return 'Tie' return 'Alice' if ans > 0 else 'Bob'Video walkthrough
Newsletter
One sharp idea, every week
System design and interview prep — short enough to finish.
No spam. Unsubscribe anytime.
Practice
Same difficulty — related problems to reinforce the pattern.