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 by1
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.
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:
-
Initial Call: Start with
dfs((1 << n) - 1)
where alln
bits are set to 1, representing all monsters being alive initially. -
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, son - 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. -
Try Each Monster: For each alive monster
i
(where biti
inmask
is 1):- Calculate days needed to defeat it:
(power[i] + gain - 1) // gain
- This is equivalent to
⌈power[i] / gain⌉
using integer arithmetic
- This is equivalent to
- Recursively calculate the minimum days for the remaining monsters:
dfs(mask ^ (1 << i))
- The XOR operation
mask ^ (1 << i)
flips thei
-th bit from 1 to 0, representing that monsteri
is now defeated
- The XOR operation
- Update the answer with the total days: current days + recursive result
- Calculate days needed to defeat it:
-
Memoization: The
@cache
decorator stores results for each uniquemask
value, preventing redundant calculations. Since there are2^n
possible states and we compute each at most once, this significantly improves efficiency. -
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 EvaluatorExample 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)
= 2dfs(101)
= min(2 + 2, 2 + other_option) = 4dfs(111)
= min(1 + 4, other_options) = 5
Optimal Path Found: The minimum is 5 days total:
- Day 1: Gain 1 mana, defeat Monster 1 (power = 1)
- Days 2-3: Gain 2 mana/day for 2 days = 4 total, defeat Monster 0 (power = 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 iteratesn
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 isO(n)
- Since
O(2^n) > O(n)
forn > 1
, the dominant space complexity isO(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
Which of the following shows the order of node visit in a Breadth-first Search?
Recommended Readings
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
Bitmask and Dynamic Programming Bit manipulation is a crucial aspect of computer programming and one of the most powerful tools for bit manipulation is bitmasks Let's first understand what a bit is A bit is a binary digit It's the smallest piece of data in a computer and can be
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!