Builds on Monotonic Deque and Longest Window, which you haven’t finished yet.
Dual Monotonic Deque (Longest Subarray Within a Max-Min Limit)
Take the integer array nums and an integer limit. Look for the longest non-empty contiguous run of nums (a subarray) in which every pair of elements inside the run differs by at most limit.
Equivalently, the run's largest value minus its smallest value must be at most limit. Return the length of the longest such run.
Dual Monotonic Deque
sliding window ➔ two deques, one decreasing and one increasing ➔ the window's spread, read at both fronts at once| 1 | from collections import deque |
| 2 | from typing import List |
| 3 | |
| 4 | def longest_subarray(nums: List[int], limit: int) -> int: |
| 5 | """Longest Continuous Subarray With Absolute Diff Less Than or Equal to Limit: two monotonic deques track the window's running max and min together. |
| 6 | Time: O(N) | Space: O(N) |
| 7 | """ |
| 8 | # max_dq and min_dq hold indices, their values strictly decreasing and strictly increasing from |
| 9 | # front to back, so nums[max_dq[0]] is the window's max and nums[min_dq[0]] is its min. |
| 10 | max_dq: deque[int] = deque() |
| 11 | min_dq: deque[int] = deque() |
| 12 | left = 0 |
| 13 | best = 0 |
| 14 | |
| 15 | for right in range(len(nums)): |
| 16 | # Phase 1: Push & Maintain Monotonicity: |
| 17 | # Run the same back-eviction rule on both deques, then take in index right on each. |
| 18 | while max_dq and nums[max_dq[-1]] <= nums[right]: |
| 19 | max_dq.pop() |
| 20 | max_dq.append(right) |
| 21 | while min_dq and nums[min_dq[-1]] >= nums[right]: |
| 22 | min_dq.pop() |
| 23 | min_dq.append(right) |
| 24 | |
| 25 | # Phase 2 & 3: Contract the Boundary, Then Evict Stale Fronts: |
| 26 | # One outlier at right can invalidate many left positions at once, so this needs a while, not an if; |
| 27 | # each deque only evicted the indices it checked itself, so both fronts are tested every time left moves. |
| 28 | while nums[max_dq[0]] - nums[min_dq[0]] > limit: |
| 29 | left += 1 |
| 30 | if max_dq[0] < left: |
| 31 | max_dq.popleft() |
| 32 | if min_dq[0] < left: |
| 33 | min_dq.popleft() |
| 34 | |
| 35 | # Phase 4: Harvest the Answer: |
| 36 | best = max(best, right - left + 1) |
| 37 | |
| 38 | return best |
max_dq holds the window's indices with strictly decreasing values and min_dq holds them with strictly increasing values, so nums[max_dq[0]] - nums[min_dq[0]] is always the window's exact spread.Two Monotonic Deques read the same window side by side: max_dq strictly decreasing, so nums[max_dq[0]] is always the window's current maximum, and min_dq strictly increasing, so nums[min_dq[0]] is always the window's current minimum. Knowing both extremes at once, without rescanning the window, is what a single deque (the Monotonic Deque card) cannot give you, since one deque only tracks one side; a heap could track one side too, but it cannot cheaply drop a value in the middle that just aged out of the window, the way a deque's stale front does. While the window's spread, nums[max_dq[0]] - nums[min_dq[0]], is greater than limit, shrink the window from the left, moving left forward one step at a time. Each deque has only evicted the indices it has personally checked, so shrinking must test both fronts on every step: max_dq's front can still be fresh while min_dq's front has already fallen behind left, or the other way around. The trap is checking only one: if max_dq[0] < left: max_dq.popleft() must be followed by its own if min_dq[0] < left: min_dq.popleft(), because skipping the second line leaves a stale index at the front of the deque you did not check, and every later comparison then reads the wrong minimum or maximum for a window that has, in truth, already shrunk further.
See it run
Longest Subarray With a Spread Limit: longest_subarray([8, 2, 4, 7], limit = 4)
left 0right -max_dq []min_dq []best 0
The top row is nums. The next two rows show the values of the indices that max_dq and min_dq hold, each under its own column.
leftthe first index of the windowrightthe index that was just readleft, rightleft and right are at the same indexin max_dqan index kept in max_dq, values falling from front to backin min_dqan index kept in min_dq, values rising from front to back
max_dq and min_dq are empty, left is 0 and best is 0. The loop reads one index right at a time.
Gotcha 1 of 4
- The trap
Gotcha 1: Shrinking checks only
max_dq's stale front, nevermin_dq'sMistakewhile nums[max_dq[0]] - nums[min_dq[0]] > limit: left += 1 if max_dq[0] < left: max_dq.popleft()Fixwhile nums[max_dq[0]] - nums[min_dq[0]] > limit: left += 1 if max_dq[0] < left: max_dq.popleft() if min_dq[0] < left: min_dq.popleft()What happens:
min_dq's own front is never removed, sonums[min_dq[0]]keeps reading an old minimum that is no longer inside the window. The comparisonnums[max_dq[0]] - nums[min_dq[0]] > limitthen stays true for a window that is already valid, soleftkeeps advancing past every indexmax_dqstill holds. Oncemax_dqempties,nums[max_dq[0]]has no front to read.Example: nums = [4, 2, 2, 2, 4, 4, 2, 2], limit = 0Mistake: raises IndexErrorFix: returns 3
- Every window only needs its maximum, as in Sliding Window Maximum (LC 239): one Monotonic Deque is enough, since nothing in the problem also depends on the window's minimum. See Monotonic Deque
Deep Dive
Why the window needs two deques
The Monotonic Deque card tracks one end of the window: the maximum. The rule in this card needs both ends at once. The window nums[left..right] is valid when its maximum minus its minimum, the spread, is at most limit.
max_dq holds indices whose values strictly decrease, so nums[max_dq[0]] is the maximum. min_dq holds indices whose values strictly increase, so nums[min_dq[0]] is the minimum. Both deques hold only indices of the current window. So nums[max_dq[0]] - nums[min_dq[0]] is the exact spread of the window, and it is read in O(1).
A heap can track one end, but it cannot cheaply remove a value from the middle that has just left the window. A deque only ever removes from its two ends, and the value that expires is always at the front.
Tracing [8, 2, 4, 7] with limit = 4
At right = 1, the value 2 is smaller than 8. min_dq pops index 0 and holds [1], and max_dq holds [0, 1]. The spread is 8 - 2 = 6, which is above 4, so left becomes 1. The front of max_dq is index 0, which is below left, so it leaves. The front of min_dq is index 1, which stays. The window is [2], and best stays 1.
At right = 2, the value 4 pops index 1 from max_dq. min_dq holds [1, 2]. The spread is 4 - 2 = 2, so the window [2, 4] is valid and best becomes 2.
At right = 3, the value 7 pops index 2 from max_dq. min_dq holds [1, 2, 3]. The spread is 7 - 2 = 5, which is above 4, so left becomes 2. This time the front of max_dq, index 3, stays, and the front of min_dq, index 1, is below left and leaves. The spread is now 7 - 4 = 3, and best stays 2.
The result is 2, the answer of Longest Continuous Subarray With Absolute Diff Less Than or Equal to Limit (LC 1438), Example 1.
Why both fronts need their own check
Each deque removes an index from its front only when the code tests that deque. When left moves, the stale index can be at the front of max_dq, at the front of min_dq, at both, or at neither. The trace shows two of these cases: max_dq at right = 1, and min_dq at right = 3.
The trap is to test only max_dq. Then a stale index stays at the front of min_dq, and nums[min_dq[0]] reads a value that is no longer in the window. For nums = [8, 2, 4, 7] and limit = 4, the spread at right = 3 stays at 7 - 2 = 5. The loop keeps moving left until max_dq is empty, and nums[max_dq[0]] raises IndexError.
The test is < left, not <= left. The window is nums[left..right], so index left is inside it. With <=, a front at left leaves too early, and a deque can become empty while the window still holds values.
The shrink step is a while. One new outlier can raise the spread so much that left must move several steps before the spread fits. The while keeps the window valid at the end of every step.
The cost, and the condition for using it
Each index is appended once to each deque and popped at most once from each. left and right only move forward. So the time is O(N). In the worst case, a deque holds the whole window, so the space is O(N).
Use this card when a window is valid only if a condition on both its maximum and its minimum holds, and a longer window can only make the spread larger. Then moving left forward is the only repair. If the condition looks at only one end, the Monotonic Deque card is enough.
60-Second Pitch
"The window is valid when its maximum minus its minimum is at most limit. So I need both extremes of the window at every step, without a scan. I keep two deques of indices. max_dq has strictly decreasing values, so its front is the maximum. min_dq has strictly increasing values, so its front is the minimum. For each right, I add the index to both deques. While the spread is above limit, I move left forward, and I check the front of each deque separately. Each deque drops its front when that index is below left. Every index enters and leaves each deque once, so the time is O(N). The deques can hold the whole window, so the space is O(N)."
Common Questions
- What is the time and space complexity?
The time is
O(N), because each index enters and leaves each deque at most once, andleftandrightonly move forward. The space isO(N)in the worst case, when a deque holds the whole window.- Why use two deques and not a heap or a sorted structure?
Two heaps cost
O(log N)per step, and a value that left the window stays inside a heap until it reaches the top. A balanced tree or a sorted container can delete it atO(log N)per step, so the total isO(N log N). The deques reachO(N), because the expired index is always at a front.- Why must the loop check both fronts after every
left += 1? Each deque removes an index only when the code tests it. One deque can have a stale front while the other has a fresh one. If the code tests only one, the other reads an extreme value from outside the window, and the spread is wrong.
- How would you count the subarrays with a spread within the limit?
Keep the same loop and add
right - left + 1to a counter after the shrink loop. Every start fromlefttorightgives a subarray whose spread is at mostlimit, because a shorter window cannot have a larger spread.- What changes if the limit applies to the sum and not to the spread?
Then one number is enough, and you can use a plain sliding window. The two deques are for conditions on the extremes. A sum or a count of values is a single running number.
SWE Reasoning
Spot it
- A window is valid only while its maximum minus its minimum stays within a limit.
- The answer is the longest such subarray, or the number of such subarrays.
- Adding an element can only keep or raise the spread, so shrinking from the left is the only repair.
- Both extremes are needed at the same time, so one deque is not enough.
The invariant, step by step
max_dqandmin_dqare empty,left = 0andbest = 0.- At each
right,max_dqpops every back value that is at mostnums[right], andmin_dqpops every back value that is at leastnums[right]. Both then appendright. - The values in
max_dqstrictly decrease, and the values inmin_dqstrictly increase. So the two fronts are the maximum and the minimum of the window. - While
nums[max_dq[0]] - nums[min_dq[0]] > limit,left += 1makes the window smaller. - After each
left += 1, each deque tests its own front. Ifmax_dq[0] < leftormin_dq[0] < left, that front is removed. - When the loop ends, the spread is at most
limit, soright - left + 1is a valid window length andbest = max(best, right - left + 1)records it.
Traps
- Testing only one front. The other deque keeps a stale index. Its extreme value is wrong, and the loop can run until a deque is empty.
- Writing
>=for the back ofmax_dq. The values ofmax_dqthen increase, and the front is the smallest value, not the largest. - Evicting on
<= left. Indexleftis still in the window. - Using
iffor the shrink step. One outlier can need several moves ofleft. - Storing values. A value cannot tell the code whether it has left the window.
Practice
Builds on Monotonic Deque and Longest Window, which you haven’t finished yet.
Dual Monotonic Deque (Longest Subarray Within a Max-Min Limit)
Take the integer array nums and an integer limit. Look for the longest non-empty contiguous run of nums (a subarray) in which every pair of elements inside the run differs by at most limit.
Equivalently, the run's largest value minus its smallest value must be at most limit. Return the length of the longest such run.
Dual Monotonic Deque
sliding window ➔ two deques, one decreasing and one increasing ➔ the window's spread, read at both fronts at once| 1 | from collections import deque |
| 2 | from typing import List |
| 3 | |
| 4 | def longest_subarray(nums: List[int], limit: int) -> int: |
| 5 | """Longest Continuous Subarray With Absolute Diff Less Than or Equal to Limit: two monotonic deques track the window's running max and min together. |
| 6 | Time: O(N) | Space: O(N) |
| 7 | """ |
| 8 | # max_dq and min_dq hold indices, their values strictly decreasing and strictly increasing from |
| 9 | # front to back, so nums[max_dq[0]] is the window's max and nums[min_dq[0]] is its min. |
| 10 | max_dq: deque[int] = deque() |
| 11 | min_dq: deque[int] = deque() |
| 12 | left = 0 |
| 13 | best = 0 |
| 14 | |
| 15 | for right in range(len(nums)): |
| 16 | # Phase 1: Push & Maintain Monotonicity: |
| 17 | # Run the same back-eviction rule on both deques, then take in index right on each. |
| 18 | while max_dq and nums[max_dq[-1]] <= nums[right]: |
| 19 | max_dq.pop() |
| 20 | max_dq.append(right) |
| 21 | while min_dq and nums[min_dq[-1]] >= nums[right]: |
| 22 | min_dq.pop() |
| 23 | min_dq.append(right) |
| 24 | |
| 25 | # Phase 2 & 3: Contract the Boundary, Then Evict Stale Fronts: |
| 26 | # One outlier at right can invalidate many left positions at once, so this needs a while, not an if; |
| 27 | # each deque only evicted the indices it checked itself, so both fronts are tested every time left moves. |
| 28 | while nums[max_dq[0]] - nums[min_dq[0]] > limit: |
| 29 | left += 1 |
| 30 | if max_dq[0] < left: |
| 31 | max_dq.popleft() |
| 32 | if min_dq[0] < left: |
| 33 | min_dq.popleft() |
| 34 | |
| 35 | # Phase 4: Harvest the Answer: |
| 36 | best = max(best, right - left + 1) |
| 37 | |
| 38 | return best |
max_dq holds the window's indices with strictly decreasing values and min_dq holds them with strictly increasing values, so nums[max_dq[0]] - nums[min_dq[0]] is always the window's exact spread.Two Monotonic Deques read the same window side by side: max_dq strictly decreasing, so nums[max_dq[0]] is always the window's current maximum, and min_dq strictly increasing, so nums[min_dq[0]] is always the window's current minimum. Knowing both extremes at once, without rescanning the window, is what a single deque (the Monotonic Deque card) cannot give you, since one deque only tracks one side; a heap could track one side too, but it cannot cheaply drop a value in the middle that just aged out of the window, the way a deque's stale front does. While the window's spread, nums[max_dq[0]] - nums[min_dq[0]], is greater than limit, shrink the window from the left, moving left forward one step at a time. Each deque has only evicted the indices it has personally checked, so shrinking must test both fronts on every step: max_dq's front can still be fresh while min_dq's front has already fallen behind left, or the other way around. The trap is checking only one: if max_dq[0] < left: max_dq.popleft() must be followed by its own if min_dq[0] < left: min_dq.popleft(), because skipping the second line leaves a stale index at the front of the deque you did not check, and every later comparison then reads the wrong minimum or maximum for a window that has, in truth, already shrunk further.
See it run
Longest Subarray With a Spread Limit: longest_subarray([8, 2, 4, 7], limit = 4)
left 0right -max_dq []min_dq []best 0
The top row is nums. The next two rows show the values of the indices that max_dq and min_dq hold, each under its own column.
leftthe first index of the windowrightthe index that was just readleft, rightleft and right are at the same indexin max_dqan index kept in max_dq, values falling from front to backin min_dqan index kept in min_dq, values rising from front to back
max_dq and min_dq are empty, left is 0 and best is 0. The loop reads one index right at a time.
Gotcha 1 of 4
- The trap
Gotcha 1: Shrinking checks only
max_dq's stale front, nevermin_dq'sMistakewhile nums[max_dq[0]] - nums[min_dq[0]] > limit: left += 1 if max_dq[0] < left: max_dq.popleft()Fixwhile nums[max_dq[0]] - nums[min_dq[0]] > limit: left += 1 if max_dq[0] < left: max_dq.popleft() if min_dq[0] < left: min_dq.popleft()What happens:
min_dq's own front is never removed, sonums[min_dq[0]]keeps reading an old minimum that is no longer inside the window. The comparisonnums[max_dq[0]] - nums[min_dq[0]] > limitthen stays true for a window that is already valid, soleftkeeps advancing past every indexmax_dqstill holds. Oncemax_dqempties,nums[max_dq[0]]has no front to read.Example: nums = [4, 2, 2, 2, 4, 4, 2, 2], limit = 0Mistake: raises IndexErrorFix: returns 3
- Every window only needs its maximum, as in Sliding Window Maximum (LC 239): one Monotonic Deque is enough, since nothing in the problem also depends on the window's minimum. See Monotonic Deque
Deep Dive
Why the window needs two deques
The Monotonic Deque card tracks one end of the window: the maximum. The rule in this card needs both ends at once. The window nums[left..right] is valid when its maximum minus its minimum, the spread, is at most limit.
max_dq holds indices whose values strictly decrease, so nums[max_dq[0]] is the maximum. min_dq holds indices whose values strictly increase, so nums[min_dq[0]] is the minimum. Both deques hold only indices of the current window. So nums[max_dq[0]] - nums[min_dq[0]] is the exact spread of the window, and it is read in O(1).
A heap can track one end, but it cannot cheaply remove a value from the middle that has just left the window. A deque only ever removes from its two ends, and the value that expires is always at the front.
Tracing [8, 2, 4, 7] with limit = 4
At right = 1, the value 2 is smaller than 8. min_dq pops index 0 and holds [1], and max_dq holds [0, 1]. The spread is 8 - 2 = 6, which is above 4, so left becomes 1. The front of max_dq is index 0, which is below left, so it leaves. The front of min_dq is index 1, which stays. The window is [2], and best stays 1.
At right = 2, the value 4 pops index 1 from max_dq. min_dq holds [1, 2]. The spread is 4 - 2 = 2, so the window [2, 4] is valid and best becomes 2.
At right = 3, the value 7 pops index 2 from max_dq. min_dq holds [1, 2, 3]. The spread is 7 - 2 = 5, which is above 4, so left becomes 2. This time the front of max_dq, index 3, stays, and the front of min_dq, index 1, is below left and leaves. The spread is now 7 - 4 = 3, and best stays 2.
The result is 2, the answer of Longest Continuous Subarray With Absolute Diff Less Than or Equal to Limit (LC 1438), Example 1.
Why both fronts need their own check
Each deque removes an index from its front only when the code tests that deque. When left moves, the stale index can be at the front of max_dq, at the front of min_dq, at both, or at neither. The trace shows two of these cases: max_dq at right = 1, and min_dq at right = 3.
The trap is to test only max_dq. Then a stale index stays at the front of min_dq, and nums[min_dq[0]] reads a value that is no longer in the window. For nums = [8, 2, 4, 7] and limit = 4, the spread at right = 3 stays at 7 - 2 = 5. The loop keeps moving left until max_dq is empty, and nums[max_dq[0]] raises IndexError.
The test is < left, not <= left. The window is nums[left..right], so index left is inside it. With <=, a front at left leaves too early, and a deque can become empty while the window still holds values.
The shrink step is a while. One new outlier can raise the spread so much that left must move several steps before the spread fits. The while keeps the window valid at the end of every step.
The cost, and the condition for using it
Each index is appended once to each deque and popped at most once from each. left and right only move forward. So the time is O(N). In the worst case, a deque holds the whole window, so the space is O(N).
Use this card when a window is valid only if a condition on both its maximum and its minimum holds, and a longer window can only make the spread larger. Then moving left forward is the only repair. If the condition looks at only one end, the Monotonic Deque card is enough.
60-Second Pitch
"The window is valid when its maximum minus its minimum is at most limit. So I need both extremes of the window at every step, without a scan. I keep two deques of indices. max_dq has strictly decreasing values, so its front is the maximum. min_dq has strictly increasing values, so its front is the minimum. For each right, I add the index to both deques. While the spread is above limit, I move left forward, and I check the front of each deque separately. Each deque drops its front when that index is below left. Every index enters and leaves each deque once, so the time is O(N). The deques can hold the whole window, so the space is O(N)."
Common Questions
- What is the time and space complexity?
The time is
O(N), because each index enters and leaves each deque at most once, andleftandrightonly move forward. The space isO(N)in the worst case, when a deque holds the whole window.- Why use two deques and not a heap or a sorted structure?
Two heaps cost
O(log N)per step, and a value that left the window stays inside a heap until it reaches the top. A balanced tree or a sorted container can delete it atO(log N)per step, so the total isO(N log N). The deques reachO(N), because the expired index is always at a front.- Why must the loop check both fronts after every
left += 1? Each deque removes an index only when the code tests it. One deque can have a stale front while the other has a fresh one. If the code tests only one, the other reads an extreme value from outside the window, and the spread is wrong.
- How would you count the subarrays with a spread within the limit?
Keep the same loop and add
right - left + 1to a counter after the shrink loop. Every start fromlefttorightgives a subarray whose spread is at mostlimit, because a shorter window cannot have a larger spread.- What changes if the limit applies to the sum and not to the spread?
Then one number is enough, and you can use a plain sliding window. The two deques are for conditions on the extremes. A sum or a count of values is a single running number.
SWE Reasoning
Spot it
- A window is valid only while its maximum minus its minimum stays within a limit.
- The answer is the longest such subarray, or the number of such subarrays.
- Adding an element can only keep or raise the spread, so shrinking from the left is the only repair.
- Both extremes are needed at the same time, so one deque is not enough.
The invariant, step by step
max_dqandmin_dqare empty,left = 0andbest = 0.- At each
right,max_dqpops every back value that is at mostnums[right], andmin_dqpops every back value that is at leastnums[right]. Both then appendright. - The values in
max_dqstrictly decrease, and the values inmin_dqstrictly increase. So the two fronts are the maximum and the minimum of the window. - While
nums[max_dq[0]] - nums[min_dq[0]] > limit,left += 1makes the window smaller. - After each
left += 1, each deque tests its own front. Ifmax_dq[0] < leftormin_dq[0] < left, that front is removed. - When the loop ends, the spread is at most
limit, soright - left + 1is a valid window length andbest = max(best, right - left + 1)records it.
Traps
- Testing only one front. The other deque keeps a stale index. Its extreme value is wrong, and the loop can run until a deque is empty.
- Writing
>=for the back ofmax_dq. The values ofmax_dqthen increase, and the front is the smallest value, not the largest. - Evicting on
<= left. Indexleftis still in the window. - Using
iffor the shrink step. One outlier can need several moves ofleft. - Storing values. A value cannot tell the code whether it has left the window.