Skip to content
AstroPaper
Go back

Monotonic Stacks from Scratch: Starting with the Next Greater Element

Edit page
Note

This post is background for LeetCode 42. Trapping Rain Water. All Python code here passes ruff checks and 3,000 randomized cross-checks against brute force.

Table of contents

Open Table of contents

First, what is a stack?

A stack is a container you can only add to and remove from at one end. The best analogy is a stack of plates:

So the last plate in is the first one out, known as “last in, first out”. The top position is called the top of the stack, and the other end is the bottom.

In Python, a plain list works as a stack with just three operations:

stack: list[int] = []

stack.append(3)  # Put one on top: [3]
stack.append(1)  # Put another: [3, 1], the top is 1
top = stack[-1]  # Peek at the top without removing it: 1
stack.pop()  # Remove the top: [3]

In this post, a stack is always written left to right, with the bottom on the left and the top on the right. For example, [3, 1] means 3 is underneath and 1 is on top.

What makes a stack “monotonic”

A monotonic stack is a stack whose elements always stay in order from bottom to top, either always decreasing or always increasing.

The definition alone doesn’t explain why anyone would want this. A monotonic stack isn’t something you design up front; it shows up naturally when you solve a certain kind of problem. So let’s start with that problem.

The problem: the next greater element

Given an array of integers, find, for each element, the first element to its right that is greater than it. If there isn’t one, use -1.

Example: nums = [3, 1, 2, 5, 4]

IndexElementScanning right for the first greater oneAnswer
031 no, 2 no, 5 yes5
112 yes2
225 yes5
354 no, end of array-1
44nothing to the right-1

The answer is [5, 2, 5, -1, -1].

Brute force

Do exactly what the table does: for each element, scan right one by one.

def next_greater_brute_force(nums: list[int]) -> list[int]:
    answer = [-1] * len(nums)
    for i, num in enumerate(nums):
        for later in nums[i + 1 :]:
            if later > num:
                answer[i] = later
                break
    return answer

In the worst case (say, an array that only decreases, so nobody finds an answer), every element scans to the end, which is O(n²).

It’s slow because the same elements get scanned over and over. Index 0 already looked at 1, 2, and 5, and then index 1 looks at them again.

Flip it around: let elements arrive and see who gets answered

Brute force has each element go looking for its answer. Flip the perspective: let the elements arrive one at a time from the left, and each time one arrives, ask whose answer it is.

Think of each number as a person, with the value as their height. Everyone wants to know: “Who is the first person to my right who is taller than me?”

Step by step

Same nums = [3, 1, 2, 5, 4]. The waiting area is listed left to right in order of arrival.

Step 1: 3 arrives

Step 2: 1 arrives

Step 3: 2 arrives

Step 4: 5 arrives

Step 5: 4 arrives

After everyone has arrived: 5 and 4 are still waiting, so both answers are -1.

As a table:

StepArrivesAnsweredWaiting area afterward
13nobody[3]
21nobody[3, 1]
321 → 2[3, 2]
452 → 5, 3 → 5[5]
54nobody[5, 4]
end5 → -1, 4 → -1

Answers: 3→5, 1→2, 2→5, 5→-1, 4→-1, or [5, 2, 5, -1, -1], the same as brute force.

The key insight: the waiting area always decreases from left to right

Look at the last column: [3], [3, 1], [3, 2], [5], [5, 4]. Every one of them gets shorter from left to right. That’s not a coincidence; it has to be this way:

Suppose a shorter person A stood to the left of a taller person B in the waiting area (A arrived first, B later, and A is shorter than B). Then the moment B arrived, A would have gotten an answer and left. So this situation can’t exist.

Since the waiting area always decreases, two useful facts follow:

  1. When someone new arrives, check from the most recent arrival backward. As soon as you meet someone who isn’t shorter than the newcomer, stop: everyone further back is even taller and can’t be answered either.
  2. People always leave and join at the same end (the right). That’s exactly last in, first out: a stack.

This always-decreasing waiting area is a monotonically decreasing stack. Nobody forced it to decrease; it happens naturally from the rule that people without an answer stay behind.

In code

In real code, the stack usually holds indices rather than values. When an element gets its answer, we need to know which position to fill. An index lets us recover the value (nums[i]) and fill the right slot.

