Part V · Aggregates, Stacks and Graphs Pattern 14 4 problems

Monotonic Stack

A stack that refuses to hold anything out of order. Whatever it evicts, it evicts at exactly the moment the answer for that item becomes known.

Every problem here has the same brute force: for each element, scan outward until you find the next bigger or smaller one. That is O(n²). A stack kept in sorted order turns it into O(n), because each element is pushed once and popped once. The hard part is not the code, it is seeing that the question is a “next greater” question in disguise.

Contents

  1. When to use
  2. Core idea
  3. The four variants
  4. The templates
  5. Common mistakes
  6. Daily Temperatures
  7. Next Greater Element II
  8. Trapping Rain Water
  9. Largest Rectangle in Histogram
  10. Recap

When to use

The trigger. For each element you need to know something about the nearest element on one side that beats it. Nearest larger, nearest smaller, how far away it is, or how far a value can extend before something taller stops it.
SignalWhat it looks like
Next or previous“next greater element”, “previous smaller”, “how many days until a warmer day”.
Span or reach“stock span”, “how far can this bar extend”, “width of the region it dominates”.
Skyline or histogramBars, heights, buildings, water. Almost always this pattern.
Remove to optimise“remove k digits to make the smallest number”, “remove duplicate letters, smallest result”.
Sliding maximum“maximum of every window of size k” uses the deque form of the same idea.
The recognition test. Write the brute force in your head: for each i, walk right until I find something bigger. If that is the shape, it is a monotonic stack. That sentence is the whole diagnosis.

Core idea

Walk the array once, keeping a stack of indices whose answers are still unknown. Maintain the invariant that the values at those indices are in order, say strictly decreasing. When a new value arrives that breaks the order, everything it beats is popped, and the new value is the answer for each thing it pops. Then push the new index and carry on.
stack holds indices with decreasing values 75 71 69 all still waiting for a bigger value 76 76 arrives it pops 69, then 71, then 75 and it is the answer for all three each index is pushed once and popped at most once so the total work is O(n) Invariant: everything on the stack is still unanswered, and it is sorted. Both facts are what make the pops correct.
Figure 14.1 — The nested while loop looks quadratic and is not. Every index enters the stack once and leaves once.

Why the nested loop is still linear

Amortised argument. Each index is pushed exactly once. Each pop removes an index permanently, so there are at most n pops across the whole run. The inner while may run many times on one iteration and zero times on the next, but the total number of inner steps over the entire loop is bounded by n. Total: O(n). This is the same accounting as the sliding window.

The four variants

All four are the same loop. Only the comparison and the direction change. Write this table out before coding and you will not get the sign backwards.

You wantStack holdsPop whileScan
Next greater to the rightdecreasing valuesstack top < currentleft to right
Next smaller to the rightincreasing valuesstack top > currentleft to right
Previous greater to the leftdecreasing valuesstack top < currentleft to right, read the top before pushing
Previous smaller to the leftincreasing valuesstack top > currentleft to right, read the top before pushing
One loop gives you both sides. When an index is popped, the arriving element is its next boundary, and whatever is left underneath on the stack is its previous boundary. Largest Rectangle in Histogram uses exactly that, and it is why the problem needs only one pass rather than two.

The templates

Template A — next greater element, by index
def next_greater(nums: list[int]) -> list[int]:
    """For each index, the index of the next strictly greater value, or -1."""
    answer = [-1] * len(nums)
    stack: list[int] = []          # indices; nums[stack] is non-increasing

    for index, value in enumerate(nums):
        # Everything smaller than value has just found its answer.
        while stack and nums[stack[-1]] < value:
            answer[stack.pop()] = index

        stack.append(index)

    return answer                  # anything left on the stack keeps -1

Store indices, not values. You can always read the value from the index, but you cannot recover a distance from a value.

