1510. Stone Game IV
Problem Description
Alice and Bob are playing a turn-based game with a pile of n
stones. Alice goes first.
On each turn, a player must remove a square number of stones from the pile. A square number is any number that can be expressed as k²
where k
is a positive integer (examples: 1, 4, 9, 16, 25, ...). The player can choose any valid square number that doesn't exceed the current number of stones in the pile.
The game continues with players alternating turns until one player cannot make a valid move (when there are 0 stones left). The player who cannot make a move loses the game.
Given a positive integer n
representing the initial number of stones, you need to determine if Alice will win the game, assuming both players play optimally (making the best possible moves). Return true
if Alice wins, false
if Bob wins.
Example scenarios:
- If
n = 1
: Alice can remove 1 stone (1 = 1²), leaving 0 stones for Bob. Bob cannot move, so Alice wins. - If
n = 2
: Alice can only remove 1 stone, leaving 1 stone for Bob. Bob removes 1 stone, leaving 0 for Alice. Alice cannot move, so Bob wins. - If
n = 4
: Alice can remove all 4 stones (4 = 2²), leaving 0 for Bob. Bob cannot move, so Alice wins.
The solution uses dynamic programming with memoization. The dfs(i)
function returns true
if the current player can win starting with i
stones. For each state with i
stones, the function tries all possible square number moves (1, 4, 9, ... up to i
) and checks if any move leads to a losing position for the opponent. If such a move exists, the current player can win.
Intuition
The key insight is recognizing this as a game theory problem where we need to determine winning and losing positions. In optimal play, each player tries to force their opponent into a losing position.
Let's think about this problem from the ground up:
- When there are 0 stones, the current player has already lost (they can't make a move)
- For any other number of stones, the current player wins if they can make at least one move that puts their opponent in a losing position
This naturally leads to a recursive approach: to know if position i
is winning, we need to check all possible moves from that position. If we can remove j²
stones (where j² ≤ i
), we reach position i - j²
. If any of these resulting positions is a losing position for our opponent, then our current position is a winning position.
Why does this work? Because in optimal play:
- If the current player can force the opponent into even one losing position, they will choose that move and win
- If all possible moves lead to winning positions for the opponent, then the current player is doomed to lose
The recursive structure becomes clear:
dfs(0)
returnsFalse
(base case - no moves available, current player loses)dfs(i)
returnsTrue
if there exists anyj
wherej² ≤ i
anddfs(i - j²)
returnsFalse
- Otherwise,
dfs(i)
returnsFalse
The @cache
decorator is crucial here because the same game states will be encountered multiple times through different paths. For example, reaching 5 stones could happen from starting with 6 (removing 1) or starting with 9 (removing 4). Memoization ensures we only compute each state once, making the solution efficient.
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:
-
Main Function Structure:
- The
winnerSquareGame
function takesn
(number of stones) as input - It defines an inner recursive function
dfs(i)
that determines if the current player can win withi
stones remaining - Returns the result of
dfs(n)
to determine if Alice (the first player) wins
- The
-
Recursive Function
dfs(i)
:@cache def dfs(i: int) -> bool:
- The
@cache
decorator automatically memoizes results, preventing redundant calculations - Parameter
i
represents the current number of stones - Returns
True
if the current player can win from this position,False
otherwise
- The
-
Base Case:
if i == 0: return False
- When there are 0 stones, the current player has no valid moves and loses
-
Recursive Case - Try All Possible Moves:
j = 1 while j * j <= i: if not dfs(i - j * j): return True j += 1 return False
- Iterate through all possible square numbers from 1² to the largest square ≤
i
- For each valid move removing
j²
stones, check the resulting positioni - j²
- If
dfs(i - j²)
returnsFalse
, it means the opponent will lose from that position, so the current player wins by making this move - If we find any such winning move, immediately return
True
- If all moves lead to winning positions for the opponent, return
False
- Iterate through all possible square numbers from 1² to the largest square ≤
Time and Space Complexity:
- Time Complexity:
O(n√n)
- For each of then
possible states, we check up to√n
possible moves - Space Complexity:
O(n)
- The cache stores results for up ton
different states, plus the recursion stack depth
The elegance of this solution lies in its simplicity: it directly models the game's win/lose conditions and leverages memoization to efficiently handle overlapping subproblems.
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 trace through the algorithm with n = 5
stones to see how the solution determines the winner.
Starting with dfs(5):
- We need to check if the current player (Alice) can win with 5 stones
- Possible moves: remove 1 (1²) or 4 (2²) stones
- Try removing 1: Check
dfs(5 - 1) = dfs(4)
- Computing dfs(4):
- Possible moves: remove 1 or 4 stones
- Try removing 1: Check
dfs(4 - 1) = dfs(3)
- Computing dfs(3):
- Possible moves: remove 1 stone only
- Try removing 1: Check
dfs(3 - 1) = dfs(2)
- Computing dfs(2):
- Possible moves: remove 1 stone only
- Try removing 1: Check
dfs(2 - 1) = dfs(1)
- Computing dfs(1):
- Possible moves: remove 1 stone only
- Try removing 1: Check
dfs(1 - 1) = dfs(0)
dfs(0)
returnsFalse
(base case)- Since opponent loses,
dfs(1)
returnsTrue
- Computing dfs(1):
dfs(1)
returnedTrue
, so opponent wins- No winning move found,
dfs(2)
returnsFalse
- Computing dfs(2):
dfs(2)
returnedFalse
, so opponent loses- Found a winning move!
dfs(3)
returnsTrue
- Computing dfs(3):
dfs(3)
returnedTrue
, so opponent wins- Try removing 4: Check
dfs(4 - 4) = dfs(0)
dfs(0)
returnsFalse
(base case)- Found a winning move!
dfs(4)
returnsTrue
dfs(4)
returnedTrue
, so opponent (Bob) wins if we leave 4 stones
- Computing dfs(4):
- Try removing 4: Check
dfs(5 - 4) = dfs(1)
- We already computed
dfs(1) = True
(cached) - Opponent (Bob) wins if we leave 1 stone
- We already computed
- Both moves lead to winning positions for Bob
- No winning move found,
dfs(5)
returnsFalse
Result: Alice loses with 5 stones, so Bob wins the game.
The key insight: Alice can only leave Bob with either 4 stones or 1 stone. In both cases, Bob has a winning strategy (removing all 4 stones from 4, or removing 1 stone from 1), so Alice is doomed to lose from position 5.
Solution Implementation
1class Solution:
2 def winnerSquareGame(self, n: int) -> bool:
3 """
4 Determine if Alice wins the square game with optimal play.
5
6 Game rules: Players take turns removing a perfect square number of stones.
7 The player who removes the last stone wins.
8
9 Args:
10 n: Number of stones initially in the pile
11
12 Returns:
13 True if Alice (first player) wins, False otherwise
14 """
15 from functools import cache
16
17 @cache
18 def can_win(remaining_stones: int) -> bool:
19 """
20 Check if the current player can win from this game state.
21
22 Args:
23 remaining_stones: Number of stones left in the pile
24
25 Returns:
26 True if current player can force a win, False otherwise
27 """
28 # Base case: no stones left means previous player took the last stone
29 if remaining_stones == 0:
30 return False
31
32 # Try all possible moves (removing perfect square number of stones)
33 square_root = 1
34 while square_root * square_root <= remaining_stones:
35 stones_to_remove = square_root * square_root
36
37 # If opponent cannot win after our move, then we can win
38 if not can_win(remaining_stones - stones_to_remove):
39 return True
40
41 square_root += 1
42
43 # No winning move found
44 return False
45
46 # Alice starts first, check if she can win
47 return can_win(n)
48
1class Solution {
2 // Memoization array to store computed results for each state
3 private Boolean[] memo;
4
5 /**
6 * Determines if the current player can win the square game.
7 * In this game, players take turns removing square number of stones.
8 * The player who removes the last stone wins.
9 *
10 * @param n The initial number of stones
11 * @return true if the current player can guarantee a win, false otherwise
12 */
13 public boolean winnerSquareGame(int n) {
14 // Initialize memoization array with size n+1 to handle states from 0 to n
15 memo = new Boolean[n + 1];
16
17 // Start the recursive search from initial state n
18 return canWin(n);
19 }
20
21 /**
22 * Recursively determines if the current player can win from state i.
23 * Uses game theory: current player wins if there exists at least one move
24 * that leaves the opponent in a losing position.
25 *
26 * @param remainingStones The number of stones remaining in current state
27 * @return true if current player can win from this state, false otherwise
28 */
29 private boolean canWin(int remainingStones) {
30 // Base case: no stones left means previous player took the last stone and won
31 if (remainingStones <= 0) {
32 return false;
33 }
34
35 // Check if we've already computed the result for this state
36 if (memo[remainingStones] != null) {
37 return memo[remainingStones];
38 }
39
40 // Try all possible square number moves
41 // j * j represents the square number of stones to remove
42 for (int j = 1; j * j <= remainingStones; j++) {
43 // If any move leads to opponent losing, current player wins
44 if (!canWin(remainingStones - j * j)) {
45 memo[remainingStones] = true;
46 return true;
47 }
48 }
49
50 // If no winning move exists, current player loses
51 memo[remainingStones] = false;
52 return false;
53 }
54}
55
1class Solution {
2public:
3 bool winnerSquareGame(int n) {
4 // dp array to store game results: 0 = unvisited, 1 = win, -1 = lose
5 int dp[n + 1];
6 memset(dp, 0, sizeof(dp));
7
8 // Recursive function with memoization to determine if current player wins
9 function<bool(int)> canWin = [&](int remainingStones) -> bool {
10 // Base case: no stones left, current player loses
11 if (remainingStones <= 0) {
12 return false;
13 }
14
15 // Check if result is already computed
16 if (dp[remainingStones] != 0) {
17 return dp[remainingStones] == 1;
18 }
19
20 // Try removing each possible perfect square number of stones
21 for (int k = 1; k * k <= remainingStones; ++k) {
22 // If opponent loses after our move, we win
23 if (!canWin(remainingStones - k * k)) {
24 dp[remainingStones] = 1; // Mark as winning position
25 return true;
26 }
27 }
28
29 // If no winning move found, current player loses
30 dp[remainingStones] = -1; // Mark as losing position
31 return false;
32 };
33
34 // Check if Alice (first player) can win with n stones
35 return canWin(n);
36 }
37};
38
1/**
2 * Determines if the first player can win the square subtraction game
3 * In this game, players take turns removing a perfect square number of stones
4 * The player who cannot make a move loses
5 *
6 * @param n - The initial number of stones
7 * @returns true if the first player wins with optimal play, false otherwise
8 */
9function winnerSquareGame(n: number): boolean {
10 // Memoization array to cache results
11 // 0: not computed, 1: winning position, -1: losing position
12 const memo: number[] = new Array(n + 1).fill(0);
13
14 /**
15 * Recursive helper function to determine if current player wins
16 * Uses dynamic programming with memoization
17 *
18 * @param remainingStones - Number of stones left in the game
19 * @returns true if current player can win from this position
20 */
21 const canWin = (remainingStones: number): boolean => {
22 // Base case: no stones left means previous player took the last stone and won
23 if (remainingStones <= 0) {
24 return false;
25 }
26
27 // Check if result is already computed
28 if (memo[remainingStones] !== 0) {
29 return memo[remainingStones] === 1;
30 }
31
32 // Try all possible moves (removing perfect square number of stones)
33 for (let square = 1; square * square <= remainingStones; ++square) {
34 // If opponent loses after our move, we win
35 if (!canWin(remainingStones - square * square)) {
36 memo[remainingStones] = 1;
37 return true;
38 }
39 }
40
41 // No winning move found, current player loses
42 memo[remainingStones] = -1;
43 return false;
44 };
45
46 // Start the game with n stones
47 return canWin(n);
48}
49
Time and Space Complexity
Time Complexity: O(n * √n)
The analysis follows from the memoized recursive approach:
- The
dfs
function can be called with at mostn
different states (from0
ton
). - Due to the
@cache
decorator, each state is computed only once. - For each state
i
, we iterate through all perfect squares less than or equal toi
, which requires checking approximately√i
values. - In the worst case, when
i = n
, we check up to√n
perfect squares. - Therefore, the total time complexity is
O(n) * O(√n) = O(n * √n)
.
Space Complexity: O(n)
The space complexity consists of:
- Memoization cache: The
@cache
decorator stores up ton
different states, requiringO(n)
space. - Recursion call stack: In the worst case, the recursion depth can reach
O(n)
when we repeatedly subtract 1 (which is a perfect square), leading to a chain of calls likedfs(n) → dfs(n-1) → dfs(n-2) → ... → dfs(0)
. - Since both the cache and the call stack can use
O(n)
space, the overall space complexity isO(n)
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Inefficient Square Number Generation
A common mistake is recalculating square numbers repeatedly or using inefficient methods to check if a number is a perfect square.
Pitfall Example:
# Inefficient: Checking all numbers up to n to find squares
for remove in range(1, i + 1):
if int(remove ** 0.5) ** 2 == remove: # Floating point issues!
if not dfs(i - remove):
return True
Problems:
- Iterates through all numbers instead of just perfect squares
- Using floating-point operations (
** 0.5
) can lead to precision errors - Time complexity becomes O(n²) instead of O(n√n)
Solution: Generate perfect squares directly by incrementing the base and squaring:
j = 1 while j * j <= i: if not dfs(i - j * j): return True j += 1
2. Missing or Incorrect Memoization
Without proper memoization, the solution will have exponential time complexity due to recalculating the same game states.
Pitfall Example:
def dfs(i): # No memoization!
if i == 0:
return False
j = 1
while j * j <= i:
if not dfs(i - j * j):
return True
j += 1
return False
Problems:
- Same game states are computed multiple times
- Time complexity becomes exponential
- Will timeout for larger values of n
Solution:
Use @cache
decorator or implement manual memoization:
from functools import cache
@cache
def dfs(i):
# ... rest of the code
Or with manual memoization:
memo = {}
def dfs(i):
if i in memo:
return memo[i]
# ... compute result
memo[i] = result
return result
3. Incorrect Base Case Logic
Misunderstanding when the game ends and who wins.
Pitfall Example:
def dfs(i):
if i == 0:
return True # Wrong! Current player already lost
Problems:
- When i = 0, the current player has no moves available
- The previous player took the last stone and won
- Returning True would incorrectly indicate the current player wins
Solution: Return False when no stones remain, as the current player cannot move:
if i == 0: return False # Current player loses (cannot make a move)
4. Off-by-One Errors in Square Calculation
Incorrectly handling the boundary conditions for valid square numbers.
Pitfall Example:
j = 0 # Starting from 0 instead of 1 while j * j <= i: if not dfs(i - j * j): # When j=0, removes 0 stones! return True j += 1
Problems:
- Starting j from 0 means considering removing 0 stones as a valid move
- This creates an infinite loop or incorrect game logic
Solution: Always start from j = 1 (smallest perfect square):
j = 1 # Start from 1² = 1 while j * j <= i: # ... rest of the code
Which algorithm should you use to find a node that is close to the root of the tree?
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
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Want a Structured Path to Master System Design Too? Don’t Miss This!