def next_greater(nums: list[int]) -> list[int]:
    answer = [-1] * len(nums)
    waiting: list[int] = []  # Indices still waiting; values decrease bottom to top

    for i, num in enumerate(nums):
        # The new element is greater than the top: the top has its answer
        while waiting and num > nums[waiting[-1]]:
            answer[waiting.pop()] = num
        waiting.append(i)

    return answer

The stack is named waiting on purpose, to say what it holds: elements still waiting for an answer. Anything left on the stack at the end needs no handling, because answer starts out filled with -1.

Matching the code to the steps above:

Why it’s O(n)

Two nested loops look like O(n²), but they aren’t. Count it differently: each index is pushed at most once and popped at most once. Every pass through the while loop pops one element, so across the whole run there are at most n pops. The total time is O(n), and the space is O(n) too (in the worst case, such as a decreasing array, everything is on the stack).

A twist: return the distance instead of the value

Many problems don’t ask what the next greater element is, but how many steps to the right it is. For example: given daily temperatures, how many days does each day have to wait for a warmer one?

This is where storing indices pays off: when an element gets its answer, subtract its index from the current index to get the distance.

def days_until_warmer(temperatures: list[int]) -> list[int]:
    answer = [0] * len(temperatures)
    waiting: list[int] = []  # Days that haven't seen a warmer day yet

    for today, temperature in enumerate(temperatures):
        while waiting and temperature > temperatures[waiting[-1]]:
            day = waiting.pop()
            answer[day] = today - day
        waiting.append(today)

    return answer

days_until_warmer([3, 1, 2, 5, 4]) returns [3, 1, 1, 0, 0]: day 0 waits 3 days (until the 5 on day 3), day 1 waits 1 day, and so on; days that never get warmer are 0.

That’s exactly 739. Daily Temperatures from the Top 100 Liked list.

A bonus: the top at push time is the nearest element to the left that isn’t smaller

A monotonic stack has another property that’s easy to miss. When a new element has finished popping everything smaller and is about to be pushed, the current top is the first element to its left that is greater than or equal to it.

From the steps above:

The reason is the same as before: everything smaller has been popped, so whatever remains on top is the closest element that isn’t smaller.

def previous_greater_or_equal(nums: list[int]) -> list[int]:
    answer = [-1] * len(nums)
    stack: list[int] = []

    for i, num in enumerate(nums):
        while stack and num > nums[stack[-1]]:
            stack.pop()
        if stack:
            answer[i] = nums[stack[-1]]
        stack.append(i)

    return answer

previous_greater_or_equal([3, 1, 2, 5, 4]) returns [-1, 3, 3, -1, 5].

In other words, one scan gives you two things at once: when an element is popped, the element that popped it is the first greater one to its right, and the new top after the pop is the first one to its left that isn’t smaller. The monotonic stack solution to Trapping Rain Water relies on exactly this: those two taller neighbors are the left and right walls of a dip.

The template and its variants

Abstracting the code above, a monotonic stack is just a few lines (this is pseudocode; replace should_pop and the handling steps for each problem):

stack = []
for i, num in enumerate(nums):
    while stack is not empty and should_pop(nums[stack[-1]], num):
        top = stack.pop()
        handle top: num is the first element to the right of top that meets the condition
    handle i: if stack is not empty, stack[-1] is the first element to the left of i that doesn't meet the pop condition
    stack.append(i)

Change the pop condition to find different things:

To findStack order (bottom → top)Pop condition
First greater element to the rightdecreasingnum > nums[stack[-1]]
First smaller element to the rightincreasingnum < nums[stack[-1]]
First greater-or-equal element to the rightstrictly decreasingnum >= nums[stack[-1]]
First smaller-or-equal element to the rightstrictly increasingnum <= nums[stack[-1]]

A way to remember it: to find something greater, let the smaller elements get popped, and what remains is naturally decreasing. To find something smaller, do the opposite.

When to reach for a monotonic stack

Consider one when a problem says things like:

Monotonic stack problems in the Top 100 Liked list:

ProblemHow it’s used
739. Daily TemperaturesNext greater element as a distance; almost exactly this post’s example
42. Trapping Rain WaterDecreasing stack; each pop yields a dip bottom, left wall, and right wall, and water is counted by layer
84. Largest Rectangle in HistogramIncreasing stack; find the first shorter bar on each side of every bar