Template B — the sentinel flush
def with_sentinel(heights: list[int]) -> None:
    """Append an impossible final value so the stack empties inside the loop."""
    stack: list[int] = []

    for index, height in enumerate([*heights, 0]):   # 0 is lower than any real bar
        while stack and heights[stack[-1]] >= height:
            resolve(stack.pop(), index)

        stack.append(index)

A sentinel removes the “now drain whatever is left” block after the loop. One code path instead of two, and the leftover case is where bugs live.

Common mistakes

The problems

1. Daily Temperatures Medium

Problem

Given daily temperatures, return an array where answer[i] is the number of days you must wait after day i for a warmer temperature. If no warmer day comes, put 0.

Approach

Solution

def daily_temperatures(temperatures: list[int]) -> list[int]:
    """Days to wait for a warmer temperature, per day.

    Args:
        temperatures: Daily temperature readings.

    Returns:
        answer[i] is the number of days after i until a warmer day, or 0.

    Example:
        >>> daily_temperatures([73, 74, 75, 71, 69, 72, 76, 73])
        [1, 1, 4, 2, 1, 1, 0, 0]
    """
    answer = [0] * len(temperatures)
    stack: list[int] = []          # indices of days still waiting; temps decreasing

    for index, temperature in enumerate(temperatures):
        # This day is warmer than everything waiting on top of the stack,
        # so it is the answer for all of them.
        while stack and temperatures[stack[-1]] < temperature:
            earlier = stack.pop()
            answer[earlier] = index - earlier

        stack.append(index)

    # Whatever is still stacked never warmed up, and 0 is already there.
    return answer

Walkthrough

[73, 74, 75, 71, 69, 72, 76, 73]:

indextemppopsstack after
073none[0]
1740, wait 1[1]
2751, wait 1[2]
371none[2, 3]
469none[2, 3, 4]
5724 wait 1, then 3 wait 2[2, 5]
6765 wait 1, then 2 wait 4[6]
773none[6, 7]

Indices 6 and 7 stay stacked and keep their 0.

TimeO(n)SpaceO(n)

Worst case for space is a strictly decreasing input, where nothing is ever popped and the stack holds all n indices. Worth naming when asked.

Edge cases to raise

Say this out loud: “The stack holds days that have not yet seen anything warmer, kept in decreasing order. A warm day resolves all of them at once, and each index is pushed and popped once, so it is linear.”

2. Next Greater Element II Medium

Problem

Given a circular array, return the next greater element for each position. Searching wraps around past the end and back to the start. Use -1 where none exists.

The circular trick

Do not build a doubled array and do not write modular index arithmetic in three places. Instead iterate 2n times and index with i % n. Two laps are always enough, because after one full lap every element has seen every other element. Then add one rule: only push indices during the first lap. The second lap exists purely to resolve leftovers, so it must not create new ones.

Approach

Solution

def next_greater_elements(nums: list[int]) -> list[int]:
    """Next greater element for each position in a circular array.

    Args:
        nums: The circular array. Searching wraps past the end.

    Returns:
        answer[i] is the first value greater than nums[i], scanning forward
        and wrapping around, or -1 if there is none.

    Example:
        >>> next_greater_elements([1, 2, 1])
        [2, -1, 2]
    """
    n = len(nums)
    answer = [-1] * n
    stack: list[int] = []          # indices; nums[stack] is non-increasing

    # Two laps. The first seeds the stack, the second resolves the wrap-around.
    for i in range(2 * n):
        value = nums[i % n]

        while stack and nums[stack[-1]] < value:
            answer[stack.pop()] = value

        if i < n:                  # never push on the second lap
            stack.append(i)

    return answer

Walkthrough

nums = [1, 2, 1], so the loop runs 6 times:

ivaluepopspush?answer
01noneyes[-1, -1, -1]
12index 0 gets 2yes[2, -1, -1]
21noneyes[2, -1, -1]
31noneno[2, -1, -1]
42index 2 gets 2no[2, -1, 2]
51noneno[2, -1, 2]

