294. Flip Game II đź”’
Problem Description
You're playing a two-player game called the Flip Game. The game starts with a string currentState
that consists only of '+'
and '-'
characters.
The rules are:
- Players take turns making moves
- On each turn, a player must flip two consecutive
"++"
characters into"--"
- A player who cannot make a valid move loses (meaning their opponent wins)
Your task is to determine if the starting player can guarantee a win, assuming both players play optimally. Return true
if the starting player has a winning strategy, and false
otherwise.
For example:
- If
currentState = "++++"
, the first player can flip positions 0-1 or 1-2 or 2-3 to get"--++"
,"+--+"
, or"++--"
respectively - The game continues until no player can find two consecutive
"++"
to flip - You need to determine if the first player can force a win regardless of how the second player responds
The solution uses dynamic programming with memoization to explore all possible game states. It represents the current state as a bitmask where 1
indicates a '+'
and 0
indicates a '-'
. The recursive function dfs
checks if the current player can win from a given state by trying all possible moves and seeing if any move leads to a losing position for the opponent.
Flowchart Walkthrough
First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:
Is it a graph?
- No: While we're exploring different game states, this isn't a traditional graph problem with explicit nodes and edges. We're dealing with a string that we modify through moves.
Need to solve for kth smallest/largest?
- No: We're not looking for any kth element. We need to determine if the first player can guarantee a win.
Involves Linked Lists?
- No: The problem works with a string, not linked lists.
Does the problem have small constraints?
- Yes: The problem involves exploring all possible game states from a given position. Since each move reduces the number of possible future moves (converting "++" to "--"), and strings in such games typically have reasonable lengths, we're dealing with a manageable state space that can be explored exhaustively.
Brute force / Backtracking?
- Yes: We need to try all possible moves from each game state and backtrack to explore different branches of the game tree. Each recursive call represents trying a move, and we backtrack when we've explored all possibilities from that state.
Conclusion: The flowchart correctly leads us to use a Backtracking approach. This makes sense because:
- We need to explore all possible moves from each game state
- We recursively check if making a particular move leads to a winning position
- We backtrack to try different moves if one doesn't guarantee a win
- The solution uses memoization (via
@cache
) to avoid recalculating the same game states, which is a common optimization for backtracking problems
Intuition
The key insight for this problem is recognizing it as a game theory problem where we need to determine if the current player has a winning strategy. In such problems, a player wins if they can make a move that puts their opponent in a losing position.
Think of it this way: if it's your turn and you can make a move that leaves your opponent with no winning moves, then you win. Conversely, if every move you make gives your opponent at least one winning path, then you're in a losing position.
This naturally leads to a recursive approach:
- From the current state, try all possible valid moves (flipping any
"++"
to"--"
) - For each move, check if the resulting state is a losing state for the opponent
- If we find even one move that puts the opponent in a losing state, we can win
- If all our moves lead to winning states for the opponent, we lose
The challenge is that checking all possibilities can lead to revisiting the same game states multiple times. For example, flipping positions 0-1 then 3-4 leads to the same state as flipping 3-4 then 0-1. This is where memoization becomes crucial - we cache the results for each unique game state.
The solution cleverly uses a bitmask to represent the game state, where each bit indicates whether that position has a '+'
(1) or '-'
(0). This allows us to:
- Efficiently check for consecutive
"++"
patterns using bit operations - Create new states by flipping specific bits
- Use the bitmask as a unique identifier for memoization
The recursive function returns true
if the current player can win from this state, and false
otherwise. By starting with the initial state and working our way through all possible game branches, we can determine if the first player has a guaranteed winning strategy.
Solution Approach
The solution implements the game theory approach using backtracking with memoization. Let's walk through the implementation step by step:
1. State Representation with Bitmask
First, we convert the string into a bitmask where each bit represents a position in the string:
'+'
→ bit value 1'-'
→ bit value 0
mask, n = 0, len(currentState)
for i, c in enumerate(currentState):
if c == '+':
mask |= 1 << i
For example, "++++"
becomes 1111
in binary (mask = 15), and "--++"
becomes 0011
in binary (mask = 3).
2. Recursive Function with Memoization
The core logic is in the dfs(mask)
function, which determines if the current player can win from the given state:
@cache
def dfs(mask):
for i in range(n - 1):
# Check if positions i and i+1 both have '+'
if (mask & (1 << i)) == 0 or (mask & (1 << (i + 1)) == 0):
continue
# Make the move: flip "++" to "--"
if dfs(mask ^ (1 << i) ^ (1 << (i + 1))):
continue
# Found a move that puts opponent in losing position
return True
# No winning move found
return False
3. The Algorithm Flow
-
Finding Valid Moves: We iterate through positions 0 to n-2, checking if both position
i
andi+1
have'+'
(bit value 1). This is done using bitwise AND operations. -
Making a Move: When we find a valid
"++"
, we flip it to"--"
by XORing the mask with(1 << i)
and(1 << (i + 1))
. This flips bits at positionsi
andi+1
from 1 to 0. -
Game Theory Logic:
- If
dfs(new_mask)
returnsTrue
, it means the opponent can win from that state, so we continue searching for other moves - If
dfs(new_mask)
returnsFalse
, it means the opponent cannot win from that state, so we've found a winning move and returnTrue
- If we've tried all possible moves and none put the opponent in a losing position, we return
False
- If
4. Base Case
The base case is implicit: when no valid moves exist (no consecutive "++"
found), the loop completes without returning True
, so the function returns False
, indicating the current player loses.
5. Memoization Optimization
The @cache
decorator ensures that once we've computed the result for a particular mask, we don't recalculate it. This is crucial because the same game state can be reached through different sequences of moves, significantly reducing the time complexity from exponential to manageable bounds.
The final answer is simply dfs(mask)
called with the initial state's bitmask representation.
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 currentState = "++++"
to illustrate how the solution works.
Step 1: Convert to Bitmask
- String:
"++++"
- Bitmask:
1111
(binary) = 15 (decimal) - Each
'+'
becomes a 1-bit
Step 2: First Player's Turn - dfs(1111)
The first player can make three possible moves:
- Flip positions 0-1:
"+++"
→"--++"
(mask: 1111 → 0011) - Flip positions 1-2:
"+++"
→"+--+"
(mask: 1111 → 1001) - Flip positions 2-3:
"+++"
→"++--"
(mask: 1111 → 1100)
Let's trace Move 1 (flip positions 0-1):
- New state:
"--++"
(mask = 0011) - Call
dfs(0011)
to see if opponent can win
Step 3: Second Player's Turn - dfs(0011)
From "--++"
, the second player has only one valid move:
- Flip positions 2-3:
"--++"
→"----"
(mask: 0011 → 0000) - Call
dfs(0000)
Step 4: First Player's Turn - dfs(0000)
From "----"
, there are no consecutive "++"
to flip.
- No valid moves available
- Return
False
(first player loses from this state)
Step 5: Backtrack to Second Player
Since dfs(0000)
returned False
:
- The second player found a move that puts the first player in a losing position
- So
dfs(0011)
returnsTrue
(second player can win)
Step 6: Backtrack to First Player
Since dfs(0011)
returned True
:
- Move 1 leads to a state where the opponent can win
- We need to check the other moves (Move 2 and Move 3)
After checking all three moves:
- Move 1 (→
"--++"
) gives opponent a winning position - Move 2 (→
"+--+"
) gives opponent a winning position - Move 3 (→
"++--"
) gives opponent a winning position
Since all moves lead to states where the opponent can win, the first player cannot guarantee a win from "++++"
. The function returns False
.
Key Observations:
- The algorithm explores all possible game branches
- Memoization prevents recalculating the same states (e.g.,
"----"
might be reached through different move sequences) - A player wins if they can find at least one move that puts the opponent in a losing position
- The bitmask representation allows efficient state manipulation using XOR operations
Solution Implementation
1from functools import cache
2
3class Solution:
4 def canWin(self, currentState: str) -> bool:
5 """
6 Determine if the current player can guarantee a win in the Flip Game.
7 Players take turns flipping consecutive "++" to "--".
8 The player who cannot make a move loses.
9
10 Args:
11 currentState: String containing '+' and '-' characters
12
13 Returns:
14 True if the current player can guarantee a win, False otherwise
15 """
16
17 @cache
18 def can_current_player_win(state_mask: int) -> bool:
19 """
20 Recursively check if current player can win from given state.
21
22 Args:
23 state_mask: Bitmask where 1 represents '+' and 0 represents '-'
24
25 Returns:
26 True if current player can force a win from this state
27 """
28 # Try all possible moves (flipping consecutive '++' to '--')
29 for i in range(string_length - 1):
30 # Check if positions i and i+1 both have '+'
31 has_plus_at_i = (state_mask & (1 << i)) != 0
32 has_plus_at_next = (state_mask & (1 << (i + 1))) != 0
33
34 # Skip if we can't flip (not consecutive '++')
35 if not has_plus_at_i or not has_plus_at_next:
36 continue
37
38 # Create new state after flipping positions i and i+1 to '-'
39 new_state_mask = state_mask ^ (1 << i) ^ (1 << (i + 1))
40
41 # If opponent cannot win from the new state, current player wins
42 if not can_current_player_win(new_state_mask):
43 return True
44
45 # No winning move found, current player loses
46 return False
47
48 # Initialize variables
49 initial_mask = 0
50 string_length = len(currentState)
51
52 # Convert string to bitmask representation
53 # Bit i is 1 if currentState[i] is '+', 0 if '-'
54 for index, char in enumerate(currentState):
55 if char == '+':
56 initial_mask |= (1 << index)
57
58 # Check if first player can win from initial state
59 return can_current_player_win(initial_mask)
60
1class Solution {
2 // Length of the game state string
3 private int n;
4 // Memoization map to cache results for each game state (represented as bitmask)
5 private Map<Long, Boolean> memo = new HashMap<>();
6
7 /**
8 * Determines if the current player can guarantee a win
9 * @param currentState String containing '+' and '-' characters
10 * @return true if current player can win, false otherwise
11 */
12 public boolean canWin(String currentState) {
13 // Convert string state to bitmask representation
14 // Bit i is 1 if position i has '+', 0 if it has '-'
15 long mask = 0;
16 n = currentState.length();
17
18 for (int i = 0; i < n; ++i) {
19 if (currentState.charAt(i) == '+') {
20 // Set bit i to 1 for '+'
21 mask |= 1L << i;
22 }
23 }
24
25 return dfs(mask);
26 }
27
28 /**
29 * Recursive DFS with memoization to check if current player can win
30 * @param mask Bitmask representing current game state
31 * @return true if current player can win from this state
32 */
33 private boolean dfs(long mask) {
34 // Check if this state has been computed before
35 if (memo.containsKey(mask)) {
36 return memo.get(mask);
37 }
38
39 // Try all possible moves (flip consecutive '++' to '--')
40 for (int i = 0; i < n - 1; ++i) {
41 // Check if positions i and i+1 both have '+'
42 if ((mask & (1L << i)) == 0 || (mask & (1L << (i + 1))) == 0) {
43 // Skip if either position is not '+'
44 continue;
45 }
46
47 // Make the move: flip positions i and i+1 from '+' to '-'
48 long nextMask = mask ^ (1L << i) ^ (1L << (i + 1));
49
50 // If opponent cannot win from the resulting state
51 if (!dfs(nextMask)) {
52 // Current player can win by making this move
53 memo.put(mask, true);
54 return true;
55 }
56 }
57
58 // No winning move found, current player loses
59 memo.put(mask, false);
60 return false;
61 }
62}
63
1using ll = long long;
2
3class Solution {
4public:
5 int stringLength;
6 unordered_map<ll, bool> memoization;
7
8 bool canWin(string currentState) {
9 stringLength = currentState.size();
10
11 // Convert the string to a bitmask representation
12 // Set bit i to 1 if currentState[i] is '+'
13 ll stateMask = 0;
14 for (int i = 0; i < stringLength; ++i) {
15 if (currentState[i] == '+') {
16 stateMask |= 1ll << i;
17 }
18 }
19
20 return canCurrentPlayerWin(stateMask);
21 }
22
23private:
24 bool canCurrentPlayerWin(ll stateMask) {
25 // Check if this state has already been computed
26 if (memoization.count(stateMask)) {
27 return memoization[stateMask];
28 }
29
30 // Try all possible moves (flipping consecutive '++' to '--')
31 for (int i = 0; i < stringLength - 1; ++i) {
32 // Check if positions i and i+1 both have '+' (bit set to 1)
33 bool currentPositionIsPlus = (stateMask & (1ll << i)) != 0;
34 bool nextPositionIsPlus = (stateMask & (1ll << (i + 1))) != 0;
35
36 if (!currentPositionIsPlus || !nextPositionIsPlus) {
37 continue; // Cannot flip if not both '+'
38 }
39
40 // Create new state after flipping positions i and i+1
41 ll newStateMask = stateMask ^ (1ll << i) ^ (1ll << (i + 1));
42
43 // If opponent cannot win from the new state, current player wins
44 if (!canCurrentPlayerWin(newStateMask)) {
45 memoization[stateMask] = true;
46 return true;
47 }
48 }
49
50 // No winning move found for current player
51 memoization[stateMask] = false;
52 return false;
53 }
54};
55
1let stringLength: number;
2let memoization: Map<bigint, boolean>;
3
4function canWin(currentState: string): boolean {
5 stringLength = currentState.length;
6 memoization = new Map<bigint, boolean>();
7
8 // Convert the string to a bitmask representation
9 // Set bit i to 1 if currentState[i] is '+'
10 let stateMask: bigint = 0n;
11 for (let i = 0; i < stringLength; i++) {
12 if (currentState[i] === '+') {
13 stateMask |= 1n << BigInt(i);
14 }
15 }
16
17 return canCurrentPlayerWin(stateMask);
18}
19
20function canCurrentPlayerWin(stateMask: bigint): boolean {
21 // Check if this state has already been computed
22 if (memoization.has(stateMask)) {
23 return memoization.get(stateMask)!;
24 }
25
26 // Try all possible moves (flipping consecutive '++' to '--')
27 for (let i = 0; i < stringLength - 1; i++) {
28 // Check if positions i and i+1 both have '+' (bit set to 1)
29 const currentPositionIsPlus: boolean = (stateMask & (1n << BigInt(i))) !== 0n;
30 const nextPositionIsPlus: boolean = (stateMask & (1n << BigInt(i + 1))) !== 0n;
31
32 if (!currentPositionIsPlus || !nextPositionIsPlus) {
33 continue; // Cannot flip if not both '+'
34 }
35
36 // Create new state after flipping positions i and i+1
37 const newStateMask: bigint = stateMask ^ (1n << BigInt(i)) ^ (1n << BigInt(i + 1));
38
39 // If opponent cannot win from the new state, current player wins
40 if (!canCurrentPlayerWin(newStateMask)) {
41 memoization.set(stateMask, true);
42 return true;
43 }
44 }
45
46 // No winning move found for current player
47 memoization.set(stateMask, false);
48 return false;
49}
50
Time and Space Complexity
Time Complexity: O(2^n * n)
where n
is the length of the input string.
The algorithm uses dynamic programming with memoization through a bitmask. The mask can have at most 2^n
different states (each bit position represents whether a '+' at that position has been flipped or not). For each unique state, we iterate through n-1
possible consecutive pairs to check if we can make a move. Due to memoization with @cache
, each state is computed only once. Therefore, the overall time complexity is O(2^n * n)
.
Space Complexity: O(2^n)
where n
is the length of the input string.
The space complexity consists of:
- The recursion call stack depth, which in the worst case can go up to
O(n)
levels deep (when we flip pairs sequentially) - The memoization cache storing up to
2^n
different states, each state being an integer mask - The dominant factor is the cache storage of
O(2^n)
Therefore, the total space complexity is O(2^n)
.
Common Pitfalls
1. Incorrect Bit Manipulation When Checking Consecutive Positions
Pitfall: A common mistake is incorrectly checking if two consecutive positions both contain '+'. Developers might write:
# WRONG: This checks if EITHER position has a '+' if (mask & (1 << i)) or (mask & (1 << (i + 1))): # Make move...
Why it's wrong: This condition is true when at least one position has '+', but we need BOTH positions to have '+' to make a valid move.
Correct approach:
# Check that BOTH positions have '+' if (mask & (1 << i)) != 0 and (mask & (1 << (i + 1))) != 0: # Make move...
2. Forgetting to XOR Both Bit Positions When Making a Move
Pitfall: When flipping two consecutive '+' to '-', forgetting to flip both bits:
# WRONG: Only flips one position new_mask = mask ^ (1 << i)
Why it's wrong: The move requires flipping TWO consecutive positions from '+' to '-', not just one.
Correct approach:
# Flip both positions i and i+1 new_mask = mask ^ (1 << i) ^ (1 << (i + 1))
3. Misunderstanding the Game Theory Logic
Pitfall: Incorrectly interpreting what the recursive call's return value means:
# WRONG: Returns True if opponent wins if dfs(new_mask): return True
Why it's wrong: If dfs(new_mask)
returns True
, it means the opponent (who becomes the current player in that state) can win. This is BAD for us, so we should continue searching for other moves.
Correct approach:
# If opponent CANNOT win from new state, we found a winning move if not dfs(new_mask): return True
4. Integer Overflow with Large Strings
Pitfall: For strings longer than 32 or 64 characters (depending on system), using bit manipulation can cause overflow issues:
# Potential overflow for large n mask |= 1 << i # If i >= 64, this might overflow
Solution: Add a length check or use alternative approaches for very long strings:
def canWin(self, currentState: str) -> bool:
n = len(currentState)
# For very long strings, use string-based memoization instead
if n > 30: # Adjust threshold based on system
@cache
def dfs(state: str) -> bool:
for i in range(len(state) - 1):
if state[i:i+2] == '++':
new_state = state[:i] + '--' + state[i+2:]
if not dfs(new_state):
return True
return False
return dfs(currentState)
# Original bitmask approach for shorter strings
# ... (rest of the code)
5. Not Using Memoization or Using It Incorrectly
Pitfall: Forgetting the @cache
decorator or trying to manually implement memoization with mutable default arguments:
# WRONG: Mutable default argument persists across function calls
def dfs(mask, memo={}): # Don't do this!
if mask in memo:
return memo[mask]
# ...
Correct approach: Use @cache
decorator or properly initialize memoization:
# Option 1: Using @cache (recommended)
@cache
def dfs(mask):
# ...
# Option 2: Manual memoization
def canWin(self, currentState: str) -> bool:
memo = {} # Initialize inside the main function
def dfs(mask):
if mask in memo:
return memo[mask]
# ... compute result ...
memo[mask] = result
return result
Which of the tree traversal order can be used to obtain elements in a binary search tree in sorted order?
Recommended Readings
Memoization Prereq Backtracking problems backtracking Memoization is a fancy word for a simple concept so is the case for a lot of things we learn in school It means saving the previous function call result in a dictionary and reading from it when we do the exact same call again
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
Want a Structured Path to Master System Design Too? Don’t Miss This!