Taro Logo

Can I Win

Medium
Asked by:
Profile picture
Profile picture
Profile picture
Profile picture
+1
More companies
Profile picture
30 views
Topics:
RecursionDynamic ProgrammingBit Manipulation

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.

Example 1:

Input: maxChoosableInteger = 10, desiredTotal = 11
Output: false
Explanation:
No matter which integer the first player choose, the first player will lose.
The first player can choose an integer from 1 up to 10.
If the first player choose 1, the second player can only choose integers from 2 up to 10.
The second player will win by choosing 10 and get a total = 11, which is >= desiredTotal.
Same with other integers chosen by the first player, the second player will always win.

Example 2:

Input: maxChoosableInteger = 10, desiredTotal = 0
Output: true

Example 3:

Input: maxChoosableInteger = 10, desiredTotal = 1
Output: true

Constraints:

  • 1 <= maxChoosableInteger <= 20
  • 0 <= desiredTotal <= 300

Solution


Clarifying Questions

When you get asked this question in a real-life environment, it will often be ambiguous (especially at FAANG). Make sure to ask these questions in that case:

  1. What are the possible values for the integers in the input array? Could they be negative, zero, or very large?
  2. What is the maximum size of the input array?
  3. If the input array is empty or null, what should I return?
  4. Are there any duplicate numbers within the input array, and if so, how should I handle them?
  5. If it is impossible for the first player to win given the initial state, what should I return?

Brute Force Solution

Approach

The brute force approach to this problem involves exploring every single possible move, assuming both players play optimally. We recursively simulate every possible game state until we reach a terminal state (someone wins or the board is full). By exploring all these scenarios, we can determine if the first player can guarantee a win.

Here's how the algorithm would work step-by-step:

  1. Imagine you and your opponent are playing the game, and you want to figure out if you can win no matter what.
  2. Start by considering all the possible numbers you could pick first.
  3. For each number you pick, pretend you picked it, and then imagine your opponent gets to pick a number.
  4. Your opponent will also try to pick the best number for themselves, meaning they will try to force you to lose.
  5. Now, it's your turn again. For each number your opponent could have picked, consider all the numbers you could pick next.
  6. Keep going back and forth, imagining all the possible moves you and your opponent could make.
  7. Eventually, one of you will either win (by reaching or exceeding the target number), or all the numbers will be used up, and nobody wins.
  8. If, after trying all possible moves for both you and your opponent, you find at least one sequence of moves where you win, then you can win the game.
  9. Otherwise, if no matter what you do, your opponent can always force you to lose or tie, then you cannot win the game.

Code Implementation

def can_i_win(max_integer, desired_total):
    if desired_total <= 0:
        return True
    if (max_integer * (max_integer + 1)) // 2 < desired_total:
        return False

    def can_win(available_integers, current_total):
        if current_total >= desired_total:
            return False

        # Check if this game state has already been computed.
        state = tuple(available_integers)
        if state in memo:
            return memo[state]

        # Iterate over remaining integers to check if any move guarantees a win
        for integer in available_integers:
            remaining_integers = available_integers.copy()
            remaining_integers.remove(integer)

            # Opponent tries to win, so negate the result
            if not can_win(remaining_integers, current_total + integer):
                memo[state] = True
                return True

        memo[state] = False
        return False

    memo = {}
    available_integers = set(range(1, max_integer + 1))
    return can_win(available_integers, 0)

Big(O) Analysis

Time Complexity
O(n!)The brute force approach explores every possible move using recursion. At each level of recursion, we iterate through the remaining available numbers (up to n). For each choice, we recursively call the function to simulate the opponent's turn. This results in a decision tree where each node has up to n children. Since we explore every permutation of numbers, in the worst case, the time complexity is proportional to the number of possible permutations of the numbers, which is n! (n factorial).
Space Complexity
O(2^N) or O(N!)The algorithm explores all possible moves recursively, leading to a decision tree. In the worst case, each player has multiple choices at each turn. The depth of recursion can go up to N, where N is the number of choices. Since each node in the recursion tree consumes space for function call stack and local variables including the used numbers, the space complexity is related to the number of nodes in the recursion tree. The branching factor may vary but exploring all possible combinations in the worst case translates to approximately O(2^N) or O(N!) due to function call overhead for each possible move. We also implicitly use a set or boolean array of size N to track the numbers already picked, thus requiring O(N) space; however this is dominated by the recursion stack.

Optimal Solution

Approach

