Facebook Pixel

1425. Constrained Subsequence Sum

Problem Description

You are given an integer array nums and an integer k. Your task is to find the maximum sum of a non-empty subsequence from this array with a special constraint.

The constraint is: for any two consecutive elements in your chosen subsequence, if they are at positions i and j in the original array (where i < j), then the distance between them must satisfy j - i <= k. In other words, you cannot pick elements that are more than k positions apart in the original array to be consecutive in your subsequence.

A subsequence means you can select any elements from the array while maintaining their original relative order. You can skip elements, but you cannot rearrange them.

For example:

  • If nums = [10, -2, -10, -5, 20] and k = 2
  • You could pick subsequence [10, -2, 20] since the consecutive pairs (10, -2) are 1 position apart and (-2, 20) are 2 positions apart, both satisfying the constraint <= 2
  • You could NOT pick subsequence [10, 20] directly since they are 4 positions apart in the original array, which violates the constraint j - i <= k = 2

The goal is to find the subsequence with the maximum possible sum while respecting this distance constraint between consecutive elements in the subsequence.

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

Intuition

Let's think about this problem step by step. We need to find a subsequence with maximum sum, where consecutive elements in the subsequence can't be more than k positions apart in the original array.

First insight: This is a dynamic programming problem. For each position i, we need to decide whether to include nums[i] in our subsequence or not. If we include it, we need to connect it to some previous element within the allowed range.

Let's define f[i] as the maximum sum of a subsequence that ends at position i. To calculate f[i], we have two choices:

  1. Start a new subsequence from position i (just take nums[i] alone)
  2. Extend a previous subsequence by adding nums[i] to it

If we choose option 2, which previous subsequence should we extend? We can only extend from positions j where i - j <= k. Among all valid positions, we want the one with the maximum f[j] value to maximize our sum.

So the recurrence relation becomes: f[i] = nums[i] + max(0, max(f[j]) for all j where i - k <= j < i)

The max(0, ...) part handles the case where it's better to start fresh rather than extend any negative-sum subsequence.

Now here's the key optimization insight: Finding the maximum f[j] in the range [i-k, i-1] for each i is essentially a sliding window maximum problem. As we move from position i to i+1, the window shifts by one position.

This is where the monotonic queue comes in! A monotonic decreasing queue can efficiently maintain the maximum value in a sliding window. The queue stores indices, and we maintain the property that f[queue[0]] >= f[queue[1]] >= ... >= f[queue[back]].

When processing position i:

  • Remove indices from the front that are outside the window (more than k positions away)
  • The front of the queue gives us the index with maximum f value in the valid range
  • After calculating f[i], add i to the queue while maintaining the decreasing property

This optimization reduces the time complexity from O(n*k) to O(n), making the solution efficient even for large inputs.

Learn more about Queue, Dynamic Programming, Sliding Window, Monotonic Queue and Heap (Priority Queue) patterns.

Solution Approach

Let's walk through the implementation step by step using dynamic programming with a monotonic queue optimization.

Step 1: Initialize Data Structures

We create:

  • A deque q to maintain our monotonic queue, initialized with [0] as a sentinel value
  • An array f of size n to store our DP values, where f[i] represents the maximum sum of subsequence ending at index i
  • Variable ans to track the maximum sum found, initialized to negative infinity

Step 2: Process Each Element

For each position i and value x in nums:

2.1 Maintain the Sliding Window

while i - q[0] > k:
    q.popleft()

We remove indices from the front of the queue that are outside our window of size k. Any index j where i - j > k is too far away to be considered.

2.2 Calculate DP Value

f[i] = max(0, f[q[0]]) + x

The front of the queue q[0] gives us the index with the maximum f value within the valid range [i-k, i-1]. We calculate:

  • f[i] = f[q[0]] + nums[i] if we extend the best previous subsequence
  • f[i] = nums[i] if it's better to start fresh (when f[q[0]] < 0)

This is why we use max(0, f[q[0]]) - it handles both cases elegantly.

2.3 Update the Answer

ans = max(ans, f[i])

We keep track of the maximum subsequence sum seen so far.