Common pitfalls

  1. Storing values or indices: most problems need indices. An index can recover the value and also gives you distances and the slot to fill; with values alone, that information is lost.
  2. Strict or non-strict comparison: > versus >= decides whether equal elements pop each other, which can change the result. Decide up front whether “equal” counts as “greater”.
  3. Forgetting what’s left on the stack: those elements never found an answer. This post initializes the answers to -1 or 0, so nothing extra is needed. If a problem treats them specially, handle them after the loop.
  4. Worrying that it’s O(n²): don’t let the nested loops fool you. Count “each element is pushed and popped at most once”, and it’s O(n).

In one sentence

A monotonic stack is a waiting area for elements still looking for their answer. When a new element arrives, it settles everything smaller (or larger) than itself, and what remains stays in order. That lets you find, for every element, the first greater (or smaller) element to its left or right in O(n) time.

Appendix: full test code

The three functions from this post and their brute-force counterparts, cross-checked on 3,000 random inputs:

"""Cross-check the monotonic stack functions against brute force."""

import random
from collections.abc import Callable


def next_greater_brute_force(nums: list[int]) -> list[int]:
    answer = [-1] * len(nums)
    for i, num in enumerate(nums):
        for later in nums[i + 1 :]:
            if later > num:
                answer[i] = later
                break
    return answer


def next_greater(nums: list[int]) -> list[int]:
    answer = [-1] * len(nums)
    waiting: list[int] = []

    for i, num in enumerate(nums):
        while waiting and num > nums[waiting[-1]]:
            answer[waiting.pop()] = num
        waiting.append(i)

    return answer


def days_until_warmer(temperatures: list[int]) -> list[int]:
    answer = [0] * len(temperatures)
    waiting: list[int] = []

    for today, temperature in enumerate(temperatures):
        while waiting and temperature > temperatures[waiting[-1]]:
            day = waiting.pop()
            answer[day] = today - day
        waiting.append(today)

    return answer


def previous_greater_or_equal(nums: list[int]) -> list[int]:
    answer = [-1] * len(nums)
    stack: list[int] = []

    for i, num in enumerate(nums):
        while stack and num > nums[stack[-1]]:
            stack.pop()
        if stack:
            answer[i] = nums[stack[-1]]
        stack.append(i)

    return answer


def days_until_warmer_brute_force(temperatures: list[int]) -> list[int]:
    answer = [0] * len(temperatures)
    for day, temperature in enumerate(temperatures):
        for later in range(day + 1, len(temperatures)):
            if temperatures[later] > temperature:
                answer[day] = later - day
                break
    return answer


def previous_greater_or_equal_brute_force(nums: list[int]) -> list[int]:
    answer = [-1] * len(nums)
    for i, num in enumerate(nums):
        for earlier in reversed(nums[:i]):
            if earlier >= num:
                answer[i] = earlier
                break
    return answer


Scan = Callable[[list[int]], list[int]]

# (monotonic stack version, brute-force version)
PAIRS: list[tuple[Scan, Scan]] = [
    (next_greater, next_greater_brute_force),
    (days_until_warmer, days_until_warmer_brute_force),
    (previous_greater_or_equal, previous_greater_or_equal_brute_force),
]


def check_fixed_cases() -> None:
    assert next_greater([3, 1, 2, 5, 4]) == [5, 2, 5, -1, -1]
    assert next_greater([]) == []
    assert days_until_warmer([3, 1, 2, 5, 4]) == [3, 1, 1, 0, 0]
    assert previous_greater_or_equal([3, 1, 2, 5, 4]) == [-1, 3, 3, -1, 5]


def check_random_cases(rounds: int = 3000, seed: int = 0) -> None:
    rng = random.Random(seed)
    for _ in range(rounds):
        nums = [rng.randint(0, 6) for _ in range(rng.randint(0, 20))]
        for fast, slow in PAIRS:
            assert fast(nums) == slow(nums), f"{fast.__name__}({nums})"


def main() -> None:
    check_fixed_cases()
    check_random_cases()
    print("all passed")


if __name__ == "__main__":
    main()

Edit page
Share this post:

Previous Post
LeetCode 42. Trapping Rain Water: Four Approaches, from Brute Force to Two Pointers