This problem asks whether the first player can always win a game where players take turns picking numbers from a pool, trying to reach a target sum. The key is to realize that winning depends on whether you can force your opponent into a losing position, which is best solved using a memorization technique.

Here's how the algorithm would work step-by-step:

  1. Think about the very simplest case: if the target is easy to reach with the numbers available, the first player just picks the biggest number and wins.
  2. If the target is harder to reach, the first player needs a strategy. They pick a number, and then hope that *no matter what* the second player picks, the first player can still win on their next turn.
  3. To know if the first player can win *after* the second player picks, you need to figure out if that second player is in a losing position. In other words, if the second player were to become the first player in that situation, would they be guaranteed to lose?
  4. Here's the trick: instead of re-calculating whether each position leads to a win or loss every single time, remember what you've already figured out. If you encounter a situation you've seen before, just look up the answer you already computed.
  5. Start from simpler scenarios where the target is small and gradually build up to the initial scenario. This makes sure you solve the simpler subproblems before moving on to the more complex ones.
  6. This 'remembering' approach makes the entire process much faster than trying out every possible sequence of moves because you never repeat work.

Code Implementation

def can_i_win(max_choosable_integer,
             desired_total):
    if desired_total <= 0:
        return True
    if (max_choosable_integer *
        (max_choosable_integer + 1)) // 2 < desired_total:
        return False

    memo = {}

    def can_win(available_numbers,
                current_total):
        tuple_representation = tuple(available_numbers)
        if tuple_representation in memo:
            return memo[tuple_representation]

        # Check every possible number to pick
        for number in available_numbers:
            # If picking this number leads to immediate win
            if current_total + number >= desired_total:
                memo[tuple_representation] = True
                return True

            # If picking this number forces opponent to lose
            remaining_numbers =
                available_numbers - {number}

            if not can_win(remaining_numbers,
                           current_total + number):
                memo[tuple_representation] = True
                return True

        # If no winning move is found
        memo[tuple_representation] = False
        return False

    all_numbers = set(range(1,
                         max_choosable_integer + 1))

    # Begin the game
    return can_win(all_numbers, 0)

Big(O) Analysis

Time Complexity
O(2^n)The most significant factor impacting the time complexity is the recursion with memoization. In the worst-case scenario, without memoization, we would explore all possible combinations of number selections, leading to a time complexity of O(n!). However, memoization dramatically improves this. The state of the game is defined by which numbers are left. With n numbers, there are 2^n possible subsets (or states) of numbers remaining. For each state, we iterate through the available numbers (at most n) and recursively check the outcome if the opponent makes optimal choices. Although memoization prevents recalculating the same state, in the worst case, we might still explore a substantial portion of the 2^n possible states, potentially leading to a time complexity approaching O(2^n) due to the overhead of traversing the decision tree, which has a maximum depth of n (number of choices initially), even with optimal memoization.
Space Complexity
O(2^N)The core of the space complexity lies in the memorization technique. We are remembering the results of subproblems to avoid recalculating them. Each unique game state is determined by which numbers have been picked. With N numbers available, there are 2^N possible combinations of picked/unpicked numbers, each representing a unique game state that may be stored. Therefore, the algorithm uses a data structure (likely a hash map or array) to store results for up to 2^N game states. Thus, the auxiliary space complexity is O(2^N).

Edge Cases

maxChoosableInteger is zero or negative
How to Handle:
Return false since no positive integer can be chosen.
desiredTotal is zero or negative
How to Handle:
Return true immediately since the first player wins by default.
Sum of all numbers is less than desiredTotal
How to Handle:
Return false since it is impossible for either player to reach the desiredTotal.
maxChoosableInteger is greater than or equal to desiredTotal
How to Handle:
Return true, as the first player can win by picking desiredTotal or higher in one turn.
desiredTotal is very large leading to excessive recursion/memoization
How to Handle:
Consider the upper bound on desiredTotal imposed by the prompt and ensure int data type is sufficient
maxChoosableInteger close to the recursion limit (e.g. 20) may cause stack overflow if not memoized
How to Handle:
Utilize memoization (e.g., a HashMap or array) to store the results of subproblems.
All numbers are chosen and the desiredTotal is not reached
How to Handle:
The base case for this condition should return false (no winner) since no more numbers are available.
Integer overflow with large maxChoosableInteger and desiredTotal
How to Handle:
Ensure that calculations of the running sum and available numbers are done with appropriate data types to prevent overflows (e.g., using long if necessary, though typically integer will suffice within problem constraints).