304 lines
14 KiB
YAML
304 lines
14 KiB
YAML
title: Can I Win
|
|
slug: can-i-win
|
|
difficulty: medium
|
|
leetcode_id: 464
|
|
leetcode_url: https://leetcode.com/problems/can-i-win/
|
|
categories:
|
|
- dynamic-programming
|
|
- recursion
|
|
patterns:
|
|
- slug: dynamic-programming
|
|
is_optimal: true
|
|
- slug: backtracking
|
|
is_optimal: false
|
|
|
|
function_signature: "def can_i_win(max_choosable: int, desired_total: int) -> bool:"
|
|
|
|
test_cases:
|
|
visible:
|
|
- input: { max_choosable: 10, desired_total: 11 }
|
|
expected: false
|
|
- input: { max_choosable: 10, desired_total: 0 }
|
|
expected: true
|
|
- input: { max_choosable: 10, desired_total: 1 }
|
|
expected: true
|
|
hidden:
|
|
- input: { max_choosable: 1, desired_total: 1 }
|
|
expected: true
|
|
- input: { max_choosable: 1, desired_total: 2 }
|
|
expected: false
|
|
- input: { max_choosable: 4, desired_total: 6 }
|
|
expected: true
|
|
- input: { max_choosable: 5, desired_total: 50 }
|
|
expected: false
|
|
- input: { max_choosable: 10, desired_total: 40 }
|
|
expected: false
|
|
- input: { max_choosable: 20, desired_total: 210 }
|
|
expected: false
|
|
- input: { max_choosable: 3, desired_total: 5 }
|
|
expected: true
|
|
|
|
description: |
|
|
In the "100 game" two players take turns adding, to a running total, any integer from `1` to `10`. The player who first causes the running total to **reach or exceed** 100 wins.
|
|
|
|
What if we change the game so that players **cannot** re-use integers?
|
|
|
|
For example, two players might take turns drawing from a common pool of numbers from `1` to `15` without replacement until they reach a total `>= 100`.
|
|
|
|
Given two integers `maxChoosableInteger` and `desiredTotal`, return `true` if the first player to move can force a win, otherwise, return `false`. Assume both players play **optimally**.
|
|
|
|
constraints: |
|
|
- `1 <= maxChoosableInteger <= 20`
|
|
- `0 <= desiredTotal <= 300`
|
|
|
|
examples:
|
|
- input: "maxChoosableInteger = 10, desiredTotal = 11"
|
|
output: "false"
|
|
explanation: "No matter which integer the first player chooses, they will lose. If the first player chooses 1, the second player can choose 10 and reach a total of 11, which is >= desiredTotal. The same logic applies for any choice the first player makes."
|
|
- input: "maxChoosableInteger = 10, desiredTotal = 0"
|
|
output: "true"
|
|
explanation: "The desired total is already reached (0 >= 0), so the first player wins immediately."
|
|
- input: "maxChoosableInteger = 10, desiredTotal = 1"
|
|
output: "true"
|
|
explanation: "The first player can choose any number >= 1 (e.g., pick 1) to immediately reach or exceed the desired total."
|
|
|
|
explanation:
|
|
intuition: |
|
|
Imagine you're playing a strategic game where you and your opponent take turns picking numbers from a shared pool, racing to reach a target sum. The twist? Once a number is picked, neither player can use it again.
|
|
|
|
The core insight is that this is a **minimax game**: you want to maximise your chances of winning while your opponent tries to minimise them. At each turn, you ask: "Is there *any* number I can pick that guarantees I win, no matter how perfectly my opponent plays afterwards?"
|
|
|
|
Think of it like chess — you're not just thinking about your next move, but about all possible responses. A winning position means there exists at least one move that puts your opponent in a losing position. A losing position means *every* move you make gives your opponent a winning position.
|
|
|
|
The key observation is that the **game state** is fully determined by:
|
|
1. Which numbers have been used (the "used" set)
|
|
2. The remaining total needed to win
|
|
|
|
Since `maxChoosableInteger <= 20`, we can represent the used numbers as a **bitmask** — a single integer where each bit indicates whether that number has been used. This makes states easy to track and memoize.
|
|
|
|
approach: |
|
|
We solve this using **Memoized Recursion with Bitmask State**:
|
|
|
|
**Step 1: Handle edge cases**
|
|
|
|
- If `desiredTotal <= 0`, the first player wins immediately (total is already reached)
|
|
- If the sum of all choosable integers `(1 + 2 + ... + maxChoosableInteger)` is less than `desiredTotal`, neither player can ever reach the target — return `false`
|
|
|
|
|
|
|
|
**Step 2: Define the recursive state**
|
|
|
|
- `used_mask`: A bitmask where bit `i` is set if number `i+1` has been used
|
|
- `remaining`: The amount still needed to reach or exceed `desiredTotal`
|
|
- The function returns `true` if the **current player** can force a win from this state
|
|
|
|
|
|
|
|
**Step 3: Implement the minimax logic**
|
|
|
|
- For each unused number `i` (from 1 to `maxChoosableInteger`):
|
|
- If `i >= remaining`, picking `i` wins immediately — return `true`
|
|
- Otherwise, pick `i` and check if the *opponent* loses from the resulting state
|
|
- If the opponent loses (recursive call returns `false`), then we win — return `true`
|
|
- If no move leads to a win, return `false`
|
|
|
|
|
|
|
|
**Step 4: Memoize results**
|
|
|
|
- Use a dictionary mapping `used_mask` to the result for that state
|
|
- The `remaining` value is implicitly determined by `used_mask` and the original `desiredTotal`
|
|
|
|
|
|
|
|
**Step 5: Return the result**
|
|
|
|
- Call the recursive function with `used_mask = 0` (no numbers used) and `remaining = desiredTotal`
|
|
|
|
common_pitfalls:
|
|
- title: Forgetting to Memoize
|
|
description: |
|
|
Without memoization, the recursion explores the same game states multiple times. With up to 2<sup>20</sup> possible states (each number either used or not), this leads to exponential time complexity.
|
|
|
|
For example, the state "numbers 1 and 3 used" can be reached by picking 1 then 3, or 3 then 1. Without caching, we'd recompute the result for this state multiple times.
|
|
wrong_approach: "Plain recursion without caching"
|
|
correct_approach: "Use a dictionary or array to memoize results by bitmask state"
|
|
|
|
- title: Incorrect Win Condition Logic
|
|
description: |
|
|
The current player wins if they can pick a number that **reaches or exceeds** the remaining total. A common mistake is checking `i > remaining` instead of `i >= remaining`.
|
|
|
|
Similarly, the recursive logic must check if the *opponent* loses after our move. If `canWin(new_state)` returns `false`, that means the opponent loses, so we win.
|
|
wrong_approach: "Checking i > remaining or misinterpreting recursive results"
|
|
correct_approach: "Win if i >= remaining OR if opponent cannot win after our move"
|
|
|
|
- title: Not Handling Edge Cases
|
|
description: |
|
|
Two edge cases need special handling:
|
|
|
|
1. If `desiredTotal <= 0`, the first player wins immediately (the target is already "reached")
|
|
2. If the sum of all numbers `1 + 2 + ... + n = n*(n+1)/2` is less than `desiredTotal`, it's impossible for anyone to win — return `false`
|
|
|
|
Missing these leads to incorrect results or infinite recursion.
|
|
wrong_approach: "Jumping straight into recursion without checking if the game is already decided"
|
|
correct_approach: "Check desiredTotal <= 0 and sum < desiredTotal before recursing"
|
|
|
|
- title: Using the Wrong State Representation
|
|
description: |
|
|
Some attempt to track state as `(remaining, used_mask)`, but `remaining` is redundant — it's fully determined by which numbers have been used. Including it in the state wastes memory and complicates the cache.
|
|
|
|
The bitmask alone uniquely identifies the state because `remaining = desiredTotal - sum(used numbers)`.
|
|
wrong_approach: "Caching with both remaining and used_mask"
|
|
correct_approach: "Cache only by used_mask; compute remaining from context"
|
|
|
|
key_takeaways:
|
|
- "**Minimax pattern**: In two-player zero-sum games, a position is winning if there exists a move that puts the opponent in a losing position"
|
|
- "**Bitmask DP**: When tracking subsets of small sets (n <= 20), use bitmasks for efficient state representation and O(1) set operations"
|
|
- "**Memoization is essential**: Game tree recursion without caching leads to exponential blowup; the number of unique states is bounded by 2<sup>n</sup>"
|
|
- "**Foundation for game theory problems**: This pattern applies to Nim variants, stone games, and other optimal play problems"
|
|
|
|
time_complexity: "O(2^n * n). There are 2^n possible states (subsets of numbers used), and for each state we try up to n choices. Here n = `maxChoosableInteger`."
|
|
space_complexity: "O(2^n). The memoization dictionary stores results for each of the 2^n possible bitmask states, plus O(n) recursion stack depth."
|
|
|
|
solutions:
|
|
- approach_name: Memoized Recursion with Bitmask
|
|
is_optimal: true
|
|
code: |
|
|
def can_i_win(max_choosable: int, desired_total: int) -> bool:
|
|
# Edge case: target already reached
|
|
if desired_total <= 0:
|
|
return True
|
|
|
|
# Edge case: impossible to reach target even with all numbers
|
|
total_sum = max_choosable * (max_choosable + 1) // 2
|
|
if total_sum < desired_total:
|
|
return False
|
|
|
|
# Memoization cache: bitmask -> can current player win?
|
|
memo = {}
|
|
|
|
def can_win(used_mask: int, remaining: int) -> bool:
|
|
# Check cache first
|
|
if used_mask in memo:
|
|
return memo[used_mask]
|
|
|
|
# Try each unused number
|
|
for i in range(1, max_choosable + 1):
|
|
# Check if number i is already used (bit i-1 is set)
|
|
if used_mask & (1 << (i - 1)):
|
|
continue
|
|
|
|
# If picking i reaches the target, we win
|
|
if i >= remaining:
|
|
memo[used_mask] = True
|
|
return True
|
|
|
|
# Pick i and see if opponent loses
|
|
new_mask = used_mask | (1 << (i - 1))
|
|
if not can_win(new_mask, remaining - i):
|
|
# Opponent loses from new state, so we win
|
|
memo[used_mask] = True
|
|
return True
|
|
|
|
# No winning move found
|
|
memo[used_mask] = False
|
|
return False
|
|
|
|
return can_win(0, desired_total)
|
|
explanation: |
|
|
**Time Complexity:** O(2^n * n) — At most 2^n states, each trying n numbers.
|
|
|
|
**Space Complexity:** O(2^n) — Memoization storage for all possible states.
|
|
|
|
We use a bitmask to track which numbers have been used. For each state, we try all unused numbers. If any move either reaches the target immediately or puts the opponent in a losing position, we win. Results are cached to avoid recomputation.
|
|
|
|
- approach_name: Iterative DP with Bitmask
|
|
is_optimal: true
|
|
code: |
|
|
def can_i_win(max_choosable: int, desired_total: int) -> bool:
|
|
# Edge cases
|
|
if desired_total <= 0:
|
|
return True
|
|
total_sum = max_choosable * (max_choosable + 1) // 2
|
|
if total_sum < desired_total:
|
|
return False
|
|
|
|
n = max_choosable
|
|
# dp[mask] = True if current player wins with this set of used numbers
|
|
dp = [False] * (1 << n)
|
|
|
|
# Process states in order of increasing popcount (fewer numbers used first)
|
|
# This ensures when we compute dp[mask], all dp[mask | (1 << i)] are ready
|
|
for mask in range((1 << n) - 1, -1, -1):
|
|
# Calculate remaining total for this state
|
|
used_sum = sum(i + 1 for i in range(n) if mask & (1 << i))
|
|
remaining = desired_total - used_sum
|
|
|
|
if remaining <= 0:
|
|
# Previous player already won by reaching target
|
|
continue
|
|
|
|
# Try each unused number
|
|
for i in range(n):
|
|
if mask & (1 << i):
|
|
continue # Number i+1 already used
|
|
|
|
num = i + 1
|
|
if num >= remaining:
|
|
# Picking this number wins immediately
|
|
dp[mask] = True
|
|
break
|
|
|
|
new_mask = mask | (1 << i)
|
|
if not dp[new_mask]:
|
|
# Opponent loses from new_mask state
|
|
dp[mask] = True
|
|
break
|
|
|
|
return dp[0]
|
|
explanation: |
|
|
**Time Complexity:** O(2^n * n) — Process each of 2^n states, trying n choices each.
|
|
|
|
**Space Complexity:** O(2^n) — DP array for all states.
|
|
|
|
This bottom-up approach processes states in reverse order of the bitmask value. States with more numbers used (higher popcount) are naturally computed first due to the reverse iteration, ensuring dependencies are resolved. For each state, we check if any unused number leads to a win.
|
|
|
|
- approach_name: Recursive Without Memoization (TLE)
|
|
is_optimal: false
|
|
code: |
|
|
def can_i_win(max_choosable: int, desired_total: int) -> bool:
|
|
# Edge cases
|
|
if desired_total <= 0:
|
|
return True
|
|
total_sum = max_choosable * (max_choosable + 1) // 2
|
|
if total_sum < desired_total:
|
|
return False
|
|
|
|
def can_win(used: set, remaining: int) -> bool:
|
|
# Try each unused number
|
|
for i in range(1, max_choosable + 1):
|
|
if i in used:
|
|
continue
|
|
|
|
# Picking i wins immediately
|
|
if i >= remaining:
|
|
return True
|
|
|
|
# Check if opponent loses after we pick i
|
|
used.add(i)
|
|
opponent_wins = can_win(used, remaining - i)
|
|
used.remove(i) # Backtrack
|
|
|
|
if not opponent_wins:
|
|
return True
|
|
|
|
return False
|
|
|
|
return can_win(set(), desired_total)
|
|
explanation: |
|
|
**Time Complexity:** O(n!) — Without memoization, we explore all permutations of number choices.
|
|
|
|
**Space Complexity:** O(n) — Recursion depth and the used set.
|
|
|
|
This naive approach uses a set to track used numbers and explores all possibilities without caching. It correctly implements the minimax logic but will time out for larger inputs. Included to demonstrate why memoization is essential — the same game state can be reached through many different move orders.
|