3186. Maximum Total Damage With Spell Casting
Problem Description
You are given an array power
where each element represents the damage value of a spell that a magician can cast. Multiple spells can have the same damage value.
The key constraint is: if the magician casts a spell with damage power[i]
, they cannot cast any spell with damage values of power[i] - 2
, power[i] - 1
, power[i] + 1
, or power[i] + 2
. In other words, casting a spell blocks out a range of 5 consecutive damage values (the spell itself and 2 values on each side).
Each spell can only be cast once, meaning you can only use each element in the array once.
Your task is to find the maximum total damage the magician can achieve by selecting spells to cast while respecting the blocking constraint.
For example, if power = [1, 1, 3, 4]
, the magician could:
- Cast both spells with damage 1 (total damage = 2), which would block damage values -1, 0, 1, 2, 3
- Or cast the spell with damage 4 (total damage = 4), which would block damage values 2, 3, 4, 5, 6
The maximum would be 4 in this case.
Intuition
The problem resembles the classic "House Robber" problem where we need to make choices about which elements to take while avoiding certain constraints. Here, the constraint is that selecting a damage value blocks a range of 5 consecutive values.
First, let's think about the structure of the problem. If we sort the array, we can make decisions in order - for each unique damage value, we either take all occurrences of it or skip it entirely. This is because if we can take one spell with damage x
, we can take all spells with damage x
since they don't interfere with each other.
The key insight is that when we decide to use all spells of damage value x
, we must skip to the next "safe" damage value - one that is greater than x + 2
. This is where binary search becomes useful: after sorting, we can efficiently find the index of the first damage value that's greater than x + 2
.
Now we can frame this as a dynamic programming problem. For each position i
in the sorted array, we have two choices:
- Skip the current damage value: Move to the next different damage value (skip all occurrences of
power[i]
) - Take the current damage value: Collect
power[i] * count[power[i]]
damage and jump to the first safe position
The challenge is handling duplicates efficiently. By using a counter to track occurrences, we can process all duplicates at once. When we skip, we skip all occurrences; when we take, we take all occurrences.
This leads to a recursive solution with memoization: at each position, we compute the maximum damage we can get from that position onwards by comparing the two choices above. The overlapping subproblems (same positions being evaluated multiple times) make memoization effective.
Learn more about Two Pointers, Binary Search, Dynamic Programming and Sorting patterns.
Solution Approach
The implementation follows these key steps:
1. Preprocessing the Data
First, we sort the power
array and create a counter cnt
to track how many times each damage value appears:
cnt = Counter(power) power.sort()
2. Building the Next Valid Index Array
For each position i
, we precompute where we can jump to if we decide to use the damage value at position i
. This is the index of the first element greater than power[i] + 2
:
nxt = [bisect_right(power, x + 2, lo=i + 1) for i, x in enumerate(power)]
The bisect_right
function performs binary search to find this position efficiently. We start searching from lo=i + 1
to optimize the search range.
3. Dynamic Programming with Memoization
We define a recursive function dfs(i)
that calculates the maximum damage starting from index i
:
@cache
def dfs(i: int) -> int:
if i >= n:
return 0
a = dfs(i + cnt[power[i]])
b = power[i] * cnt[power[i]] + dfs(nxt[i])
return max(a, b)
The function works as follows:
- Base case: If
i >= n
, we've processed all elements, return 0 - Choice 1 (Skip): Skip all occurrences of
power[i]
by jumping toi + cnt[power[i]]
. This gives us damage valuea = dfs(i + cnt[power[i]])
- Choice 2 (Take): Take all occurrences of
power[i]
(gainingpower[i] * cnt[power[i]]
damage) and jump to the next valid positionnxt[i]
. This gives us damage valueb = power[i] * cnt[power[i]] + dfs(nxt[i])
- Return the maximum of both choices
The @cache
decorator automatically memoizes the results, preventing redundant calculations for the same index.
4. Getting the Final Answer
The maximum total damage is obtained by calling dfs(0)
, which considers all possible combinations starting from the first element.
Time Complexity: O(n log n)
for sorting and binary search operations, plus O(n)
for the DP traversal, giving O(n log n)
overall.
Space Complexity: O(n)
for the counter, next array, and memoization cache.
Ready to land your dream job?
Unlock your dream job with a 3-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through the solution with power = [1, 1, 3, 4, 7]
.
Step 1: Preprocessing
- Sort the array:
power = [1, 1, 3, 4, 7]
(already sorted) - Create counter:
cnt = {1: 2, 3: 1, 4: 1, 7: 1}
- Array length:
n = 5
Step 2: Build Next Valid Index Array
For each position, find the first index where the value is greater than power[i] + 2
:
i=0
:power[0]=1
, need value > 3. Binary search finds index 3 (value 4)i=1
:power[1]=1
, need value > 3. Binary search finds index 3 (value 4)i=2
:power[2]=3
, need value > 5. Binary search finds index 4 (value 7)i=3
:power[3]=4
, need value > 6. Binary search finds index 4 (value 7)i=4
:power[4]=7
, need value > 9. Binary search finds index 5 (out of bounds)
So nxt = [3, 3, 4, 4, 5]
Step 3: Dynamic Programming Traversal
Starting from dfs(0)
:
dfs(0)
: Process damage value 1 (at index 0)
- Skip option: Jump to index
0 + cnt[1] = 0 + 2 = 2
, calldfs(2)
- Take option: Gain
1 * 2 = 2
damage, jump to indexnxt[0] = 3
, calldfs(3)
dfs(2)
: Process damage value 3 (at index 2)
- Skip option: Jump to index
2 + cnt[3] = 2 + 1 = 3
, calldfs(3)
- Take option: Gain
3 * 1 = 3
damage, jump to indexnxt[2] = 4
, calldfs(4)
dfs(3)
: Process damage value 4 (at index 3)
- Skip option: Jump to index
3 + cnt[4] = 3 + 1 = 4
, calldfs(4)
- Take option: Gain
4 * 1 = 4
damage, jump to indexnxt[3] = 4
, calldfs(4)
dfs(4)
: Process damage value 7 (at index 4)
- Skip option: Jump to index
4 + cnt[7] = 4 + 1 = 5
, calldfs(5)
- Take option: Gain
7 * 1 = 7
damage, jump to indexnxt[4] = 5
, calldfs(5)
dfs(5)
: Base case, return 0
Step 4: Backtracking with Values
dfs(5) = 0
dfs(4) = max(0, 7 + 0) = 7
dfs(3) = max(dfs(4), 4 + dfs(4)) = max(7, 4 + 7) = 11
dfs(2) = max(dfs(3), 3 + dfs(4)) = max(11, 3 + 7) = 11
dfs(0) = max(dfs(2), 2 + dfs(3)) = max(11, 2 + 11) = 13
Result: Maximum damage = 13
This is achieved by taking both spells with damage 1 (total 2) and both spells with damage 4 and 7 (total 11), giving 2 + 4 + 7 = 13. The spell with damage 3 is blocked by taking damage 1.
Solution Implementation
1from typing import List
2from collections import Counter
3from functools import cache
4from bisect import bisect_right
5
6
7class Solution:
8 def maximumTotalDamage(self, power: List[int]) -> int:
9 """
10 Calculate the maximum total damage that can be achieved.
11 When selecting a power value, all values within range [power-2, power+2] become unavailable.
12
13 Args:
14 power: List of power values
15
16 Returns:
17 Maximum total damage achievable
18 """
19
20 @cache
21 def calculate_max_damage(index: int) -> int:
22 """
23 Recursively calculate maximum damage starting from given index.
24
25 Args:
26 index: Current position in sorted power array
27
28 Returns:
29 Maximum damage achievable from this position onwards
30 """
31 # Base case: reached end of array
32 if index >= array_length:
33 return 0
34
35 # Option 1: Skip current power value and all its duplicates
36 skip_current = calculate_max_damage(index + power_count[sorted_power[index]])
37
38 # Option 2: Take current power value (and all duplicates)
39 # Then skip to next valid position (beyond power+2 range)
40 current_damage = sorted_power[index] * power_count[sorted_power[index]]
41 take_current = current_damage + calculate_max_damage(next_valid_index[index])
42
43 # Return maximum of both options
44 return max(skip_current, take_current)
45
46 # Initialize variables
47 array_length = len(power)
48
49 # Count frequency of each power value
50 power_count = Counter(power)
51
52 # Sort power values for efficient processing
53 sorted_power = sorted(power)
54
55 # Pre-compute next valid index for each position
56 # Next valid index is the first position where power > current_power + 2
57 next_valid_index = [
58 bisect_right(sorted_power, value + 2, lo=i + 1)
59 for i, value in enumerate(sorted_power)
60 ]
61
62 # Start dynamic programming from index 0
63 return calculate_max_damage(0)
64
1class Solution {
2 // Memoization array to store computed results for each index
3 private Long[] memo;
4
5 // Input array of power values
6 private int[] power;
7
8 // Map to store frequency count of each power value
9 private Map<Integer, Integer> frequencyMap;
10
11 // Array to store the next valid index after skipping conflicting elements
12 private int[] nextValidIndex;
13
14 // Length of the power array
15 private int n;
16
17 public long maximumTotalDamage(int[] power) {
18 // Sort the power array to group same values and enable binary search
19 Arrays.sort(power);
20
21 // Initialize instance variables
22 this.power = power;
23 this.n = power.length;
24 this.memo = new Long[n];
25 this.frequencyMap = new HashMap<>(n);
26 this.nextValidIndex = new int[n];
27
28 // Preprocess the array
29 for (int i = 0; i < n; i++) {
30 // Count frequency of each power value
31 frequencyMap.merge(power[i], 1, Integer::sum);
32
33 // Find the first index where power[index] >= power[i] + 3
34 // This represents the next valid position after skipping conflicting elements
35 int searchTarget = power[i] + 3;
36 int nextIndex = Arrays.binarySearch(power, searchTarget);
37
38 // If exact match not found, binarySearch returns -(insertion point) - 1
39 // Convert to the actual insertion point (first valid index)
40 nextIndex = nextIndex < 0 ? -nextIndex - 1 : nextIndex;
41 nextValidIndex[i] = nextIndex;
42 }
43
44 // Start dynamic programming from index 0
45 return dfs(0);
46 }
47
48 /**
49 * Dynamic programming function to find maximum damage starting from index i
50 * @param i current index in the power array
51 * @return maximum total damage achievable from index i to end
52 */
53 private long dfs(int i) {
54 // Base case: reached end of array
55 if (i >= n) {
56 return 0;
57 }
58
59 // Return memoized result if already computed
60 if (memo[i] != null) {
61 return memo[i];
62 }
63
64 // Option 1: Skip current power value group
65 // Move to the next different power value
66 long skipCurrent = dfs(i + frequencyMap.get(power[i]));
67
68 // Option 2: Take current power value group
69 // Calculate damage from all occurrences of current power value
70 // Then continue from the next valid index (skipping conflicting values)
71 long takeCurrent = (long) power[i] * frequencyMap.get(power[i]) + dfs(nextValidIndex[i]);
72
73 // Store and return the maximum of both options
74 memo[i] = Math.max(skipCurrent, takeCurrent);
75 return memo[i];
76 }
77}
78
1class Solution {
2public:
3 long long maximumTotalDamage(vector<int>& power) {
4 // Sort powers in ascending order for easier processing
5 sort(power.begin(), power.end());
6
7 // Store the sorted power array and its size
8 this->powers = power;
9 arraySize = power.size();
10
11 // Initialize memoization array and next valid index array
12 memo.resize(arraySize, 0);
13 nextValidIndex.resize(arraySize);
14
15 // Count frequency of each power value and precompute next valid indices
16 for (int i = 0; i < arraySize; ++i) {
17 // Count occurrences of each power value
18 powerFrequency[powers[i]]++;
19
20 // Find the next index where power[j] > power[i] + 2
21 // This is the next index we can consider if we take power[i]
22 nextValidIndex[i] = upper_bound(powers.begin() + i + 1,
23 powers.end(),
24 powers[i] + 2) - powers.begin();
25 }
26
27 // Start dynamic programming from index 0
28 return calculateMaxDamage(0);
29 }
30
31private:
32 // Map to store frequency of each power value
33 unordered_map<int, int> powerFrequency;
34
35 // Memoization array for dynamic programming
36 vector<long long> memo;
37
38 // Array to store sorted power values
39 vector<int> powers;
40
41 // Array to store next valid index for each position
42 vector<int> nextValidIndex;
43
44 // Size of the power array
45 int arraySize;
46
47 long long calculateMaxDamage(int index) {
48 // Base case: if index is out of bounds, return 0
49 if (index >= arraySize) {
50 return 0;
51 }
52
53 // Return memoized result if already calculated
54 if (memo[index] != 0) {
55 return memo[index];
56 }
57
58 // Option 1: Skip the current power value entirely
59 // Move to the next distinct power value
60 long long skipCurrentPower = calculateMaxDamage(index + powerFrequency[powers[index]]);
61
62 // Option 2: Take all occurrences of the current power value
63 // Calculate damage from current power and continue from next valid index
64 long long takeCurrentPower = static_cast<long long>(powers[index]) * powerFrequency[powers[index]]
65 + calculateMaxDamage(nextValidIndex[index]);
66
67 // Store and return the maximum of both options
68 memo[index] = max(skipCurrentPower, takeCurrentPower);
69 return memo[index];
70 }
71};
72
1/**
2 * Calculates the maximum total damage that can be achieved
3 * @param power - Array of power values
4 * @returns Maximum total damage
5 */
6function maximumTotalDamage(power: number[]): number {
7 const arrayLength: number = power.length;
8
9 // Sort power array in ascending order
10 power.sort((a: number, b: number) => a - b);
11
12 // Memoization array for dynamic programming
13 const memoization: number[] = Array(arrayLength).fill(0);
14
15 // Count frequency of each power value
16 const frequencyMap: Record<number, number> = {};
17
18 // Store the next valid index for each position (where power[index] > current + 2)
19 const nextValidIndex: number[] = Array(arrayLength).fill(0);
20
21 // Build frequency map and calculate next valid indices
22 for (let i = 0; i < arrayLength; ++i) {
23 // Count occurrences of each power value
24 frequencyMap[power[i]] = (frequencyMap[power[i]] || 0) + 1;
25
26 // Binary search to find the first index where power value > current + 2
27 let left: number = i + 1;
28 let right: number = arrayLength;
29
30 while (left < right) {
31 const mid: number = (left + right) >> 1;
32
33 if (power[mid] > power[i] + 2) {
34 right = mid;
35 } else {
36 left = mid + 1;
37 }
38 }
39
40 nextValidIndex[i] = left;
41 }
42
43 /**
44 * Dynamic programming function to calculate maximum damage starting from index
45 * @param index - Current index in the power array
46 * @returns Maximum damage achievable from this index onwards
47 */
48 const calculateMaxDamage = (index: number): number => {
49 // Base case: reached end of array
50 if (index >= arrayLength) {
51 return 0;
52 }
53
54 // Return memoized result if already calculated
55 if (memoization[index]) {
56 return memoization[index];
57 }
58
59 // Option 1: Skip current power value group
60 const skipCurrentGroup: number = calculateMaxDamage(index + frequencyMap[power[index]]);
61
62 // Option 2: Take current power value group and skip conflicting values
63 const takeCurrentGroup: number = power[index] * frequencyMap[power[index]] +
64 calculateMaxDamage(nextValidIndex[index]);
65
66 // Store and return the maximum of both options
67 return (memoization[index] = Math.max(skipCurrentGroup, takeCurrentGroup));
68 };
69
70 // Start calculation from index 0
71 return calculateMaxDamage(0);
72}
73
Time and Space Complexity
Time Complexity: O(n log n)
The time complexity is dominated by:
- Sorting the
power
array:O(n log n)
- Building the
nxt
array usingbisect_right
for each element:O(n log n)
(each binary search takesO(log n)
and we do itn
times) - The DFS with memoization visits each unique index at most once due to the
@cache
decorator, resulting inO(n)
calls - Each DFS call performs
O(1)
operations (aside from recursive calls)
Therefore, the overall time complexity is O(n log n)
.
Space Complexity: O(n)
The space complexity consists of:
cnt
Counter dictionary:O(n)
in worst case (all elements unique)- Sorted
power
array:O(n)
nxt
array:O(n)
- DFS recursion stack depth:
O(n)
in worst case - Memoization cache:
O(n)
for storing results of up ton
different indices
The total space complexity is O(n)
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Not Handling Duplicate Values Correctly
The Pitfall: A common mistake is treating each element in the array independently without considering that multiple spells can have the same damage value. If you process each element one by one, you might incorrectly skip or take individual occurrences of the same value, leading to suboptimal solutions.
Why It Happens: The problem states "each spell can only be cast once," which might lead to thinking we need to make decisions for each array element individually. However, when we decide to cast a spell with a certain damage value, we should cast ALL available spells with that exact damage value to maximize damage.
Example of Incorrect Approach:
# WRONG: Processing each element individually
def dfs(i):
if i >= n:
return 0
# Skip current element
skip = dfs(i + 1)
# Take current element and jump to next valid
take = power[i] + dfs(next_valid[i])
return max(skip, take)
The Solution: Group identical damage values together and make decisions for the entire group. When we choose to use a damage value, we use ALL occurrences of it:
# CORRECT: Process all duplicates together
def dfs(i):
if i >= n:
return 0
# Skip ALL occurrences of power[i]
skip = dfs(i + cnt[power[i]])
# Take ALL occurrences of power[i]
take = power[i] * cnt[power[i]] + dfs(nxt[i])
return max(skip, take)
2. Incorrect Next Valid Index Calculation
The Pitfall: Miscalculating where to jump after selecting a damage value. Common errors include:
- Using
bisect_left
instead ofbisect_right
- Looking for values greater than
power[i] + 1
instead ofpower[i] + 2
- Not optimizing the search range with
lo=i+1
Why It Happens: The constraint blocks values in the range [power[i] - 2, power[i] + 2]
. The next valid value must be strictly greater than power[i] + 2
, not greater than or equal to.
Example of Incorrect Approaches:
# WRONG: Using bisect_left (might include power[i] + 2) nxt = [bisect_left(power, x + 3) for x in power] # WRONG: Looking for wrong boundary nxt = [bisect_right(power, x + 1) for x in power] # INEFFICIENT: Not optimizing search range nxt = [bisect_right(power, x + 2) for x in power]
The Solution: Use bisect_right
to find the first index with value strictly greater than power[i] + 2
:
# CORRECT: Find first value > power[i] + 2, starting from i+1
nxt = [bisect_right(power, x + 2, lo=i + 1) for i, x in enumerate(power)]
3. Not Sorting the Array First
The Pitfall: Attempting to solve the problem without sorting leads to incorrect results because:
- The binary search for next valid index won't work correctly
- The DP state transitions become invalid when jumping indices
Why It Happens: The original problem doesn't explicitly mention sorting, and one might think the order of spells matters.
The Solution: Always sort the array first. The order of casting spells doesn't affect the total damage - only which spells are cast matters. Sorting enables efficient binary search and proper DP transitions.
power.sort() # Essential first step
Which of the following uses divide and conquer strategy?
Recommended Readings
Tech Interview Pattern Two Pointers Introduction If you prefer videos here's a super quick introduction to Two Pointers div class responsive iframe iframe src https www youtube com embed xZ4AfXHQ1VQ title YouTube video player frameborder 0 allow accelerometer autoplay clipboard write encrypted media gyroscope picture in picture allowfullscreen iframe div Two pointers is a common interview
Binary Search Speedrun For each of the Speedrun questions you will be given a binary search related problem and a corresponding multiple choice question The multiple choice questions are related to the techniques and template s introduced in the binary search section It's recommended that you have gone through at
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
Want a Structured Path to Master System Design Too? Don’t Miss This!