Facebook Pixel

2403. Minimum Time to Kill All Monsters 🔒

Problem Description

You have an array power where power[i] represents the power level of the i-th monster that you need to defeat.

You begin the game with 0 mana points. Each day, you gain mana points equal to a value called gain, which starts at 1.

On any given day, after gaining your daily mana, you can choose to fight and defeat a monster if your current mana points are greater than or equal to that monster's power. When you defeat a monster:

  • Your mana points reset to 0
  • Your daily gain value increases by 1

The goal is to find the minimum number of days required to defeat all the monsters.

The solution uses state compression with memoization. Since there are at most 17 monsters, we can represent which monsters are still alive using a bitmask (a binary number where the i-th bit being 1 means the i-th monster is alive).

The recursive function dfs(mask) calculates the minimum days needed when the current state of monsters is represented by mask. For each alive monster, we calculate how many days it would take to accumulate enough mana to defeat it: ⌈power[i] / gain⌉ days, where gain = 1 + (number of monsters already defeated).

The algorithm tries all possible orders of defeating monsters and returns the minimum total days needed. The answer is dfs((1 << n) - 1), which represents the initial state where all n monsters are alive.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

The key insight is recognizing that this is an optimization problem where the order in which we defeat monsters matters significantly. Each time we defeat a monster, our daily mana gain increases, making it easier to defeat subsequent monsters. This creates a dependency where defeating weaker monsters first might allow us to build up our gain value, making stronger monsters easier to defeat later.

Since we need to find the optimal order to defeat all monsters, we need to explore different sequences. With at most 17 monsters, trying all possible permutations would be 17! which is computationally expensive. However, we can observe that what matters is not the exact sequence, but rather which monsters have been defeated at any point (since this determines our current gain value).

This leads us to think about states - at any point in our journey, we can represent our progress by which monsters are still alive. With a small number of monsters (≤17), we can use bit manipulation where each bit represents whether a specific monster is alive (1) or defeated (0).

The dynamic programming insight comes from realizing that once we know the minimum days to defeat a certain subset of monsters, we can use this information to calculate the minimum days for larger subsets. For any state with multiple alive monsters, we try defeating each one and see which choice leads to the minimum total days.

The formula ⌈power[i] / gain⌉ for calculating days needed emerges naturally - if we gain gain mana per day and need power[i] total mana, we need to wait this many days. The ceiling function handles cases where the power isn't perfectly divisible by the gain.

By using memoization with our recursive approach, we ensure each state is calculated only once, reducing our time complexity from factorial to O(2^n × n), making the solution efficient enough for the given constraints.

Learn more about Dynamic Programming and Bitmask patterns.

Solution Approach

The solution uses state compression with memoization to efficiently explore all possible orders of defeating monsters.

State Representation: We use a bitmask mask to represent which monsters are still alive. For example, if mask = 1011 (in binary), it means monsters at positions 0, 1, and 3 are alive, while the monster at position 2 has been defeated.

Recursive Function Design: The function dfs(mask) returns the minimum number of days needed to defeat all monsters represented by the current mask. The base case is when mask = 0 (all monsters defeated), which returns 0 days.

Implementation Steps:

  1. Initial Call: Start with dfs((1 << n) - 1) where all n bits are set to 1, representing all monsters being alive initially.

  2. Calculate Current Gain: For any state mask, the daily mana gain is calculated as:

    gain = 1 + (n - mask.bit_count())

    Here, mask.bit_count() gives the number of alive monsters, so n - mask.bit_count() is the number of defeated monsters. Since gain starts at 1 and increases by 1 for each defeated monster, we add 1 to get the current gain value.

  3. Try Each Monster: For each alive monster i (where bit i in mask is 1):

    • Calculate days needed to defeat it: (power[i] + gain - 1) // gain
      • This is equivalent to ⌈power[i] / gain⌉ using integer arithmetic
    • Recursively calculate the minimum days for the remaining monsters: dfs(mask ^ (1 << i))
      • The XOR operation mask ^ (1 << i) flips the i-th bit from 1 to 0, representing that monster i is now defeated
    • Update the answer with the total days: current days + recursive result
  4. Memoization: The @cache decorator stores results for each unique mask value, preventing redundant calculations. Since there are 2^n possible states and we compute each at most once, this significantly improves efficiency.

  5. Return Minimum: After trying all possible next monsters to defeat, return the minimum days found.