2.4 Maintain Monotonic Property

while q and f[q[-1]] <= f[i]:
    q.pop()
q.append(i)

Before adding index i to the queue, we remove all indices from the back whose f values are less than or equal to f[i]. This maintains the decreasing monotonic property of the queue.

Why can we safely remove these elements? If f[j] <= f[i] and j < i, then for any future position, i will always be a better choice than j because:

  • i is closer (less likely to fall outside the window)
  • f[i] is greater or equal in value

Step 3: Return Result

After processing all elements, ans contains the maximum sum of any valid subsequence.

Time Complexity: O(n) - Each element is added and removed from the queue at most once.

Space Complexity: O(n) - We use an array f of size n and a queue that can contain at most min(n, k+1) elements.

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 trace through the algorithm with nums = [10, -2, -10, -5, 20] and k = 2.

Initialization:

  • q = deque([0]) (sentinel value)
  • f = [0, 0, 0, 0, 0] (size 5)
  • ans = -inf

Processing i = 0, x = 10:

  • Window check: 0 - q[0] = 0 - 0 = 0 ≤ 2 ✓ (no removal needed)
  • Calculate: f[0] = max(0, f[0]) + 10 = max(0, 0) + 10 = 10
  • Update answer: ans = max(-inf, 10) = 10
  • Maintain queue: f[0] = 0 ≤ f[0] = 10, so pop 0
  • Queue becomes: q = [0] (index 0 for nums[0])

Processing i = 1, x = -2:

  • Window check: 1 - 0 = 1 ≤ 2 ✓ (no removal)
  • Calculate: f[1] = max(0, f[0]) + (-2) = max(0, 10) + (-2) = 8
  • Update answer: ans = max(10, 8) = 10
  • Maintain queue: f[0] = 10 > f[1] = 8, so keep 0
  • Queue becomes: q = [0, 1]

Processing i = 2, x = -10:

  • Window check: 2 - 0 = 2 ≤ 2 ✓ (no removal)
  • Front of queue is 0, so f[2] = max(0, f[0]) + (-10) = 10 + (-10) = 0
  • Update answer: ans = max(10, 0) = 10
  • Maintain queue: Remove 1 since f[1] = 8 ≤ f[2] = 0 is false, keep both
  • Actually, f[1] = 8 > f[2] = 0, so keep 1
  • Queue becomes: q = [0, 1, 2]

Processing i = 3, x = -5:

  • Window check: 3 - 0 = 3 > 2 ✗ (remove 0 from front)
  • Queue becomes: q = [1, 2]
  • Front is now 1, so f[3] = max(0, f[1]) + (-5) = 8 + (-5) = 3
  • Update answer: ans = max(10, 3) = 10
  • Maintain queue: f[2] = 0 ≤ f[3] = 3, so pop 2
  • f[1] = 8 > f[3] = 3, so keep 1
  • Queue becomes: q = [1, 3]

Processing i = 4, x = 20:

  • Window check: 4 - 1 = 3 > 2 ✗ (remove 1 from front)
  • Queue becomes: q = [3]
  • Front is 3, so f[4] = max(0, f[3]) + 20 = 3 + 20 = 23
  • Update answer: ans = max(10, 23) = 23
  • Maintain queue: f[3] = 3 ≤ f[4] = 23, so pop 3
  • Queue becomes: q = [4]

Final Result: 23

This corresponds to the subsequence [10, -2, -5, 20]:

  • 10 at index 0
  • -2 at index 1 (distance from 0: 1 ≤ 2) ✓
  • -5 at index 3 (distance from 1: 2 ≤ 2) ✓
  • 20 at index 4 (distance from 3: 1 ≤ 2) ✓
  • Sum: 10 + (-2) + (-5) + 20 = 23

The algorithm correctly identifies that connecting through the negative values is worth it to reach the large positive value at the end.

Solution Implementation

