To maximum the width j - i, we want two things:
- The smallest number before (Easy to find the larger number later)
- The largest number after (Easy to find the smaller number earlier)
We can pre-compute the two information for every single index:
- Prefix minimum: The minimum value we've seen so far from the left side. This answers the question: "If I am forced to pick a starting number before/at index
i, what is the smallest (best) choice?"
A = [6, 0, 8, 2, 1, 5]
--> min
6 0 0 0 0 0- Suffix maximum: The maximum value we've seen so far from the right side. This answers the question: "If I am forced to pick a ending number after/at index
j, what is the largest (best) choice?"
A = [6, 0, 8, 2, 1, 5]
max <--
8 8 8 5 5 5Please note that both the prefix minimum and suffix maximum are decreasing order, it's a key observation for the following explanation in two pointers and monotonic stack approach.
Because both the prefix minimum and suffix maximum are sorted in descending order (when reading from left to right), then we can use two pointers to find the maximum ramp.
left: Scan the prefix minimum to look for starting candidate.right: Scan the suffix maximum to look for ending candidate.
We iterate each index, and for each left pointer, we try to find the maximum right pointer such that prefix minimum <= suffix maximum:
- If
minBefore[left] > maxAfter[right], the starting candidate is too large to match the ending candidate.- We need smaller starting candidates, so we move
left + 1because the prefix minimum gets same or smaller as we move right. - Moving
right + 1will gets same or smaller suffix maximum as well, andminBefore[left] > maxAfter[right], smallermaxAfter[right]makes the situation worse.
- We need smaller starting candidates, so we move
- If
minBefore[left] <= maxAfter[right], it's a match.- Update the maximum width with
right - leftaccordingly. - Move the
right + 1to get a wider ramp. - Why not moving the
left? Because movingleftgets the narrower ramp. We know movingrightwill makemaxAfter[right]smaller, but it's the only direction that increase the width.
- Update the maximum width with
A = [6, 0, 8, 2, 1, 5]
6 0 0 0 0 0 // prefix minimum
8 8 8 5 5 5 // suffix maximum
L
|-----R // The longest ramp starting from `6`
// Next iteration
L
|-----------R // The longest ramp starting from `0`, the final answerThe monotonicity provides the predictability, we know exactly what happens when we move the pointers:
- Moving
left + 1: The prefix minimum will stay the same or decrease, which makes it easier to find a matching ending candidate. - Moving
right + 1: The suffix maximum will stay the same or decrease, which makes it harder to find a matching starting candidate, but it makes a potential wider ramp.
We never need to move a pointer backwards.
- If a ramp is valid, we expand the
rightpointer to find a wider ramp. - If a ramp is invalid, we fix by moving
leftto find a smaller starting candidate.
fun maxWidthRamp(nums: IntArray): Int {
val n = nums.size
val minBefore = IntArray(n)
val maxAfter = IntArray(n)
minBefore[0] = nums[0]
for (i in 1 until n) {
minBefore[i] = minOf(nums[i], minBefore[i - 1])
}
maxAfter[n - 1] = nums[n - 1]
for (i in n - 2 downTo 0) {
maxAfter[i] = maxOf(nums[i], maxAfter[i + 1])
}
var i = 0
var j = 0
var maxWidth = 0
while (j < n) {
if (minBefore[i] <= maxAfter[j]) {
maxWidth = maxOf(maxWidth, j - i)
j++
} else {
i++
}
}
return maxWidth
}- Time Complexity:
O(n)for pre-computing the prefix minimum and suffix maximum, andO(n)for two pointers scan, totalO(n). - Space Complexity:
O(n)for the prefix minimum and suffix maximum.
There are two rules to form a valid ramp:
- Value Rule:
nums[i] <= nums[j]. - Index Rule:
i < j, and try to maximize the widthj - i.
We can use sorting to solve the Value Rule first, so we only have to care about the Index Rule after that.
We need to sort the value, but meanwhile we also need to keep track of the original index. So we can create an array of indices and sort the indices by its value.
nums = [7, 6, 5, 8]
index = [0, 1, 2, 3]
// Sort the index by its value
values = [5, 6, 7, 8]
sortedIndex = [2, 1, 0, 3]After sorting, all values after nums[i] are >= nums[i], that is, all later number will be a valid ending candidate.
To find the maximum width, we iterate the sorted index as the ending index j, and keep track of the minimum starting index i we've seen so far. The width is j - i, and we update the maximum width accordingly.
fun maxWidthRamp(nums: IntArray): Int {
val n = nums.size
val index = IntArray(n)
for (i in nums.indices) {
index[i] = i
}
val sortedIndex = index.sortedBy { nums[it] }
var minIndex = sortedIndex.first()
var maxWidth = 0
for (i in 1 until n) {
maxWidth = maxOf(maxWidth, sortedIndex[i] - minIndex)
minIndex = minOf(minIndex, sortedIndex[i])
}
return maxWidth
}- Time Complexity:
O(n log n)for sorting. - Space Complexity:
O(n)for the sorted index.
Think the key questions before diving deep into this approach:
Q1. Suppose we're looking for the starting index
i, and we have6at index0, then next we have8at index1, could index1ever be a better starting index than index0for the maximum width ramp?
No, because 6 is smaller than 8 and the index 0 is smaller than index 1, it's easier to match a larger number later and get a wider ramp. So if we have a smaller number before, then we can ignore the larger number at the later index. We keep track of the valid starting candidates in a decreasing order, and we only care about the smaller numbers.
6, _, _, 8, _, _, 9, _, _
O X X
6, 5, 3, _, 0, ....
O O O ONotice the pattern from two pointers approach above? Both the prefix minimum and suffix maximum are monotonically decreasing. This is key observation for monotonic stack approach.
Q2. After gathering all the valid starting indices
i, and our goal is to maximize the widthj - i, how do we find the maximum width ramp?
To find the maximum j - i, we aim to have larger j and smaller i. So we should scane the array from right to left to find the valid ending index j.
Based on the above key insights, we can break the problem into two phases:
Since the larger number at the later index can never be a better starting candidate than the smaller number at the earlier index, we can maintain a monotonic decreasing stack to store the valid starting indices.
Let's take the example A = [6, 0, 8, 2, 1, 5] to illustrate how we build the stack:
nums[0] = 6, the stack is empty, it's a candidate.nums[1] = 0, it's smaller than6, so it's also a candidate, we pop6and push0.nums[2] = 8, it's larger than0, so we ignore it. Why? Because0is smaller and smaller index. Any validjfor8would also be valid for0, but0can potentially get a wider ramp than8.- Later numbers are larger than
0, so we ignore them, the stack is[0, 1]after phase 1.
Now we find the ending index j, to maximum the width j - i, we scan from right to left and keep track of the maximum width.
-
nums[j] = 5: It's larger thanstack.last() = 0, it's valid ramp.- The magic step: Once we find a valid ramp, we pop the starting index from stack forever. Why? Because as we iterating from right to left,
jbecomes smaller. Even if we find another valid ramp with the same starting index, the width will be smaller than the current ramp.
6, 0, _, _, 1, 5 i-----------j // valid, final answer <-- i--------j // valid, but narrower ...
- We should keep checking the same
jagaist the stack untiljis no longer valid. (The stack becomes empty or meets the larger number thannums[j])
- The magic step: Once we find a valid ramp, we pop the starting index from stack forever. Why? Because as we iterating from right to left,
-
nums[j] = 1: It's larger thanstack.last() = 0, it's valid ramp. We do the same thing as above. -
Keep moving
jto the left until the stack becomes empty or the end of the array.
fun monotonicStack(nums: IntArray): Int {
// Index, decreasing stack, store the valid starting element.
val stack = ArrayDeque<Int>()
// Find the valid starting `i`
for (i in nums.indices) {
if (stack.isEmpty() ||
(stack.isNotEmpty() && nums[stack.last()] > nums[i])) stack.addLast(i)
}
var maxWidth = 0
// Iterate from right to left backward to find the valid ending `j`
for (j in nums.size - 1 downTo 0) {
// No any valid starting index.
if (stack.isEmpty()) break
while (stack.isNotEmpty() && nums[stack.last()] <= nums[j]) {
val i = stack.removeLast()
maxWidth = maxOf(maxWidth, j - i)
}
}
return maxWidth
}- Time Complexity:
O(n). Each index is pushed and popped at most once. - Space Complexity:
O(n)for the stack.