Time Complexity: O(2^n × n) where n is the number of monsters. We have 2^n possible states, and for each state, we iterate through up to n monsters.

Space Complexity: O(2^n) for the memoization cache storing results for each possible state.

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through a small example with power = [3, 1, 4] to illustrate the solution approach.

Initial Setup:

  • We have 3 monsters with powers [3, 1, 4]
  • Starting state: mask = 111 (binary) = 7 (decimal), meaning all 3 monsters are alive
  • Initial gain = 1 (no monsters defeated yet)

Step 1: Call dfs(111)

  • Current gain = 1 + (3 - 3) = 1
  • We can choose to defeat any of the 3 monsters first:

Option A: Defeat Monster 0 (power = 3) first

  • Days needed: ⌈3/1⌉ = 3 days
  • New state after defeating: 111 ^ 001 = 110 (monsters 1 and 2 remain)
  • Total days = 3 + dfs(110)

Option B: Defeat Monster 1 (power = 1) first

  • Days needed: ⌈1/1⌉ = 1 day
  • New state after defeating: 111 ^ 010 = 101 (monsters 0 and 2 remain)
  • Total days = 1 + dfs(101)

Option C: Defeat Monster 2 (power = 4) first

  • Days needed: ⌈4/1⌉ = 4 days
  • New state after defeating: 111 ^ 100 = 011 (monsters 0 and 1 remain)
  • Total days = 4 + dfs(011)

Let's explore Option B (defeating the weakest monster first):

Step 2: Call dfs(101) - Monsters 0 and 2 alive

  • Current gain = 1 + (3 - 2) = 2 (one monster defeated)
  • Choose between monsters 0 and 2:

Option B1: Defeat Monster 0 (power = 3)

  • Days needed: ⌈3/2⌉ = 2 days
  • New state: 101 ^ 001 = 100 (only monster 2 remains)
  • Total days = 2 + dfs(100)

Option B2: Defeat Monster 2 (power = 4)

  • Days needed: ⌈4/2⌉ = 2 days
  • New state: 101 ^ 100 = 001 (only monster 0 remains)
  • Total days = 2 + dfs(001)

Following Option B1:

Step 3: Call dfs(100) - Only Monster 2 alive

  • Current gain = 1 + (3 - 1) = 3 (two monsters defeated)
  • Only choice: defeat Monster 2 (power = 4)
  • Days needed: ⌈4/3⌉ = 2 days
  • New state: 100 ^ 100 = 000 (all defeated)
  • Total days = 2 + dfs(000) = 2 + 0 = 2

Backtracking:

  • dfs(100) = 2
  • dfs(101) = min(2 + 2, 2 + other_option) = 4
  • dfs(111) = min(1 + 4, other_options) = 5

Optimal Path Found: The minimum is 5 days total:

  1. Day 1: Gain 1 mana, defeat Monster 1 (power = 1)
  2. Days 2-3: Gain 2 mana/day for 2 days = 4 total, defeat Monster 0 (power = 3)
  3. Days 4-5: Gain 3 mana/day for 2 days = 6 total, defeat Monster 2 (power = 4)

The algorithm explores all possible orderings through the recursive calls and memoization ensures we don't recalculate the same state twice, ultimately finding that defeating monsters in order [1, 0, 2] takes the minimum 5 days.

Solution Implementation