1class Solution:
2    def constrainedSubsetSum(self, nums: List[int], k: int) -> int:
3        # Monotonic deque to maintain indices of maximum dp values within window
4        indices_deque = deque([0])
5        n = len(nums)
6      
7        # dp[i] represents the maximum sum of subsequence ending at index i
8        dp = [0] * n
9      
10        # Track the maximum sum found so far
11        max_sum = float('-inf')
12      
13        # Process each element in the array
14        for current_index, current_value in enumerate(nums):
15            # Remove indices that are outside the k-distance window
16            while indices_deque and current_index - indices_deque[0] > k:
17                indices_deque.popleft()
18          
19            # Calculate dp value for current position
20            # Either start fresh from current element or extend from best previous subsequence
21            dp[current_index] = max(0, dp[indices_deque[0]]) + current_value
22          
23            # Update the maximum sum encountered
24            max_sum = max(max_sum, dp[current_index])
25          
26            # Maintain monotonic decreasing property of deque
27            # Remove elements with smaller or equal dp values from the back
28            while indices_deque and dp[indices_deque[-1]] <= dp[current_index]:
29                indices_deque.pop()
30          
31            # Add current index to the deque
32            indices_deque.append(current_index)
33      
34        return max_sum
35
1class Solution {
2    public int constrainedSubsetSum(int[] nums, int k) {
3        // Deque to maintain indices in a way that helps find maximum in sliding window
4        // Stores indices of elements in decreasing order of their dp values
5        Deque<Integer> deque = new ArrayDeque<>();
6      
7        // Initialize with index 0 (dummy index for easier handling)
8        deque.offer(0);
9      
10        int n = nums.length;
11      
12        // dp[i] represents the maximum sum of subsequence ending at index i
13        int[] dp = new int[n];
14      
15        // Initialize result with a very small value
16        int maxSum = Integer.MIN_VALUE;
17      
18        for (int i = 0; i < n; i++) {
19            // Remove indices that are outside the window of size k
20            // The valid window for index i is [i-k, i-1]
21            while (i - deque.peekFirst() > k) {
22                deque.pollFirst();
23            }
24          
25            // Calculate dp[i]: either start fresh from nums[i] or extend from previous subsequence
26            // dp[deque.peekFirst()] gives the maximum dp value in the valid window
27            dp[i] = Math.max(0, dp[deque.peekFirst()]) + nums[i];
28          
29            // Update the maximum sum found so far
30            maxSum = Math.max(maxSum, dp[i]);
31          
32            // Maintain deque in decreasing order of dp values
33            // Remove elements from the back if they are smaller than or equal to current dp[i]
34            while (!deque.isEmpty() && dp[deque.peekLast()] <= dp[i]) {
35                deque.pollLast();
36            }
37          
38            // Add current index to the deque
39            deque.offerLast(i);
40        }
41      
42        return maxSum;
43    }
44}
45
1class Solution {
2public:
3    int constrainedSubsetSum(vector<int>& nums, int k) {
4        // Monotonic deque to maintain indices of potential maximum values
5        // within the sliding window of size k
6        deque<int> dq;
7        dq.push_back(0);
8      
9        int n = nums.size();
10      
11        // dp[i] represents the maximum sum of a subsequence ending at index i
12        vector<int> dp(n);
13        dp[0] = nums[0];
14      
15        // Track the maximum sum found so far
16        int maxSum = nums[0];
17      
18        // Process each element starting from index 1
19        for (int i = 1; i < n; ++i) {
20            // Remove indices that are outside the window of size k
21            // The valid range is [i-k, i-1]
22            while (!dq.empty() && dq.front() < i - k) {
23                dq.pop_front();
24            }
25          
26            // Calculate dp[i]: either start fresh with nums[i] alone,
27            // or extend the best subsequence within the window
28            dp[i] = nums[i] + max(0, dp[dq.front()]);
29          
30            // Update the global maximum
31            maxSum = max(maxSum, dp[i]);
32          
33            // Maintain monotonic decreasing property of the deque
34            // Remove elements with smaller or equal dp values from the back
35            while (!dq.empty() && dp[dq.back()] <= dp[i]) {
36                dq.pop_back();
37            }
38          
39            // Add current index to the deque
40            dq.push_back(i);
41        }
42      
43        return maxSum;
44    }
45};
46
1// Node structure for deque implementation
2interface DequeNode<T> {
3    value: T;
4    next: DequeNode<T> | null;
5    prev: DequeNode<T> | null;
6}
7
8// Deque data structure to maintain sliding window maximum
9interface DequeStructure<T> {
10    front: DequeNode<T> | null;
11    back: DequeNode<T> | null;
12    size: number;
13}
14
15// Create a new deque node
16function createDequeNode<T>(value: T): DequeNode<T> {
17    return {
18        value,
19        next: null,
20        prev: null
21    };
22}
23
24// Initialize an empty deque
25function createDeque<T>(): DequeStructure<T> {
26    return {
27        front: null,
28        back: null,
29        size: 0
30    };
31}
32
33// Add element to the front of deque
34function pushFront<T>(deque: DequeStructure<T>, val: T): void {
35    const newNode = createDequeNode(val);
36  
37    if (isEmpty(deque)) {
38        deque.front = newNode;
39        deque.back = newNode;
40    } else {
41        newNode.next = deque.front;
42        deque.front!.prev = newNode;
43        deque.front = newNode;
44    }
45    deque.size++;
46}
47
48// Add element to the back of deque
49function pushBack<T>(deque: DequeStructure<T>, val: T): void {
50    const newNode = createDequeNode(val);
51  
52    if (isEmpty(deque)) {
53        deque.front = newNode;
54        deque.back = newNode;
55    } else {
56        newNode.prev = deque.back;
57        deque.back!.next = newNode;
58        deque.back = newNode;
59    }
60    deque.size++;
61}
62
63// Remove and return element from the front of deque
64function popFront<T>(deque: DequeStructure<T>): T | undefined {
65    if (isEmpty(deque)) {
66        return undefined;
67    }
68  
69    const value = deque.front!.value;
70    deque.front = deque.front!.next;
71  
72    if (deque.front !== null) {
73        deque.front.prev = null;
74    } else {
75        deque.back = null;
76    }
77  
78    deque.size--;
79    return value;
80}
81
82// Remove and return element from the back of deque
83function popBack<T>(deque: DequeStructure<T>): T | undefined {
84    if (isEmpty(deque)) {
85        return undefined;
86    }
87  
88    const value = deque.back!.value;
89    deque.back = deque.back!.prev;
90  
91    if (deque.back !== null) {
92        deque.back.next = null;
93    } else {
94        deque.front = null;
95    }
96  
97    deque.size--;
98    return value;
99}
100
101// Get the value at the front of deque without removing it
102function frontValue<T>(deque: DequeStructure<T>): T | undefined {
103    return deque.front?.value;
104}
105
106// Get the value at the back of deque without removing it
107function backValue<T>(deque: DequeStructure<T>): T | undefined {
108    return deque.back?.value;
109}
110
111// Get the current size of deque
112function getSize<T>(deque: DequeStructure<T>): number {
113    return deque.size;
114}
115
116// Check if deque is empty
117function isEmpty<T>(deque: DequeStructure<T>): boolean {
118    return deque.size === 0;
119}
120
121/**
122 * Find the maximum sum of a non-empty subarray with constraint that
123 * the distance between any two elements in the subarray is at most k
124 * 
125 * @param nums - Array of integers
126 * @param k - Maximum distance constraint between elements
127 * @returns Maximum sum of constrained subarray
128 */
129function constrainedSubsetSum(nums: number[], k: number): number {
130    // Deque to store indices of elements in sliding window
131    const indexDeque = createDeque<number>();
132    const n = nums.length;
133  
134    // Initialize deque with first index
135    pushBack(indexDeque, 0);
136    let maxSum = nums[0];
137  
138    // dp[i] represents maximum sum ending at index i
139    const dp: number[] = Array(n).fill(0);
140  
141    for (let i = 0; i < n; i++) {
142        // Remove indices outside the window of size k
143        while (i - frontValue(indexDeque)! > k) {
144            popFront(indexDeque);
145        }
146      
147        // Calculate maximum sum ending at current index
148        // Either start fresh from current element or extend from previous maximum
149        dp[i] = Math.max(0, dp[frontValue(indexDeque)!]) + nums[i];
150      
151        // Update global maximum
152        maxSum = Math.max(maxSum, dp[i]);
153      
154        // Maintain deque in decreasing order of dp values
155        // Remove smaller values from back as they won't be useful
156        while (!isEmpty(indexDeque) && dp[backValue(indexDeque)!] <= dp[i]) {
157            popBack(indexDeque);
158        }
159      
160        // Add current index to deque
161        pushBack(indexDeque, i);
162    }
163  
164    return maxSum;
165}
166

