Maximum Total Damage With Spell Casting
MedPrompt
A magician has various spells.
You are given an array power, where each element represents the damage of a spell. Multiple spells can have the same damage value.
It is a known fact that if a magician decides to cast a spell with a damage of power[i], they cannot cast any spell with a damage of power[i] - 2, power[i] - 1, power[i] + 1, or power[i] + 2.
Each spell can be cast only once.
Return the maximum possible total damage that a magician can cast.
Example 1:
Input: power = [1,1,3,4]
Output: 6
Explanation:
The maximum possible damage of 6 is produced by casting spells 0, 1, 3 with damage 1, 1, 4.
Example 2:
Input: power = [7,1,6,6]
Output: 13
Explanation:
The maximum possible damage of 13 is produced by casting spells 1, 2, 3 with damage 1, 6, 6.
Constraints:
1 <= power.length <= 1051 <= power[i] <= 109
Approaches
3 approaches with complexity analysis and trade-offs.
This approach explores all possible valid combinations of spells by making a decision for each unique spell power: either cast it or skip it. It uses a recursive function to traverse the decision tree. While simple to understand, its exponential nature makes it impractical for the given constraints.
Algorithm
- Create a frequency map of the
powerarray to count occurrences of each spell damage. - Extract the unique damage values into a list and sort them in ascending order. Let this be
uniquePowers. - Define a recursive function,
solve(index), which computes the maximum damage fromuniquePowersstarting fromindex. - Base Case: If
indexis out of bounds (>= uniquePowers.length), return 0. - Recursive Step: For the spell at
uniquePowers[index], there are two choices:- Skip: Don't cast this spell. The damage is
solve(index + 1). - Cast: Cast this spell. The damage is
(uniquePowers[index] * frequency). We then find the next non-conflicting spell. This is the first spelluniquePowers[nextIndex]such thatuniquePowers[nextIndex] > uniquePowers[index] + 2. The total damage for this choice is(current spell's damage) + solve(nextIndex).
- Skip: Don't cast this spell. The damage is
- The function returns the maximum of the 'Skip' and 'Cast' options.
- The initial call is
solve(0).
Walkthrough
The core idea is to simplify the problem first. Since the constraint depends on the damage value, not the spell's index, we can group spells by their power. If we decide to cast a spell with power p, it's always optimal to cast all spells with power p to maximize damage without adding new restrictions.
-
Preprocessing: We first count the frequencies of each power value using a HashMap. Then, we extract the unique power values and sort them. This gives us a sorted array of unique powers, say
uniquePowers. -
Recursion: We define a recursive function
solve(index)that calculates the maximum damage possible from the spells inuniquePowersfromindexto the end. At eachindex, we face a choice:- Skip
uniquePowers[index]: We move to the next unique power, so the damage issolve(index + 1). - Cast
uniquePowers[index]: We gainuniquePowers[index] * countdamage. Due to the constraint, we cannot cast spells with powerp+1orp+2. We must find the next available spell, which is the first one with power greater thanuniquePowers[index] + 2. Let its index benextIndex. We then add the result ofsolve(nextIndex).
- Skip
The function returns the maximum of these two outcomes. This method explores the entire search space, leading to a correct but very slow solution.
import java.util.*; class Solution { private Map<Integer, Integer> counts; private int[] uniquePowers; public long maximumTotalDamage(int[] power) { counts = new HashMap<>(); for (int p : power) { counts.put(p, counts.getOrDefault(p, 0) + 1); } uniquePowers = new int[counts.size()]; int i = 0; for (int p : counts.keySet()) { uniquePowers[i++] = p; } Arrays.sort(uniquePowers); return solve(0); } private long solve(int index) { if (index >= uniquePowers.length) { return 0; } // Option 1: Skip current power long damageSkip = solve(index + 1); // Option 2: Take current power long currentPower = uniquePowers[index]; long currentDamage = currentPower * counts.get((int)currentPower); // Find the next index to jump to int nextIndex = index + 1; while (nextIndex < uniquePowers.length && uniquePowers[nextIndex] <= currentPower + 2) { nextIndex++; } long damageTake = currentDamage + solve(nextIndex); return Math.max(damageSkip, damageTake); }}Complexity
Time
O(N + 2^M), where N is the length of the `power` array and M is the number of unique powers. O(N) is for preprocessing. The recursive part can have up to 2^M calls in the worst case, making it exponential.
Space
O(M), where M is the number of unique powers. This is for storing the unique powers and for the recursion stack depth.
Trade-offs
Pros
Conceptually simple and a direct translation of the problem statement.
Cons
Extremely inefficient due to its exponential time complexity.
Will result in a 'Time Limit Exceeded' (TLE) error for moderately large inputs.
This approach optimizes the brute-force recursion by using memoization, a top-down dynamic programming technique. It recognizes that the recursive solution computes the same subproblems multiple times. By storing the results of these subproblems in a cache (e.g., an array or map), we can avoid redundant calculations, drastically improving performance.
Algorithm
- Preprocess the
powerarray to get a frequency map and a sorted array ofuniquePowers. - Create a memoization table,
memo, of the same size asuniquePowers, initialized to a sentinel value (e.g., -1). - Use the same recursive function
solve(index)as in the brute-force approach. - Before any computation in
solve(index), check ifmemo[index]already has a computed value. If so, return it immediately. - If not, compute the result for the 'Skip' and 'Cast' options.
- To find the
nextIndexfor the 'Cast' option efficiently, use binary search on theuniquePowersarray. This finds the next non-conflicting spell in O(log M) time. - Store the computed result in
memo[index]before returning it.
Walkthrough
The problem exhibits optimal substructure and overlapping subproblems, making it a perfect candidate for dynamic programming. The recursive brute-force approach is inefficient because it re-calculates solve(k) for the same k multiple times through different recursive paths.
We can eliminate this redundant work by storing the result of solve(index) the first time it's computed. A memo array (or a HashMap) can be used for this. memo[index] will store the result of solve(index).
The logic is as follows:
- Inside
solve(index), first check ifmemo[index]contains a valid result. If yes, return it. - If not, proceed with the regular recursive calculation: find the max damage by either skipping or casting the spell at
uniquePowers[index]. - To make the 'Cast' option efficient, we need to quickly find the next non-conflicting spell. Instead of a linear scan, we can use binary search on the sorted
uniquePowersarray to find the first power greater thancurrentPower + 2. This reduces the time for this step from O(M) to O(log M). - Once the result is computed, store it in
memo[index]before returning.
This ensures that each subproblem solve(index) is computed only once.
import java.util.*; class Solution { private Map<Integer, Integer> counts; private int[] uniquePowers; private long[] memo; public long maximumTotalDamage(int[] power) { counts = new HashMap<>(); for (int p : power) { counts.put(p, counts.getOrDefault(p, 0) + 1); } uniquePowers = new int[counts.size()]; int i = 0; for (int p : counts.keySet()) { uniquePowers[i++] = p; } Arrays.sort(uniquePowers); memo = new long[uniquePowers.length]; Arrays.fill(memo, -1); return solve(0); } private long solve(int index) { if (index >= uniquePowers.length) { return 0; } if (memo[index] != -1) { return memo[index]; } // Option 1: Skip current power long damageSkip = solve(index + 1); // Option 2: Take current power long currentPower = uniquePowers[index]; long currentDamage = currentPower * counts.get((int)currentPower); // Find the next non-conflicting index using binary search int low = index + 1, high = uniquePowers.length - 1; int nextIndex = uniquePowers.length; while (low <= high) { int mid = low + (high - low) / 2; if (uniquePowers[mid] > currentPower + 2) { nextIndex = mid; high = mid - 1; } else { low = mid + 1; } } long damageTake = currentDamage + solve(nextIndex); memo[index] = Math.max(damageSkip, damageTake); return memo[index]; }}Complexity
Time
O(N + M log M). O(N) for frequency counting, O(M log M) for sorting. Each of the M DP states is computed once, and each computation involves a binary search taking O(log M), leading to O(M log M) for the DP part.
Space
O(M), for the frequency map, unique powers array, memoization table, and the recursion stack.
Trade-offs
Pros
Efficient enough to pass the given constraints.
The recursive structure is often intuitive and closely follows the problem's logic.
Cons
Slight overhead due to recursion function calls.
Can lead to a StackOverflowError if the number of unique powers is very large (though unlikely with given constraints).
This approach uses bottom-up dynamic programming, an iterative method that builds the solution from the smallest subproblems. It avoids recursion, which can offer a slight performance benefit and prevents stack overflow issues. The logic is similar to the House Robber problem on a non-uniform set of values.
Algorithm
- Perform the same preprocessing: create a frequency map and a sorted array
uniquePowersof sizeM. - Create a DP array,
dp, of sizeM.dp[i]will store the maximum damage considering spells up touniquePowers[i]. - Iterate from
i = 0toM-1to fill thedparray. - For each
i, calculate the max damage by considering two choices foruniquePowers[i]:- Skip: The max damage is the result from the previous state,
dp[i-1](or 0 ifi=0). - Cast: The damage is
(uniquePowers[i] * frequency). To this, we add the max damage from non-conflicting previous spells. This requires finding the result for the largest poweruniquePowers[j]such thatuniquePowers[j] < uniquePowers[i] - 2. This corresponds todp[j].
- Skip: The max damage is the result from the previous state,
- The value
dp[i]is the maximum of these two choices. - The index
jcan be found efficiently using a two-pointer approach. Maintain a pointerjthat tracks the last non-conflicting state. Asiincreases,jonly moves forward, leading to an O(M) DP calculation. - The final answer is
dp[M-1].
Walkthrough
An iterative DP approach is often preferred for its performance and avoidance of recursion limits. We build a dp array where dp[i] represents the maximum total damage achievable by considering the first i+1 unique spell powers (i.e., from uniquePowers[0] to uniquePowers[i]).
-
Preprocessing: As before, we get the sorted
uniquePowersarray and their frequencies. -
DP State:
dp[i]= Maximum damage using a subset of{uniquePowers[0], ..., uniquePowers[i]}. -
DP Transition: We iterate
ifrom0toM-1and computedp[i]:- Choice 1 (Skip
uniquePowers[i]): If we don't cast this spell, the maximum damage is whatever we could achieve with the previous spells. This is simplydp[i-1](ifi > 0). - Choice 2 (Cast
uniquePowers[i]): We gainuniquePowers[i] * countdamage. We must add the maximum damage from spells that don't conflict. We need to find the maximum damage from the subproblem ending just before the conflicting range, i.e., from spells with power< uniquePowers[i] - 2. This corresponds todp[j]whereuniquePowers[j]is the largest power satisfying this condition.
The recurrence is
dp[i] = max(damage_if_skip, damage_if_cast). Finding the indexjfor eachican be done with a binary search (O(M log M)) or, even better, a two-pointer technique (O(M)), as the requiredjis non-decreasing asiincreases. - Choice 1 (Skip
import java.util.*; class Solution { public long maximumTotalDamage(int[] power) { if (power == null || power.length == 0) { return 0; } Map<Integer, Integer> counts = new HashMap<>(); for (int p : power) { counts.put(p, counts.getOrDefault(p, 0) + 1); } int[] uniquePowers = new int[counts.size()]; int i = 0; for (int p : counts.keySet()) { uniquePowers[i++] = p; } Arrays.sort(uniquePowers); int m = uniquePowers.length; long[] dp = new long[m]; int j = 0; // Two-pointer for finding previous non-conflicting state for (i = 0; i < m; i++) { long currentPower = uniquePowers[i]; long currentTotalDamage = currentPower * counts.get((int)currentPower); // Move pointer j forward. It will point to the first power that is >= currentPower - 2. while (uniquePowers[j] < currentPower - 2) { j++; } // The last valid subproblem result is at dp[j-1] long prevDamage = (j > 0) ? dp[j - 1] : 0; long damageTake = currentTotalDamage + prevDamage; // Option to skip the current power long damageSkip = (i > 0) ? dp[i - 1] : 0; dp[i] = Math.max(damageTake, damageSkip); } return m > 0 ? dp[m - 1] : 0; }}Complexity
Time
O(N + M log M). O(N) for frequency counting, O(M log M) for sorting. The DP calculation with the two-pointer optimization takes O(M) time. The sorting step dominates the complexity.
Space
O(M), for the frequency map, unique powers array, and the DP table. This can be optimized to O(1) extra space (besides storing unique powers) since `dp[i]` only depends on `dp[i-1]` and `dp[j]`, but it complicates the code.
Trade-offs
Pros
Most efficient approach in practice due to iterative nature (no recursion overhead).
Avoids potential stack overflow errors.
Cons
Can be slightly less intuitive to formulate than the recursive top-down approach.
Requires careful handling of indices and base cases.
Solutions
Solution
class Solution {private Long[] f;private int[] power;private Map<Integer, Integer> cnt;private int[] nxt;private int n;public long maximumTotalDamage(int[] power) { Arrays.sort(power); this.power = power; n = power.length; f = new Long[n]; cnt = new HashMap<>(n); nxt = new int[n]; for (int i = 0; i < n; ++i) { cnt.merge(power[i], 1, Integer : : sum); int l = Arrays.binarySearch(power, power[i] + 3); l = l < 0 ? -l - 1 : l; nxt[i] = l; } return dfs(0); }private long dfs(int i) { if (i >= n) { return 0; } if (f[i] != null) { return f[i]; } long a = dfs(i + cnt.get(power[i])); long b = 1L * power[i] * cnt.get(power[i]) + dfs(nxt[i]); return f[i] = Math.max(a, b); }}class Solution {public: long long maximumTotalDamage(vector<int> &power) { sort(power.begin(), power.end()); this->power = power; n = power.size(); f.resize(n); nxt.resize(n); for (int i = 0; i < n; ++i) { cnt[power[i]]++; nxt[i] = upper_bound(power.begin() + i + 1, power.end(), power[i] + 2) - power.begin(); } return dfs(0); } private: unordered_map<int, int> cnt; vector<long long> f; vector<int> power; vector<int> nxt; int n; long long dfs(int i) { if (i >= n) { return 0; } if (f[i]) { return f[i]; } long long a = dfs(i + cnt[power[i]]); long long b = 1LL * power[i] * cnt[power[i]] + dfs(nxt[i]); return f[i] = max(a, b); }};class Solution: def maximumTotalDamage(self, power: List[int]) -> int: @ 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) n = len(power) cnt = Counter(power) power . sort() nxt = [bisect_right(power, x + 2, lo=i + 1) for i, x in enumerate(power)] return dfs(0)Video walkthrough
Newsletter
One sharp idea, every week
System design and interview prep — short enough to finish.
No spam. Unsubscribe anytime.
Practice
Same difficulty — related problems to reinforce the pattern.