1from typing import List
2from functools import cache
3from math import inf
4
5class Solution:
6    def minimumTime(self, power: List[int]) -> int:
7        """
8        Calculate minimum time to defeat all monsters.
9        Each monster has a power value, and gain increases by 1 for each defeated monster.
10      
11        Args:
12            power: List of integers representing power of each monster
13          
14        Returns:
15            Minimum time required to defeat all monsters
16        """
17      
18        @cache
19        def dfs(mask: int) -> int:
20            """
21            Dynamic programming function to find minimum time for given state.
22          
23            Args:
24                mask: Bitmask representing which monsters are still alive (1 = alive, 0 = defeated)
25              
26            Returns:
27                Minimum time to defeat all monsters represented by mask
28            """
29            # Base case: no monsters left to defeat
30            if mask == 0:
31                return 0
32          
33            # Initialize minimum time to infinity
34            min_time = inf
35          
36            # Calculate current gain (1 + number of already defeated monsters)
37            num_defeated = n - mask.bit_count()
38            current_gain = 1 + num_defeated
39          
40            # Try defeating each remaining monster
41            for i, monster_power in enumerate(power):
42                # Check if this monster is still alive (bit at position i is 1)
43                if mask >> i & 1:
44                    # Calculate new mask after defeating this monster
45                    new_mask = mask ^ (1 << i)
46                  
47                    # Calculate time to defeat this monster with current gain
48                    # Using ceiling division: (monster_power + current_gain - 1) // current_gain
49                    time_to_defeat = (monster_power + current_gain - 1) // current_gain
50                  
51                    # Update minimum time
52                    min_time = min(min_time, dfs(new_mask) + time_to_defeat)
53          
54            return min_time
55      
56        # Number of monsters
57        n = len(power)
58      
59        # Start with all monsters alive (all bits set to 1)
60        initial_mask = (1 << n) - 1
61      
62        return dfs(initial_mask)
63
1class Solution {
2    private int taskCount;
3    private int[] taskPowers;
4    private Long[] memoizationTable;
5
6    public long minimumTime(int[] power) {
7        // Initialize instance variables
8        taskCount = power.length;
9        this.taskPowers = power;
10      
11        // Create memoization table for all possible task combinations (2^n states)
12        memoizationTable = new Long[1 << taskCount];
13      
14        // Start with all tasks remaining (all bits set to 1)
15        int allTasksMask = (1 << taskCount) - 1;
16        return dfs(allTasksMask);
17    }
18
19    private long dfs(int remainingTasksMask) {
20        // Base case: no tasks remaining
21        if (remainingTasksMask == 0) {
22            return 0;
23        }
24      
25        // Return memoized result if already computed
26        if (memoizationTable[remainingTasksMask] != null) {
27            return memoizationTable[remainingTasksMask];
28        }
29      
30        // Initialize minimum time for current state
31        memoizationTable[remainingTasksMask] = Long.MAX_VALUE;
32      
33        // Calculate current gain factor
34        // Gain = 1 + (number of completed tasks)
35        int completedTasksCount = taskCount - Integer.bitCount(remainingTasksMask);
36        int currentGain = 1 + completedTasksCount;
37      
38        // Try completing each remaining task
39        for (int taskIndex = 0; taskIndex < taskCount; ++taskIndex) {
40            // Check if task at taskIndex is still remaining
41            if ((remainingTasksMask >> taskIndex & 1) == 1) {
42                // Remove current task from mask using XOR
43                int newMask = remainingTasksMask ^ (1 << taskIndex);
44              
45                // Calculate time to complete current task with current gain
46                // Using ceiling division: (power + gain - 1) / gain
47                long timeForCurrentTask = (taskPowers[taskIndex] + currentGain - 1) / currentGain;
48              
49                // Update minimum time by considering this task completion path
50                long totalTime = dfs(newMask) + timeForCurrentTask;
51                memoizationTable[remainingTasksMask] = Math.min(
52                    memoizationTable[remainingTasksMask], 
53                    totalTime
54                );
55            }
56        }
57      
58        return memoizationTable[remainingTasksMask];
59    }
60}
61
1class Solution {
2public:
3    long long minimumTime(vector<int>& power) {
4        int n = power.size();
5      
6        // DP memoization table: dp[mask] stores minimum time to complete tasks in mask
7        // -1 indicates uncomputed state
8        long long dp[1 << n];
9        memset(dp, -1, sizeof(dp));
10      
11        // Recursive function with memoization to find minimum time
12        // mask: bitmask representing which tasks are still pending (1 = pending, 0 = completed)
13        auto dfs = [&](this auto&& dfs, int mask) -> long long {
14            // Base case: no tasks remaining
15            if (mask == 0) {
16                return 0;
17            }
18          
19            // Return memoized result if already computed
20            if (dp[mask] != -1) {
21                return dp[mask];
22            }
23          
24            // Initialize result to maximum value
25            dp[mask] = LLONG_MAX;
26          
27            // Calculate current gain based on completed tasks
28            // gain = 1 (base) + number of completed tasks
29            int completedTasks = n - __builtin_popcount(mask);
30            int currentGain = 1 + completedTasks;
31          
32            // Try completing each pending task
33            for (int i = 0; i < n; ++i) {
34                // Check if task i is pending (bit i is set in mask)
35                if ((mask >> i) & 1) {
36                    // Calculate time to complete task i with current gain
37                    // Time = ceiling(power[i] / currentGain)
38                    long long timeForTask = (power[i] + currentGain - 1) / currentGain;
39                  
40                    // Update minimum time by considering completing task i next
41                    // Remove task i from mask using XOR operation
42                    dp[mask] = min(dp[mask], dfs(mask ^ (1 << i)) + timeForTask);
43                }
44            }
45          
46            return dp[mask];
47        };
48      
49        // Start with all tasks pending (all bits set)
50        return dfs((1 << n) - 1);
51    }
52};
53
1/**
2 * Calculates the minimum time needed to complete all tasks
3 * @param power - Array of power values for each task
4 * @returns The minimum time required to complete all tasks
5 */
6function minimumTime(power: number[]): number {
7    const taskCount: number = power.length;
8    // Memoization array for dynamic programming, indexed by bitmask
9    // -1 indicates uncomputed state, stores minimum time for each subset
10    const memoTable: number[] = Array(1 << taskCount).fill(-1);
11  
12    /**
13     * Depth-first search with memoization to find minimum time
14     * @param bitmask - Binary representation of remaining tasks (1 = task remaining, 0 = completed)
15     * @returns Minimum time to complete tasks represented by the bitmask
16     */
17    const dfs = (bitmask: number): number => {
18        // Base case: no tasks remaining
19        if (bitmask === 0) {
20            return 0;
21        }
22      
23        // Return memoized result if already computed
24        if (memoTable[bitmask] !== -1) {
25            return memoTable[bitmask];
26        }
27      
28        // Initialize minimum time to infinity
29        memoTable[bitmask] = Infinity;
30      
31        // Calculate current gain based on completed tasks
32        // Gain = 1 (base) + number of completed tasks
33        const currentGain: number = 1 + (taskCount - bitCount(bitmask));
34      
35        // Try completing each remaining task
36        for (let taskIndex = 0; taskIndex < taskCount; ++taskIndex) {
37            // Check if task at taskIndex is still pending (bit is set)
38            if ((bitmask >> taskIndex) & 1) {
39                // Calculate time if we complete this task next
40                // Remove task from bitmask using XOR, then add time for current task
41                const timeForThisChoice: number = dfs(bitmask ^ (1 << taskIndex)) + 
42                                                  Math.ceil(power[taskIndex] / currentGain);
43              
44                // Update minimum time
45                memoTable[bitmask] = Math.min(memoTable[bitmask], timeForThisChoice);
46            }
47        }
48      
49        return memoTable[bitmask];
50    };
51  
52    // Start with all tasks pending (all bits set to 1)
53    return dfs((1 << taskCount) - 1);
54}
55
56/**
57 * Counts the number of set bits (1s) in a binary representation
58 * Uses bit manipulation tricks for efficient counting
59 * @param i - The integer to count bits for
60 * @returns The number of set bits in the integer
61 */
62function bitCount(i: number): number {
63    // Step 1: Count bits in groups of 2
64    i = i - ((i >>> 1) & 0x55555555);
65  
66    // Step 2: Count bits in groups of 4
67    i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
68  
69    // Step 3: Count bits in groups of 8
70    i = (i + (i >>> 4)) & 0x0f0f0f0f;
71  
72    // Step 4: Count bits in groups of 16
73    i = i + (i >>> 8);
74  
75    // Step 5: Count bits in groups of 32
76    i = i + (i >>> 16);
77  
78    // Return final count (masked to 6 bits as maximum is 32)
79    return i & 0x3f;
80}
81