Time and Space Complexity

Time Complexity: O(n)

The algorithm iterates through the array once with a for loop that runs n times. Within each iteration:

  • The first while loop removes outdated indices from the deque. Each element can be removed at most once throughout the entire execution, contributing O(n) total operations across all iterations.
  • The second while loop maintains the monotonic property of the deque by removing elements. Similarly, each element can be added and removed from the deque at most once, contributing O(n) total operations.
  • All other operations (max comparisons, deque append, array access) are O(1).

Since each element is processed a constant number of times despite the nested loops, the overall time complexity is O(n).

Space Complexity: O(n)

The algorithm uses:

  • An array f of size n to store the maximum sum ending at each position: O(n)
  • A deque q that can contain at most min(k+1, n) elements, which is bounded by O(n)
  • A few constant space variables (ans, i, x): O(1)

The dominant space usage comes from the array f, resulting in a total space complexity of O(n).

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Incorrect Initialization of the Deque

Pitfall: Starting with an empty deque or initializing it with [0] when processing starts from index 0 can cause issues.

When we reach the first element (index 0), if the deque is initialized with [0], the code tries to access dp[0] before it's been set, potentially causing incorrect calculations or errors.

Solution: Initialize the deque as empty and handle the first element specially, or ensure proper bounds checking:

def constrainedSubsetSum(self, nums: List[int], k: int) -> int:
    indices_deque = deque()  # Start with empty deque
    n = len(nums)
    dp = [0] * n
    max_sum = float('-inf')
  
    for current_index, current_value in enumerate(nums):
        # Remove indices outside window
        while indices_deque and current_index - indices_deque[0] > k:
            indices_deque.popleft()
      
        # Handle first element or when deque is empty
        if not indices_deque:
            dp[current_index] = current_value
        else:
            dp[current_index] = max(0, dp[indices_deque[0]]) + current_value
      
        max_sum = max(max_sum, dp[current_index])
      
        # Maintain monotonic property
        while indices_deque and dp[indices_deque[-1]] <= dp[current_index]:
            indices_deque.pop()
      
        indices_deque.append(current_index)
  
    return max_sum

2. Forgetting to Handle Negative Values Properly

Pitfall: Not using max(0, dp[indices_deque[0]]) and instead directly using dp[indices_deque[0]] can lead to suboptimal solutions when dealing with negative values.

If the best previous subsequence has a negative sum, it's better to start fresh from the current element rather than extending that negative subsequence.

Wrong approach:

dp[current_index] = dp[indices_deque[0]] + current_value  # Wrong!

Correct approach:

dp[current_index] = max(0, dp[indices_deque[0]]) + current_value  # Correct!

This ensures that we either extend a positive subsequence or start fresh if all previous options are negative.

3. Using Strict Inequality in Monotonic Queue Maintenance

Pitfall: Using strict inequality (<) instead of <= when maintaining the monotonic property:

# Wrong - can lead to keeping redundant elements
while indices_deque and dp[indices_deque[-1]] < dp[current_index]:
    indices_deque.pop()

Why it matters: If we keep elements with equal dp values, we're storing redundant information. Since the current index is more recent (and thus will stay in the window longer), it's always preferable to indices with equal dp values.

Correct approach:

while indices_deque and dp[indices_deque[-1]] <= dp[current_index]:
    indices_deque.pop()
Discover Your Strengths and Weaknesses: Take Our 3-Minute Quiz to Tailor Your Study Plan:

A heap is a ...?


Recommended Readings

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

Load More