Facebook Pixel

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.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

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:

  1. Skip the current damage value: Move to the next different damage value (skip all occurrences of power[i])
  2. 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 to i + cnt[power[i]]. This gives us damage value a = dfs(i + cnt[power[i]])
  • Choice 2 (Take): Take all occurrences of power[i] (gaining power[i] * cnt[power[i]] damage) and jump to the next valid position nxt[i]. This gives us damage value b = 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 Evaluator

Example 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, call dfs(2)
  • Take option: Gain 1 * 2 = 2 damage, jump to index nxt[0] = 3, call dfs(3)

dfs(2): Process damage value 3 (at index 2)

  • Skip option: Jump to index 2 + cnt[3] = 2 + 1 = 3, call dfs(3)
  • Take option: Gain 3 * 1 = 3 damage, jump to index nxt[2] = 4, call dfs(4)

dfs(3): Process damage value 4 (at index 3)

  • Skip option: Jump to index 3 + cnt[4] = 3 + 1 = 4, call dfs(4)
  • Take option: Gain 4 * 1 = 4 damage, jump to index nxt[3] = 4, call dfs(4)

dfs(4): Process damage value 7 (at index 4)

  • Skip option: Jump to index 4 + cnt[7] = 4 + 1 = 5, call dfs(5)
  • Take option: Gain 7 * 1 = 7 damage, jump to index nxt[4] = 5, call dfs(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 using bisect_right for each element: O(n log n) (each binary search takes O(log n) and we do it n times)
  • The DFS with memoization visits each unique index at most once due to the @cache decorator, resulting in O(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 to n 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 of bisect_right
  • Looking for values greater than power[i] + 1 instead of power[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
Discover Your Strengths and Weaknesses: Take Our 3-Minute Quiz to Tailor Your Study Plan:

Which of the following uses divide and conquer strategy?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More