Time and Space Complexity

Time Complexity: O(2^n × n)

The algorithm uses dynamic programming with bitmask to explore all possible orderings of defeating monsters. The dfs function is memoized using @cache, which stores results for each unique mask state.

  • There are 2^n possible mask states (each bit represents whether a monster is defeated or not)
  • For each mask state, we iterate through all n monsters to check which ones can be defeated next (the for loop iterates n times)
  • Each state is computed only once due to memoization
  • Therefore, the total time complexity is O(2^n × n)

Space Complexity: O(2^n)

The space complexity comes from:

  • The memoization cache stores up to 2^n different mask states
  • The recursion stack depth is at most n (we defeat one monster per recursive call), which is O(n)
  • Since O(2^n) > O(n) for n > 1, the dominant space complexity is O(2^n)

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Incorrect Gain Calculation

A frequent mistake is miscalculating the current gain value. Since gain starts at 1 and increases by 1 for each defeated monster, developers often write:

# WRONG: This gives the number of alive monsters, not defeated ones
current_gain = mask.bit_count()

Correct approach:

# Calculate number of defeated monsters first
num_defeated = n - mask.bit_count()
current_gain = 1 + num_defeated

2. Integer Division Instead of Ceiling Division

When calculating days needed to defeat a monster, using simple integer division truncates the result:

