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.

LeetCode Problem 3273

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:

  1. All currently alive enemies deal damage to Bob.
  2. Bob chooses one alive enemy and deals power damage 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:

  • n can be as large as 100000.
  • 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

  1. 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.