LeetCode 3273 - Minimum Amount of Damage Dealt to Bob
This problem asks us to determine the minimum total damage Bob will receive while fighting a group of enemies. Each enemy has two attributes: - damage[i], the amount of damage they inflict on Bob every second while alive. - health[i], the amount of health they start with.
Difficulty: 🔴 Hard
Topics: Array, Greedy, Sorting
Solution
LeetCode 3273 - Minimum Amount of Damage Dealt to Bob
Problem Understanding
This problem asks us to determine the minimum total damage Bob will receive while fighting a group of enemies.
Each enemy has two attributes:
damage[i], the amount of damage they inflict on Bob every second while alive.health[i], the amount of health they start with.
Bob attacks once per second. The order of events during a second is important:
- All currently alive enemies deal damage to Bob.
- Bob chooses one alive enemy and deals
powerdamage to it.
An enemy remains alive until its health becomes zero or negative after Bob's attack.
Since Bob can attack only one enemy per second, killing an enemy requires multiple attacks if its health exceeds power.
The goal is to choose the order in which enemies are eliminated so that the cumulative damage Bob receives over the entire battle is minimized.
A useful observation is that enemy i requires:
$$\text{hits}_i = \left\lceil \frac{\text{health}[i]}{\text{power}} \right\rceil$$
attacks to kill.
Once we know how many attacks each enemy requires, the problem becomes a scheduling problem. Every enemy behaves like a job:
- Processing time = number of attacks needed.
- Weight = damage dealt per second.
We want to determine the optimal order in which to finish these jobs.
The constraints are significant:
ncan be as large as100000.- Health and damage values can be as large as
10000.
An algorithm worse than O(n log n) will likely be too slow.
Important edge cases include:
- A single enemy.
- Multiple enemies with identical damage values.
- Multiple enemies with identical health values.
- Extremely large health requiring many attacks.
- Cases where a low-health enemy should be killed before a high-damage enemy because it can be removed much faster.
- Cases where all enemies require exactly one attack.
Approaches
Brute Force
A brute force solution would try every possible order in which enemies could be eliminated.
For each permutation, we could simulate the battle and compute the total damage received.
This approach is correct because it explicitly evaluates every possible strategy and selects the best one.
However, there are n! possible orders. Even for relatively small values of n, this becomes completely infeasible. With n = 100000, it is astronomically impossible.
Therefore, we need to exploit structure in the problem.
Key Insight
Let:
t[i] = ceil(health[i] / power)be the number of attacks required.d[i] = damage[i].
Suppose two enemies A and B are consecutive in the elimination order.
If we kill A before B:
Cost contribution:
$$d_A \cdot t_A + d_B \cdot (t_A + t_B)$$
If we kill B before A:
$$d_B \cdot t_B + d_A \cdot (t_B + t_A)$$
The first order is better when:
$$d_A t_A + d_B(t_A+t_B) < d_B t_B + d_A(t_B+t_A)$$
After simplification:
$$d_B t_A < d_A t_B$$
which is equivalent to:
$$\frac{d_A}{t_A} > \frac{d_B}{t_B}$$
This is exactly the classic weighted completion time scheduling rule known as Smith's Rule.
Therefore, enemies should be processed in descending order of:
$$\frac{\text{damage}}{\text{hits}}$$
To avoid floating point precision issues, we compare:
$$d_A \cdot t_B$$
and
$$d_B \cdot t_A$$
directly.
Once sorted, we can compute the total damage efficiently.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n! · n) | O(n) | Tries every elimination order |
| Optimal | O(n log n) | O(n) | Greedy scheduling using pairwise exchange argument |
Algorithm Walkthrough
- For every enemy, compute the number of attacks required:
$$\text{hits} = \left\lceil \frac{\text{health}}{\text{power}} \right\rceil$$
This represents how many seconds Bob must spend attacking that enemy before it dies.
2. Store each enemy as a pair (damage, hits).
3. Sort the enemies using the comparator:
- Enemy A comes before enemy B if:
$$d_A \cdot t_B > d_B \cdot t_A$$
This implements descending order of damage / hits without floating point arithmetic.
4. Compute the total damage currently being dealt by all enemies:
$$\text{currentDamage} = \sum damage[i]$$
5. Process enemies in sorted order.
6. When an enemy requiring hits attacks is selected, Bob spends exactly hits seconds eliminating it.
7. During those hits seconds, all currently alive enemies contribute damage, so add:
$$currentDamage \times hits$$
to the answer. 8. After the enemy dies, remove its damage contribution:
$$currentDamage -= damage$$ 9. Continue until all enemies have been processed. 10. Return the accumulated damage.
Why it works
The pairwise exchange argument proves optimality. For any two consecutive enemies, placing enemy A before enemy B is better exactly when:
$$d_A t_B > d_B t_A$$
Sorting by this rule guarantees that no adjacent swap can improve the solution. Since every inversion would increase total damage, the resulting order is globally optimal. This is the same proof used for minimizing weighted completion time in scheduling theory.
Python Solution
from typing import List
from functools import cmp_to_key
class Solution:
def minDamage(self, power: int, damage: List[int], health: List[int]) -> int:
enemies = []
for d, h in zip(damage, health):
hits = (h + power - 1) // power
enemies.append((d, hits))
def compare(a, b):
left = a[0] * b[1]
right = b[0] * a[1]
if left > right:
return -1
if left < right:
return 1
return 0
enemies.sort(key=cmp_to_key(compare))
current_damage = sum(damage)
answer = 0
for d, hits in enemies:
answer += current_damage * hits
current_damage -= d
return answer
The implementation begins by converting each enemy into a scheduling job. The number of attacks required is computed using ceiling division.
A custom comparator sorts enemies according to the exchange argument. Rather than computing floating point ratios, cross multiplication is used to maintain exact comparisons.
After sorting, current_damage stores the combined damage output of all enemies currently alive. When Bob spends hits seconds killing an enemy, every alive enemy continues damaging him during those seconds, so current_damage * hits is added to the answer.
Once the enemy dies, its damage contribution is removed. Repeating this process for all enemies yields the minimum total damage.
Go Solution
package main
import "sort"
func minDamage(power int, damage []int, health []int) int64 {
n := len(damage)
type Enemy struct {
damage int
hits int
}
enemies := make([]Enemy, n)
var currentDamage int64
for i := 0; i < n; i++ {
hits := (health[i] + power - 1) / power
enemies[i] = Enemy{
damage: damage[i],
hits: hits,
}
currentDamage += int64(damage[i])
}
sort.Slice(enemies, func(i, j int) bool {
left := int64(enemies[i].damage) * int64(enemies[j].hits)
right := int64(enemies[j].damage) * int64(enemies[i].hits)
return left > right
})
var answer int64
for _, enemy := range enemies {
answer += currentDamage * int64(enemy.hits)
currentDamage -= int64(enemy.damage)
}
return answer
}
The Go implementation follows the same logic as the Python version. The primary difference is the use of sort.Slice with a custom comparator.
Because the final answer can become very large, all damage accumulation is performed using int64. This prevents overflow when processing the maximum constraints.
Worked Examples
Example 1
Input:
power = 4
damage = [1,2,3,4]
health = [4,5,6,8]
Compute hits:
| Enemy | Damage | Health | Hits |
|---|---|---|---|
| 0 | 1 | 4 | 1 |
| 1 | 2 | 5 | 2 |
| 2 | 3 | 6 | 2 |
| 3 | 4 | 8 | 2 |
Ratios:
| Enemy | Damage/Hits |
|---|---|
| 0 | 1.0 |
| 1 | 1.0 |
| 2 | 1.5 |
| 3 | 2.0 |
Sorted order:
3 -> 2 -> 0 -> 1
Initial:
currentDamage = 10
answer = 0
| Enemy | Hits | Current Damage | Added Cost | Answer |
|---|---|---|---|---|
| 3 | 2 | 10 | 20 | 20 |
| 2 | 2 | 6 | 12 | 32 |
| 0 | 1 | 3 | 3 | 35 |
| 1 | 2 | 2 | 4 | 39 |
Result:
39
Example 2
Input:
power = 1
damage = [1,1,1,1]
health = [1,2,3,4]
Hits:
| Enemy | Hits |
|---|---|
| 0 | 1 |
| 1 | 2 |
| 2 | 3 |
| 3 | 4 |
Since all damages are equal, shorter jobs come first.
Order:
0 -> 1 -> 2 -> 3
| Enemy | Hits | Current Damage | Added |
|---|---|---|---|
| 0 | 1 | 4 | 4 |
| 1 | 2 | 3 | 6 |
| 2 | 3 | 2 | 6 |
| 3 | 4 | 1 | 4 |
Total:
20
Example 3
Input:
power = 8
damage = [40]
health = [59]
Hits:
$$\lceil 59/8 \rceil = 8$$
Only one enemy exists.
| Current Damage | Hits | Added |
|---|---|---|
| 40 | 8 | 320 |
Result:
320
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(n log n) | Sorting dominates the runtime |
| Space | O(n) | Stores all enemy records |
The preprocessing step computes the required hits for every enemy in linear time. The sorting step requires O(n log n) comparisons. The final accumulation pass is linear. Therefore the overall complexity is O(n log n), which comfortably handles n = 100000.
Test Cases
sol = Solution()
assert sol.minDamage(4, [1,2,3,4], [4,5,6,8]) == 39 # example 1
assert sol.minDamage(1, [1,1,1,1], [1,2,3,4]) == 20 # example 2
assert sol.minDamage(8, [40], [59]) == 320 # example 3
assert sol.minDamage(10, [5], [1]) == 5 # single attack enemy
assert sol.minDamage(1, [10], [10]) == 100 # single long enemy
assert sol.minDamage(5, [5,5], [5,5]) == 15 # identical enemies
assert sol.minDamage(10, [100,1], [1000,1]) == 1111 # high damage enemy first
assert sol.minDamage(3, [2,4,6], [3,6,9]) == 34 # mixed ratios
assert sol.minDamage(10000, [1,2,3], [1,1,1]) == 10 # all die in one hit
assert sol.minDamage(2, [8,1], [10,1]) == 46 # long high damage enemy
Test Case Summary
| Test | Why |
|---|---|
| Example 1 | Verifies core greedy ordering |
| Example 2 | Equal damages, shortest job first |
| Example 3 | Single enemy |
| One enemy, one hit | Smallest nontrivial case |
| One enemy, many hits | Long duration battle |
| Identical enemies | Comparator tie behavior |
| Huge damage enemy | Priority ordering validation |
| Mixed ratios | General scheduling correctness |
| All enemies one hit | Simplest multi-enemy scenario |
| Long high-damage enemy | Exchange argument stress case |
Edge Cases
Single Enemy
When only one enemy exists, there is no ordering decision to make. The answer is simply:
$$damage \times \left\lceil \frac{health}{power} \right\rceil$$
The algorithm naturally handles this because sorting a single element does nothing and the accumulation loop runs once.
Equal Damage-to-Hits Ratios
Different enemies can produce identical values of damage / hits. In such situations, either order yields the same total cost. The comparator returns equality, and the sort may place them in any order. Since the exchange argument shows both orders are equivalent, correctness is preserved.
Very Large Answers
With up to 100000 enemies and large damage values, the total accumulated damage can exceed 32-bit integer limits. The Go implementation therefore uses int64 throughout the accumulation phase. Python integers already support arbitrary precision, so no special handling is required there.
Enemies Requiring Many Attacks
An enemy may require up to:
$$\left\lceil \frac{10000}{1} \right\rceil = 10000$$
attacks.
A simulation that processes every attack individually would be unnecessarily expensive. The implementation instead treats each enemy as a single job with processing time equal to its required attacks, allowing the total damage contribution to be computed in constant time per enemy. This keeps the algorithm efficient even in the worst case.