1770. Maximum Score from Performing Multiplication Operations
Problem Description
You have two arrays: nums
and multipliers
. The nums
array has n
elements, and the multipliers
array has m
elements, where n >= m
.
Starting with a score of 0, you need to perform exactly m
operations. For each operation i
(where i
goes from 0 to m-1):
-
Pick an element from either the beginning or the end of the
nums
array. Let's call this elementx
. -
Calculate points by multiplying the chosen element
x
withmultipliers[i]
and add this product to your total score. -
Remove the chosen element
x
from thenums
array (so the array shrinks by one element after each operation).
The goal is to find the maximum possible score you can achieve after completing all m
operations.
Example walkthrough:
- If
nums = [1, 2, 3]
andmultipliers = [3, 2]
- You need to perform 2 operations
- Operation 1: You could pick 1 (from start) or 3 (from end), multiply by
multipliers[0] = 3
- Operation 2: After removing your first choice, pick from the remaining elements at either end, multiply by
multipliers[1] = 2
- Different choices lead to different total scores, and you want the maximum possible score
The challenge is determining which elements to pick from which end of the array and in what order to maximize the final score.
Intuition
When we look at this problem, we need to make a series of choices: at each step, should we pick from the left end or the right end of the array? This creates a decision tree where each path represents a different sequence of choices.
The key insight is that we can think of this problem in terms of states. At any point during our operations, we can describe our current situation using just a few pieces of information:
- How many elements we've taken from the left side
- How many elements we've taken from the right side
- Which operation we're currently on
But wait - if we know how many elements we've taken from the left (i
elements) and which operation number we're on (k
), we can figure out how many we've taken from the right (it would be k - i
elements). So we really only need to track the left pointer and the operation number.
This observation leads us to dynamic programming. For any given state (left index, right index, operation number), we have two choices:
- Take from the left: multiply
nums[left]
bymultipliers[operation]
and move to the next state - Take from the right: multiply
nums[right]
bymultipliers[operation]
and move to the next state
We want to choose whichever option gives us the maximum score.
The recursive nature becomes clear: the best score from any state equals the current operation's score plus the best score from the remaining operations. By using memoization (the @cache
decorator), we avoid recalculating the same states multiple times.
The function f(i, j, k)
represents: "What's the maximum score we can get starting from index i
on the left, index j
on the right, and operation k
?" We recursively try both options (taking from left or right) and return the maximum.
Learn more about Dynamic Programming patterns.
Solution Approach
The solution implements a top-down dynamic programming approach using memoization. Let's walk through the implementation step by step:
Function Definition:
@cache
def f(i, j, k):
The function f(i, j, k)
represents the maximum score we can achieve where:
i
is the current left index of thenums
arrayj
is the current right index of thenums
arrayk
is the current operation number (index inmultipliers
)
The @cache
decorator automatically memoizes the results, preventing redundant calculations for the same state.
Base Case:
if k >= m or i >= n or j < 0: return 0
We return 0 when:
- We've completed all
m
operations (k >= m
) - The left pointer goes out of bounds (
i >= n
) - The right pointer goes out of bounds (
j < 0
)
Recursive Cases:
a = f(i + 1, j, k + 1) + nums[i] * multipliers[k]
b = f(i, j - 1, k + 1) + nums[j] * multipliers[k]
return max(a, b)
For each state, we calculate two options:
- Take from left (
a
): Addnums[i] * multipliers[k]
to the score from the remaining subproblemf(i + 1, j, k + 1)
- Take from right (
b
): Addnums[j] * multipliers[k]
to the score from the remaining subproblemf(i, j - 1, k + 1)
We return the maximum of these two choices.
Initial Call:
n = len(nums)
m = len(multipliers)
return f(0, n - 1, 0)
We start with:
- Left pointer at index 0
- Right pointer at index
n - 1
- Operation number 0
The algorithm explores all possible combinations of taking elements from either end, but memoization ensures each unique state is computed only once. The time complexity is O(m^2)
since there are at most m
possible values for the left pointer (we can take at most m
elements from the left), m
possible values for the right pointer, and m
operations. However, since the number of elements taken from left and right must sum to the operation number, the actual number of valid states is O(m^2)
.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's work through a concrete example with nums = [3, 1, 5, 2]
and multipliers = [2, 4, 3]
.
We need to perform 3 operations total. Let's trace through the recursive solution:
Initial State: f(0, 3, 0)
- left pointer at index 0, right pointer at index 3, operation 0
At this state, we have two choices:
- Take from left:
nums[0] * multipliers[0] = 3 * 2 = 6
, then solvef(1, 3, 1)
- Take from right:
nums[3] * multipliers[0] = 2 * 2 = 4
, then solvef(0, 2, 1)
Let's explore the left branch first:
Path 1: Take left (3)
State: f(1, 3, 1)
- remaining array looks like [1, 5, 2]
from indices 1 to 3
- Take from left:
nums[1] * multipliers[1] = 1 * 4 = 4
, then solvef(2, 3, 2)
- Take from right:
nums[3] * multipliers[1] = 2 * 4 = 8
, then solvef(1, 2, 2)
Following the better option (take right with value 8):
State: f(1, 2, 2)
- remaining array looks like [1, 5]
from indices 1 to 2
- Take from left:
nums[1] * multipliers[2] = 1 * 3 = 3
, then solvef(2, 2, 3)
- Take from right:
nums[2] * multipliers[2] = 5 * 3 = 15
, then solvef(1, 1, 3)
Taking right (better option with 15):
State: f(1, 1, 3)
- base case reached (k >= m), returns 0
So this path gives: 6 + 8 + 15 = 29
Path 2: Take right (2) initially
State: f(0, 2, 1)
- remaining array looks like [3, 1, 5]
from indices 0 to 2
- Take from left:
nums[0] * multipliers[1] = 3 * 4 = 12
, then solvef(1, 2, 2)
- Take from right:
nums[2] * multipliers[1] = 5 * 4 = 20
, then solvef(0, 1, 2)
Following the better option (take right with value 20):
State: f(0, 1, 2)
- remaining array looks like [3, 1]
from indices 0 to 1
- Take from left:
nums[0] * multipliers[2] = 3 * 3 = 9
, then solvef(1, 1, 3)
- Take from right:
nums[1] * multipliers[2] = 1 * 3 = 3
, then solvef(0, 0, 3)
Taking left (better option with 9):
State: f(1, 1, 3)
- base case reached, returns 0
This path gives: 4 + 20 + 9 = 33
The algorithm explores all paths (with memoization preventing recalculation) and returns the maximum score of 33, achieved by taking elements in the order: right (2), right (5), left (3).
Solution Implementation
1class Solution:
2 def maximumScore(self, nums: List[int], multipliers: List[int]) -> int:
3 """
4 Calculate maximum score by selecting elements from start or end of nums array
5 and multiplying with multipliers in order.
6
7 Args:
8 nums: Array of integers to select from
9 multipliers: Array of multipliers to apply in order
10
11 Returns:
12 Maximum possible score
13 """
14 from functools import cache
15
16 @cache
17 def dp(left_index: int, right_index: int, mult_index: int) -> int:
18 """
19 Dynamic programming function to find maximum score.
20
21 Args:
22 left_index: Current left pointer in nums array
23 right_index: Current right pointer in nums array
24 mult_index: Current index in multipliers array
25
26 Returns:
27 Maximum score achievable from current state
28 """
29 # Base case: all multipliers have been used
30 if mult_index >= num_multipliers:
31 return 0
32
33 # Invalid state check (shouldn't occur with correct logic)
34 if left_index >= nums_length or right_index < 0:
35 return 0
36
37 # Option 1: Take element from left side
38 take_left = dp(left_index + 1, right_index, mult_index + 1) + nums[left_index] * multipliers[mult_index]
39
40 # Option 2: Take element from right side
41 take_right = dp(left_index, right_index - 1, mult_index + 1) + nums[right_index] * multipliers[mult_index]
42
43 # Return maximum of both options
44 return max(take_left, take_right)
45
46 # Store array lengths for easier access
47 nums_length = len(nums)
48 num_multipliers = len(multipliers)
49
50 # Start recursion with full nums array and first multiplier
51 return dp(0, nums_length - 1, 0)
52
1class Solution {
2 // Memoization table to store computed results
3 private Integer[][] memo;
4 private int[] multipliers;
5 private int[] nums;
6 private int numsLength;
7 private int multipliersLength;
8
9 /**
10 * Calculates the maximum score by choosing elements from either end of nums array
11 * and multiplying them with multipliers in order.
12 *
13 * @param nums Array of integers to choose from
14 * @param multipliers Array of multipliers to apply in order
15 * @return Maximum possible score
16 */
17 public int maximumScore(int[] nums, int[] multipliers) {
18 this.numsLength = nums.length;
19 this.multipliersLength = multipliers.length;
20 this.memo = new Integer[multipliersLength][multipliersLength];
21 this.nums = nums;
22 this.multipliers = multipliers;
23
24 // Start DFS from position (0, 0)
25 return dfs(0, 0);
26 }
27
28 /**
29 * Recursive helper function with memoization to find maximum score.
30 *
31 * @param left Number of elements taken from the left side of nums
32 * @param right Number of elements taken from the right side of nums
33 * @return Maximum score achievable from current state
34 */
35 private int dfs(int left, int right) {
36 // Base case: all multipliers have been used
37 int operationCount = left + right;
38 if (left >= multipliersLength || right >= multipliersLength || operationCount >= multipliersLength) {
39 return 0;
40 }
41
42 // Check if result is already computed
43 if (memo[left][right] != null) {
44 return memo[left][right];
45 }
46
47 // Current operation index (number of operations performed)
48 int currentIndex = operationCount;
49
50 // Option 1: Take element from left side of nums
51 int takeLeft = dfs(left + 1, right) + nums[left] * multipliers[currentIndex];
52
53 // Option 2: Take element from right side of nums
54 int takeRight = dfs(left, right + 1) + nums[numsLength - 1 - right] * multipliers[currentIndex];
55
56 // Store and return the maximum of both options
57 memo[left][right] = Math.max(takeLeft, takeRight);
58 return memo[left][right];
59 }
60}
61
1class Solution {
2public:
3 int maximumScore(vector<int>& nums, vector<int>& multipliers) {
4 int numsSize = nums.size();
5 int multiplierSize = multipliers.size();
6
7 // dp[left][right] represents the maximum score when we've taken
8 // 'left' elements from the left side and 'right' elements from the right side
9 int dp[multiplierSize][multiplierSize];
10
11 // Initialize with a large value to indicate unvisited states
12 memset(dp, 0x3f, sizeof(dp));
13
14 // Recursive function with memoization
15 // leftTaken: number of elements taken from the left side of nums
16 // rightTaken: number of elements taken from the right side of nums
17 function<int(int, int)> calculateMaxScore = [&](int leftTaken, int rightTaken) -> int {
18 // Base case: if we've used all multipliers or indices are out of bounds
19 if (leftTaken >= multiplierSize || rightTaken >= multiplierSize ||
20 (leftTaken + rightTaken) >= multiplierSize) {
21 return 0;
22 }
23
24 // Return memoized result if already computed
25 if (dp[leftTaken][rightTaken] != 0x3f3f3f3f) {
26 return dp[leftTaken][rightTaken];
27 }
28
29 // Current multiplier index (total operations done so far)
30 int currentMultiplierIndex = leftTaken + rightTaken;
31
32 // Option 1: Take from the left side of nums
33 int takeFromLeft = calculateMaxScore(leftTaken + 1, rightTaken) +
34 nums[leftTaken] * multipliers[currentMultiplierIndex];
35
36 // Option 2: Take from the right side of nums
37 int takeFromRight = calculateMaxScore(leftTaken, rightTaken + 1) +
38 nums[numsSize - rightTaken - 1] * multipliers[currentMultiplierIndex];
39
40 // Store and return the maximum of both options
41 return dp[leftTaken][rightTaken] = max(takeFromLeft, takeFromRight);
42 };
43
44 // Start the recursion with 0 elements taken from both sides
45 return calculateMaxScore(0, 0);
46 }
47};
48
1/**
2 * Calculates the maximum score by selecting elements from either end of nums array
3 * and multiplying them with elements from multipliers array in order.
4 *
5 * @param nums - The array of numbers to select from (can pick from start or end)
6 * @param multipliers - The array of multipliers to apply in order
7 * @returns The maximum possible score
8 */
9function maximumScore(nums: number[], multipliers: number[]): number {
10 // Set a large negative value to represent negative infinity
11 const NEGATIVE_INFINITY = 1 << 30;
12
13 // Get array lengths
14 const numsLength = nums.length;
15 const multipliersLength = multipliers.length;
16
17 // Initialize DP table
18 // dp[i][j] represents the maximum score when:
19 // - i elements are taken from the start of nums
20 // - j elements are taken from the end of nums
21 const dp: number[][] = new Array(multipliersLength + 1)
22 .fill(0)
23 .map(() => new Array(multipliersLength + 1).fill(-NEGATIVE_INFINITY));
24
25 // Base case: no elements selected, score is 0
26 dp[0][0] = 0;
27
28 // Track the maximum score
29 let maxScore = -NEGATIVE_INFINITY;
30
31 // Iterate through all possible combinations of picking from start and end
32 for (let fromStart = 0; fromStart <= multipliersLength; ++fromStart) {
33 for (let fromEnd = 0; fromEnd <= multipliersLength - fromStart; ++fromEnd) {
34 // Calculate which multiplier to use (0-indexed)
35 const multiplierIndex = fromStart + fromEnd - 1;
36
37 // Transition: pick from the start of nums
38 if (fromStart > 0) {
39 const scoreFromStart = dp[fromStart - 1][fromEnd] +
40 nums[fromStart - 1] * multipliers[multiplierIndex];
41 dp[fromStart][fromEnd] = Math.max(dp[fromStart][fromEnd], scoreFromStart);
42 }
43
44 // Transition: pick from the end of nums
45 if (fromEnd > 0) {
46 const scoreFromEnd = dp[fromStart][fromEnd - 1] +
47 nums[numsLength - fromEnd] * multipliers[multiplierIndex];
48 dp[fromStart][fromEnd] = Math.max(dp[fromStart][fromEnd], scoreFromEnd);
49 }
50
51 // Check if we've used all multipliers
52 if (fromStart + fromEnd === multipliersLength) {
53 maxScore = Math.max(maxScore, dp[fromStart][fromEnd]);
54 }
55 }
56 }
57
58 return maxScore;
59}
60
Time and Space Complexity
Time Complexity: O(m^2)
where m
is the length of the multipliers array.
The recursive function f(i, j, k)
uses three parameters, but there's a key relationship: at any step k
, we've taken exactly k
elements total from either the left (tracked by i
) or right (tracked by j
) ends. This means i + (n - 1 - j) = k
, which reduces the state space from three dimensions to two.
The memoization with @cache
ensures each unique state (i, j, k)
is computed only once. Since k
ranges from 0
to m-1
, and for each k
, the value of i
can range from 0
to k
(as we can take at most k
elements from the left), there are approximately O(m^2)
unique states to compute. Each state computation takes O(1)
time.
Space Complexity: O(m^2)
The space is used for:
- Memoization cache: Stores up to
O(m^2)
unique states as analyzed above - Recursion call stack: Maximum depth is
m
, contributingO(m)
space
The dominant factor is the memoization cache, giving us O(m^2)
total space complexity.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Memory Limit Exceeded (MLE) - Using 3D DP State
The Pitfall: Many developers initially try to track all three parameters (left_index, right_index, mult_index) independently in their DP state. However, this creates unnecessary redundancy because these three values are actually interdependent. If you know how many elements were taken from the left and which operation you're on, you can calculate how many were taken from the right.
Using all three parameters leads to:
- Excessive memory usage: O(n × n × m) space in worst case
- Many invalid states being cached (where left_index + (n - 1 - right_index) ≠ mult_index)
- Potential Memory Limit Exceeded errors on large inputs
The Solution: Optimize the state representation by using only two parameters. Since we know:
- Total operations performed = mult_index
- Elements taken from left = left_index
- Elements taken from right = mult_index - left_index
We can reconstruct the right pointer: right_index = n - 1 - (mult_index - left_index)
class Solution:
def maximumScore(self, nums: List[int], multipliers: List[int]) -> int:
from functools import cache
n = len(nums)
m = len(multipliers)
@cache
def dp(left: int, mult_idx: int) -> int:
# Base case: all multipliers used
if mult_idx >= m:
return 0
# Calculate right pointer from left and mult_idx
right = n - 1 - (mult_idx - left)
# Take from left
take_left = nums[left] * multipliers[mult_idx] + dp(left + 1, mult_idx + 1)
# Take from right
take_right = nums[right] * multipliers[mult_idx] + dp(left, mult_idx + 1)
return max(take_left, take_right)
return dp(0, 0)
This optimization reduces:
- Space complexity from O(n × n × m) to O(m × m)
- Number of states from potentially n² × m to just m²
- Risk of MLE on large inputs where n is significantly larger than m
2. Integer Overflow Concerns
The Pitfall: When nums and multipliers contain large values, their products and accumulated sums might exceed standard integer limits in some languages (though Python handles arbitrary precision integers automatically).
The Solution: While Python doesn't have this issue, be aware when implementing in other languages. Use appropriate data types (long long in C++, long in Java) to handle the range of possible values according to problem constraints.
Consider the classic dynamic programming of fibonacci numbers, what is the recurrence relation?
Recommended Readings
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
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Recursion Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https assets algo monster recursion jpg You first call Ben and ask
Want a Structured Path to Master System Design Too? Don’t Miss This!