Count Partitions With Max-Min Difference at Most K
MedPrompt
You are given an integer array nums and an integer k. Your task is to partition nums into one or more non-empty contiguous segments such that in each segment, the difference between its maximum and minimum elements is at most k.
Return the total number of ways to partition nums under this condition.
Since the answer may be too large, return it modulo 109 + 7.
Example 1:
Input: nums = [9,4,1,3,7], k = 4
Output: 6
Explanation:
There are 6 valid partitions where the difference between the maximum and minimum elements in each segment is at most k = 4:
[[9], [4], [1], [3], [7]][[9], [4], [1], [3, 7]][[9], [4], [1, 3], [7]][[9], [4, 1], [3], [7]][[9], [4, 1], [3, 7]][[9], [4, 1, 3], [7]]
Example 2:
Input: nums = [3,3,4], k = 0
Output: 2
Explanation:
There are 2 valid partitions that satisfy the given conditions:
[[3], [3], [4]][[3, 3], [4]]
Constraints:
2 <= nums.length <= 5 * 1041 <= nums[i] <= 1090 <= k <= 109
Approaches
3 approaches with complexity analysis and trade-offs.
This approach uses dynamic programming to solve the problem. We define dp[i] as the number of ways to partition the prefix of the array nums[0...i-1]. To compute dp[i], we consider all possible last segments ending at index i-1. If a segment nums[j...i-1] is valid (i.e., its max-min difference is at most k), we can form a valid partition by appending this segment to any valid partition of nums[0...j-1]. The number of ways to do this is dp[j]. By summing up dp[j] for all valid j, we get dp[i].
Algorithm
- Let
dp[i]be the number of ways to partition the prefixnums[0...i-1]. - The base case is
dp[0] = 1, representing one way to partition an empty prefix (by doing nothing). - The recurrence relation is
dp[i] = sum(dp[j])for all0 <= j < isuch that the segmentnums[j...i-1]is valid. - A segment
nums[j...i-1]is valid ifmax(nums[j...i-1]) - min(nums[j...i-1]) <= k. - We can compute
dp[i]forifrom 1 ton:- For each
i, iteratejfromi-1down to 0. - In this inner loop, maintain the maximum and minimum values of the current segment
nums[j...i-1]. - If the segment is valid, add
dp[j]todp[i](modulo10^9 + 7). - If the segment becomes invalid, we can break the inner loop because any segment starting before
jwill also be invalid (as the max-min difference is non-decreasing as the segment grows).
- For each
- The final answer is
dp[n].
Walkthrough
We build a dp array of size n+1, where dp[i] stores the number of valid partitions for the first i elements of nums. We initialize dp[0] = 1.
We then iterate from i = 1 to n. For each i, we need to calculate dp[i]. We do this by iterating backwards from j = i-1 to 0. The subarray nums[j...i-1] represents the last segment of a potential partition of nums[0...i-1].
In the inner loop, we keep track of the maximum and minimum elements in the segment nums[j...i-1]. If max - min <= k, it means this is a valid last segment. The number of ways to partition the rest of the array nums[0...j-1] is dp[j], so we add dp[j] to dp[i]. If max - min > k, we can stop the inner loop for the current i. This is because as j decreases, the segment nums[j...i-1] expands, and the difference between the maximum and minimum can only increase or stay the same. Therefore, all segments starting before j will also be invalid.
class Solution { public int countPartitions(int[] nums, int k) { int n = nums.length; int MOD = 1_000_000_007; long[] dp = new long[n + 1]; dp[0] = 1; for (int i = 1; i <= n; i++) { long currentMax = nums[i-1]; long currentMin = nums[i-1]; // j is the start index of the last segment for (int j = i - 1; j >= 0; j--) { currentMax = Math.max(currentMax, nums[j]); currentMin = Math.min(currentMin, nums[j]); if (currentMax - currentMin <= k) { // Last segment is nums[j...i-1] // Number of ways is dp[j] dp[i] = (dp[i] + dp[j]) % MOD; } else { // Since max-min is monotonic as j decreases, // we can break early. break; } } } return (int) dp[n]; }}Complexity
Time
O(n^2) because of the nested loops. The outer loop runs `n` times, and the inner loop can run up to `n` times in the worst case.
Space
O(n) to store the `dp` array.
Trade-offs
Pros
- It's a straightforward implementation of the DP recurrence.
- The logic is relatively easy to understand.
Cons
- The time complexity of
O(n^2)is too slow for the given constraints (n <= 5 * 10^4), and will likely result in a Time Limit Exceeded (TLE) error.
- The time complexity of
This approach optimizes the O(n^2) DP solution. We observe that for a fixed end position i-1, the valid start positions j form a contiguous block [p, i-1]. This is because if nums[p...i-1] is valid, any subsegment nums[j...i-1] (where j > p) will also be valid. This allows us to rewrite the recurrence as dp[i] = dp[p] + ... + dp[i-1]. This sum can be calculated efficiently using prefix sums on the dp array.
The main challenge is to find the smallest valid start index p for each i. Since the validity condition is monotonic with respect to the start index j, we can use binary search to find p. To make the check inside the binary search efficient (i.e., finding max/min in a range), we use a data structure like a Segment Tree, which can answer range queries in logarithmic time.
Algorithm
- The core DP recurrence is
dp[i] = sum(dp[j])for a range of valid start indicesj. - The set of valid start indices
jfor a segment ending ati-1forms a contiguous range, say[p, i-1]. - The problem reduces to finding the smallest valid start index
pfor eachi. - We can compute the sum
dp[p] + ... + dp[i-1]inO(1)using a prefix sum array overdp. - To find
p, we can use binary search over the possible start indices[0, i-1]. - For each
midin the binary search, we need to check if the segmentnums[mid...i-1]is valid. This check involves finding the max and min in that range. - We can pre-build a Segment Tree on
numsto answer range max/min queries inO(log n)time. - The binary search takes
O(log n)steps. Each step involves anO(log n)query. So, findingpfor eachitakesO(log^2 n). - The total time complexity is
O(n * log^2 n).
Walkthrough
First, we build a Segment Tree on the input array nums. This allows us to query the maximum and minimum values in any range [l, r] in O(log n) time. The build process takes O(n) time.
We maintain a dp array and a prefix sum array ps over dp. ps[i] will store dp[0] + ... + dp[i-1].
We iterate i from 1 to n. For each i, we perform a binary search on indices j from 0 to i-1 to find the smallest p such that max(nums[p...i-1]) - min(nums[p...i-1]) <= k.
- The binary search works as follows: for a
midindex, we query our segment tree for the max and min innums[mid...i-1]. - If
max - min <= k, it meansmidis a valid starting point, and there might be an even smaller valid index, so we search in the left half[low, mid-1]and recordmidas a potential answer. - If
max - min > k,midis not a valid start, so we must search in the right half[mid+1, high].
Once we find p, we calculate dp[i] = (ps[i] - ps[p] + MOD) % MOD. Then we update the prefix sum array: ps[i+1] = (ps[i] + dp[i]) % MOD.
class Solution { // Assume a SegmentTree class is implemented with a method: // query(left, right) that returns {min, max} in O(log n). SegmentTree st; public int countPartitions(int[] nums, int k) { int n = nums.length; st = new SegmentTree(nums); // O(n) build int MOD = 1_000_000_007; long[] dp = new long[n + 1]; long[] ps = new long[n + 2]; dp[0] = 1; ps[1] = 1; for (int i = 1; i <= n; i++) { // Binary search for the smallest valid starting index p int low = 0, high = i - 1; int p = i; while (low <= high) { int mid = low + (high - low) / 2; long[] range = st.query(mid, i - 1); // {min, max} if (range[1] - range[0] <= k) { p = mid; high = mid - 1; } else { low = mid + 1; } } if (p < i) { long ways = (ps[i] - ps[p] + MOD) % MOD; dp[i] = ways; } ps[i + 1] = (ps[i] + dp[i]) % MOD; } return (int) dp[n]; }}Complexity
Time
O(n log^2 n). The main loop runs `n` times. Inside, binary search takes `O(log n)` steps, and each step involves a segment tree query of `O(log n)`.
Space
O(n) for the `dp` array, prefix sum array, and the segment tree.
Trade-offs
Pros
- Much faster than the naive
O(n^2)DP.
- Much faster than the naive
- Demonstrates the use of powerful data structures to optimize DP transitions.
Cons
- The implementation is more complex, requiring both a segment tree and binary search within the DP loop.
- While faster than
O(n^2), it may still be too slow for the tightest time limits.
- While faster than
This is the most efficient approach, achieving a linear time complexity. It builds upon the same DP formulation but finds the valid range of starting positions in a much faster way. Instead of re-computing or searching for the boundary p for each i from scratch, we use a sliding window. The right end of the window is the current index i-1, and the left end p is adjusted as we iterate. To find the max and min in this sliding window in amortized constant time, we use a classic technique involving two monotonic deques. One deque tracks the maximum in the current window, and the other tracks the minimum. This allows us to find p and compute dp[i] in a single pass through the array.
Algorithm
- This approach also uses the DP relation
dp[i] = sum_{j=p}^{i-1} dp[j]and prefix sums. - The key is to find the smallest valid start index
pfor eachiin amortizedO(1)time. - We use a sliding window approach. We iterate
ifrom 1 ton, which acts as the right end of our window. We maintain aleftpointer, which corresponds top. - As
iincrements,leftonly moves to the right or stays put. - To get the max and min of the sliding window
[left, i-1]efficiently, we use two monotonic deques (double-ended queues).maxDeque: Stores indices in decreasing order of theirnumsvalues.minDeque: Stores indices in increasing order of theirnumsvalues.
- For each
i:- Update the deques with the new element
nums[i-1]. - Check the window's validity:
nums[maxDeque.front()] - nums[minDeque.front()]. - If the window is invalid (
> k), shrink it from the left by incrementingleftand removing indices from the deques that are no longer in the window. - Once the window is valid,
leftis ourp. Calculatedp[i]using prefix sums.
- Update the deques with the new element
- The total time is linear because each element is added to and removed from the deques at most once.
Walkthrough
We use the same DP state dp[i] and prefix sum array ps as in the previous approach. We also initialize a left pointer to 0, which will track the start of our valid window.
We iterate i from 1 to n. In each iteration, i-1 is the index of the new element being added to the right of our window.
-
Update Deques: We add
i-1to our two deques. For themaxDeque, we remove all indices from the end that correspond to values smaller than or equal tonums[i-1]. For theminDeque, we remove indices corresponding to values larger than or equal tonums[i-1]. This maintains the monotonic property of the deques. -
Shrink Window: The maximum in the current window
[left, i-1]isnums[maxDeque.peekFirst()]and the minimum isnums[minDeque.peekFirst()]. We check if their difference exceedsk. If it does, the window is invalid, so we must shrink it from the left by incrementingleft. When we incrementleft, we also check if the indices at the front of the deques have fallen out of the new window boundary (< left) and remove them if so. -
Calculate DP: After the shrinking step,
leftis the smallest indexpsuch thatnums[p...i-1]is a valid segment. We can then calculatedp[i] = (ps[i] - ps[left] + MOD) % MOD. -
Update Prefix Sum: Finally, we update
ps[i+1] = (ps[i] + dp[i]) % MOD.
This process ensures that each element is processed in amortized constant time, leading to an overall linear time solution.
import java.util.ArrayDeque;import java.util.Deque; class Solution { public int countPartitions(int[] nums, int k) { int n = nums.length; int MOD = 1_000_000_007; long[] dp = new long[n + 1]; long[] ps = new long[n + 2]; dp[0] = 1; ps[1] = 1; Deque<Integer> maxDeque = new ArrayDeque<>(); Deque<Integer> minDeque = new ArrayDeque<>(); int left = 0; for (int i = 1; i <= n; i++) { int currentIndex = i - 1; while (!maxDeque.isEmpty() && nums[maxDeque.peekLast()] <= nums[currentIndex]) { maxDeque.pollLast(); } maxDeque.addLast(currentIndex); while (!minDeque.isEmpty() && nums[minDeque.peekLast()] >= nums[currentIndex]) { minDeque.pollLast(); } minDeque.addLast(currentIndex); while (nums[maxDeque.peekFirst()] - nums[minDeque.peekFirst()] > k) { left++; if (maxDeque.peekFirst() < left) { maxDeque.pollFirst(); } if (minDeque.peekFirst() < left) { minDeque.pollFirst(); } } long ways = (ps[i] - ps[left] + MOD) % MOD; dp[i] = ways; ps[i + 1] = (ps[i] + dp[i]) % MOD; } return (int) dp[n]; }}Complexity
Time
O(n). Each index is pushed onto and popped from each deque at most once. The `left` pointer only moves forward. Thus, the work inside the loop is amortized O(1).
Space
O(n) for the `dp` array, prefix sum array, and the deques. In the worst case, the deques can hold up to `n` elements.
Trade-offs
Pros
- Optimal
O(n)time complexity, which is very efficient and passes all constraints.
- Optimal
- Uses a clever combination of sliding window and monotonic deques.
Cons
- The logic for maintaining the sliding window and monotonic deques can be complex and tricky to implement correctly.
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.