LeetCode 3186 - Maximum Total Damage With Spell Casting
The problem gives us an array power, where each element represents the damage value of a spell. Every spell can be used at most once, and multiple spells may share the same damage value. The restriction is the important part of the problem.
Difficulty: 🟡 Medium
Topics: Array, Hash Table, Two Pointers, Binary Search, Dynamic Programming, Sorting, Counting
Solution
Problem Understanding
The problem gives us an array power, where each element represents the damage value of a spell. Every spell can be used at most once, and multiple spells may share the same damage value.
The restriction is the important part of the problem. If we decide to cast a spell with damage x, then we are forbidden from casting any spell whose damage is:
x - 2x - 1x + 1x + 2
In other words, any two chosen spell damage values must differ by at least 3.
The goal is to maximize the total damage dealt by selecting a subset of spells that satisfies this restriction.
A key detail is that duplicates are allowed. If we choose damage value x, we are still allowed to cast every spell whose damage is also x, because the restriction only applies to values differing by 1 or 2. This means identical values are compatible with each other.
For example, if the array is:
[7,1,6,6]
we may choose both 6s and also choose 1, because:
6and6are compatible1differs from6by5, which is allowed
The total becomes:
1 + 6 + 6 = 13
The constraints are large:
power.lengthcan reach100000power[i]can reach10^9
This immediately tells us that exponential subset enumeration is impossible. Even quadratic solutions may become too slow. We need something around O(n log n).
Important edge cases include:
- Arrays where all values are identical
- Arrays where every value conflicts with every other value
- Sparse large numbers such as
[1, 1000000000] - Heavy duplication counts
- Situations where taking a smaller value multiple times is better than taking a larger single value
These cases make greedy selection unreliable, so dynamic programming becomes necessary.
Approaches
Brute Force Approach
A brute force solution would try every possible subset of spells.
For each subset, we would verify whether every pair of chosen spell values satisfies the rule that their difference is at least 3. If valid, we compute the total damage and keep the maximum.
This approach is correct because it explores all possibilities, guaranteeing that the optimal subset is considered.
However, the complexity is catastrophic. With n spells, there are 2^n subsets. Even for n = 30, this becomes impractical. The problem allows 100000 elements, so brute force is completely infeasible.
Key Insight
The critical observation is that spells with the same damage value are always compatible with each other.
Suppose value 5 appears four times. If we decide to use damage 5, there is never a reason to take only some of them. Since equal values do not conflict, taking all of them always increases the total damage.
This means we can compress the array into:
damage value -> total contribution
For example:
[1,1,3,4]
becomes:
1 -> 2
3 -> 3
4 -> 4
Now the problem transforms into:
Choose distinct damage values so that chosen values differ by at least
3, maximizing the sum of their grouped contributions.
This becomes very similar to the classic "House Robber" style dynamic programming problem, except the conflict distance is not fixed by index, but by numeric value difference.
After sorting unique values, we can use dynamic programming with binary search or two pointers to find the last compatible value for each position.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(2^n * n) | O(n) | Tries every subset and validates it |
| Optimal | O(n log n) | O(n) | Uses counting, sorting, and DP |
Algorithm Walkthrough
Optimal Dynamic Programming Algorithm
- Count the total contribution of each damage value.
We use a hash map where:
total[value] += value
If 5 appears three times, its contribution becomes 15.
This compression is important because duplicates are always either all taken or all skipped. 2. Extract and sort all unique damage values.
Suppose the unique values are:
values = [1, 3, 4, 7]
Sorting allows us to process values from small to large while maintaining compatibility relationships. 3. Define the DP state.
Let:
dp[i]
represent the maximum total damage achievable using unique values up to index i.
4. For each value, determine the last compatible value.
If current value is:
values[i]
then we need the largest index j < i such that:
values[j] <= values[i] - 3
because differences of 1 and 2 are forbidden.
5. Use binary search to find that compatible index efficiently.
Since the values array is sorted, we can binary search for:
values[i] - 3
- Compute the transition.
We have two choices:
- Skip current value:
dp[i - 1]
- Take current value:
total[values[i]] + dp[j]
where j is the last compatible index.
Therefore:
dp[i] = max(skip, take)
- Return the final DP value.
The answer is:
dp[last index]
Why it works
The algorithm works because the decision for each unique value is independent once we know the last compatible value. Sorting guarantees that all conflicts occur locally in the ordered sequence. Dynamic programming ensures that every optimal prefix solution is reused instead of recomputed. Since each state considers both taking and skipping the current value, all valid combinations are implicitly explored.
Python Solution
from typing import List
from collections import Counter
import bisect
class Solution:
def maximumTotalDamage(self, power: List[int]) -> int:
total_damage = Counter()
for value in power:
total_damage[value] += value
values = sorted(total_damage.keys())
n = len(values)
dp = [0] * n
for i in range(n):
current_value = values[i]
current_total = total_damage[current_value]
# Find last index with value <= current_value - 3
j = bisect.bisect_right(values, current_value - 3) - 1
take = current_total
if j >= 0:
take += dp[j]
skip = dp[i - 1] if i > 0 else 0
dp[i] = max(take, skip)
return dp[-1]
The implementation begins by grouping identical spell values together using a Counter. Instead of treating every spell independently, we aggregate their total contribution. This significantly reduces the effective problem size.
Next, we sort the unique values. Sorting is necessary because compatibility depends on numeric distance.
The dynamic programming array stores the best achievable total up to each unique value index.
For every value, we use bisect_right to locate the last value that differs by at least 3. This gives the latest compatible state that can safely combine with the current value.
The transition compares two possibilities:
- Taking the current grouped value
- Skipping it
The larger result becomes the optimal solution for that prefix.
Finally, the last DP entry contains the global optimum.
Go Solution
package main
import (
"sort"
)
func maximumTotalDamage(power []int) int64 {
totalDamage := make(map[int]int64)
for _, value := range power {
totalDamage[value] += int64(value)
}
values := make([]int, 0, len(totalDamage))
for value := range totalDamage {
values = append(values, value)
}
sort.Ints(values)
n := len(values)
dp := make([]int64, n)
for i := 0; i < n; i++ {
currentValue := values[i]
currentTotal := totalDamage[currentValue]
// Find last index with value <= currentValue - 3
j := sort.Search(len(values), func(k int) bool {
return values[k] > currentValue-3
}) - 1
take := currentTotal
if j >= 0 {
take += dp[j]
}
var skip int64
if i > 0 {
skip = dp[i-1]
}
if take > skip {
dp[i] = take
} else {
dp[i] = skip
}
}
return dp[n-1]
}
The Go implementation follows the same logic as the Python version, but there are a few language specific considerations.
The total damage values are stored as int64 because the answer can exceed 32 bit integer limits. If all 100000 spells have value 10^9, the result can become extremely large.
Go does not provide a direct equivalent of Python's bisect_right, so we use sort.Search with a custom predicate to locate the first value greater than currentValue - 3.
Slices are dynamically sized and work naturally for the DP array and sorted unique values.
Worked Examples
Example 1
Input:
power = [1,1,3,4]
Step 1: Group identical values
| Value | Total Contribution |
|---|---|
| 1 | 2 |
| 3 | 3 |
| 4 | 4 |
Sorted unique values:
[1, 3, 4]
Step 2: DP Processing
| i | Current Value | Compatible Index j | Take | Skip | dp[i] |
|---|---|---|---|---|---|
| 0 | 1 | -1 | 2 | 0 | 2 |
| 1 | 3 | -1 | 3 | 2 | 3 |
| 2 | 4 | 0 | 4 + 2 = 6 | 3 | 6 |
Final answer:
6
Chosen values are:
1, 1, 4
Example 2
Input:
power = [7,1,6,6]
Step 1: Group identical values
| Value | Total Contribution |
|---|---|
| 1 | 1 |
| 6 | 12 |
| 7 | 7 |
Sorted unique values:
[1, 6, 7]
Step 2: DP Processing
| i | Current Value | Compatible Index j | Take | Skip | dp[i] |
|---|---|---|---|---|---|
| 0 | 1 | -1 | 1 | 0 | 1 |
| 1 | 6 | 0 | 12 + 1 = 13 | 1 | 13 |
| 2 | 7 | 0 | 7 + 1 = 8 | 13 | 13 |
Final answer:
13
Chosen values are:
1, 6, 6
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(n log n) | Sorting unique values and binary searches dominate |
| Space | O(n) | Hash map and DP array store up to n unique values |
The counting phase is linear. Sorting the unique values costs O(k log k), where k is the number of distinct values and k <= n.
For each unique value, we perform one binary search, costing O(log k). Therefore the total complexity remains O(n log n).
The space complexity is linear because we store grouped totals, sorted unique values, and the DP array.
Test Cases
from typing import List
class Solution:
def maximumTotalDamage(self, power: List[int]) -> int:
from collections import Counter
import bisect
total_damage = Counter()
for value in power:
total_damage[value] += value
values = sorted(total_damage.keys())
n = len(values)
dp = [0] * n
for i in range(n):
current_value = values[i]
current_total = total_damage[current_value]
j = bisect.bisect_right(values, current_value - 3) - 1
take = current_total
if j >= 0:
take += dp[j]
skip = dp[i - 1] if i > 0 else 0
dp[i] = max(take, skip)
return dp[-1]
solution = Solution()
assert solution.maximumTotalDamage([1,1,3,4]) == 6 # Provided example 1
assert solution.maximumTotalDamage([7,1,6,6]) == 13 # Provided example 2
assert solution.maximumTotalDamage([5]) == 5 # Single element
assert solution.maximumTotalDamage([2,2,2]) == 6 # All identical values
assert solution.maximumTotalDamage([1,2,3]) == 3 # Every value conflicts
assert solution.maximumTotalDamage([1,4,7]) == 12 # All values compatible
assert solution.maximumTotalDamage([1,1,1,4,4,4]) == 15 # Multiple compatible duplicates
assert solution.maximumTotalDamage([1,2,4,5,7,8]) == 12 # Alternating compatible values
assert solution.maximumTotalDamage([10,12,13,15]) == 25 # Optimal skipping
assert solution.maximumTotalDamage([1000000000]) == 1000000000 # Maximum value boundary
assert solution.maximumTotalDamage([1,1000000000]) == 1000000001 # Large sparse gap
assert solution.maximumTotalDamage([3,3,3,5,5,8]) == 17 # Mixed duplicates and conflicts
| Test | Why |
|---|---|
[1,1,3,4] |
Validates provided example |
[7,1,6,6] |
Validates duplicate handling |
[5] |
Smallest possible input |
[2,2,2] |
Ensures all duplicates are accumulated |
[1,2,3] |
Every value conflicts with others |
[1,4,7] |
All values are compatible |
[1,1,1,4,4,4] |
Large grouped contributions |
[1,2,4,5,7,8] |
Multiple competing choices |
[10,12,13,15] |
Verifies DP transition correctness |
[1000000000] |
Large numeric boundary |
[1,1000000000] |
Sparse values with no conflicts |
[3,3,3,5,5,8] |
Combination of duplicates and spacing |
Edge Cases
One important edge case occurs when all spell values are identical. For example:
[5,5,5,5]
A buggy implementation might incorrectly think duplicate values conflict with each other. In reality, only values differing by 1 or 2 are forbidden. The grouping strategy handles this correctly by summing all identical values together.
Another important case occurs when every value conflicts with every other value. Consider:
[1,2,3]
Here, every pair differs by at most 2, so only one distinct value may be selected. The DP correctly evaluates all possibilities and chooses the maximum contribution.
A third critical case involves sparse large numbers:
[1,1000000000]
The values are extremely far apart, so both can be selected together. Since the algorithm relies only on sorted ordering and binary search, large numeric gaps cause no issues.
Another subtle edge case is when taking many smaller duplicates is better than taking a single larger value. For example:
[4,4,4,6]
The grouped contribution for 4 becomes 12, which is better than choosing 6. A greedy algorithm focused only on larger individual values would fail here, but the DP correctly compares total grouped contributions.