# WRONG: This rounds down, potentially underestimating days needed
time_to_defeat = power[i] // current_gain

Correct approach:

# Use ceiling division formula: ⌈a/b⌉ = (a + b - 1) // b
time_to_defeat = (power[i] + current_gain - 1) // current_gain

3. Incorrect Bit Manipulation for State Transitions

Using the wrong operation to update the mask after defeating a monster:

# WRONG: Using OR operation adds the bit instead of removing it
new_mask = mask | (1 << i)

# WRONG: Using AND with the bit itself keeps only that bit
new_mask = mask & (1 << i)

Correct approach:

# XOR flips the bit from 1 to 0, removing the defeated monster
new_mask = mask ^ (1 << i)

4. Checking Monster Availability Incorrectly

Improperly checking if a monster at position i is still alive:

# WRONG: This checks if ANY bit is set, not the specific i-th bit
if mask & 1:
    # process monster i

# WRONG: Forgetting to shift before checking
if mask & (1 << i) == 1:  # This compares the entire result with 1
    # process monster i

Correct approach:

# Shift mask right by i positions and check the least significant bit
if mask >> i & 1:
    # process monster i

# Alternative: Check if the i-th bit is set
if mask & (1 << i):
    # process monster i

5. Missing or Incorrect Base Case

Forgetting to handle the base case or returning the wrong value:

# WRONG: Forgetting base case causes infinite recursion
def dfs(mask):
    min_time = inf
    for i in range(n):
        # ... recursive calls
    return min_time

# WRONG: Returning wrong value for empty mask
if mask == 0:
    return 1  # Should be 0, as no monsters means 0 days needed

Correct approach:

if mask == 0:
    return 0  # No monsters left = 0 days needed

6. Not Using Memoization

Implementing the recursive solution without caching results leads to exponential time complexity:

# WRONG: No memoization causes repeated calculations
def dfs(mask):
    # ... implementation without @cache

Correct approach:

from functools import cache

@cache
def dfs(mask):
    # ... implementation with automatic memoization
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which of the following shows the order of node visit in a Breadth-first Search?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More