Index 1 holds the array maximum, so it correctly keeps -1.

TimeO(n)SpaceO(n)

2n iterations is still O(n). Do not let the doubling talk you into calling it quadratic.

The non-circular sibling

def next_greater_element_i(nums1: list[int], nums2: list[int]) -> list[int]:
    """Next Greater Element I: nums1 is a subset of nums2. Solve on nums2, then look up."""
    greater: dict[int, int] = {}
    stack: list[int] = []          # values this time, since nums2 has no duplicates

    for value in nums2:
        while stack and stack[-1] < value:
            greater[stack.pop()] = value
        stack.append(value)

    return [greater.get(value, -1) for value in nums1]

Here the values are guaranteed distinct, so a value-keyed map is safe and the stack can hold values directly. When duplicates are possible, go back to indices.

Edge cases to raise

Say this out loud: “Two laps with a modulo index handles the wrap-around, and the second lap must not push anything, or elements that genuinely have no greater value would get resolved by their own second copy.”

3. Trapping Rain Water Hard

Problem

Given an elevation map where height[i] is the height of a bar of width 1, compute how much rain water is trapped after it rains.

The governing fact

The water sitting above position i is min(tallest bar to the left, tallest bar to the right) - height[i], or zero if that is negative. Water is held in by the shorter of the two walls, which is why the min is there. Every solution below is a different way to get those two maxima cheaply.
This is not Container With Most Water. That problem picks two bars and ignores everything between them. This one uses every bar, and the terrain in between is what holds or spills the water. Interviewers ask them back to back precisely to see whether you notice.

Solution: monotonic stack, filling layer by layer

def trap(height: list[int]) -> int:
    """Total rain water trapped above an elevation map.

    Fills the terrain in horizontal layers. Each pop identifies a basin
    floor, with the popped bar's left neighbour on the stack as one wall
    and the arriving bar as the other.

    Args:
        height: Non-negative bar heights.

    Returns:
        Total trapped water.

    Example:
        >>> trap([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1])
        6
    """
    total = 0
    stack: list[int] = []          # indices; heights non-increasing

    for right, bar in enumerate(height):
        # A taller bar closes off one or more basins to its left.
        while stack and height[stack[-1]] < bar:
            floor = stack.pop()

            if not stack:
                break              # no left wall, so the water spills out

            left = stack[-1]
            width = right - left - 1
            depth = min(height[left], bar) - height[floor]
            total += width * depth

        stack.append(right)

    return total

Solution: two pointers, O(1) space

def trap_two_pointers(height: list[int]) -> int:
    """Same answer in O(1) space, converging from both ends.

    The insight: if the tallest bar seen from the left is no taller than the
    tallest seen from the right, then the left maximum is the binding wall
    for the left position, whatever lies in between. So it is safe to settle
    that side immediately.
    """
    if not height:
        return 0

    left, right = 0, len(height) - 1
    left_max, right_max = height[left], height[right]
    total = 0

    while left < right:
        if left_max <= right_max:
            left += 1
            left_max = max(left_max, height[left])
            total += left_max - height[left]
        else:
            right -= 1
            right_max = max(right_max, height[right])
            total += right_max - height[right]

    return total

The three solutions, ranked

ApproachTimeSpaceVerdict
Precompute left-max and right-max arraysO(n)O(n)Easiest to derive and explain. Start here.
Monotonic stackO(n)O(n)The pattern answer. Fills horizontally, one basin per pop.
Two pointersO(n)O(1)The best answer. Offer it last, with the reason it is safe.
A good sequence under pressure: describe the min(left_max, right_max) rule, write the two-array version, then say “I can drop the arrays with two pointers” and write that. The stack version is worth knowing because it is the same machinery as the next problem, and because it computes the water in a completely different geometry.

Walkthrough of the stack version

