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]
andk = 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 constraintj - 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.
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:
- Start a new subsequence from position
i
(just takenums[i]
alone) - 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]
, addi
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 sizen
to store our DP values, wheref[i]
represents the maximum sum of subsequence ending at indexi
- 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 subsequencef[i] = nums[i]
if it's better to start fresh (whenf[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 EvaluatorExample 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 sizen
to store the maximum sum ending at each position:O(n)
- A deque
q
that can contain at mostmin(k+1, n)
elements, which is bounded byO(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()
A heap is a ...?
Recommended Readings
Queue Intro Think of the last time you stood in line to buy movie tickets The first person in line gets their ticket first and any newcomers join the end of the line This system where the first person to arrive is the first to be served is a queue in real
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
https assets algo monster cover_photos stack svg Sliding Window Maximum Monotonic Stack We have an array and a sliding window defined by a start index and an end index The sliding window moves from left of the array to right There are always k elements in the window The window
Want a Structured Path to Master System Design Too? Don’t Miss This!