Maximize Score After N Operations
HardPrompt
You are given nums, an array of positive integers of size 2 * n. You must perform n operations on this array.
In the ith operation (1-indexed), you will:
- Choose two elements,
xandy. - Receive a score of
i * gcd(x, y). - Remove
xandyfromnums.
Return the maximum score you can receive after performing n operations.
The function gcd(x, y) is the greatest common divisor of x and y.
Example 1:
Input: nums = [1,2]
Output: 1
Explanation: The optimal choice of operations is:
(1 * gcd(1, 2)) = 1Example 2:
Input: nums = [3,4,6,8]
Output: 11
Explanation: The optimal choice of operations is:
(1 * gcd(3, 6)) + (2 * gcd(4, 8)) = 3 + 8 = 11Example 3:
Input: nums = [1,2,3,4,5,6]
Output: 14
Explanation: The optimal choice of operations is:
(1 * gcd(1, 5)) + (2 * gcd(2, 4)) + (3 * gcd(3, 6)) = 1 + 4 + 9 = 14
Constraints:
1 <= n <= 7nums.length == 2 * n1 <= nums[i] <= 106
Approaches
2 approaches with complexity analysis and trade-offs.
This approach involves exploring every possible sequence of pairs that can be formed from the input array. It uses a recursive backtracking method to generate all valid combinations of n pairs, calculates the score for each complete sequence of operations, and keeps track of the maximum score found.
Algorithm
- Create a recursive function
backtrack(op, currentScore, mask). op: The current operation number, from 1 ton.currentScore: The score accumulated so far.mask: A bitmask representing the indices of numbers that have already been used.- Base Case: If
opis greater thann, it meansnoperations have been completed. We comparecurrentScorewith a global maximum and update it ifcurrentScoreis larger. - Recursive Step: Iterate through all possible pairs of unused numbers
(nums[i], nums[j]). To find unused numbers, check if thei-th andj-th bits are unset in themask. - For each valid pair, calculate the score for the current operation:
op * gcd(nums[i], nums[j]). - Make a recursive call for the next operation:
backtrack(op + 1, currentScore + newScore, newMask), wherenewMaskis the oldmaskwith bits foriandjset. - The initial call to start the process is
backtrack(1, 0, 0).
Walkthrough
The brute-force strategy is implemented with a recursive helper function that systematically tries every choice at each step. The state of the recursion is defined by the current operation number (op), the score accumulated so far (currentScore), and a bitmask (mask) to keep track of which numbers have been used.
In the op-th step, the function iterates through all available numbers, forms a pair (x, y), adds op * gcd(x, y) to the score, and then recursively calls itself for the (op+1)-th step with the updated state. The recursion bottoms out after n operations have been simulated. A global variable is maintained to store the maximum score encountered across all the explored paths.
class Solution { int maxScoreVal = 0; int[] Nums; int N; public int maxScore(int[] nums) { this.Nums = nums; this.N = nums.length; backtrack(1, 0, 0); return maxScoreVal; } private void backtrack(int op, int currentScore, int mask) { if (op > N / 2) { maxScoreVal = Math.max(maxScoreVal, currentScore); return; } for (int i = 0; i < N; i++) { if ((mask & (1 << i)) != 0) continue; for (int j = i + 1; j < N; j++) { if ((mask & (1 << j)) != 0) continue; int newMask = mask | (1 << i) | (1 << j); int scoreToAdd = op * gcd(Nums[i], Nums[j]); backtrack(op + 1, currentScore + scoreToAdd, newMask); } } } private int gcd(int a, int b) { while (b != 0) { int temp = b; b = a % b; a = temp; } return a; }}Complexity
Time
O(n * (2n)! / (2^n * n!)). The recursion tree is very wide. At each of the `n` levels of recursion, we choose a pair from the remaining numbers. The number of ways to partition `2n` elements into `n` pairs is `(2n)! / (n! * 2^n)`. Since the order of pairs matters, we explore many permutations. This complexity is too high for the given constraints.
Space
O(n), for the recursion stack depth. The space for the mask and other variables is constant per call.
Trade-offs
Pros
Conceptually simple and a direct translation of the problem statement.
Guaranteed to find the correct answer as it explores the entire search space.
Cons
Extremely inefficient due to a very large number of redundant computations. The same subproblem (finding the max score for a given subset of remaining numbers) is solved multiple times.
The time complexity is prohibitive and will result in a 'Time Limit Exceeded' error on most platforms for the given constraints.
This approach significantly optimizes the brute-force solution by using dynamic programming with memoization. The state of a subproblem is defined by the subset of numbers that have already been used. A bitmask is a perfect way to represent this subset. By storing the result for each mask, we avoid re-computing the answer for the same subproblem, drastically reducing the overall computation time.
Algorithm
- Create a memoization array
memoof size2^(2*n)and initialize it with a value indicating that states are not yet computed (e.g., 0, since scores are positive). - Define a recursive function
solve(mask)which calculates the maximum score from the set of numbers not yet used (as indicated by themask). - Base Case: If
maskhas all bits set to 1, it means all numbers have been used. Return 0. - Memoization Check: If
memo[mask]has been computed, return the stored value. - Recursive Step:
- Determine the current operation number. This can be calculated from the number of set bits in the
mask:op = (Integer.bitCount(mask) / 2) + 1. - Initialize a variable
maxValto 0 to track the maximum score for the current state. - Iterate through all pairs of indices
(i, j)corresponding to numbers not yet used (i.e.,i-th andj-th bits are 0 inmask). - For each pair, calculate the score for this choice and add the result of the recursive call for the subsequent state:
currentScore = op * gcd(nums[i], nums[j]) + solve(newMask), wherenewMaskismask | (1 << i) | (1 << j). - Update
maxVal = Math.max(maxVal, currentScore).
- Determine the current operation number. This can be calculated from the number of set bits in the
- Store the result
maxValinmemo[mask]before returning it. - The final answer is the result of the initial call
solve(0).
Walkthrough
The key insight is that the maximum score achievable from a set of remaining numbers is independent of how the previous pairs were formed, only depending on which numbers are left and which operation number is next. We can define a function, solve(mask), that computes the maximum score for the numbers not yet taken, represented by the mask.
The number of operations already performed can be deduced from the number of set bits in the mask. If k bits are set, k/2 pairs have been chosen, and we are at operation k/2 + 1.
We use a memo array to store the results of solve(mask). When solve(mask) is called, it first checks if the result is already in memo. If so, it returns it immediately. Otherwise, it computes the result by trying all possible pairs from the available numbers, making recursive calls for the next state, and storing the best result in memo before returning.
class Solution { int[] memo; int[] Nums; int N; // This will be 2*n public int maxScore(int[] nums) { this.Nums = nums; this.N = nums.length; this.memo = new int[1 << N]; return solve(0); } private int solve(int mask) { if (mask == (1 << N) - 1) { return 0; // All numbers have been used } if (memo[mask] != 0) { return memo[mask]; } int usedCount = Integer.bitCount(mask); int op = usedCount / 2 + 1; int maxVal = 0; for (int i = 0; i < N; i++) { if ((mask & (1 << i)) != 0) continue; for (int j = i + 1; j < N; j++) { if ((mask & (1 << j)) != 0) continue; int newMask = mask | (1 << i) | (1 << j); int currentScore = op * gcd(Nums[i], Nums[j]) + solve(newMask); maxVal = Math.max(maxVal, currentScore); } } memo[mask] = maxVal; return maxVal; } private int gcd(int a, int b) { while (b != 0) { int temp = b; b = a % b; a = temp; } return a; }}Complexity
Time
O(2^(2n) * (2n)^2). There are `2^(2n)` possible states (masks). For each state, we iterate through all possible pairs of available numbers, which is at most `O((2n)^2)`. The `gcd` calculation adds a logarithmic factor, making it `O(2^(2n) * (2n)^2 * log(A))`, where `A` is the maximum value in `nums`.
Space
O(2^(2n)). The dominant factor is the memoization table, which needs to store a result for each possible bitmask. The recursion stack depth adds `O(n)`, which is negligible.
Trade-offs
Pros
Guarantees the optimal solution.
Efficient enough to pass within the time limits for the given constraints (
n <= 7).It's a standard and powerful technique for problems with small constraints involving subsets or permutations.
Cons
The space complexity is exponential,
O(2^(2n)), which limits the feasibility of this approach to small values ofn(up to around 10-12).
Solutions
Solution
class Solution {public int maxScore(int[] nums) { int m = nums.length; int[][] g = new int[m][m]; for (int i = 0; i < m; ++i) { for (int j = i + 1; j < m; ++j) { g[i][j] = gcd(nums[i], nums[j]); } } int[] f = new int[1 << m]; for (int k = 0; k < 1 << m; ++k) { int cnt = Integer.bitCount(k); if (cnt % 2 == 0) { for (int i = 0; i < m; ++i) { if (((k >> i) & 1) == 1) { for (int j = i + 1; j < m; ++j) { if (((k >> j) & 1) == 1) { f[k] = Math.max(f[k], f[k ^ (1 << i) ^ (1 << j)] + cnt / 2 * g[i][j]); } } } } } } return f[(1 << m) - 1]; }private int gcd(int a, int b) { return b == 0 ? a : gcd(b, a % b); }}class Solution {public: int maxScore(vector<int> &nums) { int m = nums.size(); int g[m][m]; for (int i = 0; i < m; ++i) { for (int j = i + 1; j < m; ++j) { g[i][j] = gcd(nums[i], nums[j]); } } int f[1 << m]; memset(f, 0, sizeof f); for (int k = 0; k < 1 << m; ++k) { int cnt = __builtin_popcount(k); if (cnt % 2 == 0) { for (int i = 0; i < m; ++i) { if (k >> i & 1) { for (int j = i + 1; j < m; ++j) { if (k >> j & 1) { f[k] = max(f[k], f[k ^ (1 << i) ^ (1 << j)] + cnt / 2 * g[i][j]); } } } } } } return f[(1 << m) - 1]; }};class Solution: def maxScore(self, nums: List[int]) -> int: m = len(nums) f = [0] * (1 << m) g = [[0] * m for _ in range(m)] for i in range(m): for j in range(i + 1, m): g[i][j] = gcd(nums[i], nums[j]) for k in range(1 << m): if (cnt: = k . bit_count()) % 2 == 0: for i in range(m): if k >> i & 1: for j in range(i + 1, m): if k >> j & 1: f[k] = max(f[k], f[k ^ (1 << i) ^ (1 << j)] + cnt // 2 * g[i][j], ) return f[- 1]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.