height = [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]. At index 3 the bar of height 2 arrives. It pops the 0 at index 2, finds index 1 underneath as the left wall, and adds width 1 × depth (min(1, 2) - 0) = 1. Later the bar of height 3 at index 7 pops a run of small bars and adds the large middle basin. The layers accumulate to 6.

StackO(n) time, O(n) spaceTwo pointersO(n) time, O(1) space

Edge cases to raise

Say this out loud: “Water above a position is the smaller of the two surrounding maxima, minus the bar. With two pointers I always advance the side whose maximum is smaller, because that side’s answer is already decided.”

4. Largest Rectangle in Histogram Hard

Problem

Given bar heights of width 1, find the area of the largest rectangle that fits inside the histogram.

The reframing

Every candidate rectangle is limited in height by its shortest bar. So iterate over the bars and ask, for each one: if this bar is the shortest in the rectangle, how wide can the rectangle be? The answer is bounded by the nearest strictly shorter bar on each side. That is the previous-smaller and next-smaller pair, which one monotonic stack produces in a single pass.

Once you say “each bar is the height, and I need its left and right smaller boundaries”, the problem is finished. Getting there is the interview.

Approach

Solution

def largest_rectangle_area(heights: list[int]) -> int:
    """Area of the largest rectangle that fits under a histogram.

    Each bar is considered as the limiting height of a rectangle. Its width
    runs between the nearest shorter bar on each side, both of which fall out
    of a single increasing stack.

    Args:
        heights: Non-negative bar heights, each of width 1.

    Returns:
        The maximum rectangle area.

    Example:
        >>> largest_rectangle_area([2, 1, 5, 6, 2, 3])
        10
    """
    best = 0
    stack: list[int] = []          # indices; heights strictly increasing

    # The trailing 0 is shorter than any bar, so it flushes the stack and
    # every bar gets resolved inside the loop.
    for index, bar in enumerate([*heights, 0]):
        while stack and heights[stack[-1]] >= bar:
            height = heights[stack.pop()]

            # Left boundary: whatever is now on top is the nearest shorter
            # bar to the left. If the stack is empty, the rectangle reaches
            # all the way to index 0.
            left = stack[-1] + 1 if stack else 0
            best = max(best, height * (index - left))

        stack.append(index)

    return best

Walkthrough

heights = [2, 1, 5, 6, 2, 3]:

indexbarpops and areasbest
02none0
11pop 2, width 1, area 22
25none2
36none2
42pop 6 width 1 area 6, pop 5 width 2 area 1010
53none10
60pop 3 area 3, pop 2 area 8, pop 1 area 610

The winner is the 5 × 2 rectangle spanning the bars of height 5 and 6.

TimeO(n)SpaceO(n)

The follow-up: Maximal Rectangle in a binary matrix

def maximal_rectangle(matrix: list[list[str]]) -> int:
    """Largest all-ones rectangle in a binary matrix.

    Treat each row as the base of a histogram whose bar heights are the
    runs of ones stacked above that row. Then it is this problem, once per row.
    """
    if not matrix or not matrix[0]:
        return 0

    heights = [0] * len(matrix[0])
    best = 0

    for row in matrix:
        for c, cell in enumerate(row):
            heights[c] = heights[c] + 1 if cell == "1" else 0

        best = max(best, largest_rectangle_area(heights))

    return best

A Hard problem that becomes eight lines once you own the histogram routine. Worth knowing that this is why the histogram problem is asked at all.

Edge cases to raise

Say this out loud: “Fix each bar as the limiting height and ask how far it can spread. Its boundaries are the nearest shorter bar on each side, and one increasing stack gives me both, because the thing underneath a popped index is its left wall.”

Recap

The six things to carry forward

Where this goes next

Pattern 15, Topological Sort, moves back to graphs. It is the ordering algorithm that BFS and DFS both hint at without covering, and it is the standard answer to any question about dependencies.


13 — Prefix Sum and Hash Map 15 — Topological Sort