python_coding / 08 · stacks-monotonic lesson 8 / 19

Stacks, queues & monotonic stacks

Remember the recent past, in order. A stack is the right tool whenever the most-recent unresolved thing is the next to resolve; the monotonic-stack specialization answers "nearest greater/smaller element" for every position in a single O(n) sweep.

Why this pattern

A stack (LIFO) is the right tool whenever the MOST RECENT unresolved thing is the next to resolve: matching brackets, undo, nested structure, "the last open context." A queue (FIFO) is the right tool when order of arrival must be preserved: BFS frontiers, task pipelines. Python's list is a fine stack (append/pop are O(1)); use collections.deque for a queue (popleft is O(1), whereas list.pop(0) is O(n)).

The high-value specialization is the MONOTONIC STACK / DEQUE, which is what interviewers are usually probing for.

The monotonic-stack mental model

Trigger signal — phrased almost verbatim in the problem: "for each element, find the NEAREST greater/smaller element to its left/right" (next greater element, daily temperatures, stock span, largest rectangle, trapping rain).

Keep a stack of candidates that are still "waiting for their answer," kept in monotonic order (e.g. strictly decreasing). The INVARIANT: every element on the stack is still un-resolved, and they're ordered so that the newcomer resolves a contiguous run of them from the top.

When a new element arrives, POP WHILE THE NEW ELEMENT DOMINATES the stack top (e.g. new value > top for a decreasing stack). Each pop means "the newcomer is this popped element's answer (its next-greater)." Then push the newcomer.

Why it's O(n) despite the inner while
Every index is pushed exactly once and popped at most once. The total number of pop operations across the entire run is therefore <= n. So the inner while is O(n) AMORTIZED over the whole scan, not per element — the same amortization argument as the sliding window (lesson 05).
Why it's correct
Because the stack is kept monotonic, when we pop element x for newcomer y, every element BETWEEN x and y was already smaller than x (else it would have popped x earlier) — so y really is the FIRST element past x that dominates it. The monotonic invariant is exactly the proof of "nearest."

Monotonic deque (sliding-window maximum). A deque variant supports a window that moves on BOTH ends: push new elements on the right (popping smaller ones, since they can never be the max while a bigger newer element exists), and pop from the LEFT when the front index slides out of the window. The front is always the current window maximum. O(n) total.

Plain stack uses

LeetCode 20 — Valid Parentheses

The most recent unmatched opener must be the first to close (LIFO). Push openers; on a closer, the stack top MUST be its match, else it's invalid. An empty stack at the end means everything matched.

def is_valid_parentheses(s: str) -> bool:
    match = {")": "(", "]": "[", "}": "{"}
    stack = []
    for ch in s:
        if ch in match:                       # a closer
            if not stack or stack[-1] != match[ch]:
                return False
            stack.pop()
        else:                                 # an opener
            stack.append(ch)
    return not stack
Complexity
TIME O(n), SPACE O(n). When to use: nested/balanced structure where last-opened closes first.

LeetCode 155 — Min Stack

push/pop/top/getMin all O(1). A second "mins" stack mirrors the main stack, where mins[i] is the minimum of everything at or below depth i. Each push records the running min (min of new value and previous min), so getMin is just mins[-1]. No scanning needed.

class MinStack:
    def __init__(self):
        self._data = []
        self._mins = []
    def push(self, x: int) -> None:
        self._data.append(x)
        self._mins.append(x if not self._mins else min(x, self._mins[-1]))
    def pop(self) -> None:
        self._data.pop()
        self._mins.pop()
    def top(self) -> int:
        return self._data[-1]
    def get_min(self) -> int:
        return self._mins[-1]
Complexity
TIME O(1) per op, SPACE O(n). When to use: stack that must also report its current minimum in O(1).

LeetCode 150 — Evaluate Reverse Polish Notation

In postfix, an operator applies to the two most recent operands — exactly LIFO. Push numbers; on an operator pop two, combine, push the result. Order matters for - and /: the deeper pop is the left operand.

def eval_rpn(tokens: List[str]) -> int:
    stack = []
    ops = {"+", "-", "*", "/"}
    for tok in tokens:
        if tok in ops:
            b = stack.pop()
            a = stack.pop()
            if tok == "+":
                stack.append(a + b)
            elif tok == "-":
                stack.append(a - b)
            elif tok == "*":
                stack.append(a * b)
            else:
                stack.append(int(a / b))      # truncate toward zero
        else:
            stack.append(int(tok))
    return stack[-1]
Complexity
TIME O(n), SPACE O(n). When to use: evaluate postfix / fold a sequence with deferred operands.

LeetCode 71 — Simplify Path

Unix absolute path canonicalization. A path is a stack of directory names. "." is a no-op, ".." pops the last directory (if any), a real name pushes. Join the survivors.

def simplify_path(path: str) -> str:
    stack = []
    for part in path.split("/"):
        if part == "" or part == ".":
            continue
        if part == "..":
            if stack:
                stack.pop()
        else:
            stack.append(part)
    return "/" + "/".join(stack)
Complexity
TIME O(n), SPACE O(n). When to use: hierarchical navigation with "go up one level" semantics.

LeetCode 394 — Decode String

E.g. "3[a2[c]]" -> "accaccacc". Nesting ⇒ stack. On '[' push the (count, partial-string-so-far) context and reset; on ']' pop the context and append the just-built chunk repeated count times. The stack restores each enclosing context in LIFO order, which is exactly how nested brackets unwind.

def decode_string(s: str) -> str:
    stack = []                       # holds (prev_string, repeat_count)
    cur = ""
    num = 0
    for ch in s:
        if ch.isdigit():
            num = num * 10 + int(ch)
        elif ch == "[":
            stack.append((cur, num))
            cur, num = "", 0
        elif ch == "]":
            prev, count = stack.pop()
            cur = prev + cur * count
        else:
            cur += ch
    return cur
Complexity
TIME O(output length), SPACE O(depth + output). When to use: nested encodings / recursive structure parsed iteratively.

Queue / deque

LeetCode 232 — Implement Queue using Stacks

An in stack receives pushes; an out stack serves pops. When out is empty, pour in into out, reversing order so the oldest element surfaces (FIFO from two LIFOs). Each element is moved at most once ⇒ O(1) amortized per op.

class MyQueue:
    def __init__(self):
        self._in = []
        self._out = []
    def push(self, x: int) -> None:
        self._in.append(x)
    def _shift(self) -> None:
        if not self._out:
            while self._in:
                self._out.append(self._in.pop())
    def pop(self) -> int:
        self._shift()
        return self._out.pop()
    def peek(self) -> int:
        self._shift()
        return self._out[-1]
    def empty(self) -> bool:
        return not self._in and not self._out
Complexity
TIME O(1) amortized, SPACE O(n). When to use: FIFO from stack primitives; the classic two-stack trick.

Monotonic stack

LeetCode 496-style — Next Greater Element

For each index, the next greater element to its RIGHT (or -1 if none). Keep a stack of indices whose answer is still pending, in DECREASING value order. The newcomer pops every pending index it exceeds — it is their next-greater. Survivors stay waiting. Each index pushed/popped once ⇒ O(n).

def next_greater_elements(nums: List[int]) -> List[int]:
    n = len(nums)
    ans = [-1] * n
    stack = []                         # indices, nums[stack] strictly decreasing
    for i, x in enumerate(nums):
        while stack and nums[stack[-1]] < x:
            ans[stack.pop()] = x       # x dominates this pending index
        stack.append(i)
    return ans
Complexity
TIME O(n), SPACE O(n). When to use: "nearest greater element to the right" for every position.

LeetCode 739 — Daily Temperatures

ans[i] = days until a warmer temperature (0 if none). Identical monotonic-stack skeleton, but store INDICES and report the DISTANCE (i - popped_index) instead of the value — the question asks "how many days," not "which temperature."

def daily_temperatures(temps: List[int]) -> List[int]:
    n = len(temps)
    ans = [0] * n
    stack = []                         # indices with decreasing temperature
    for i, t in enumerate(temps):
        while stack and temps[stack[-1]] < t:
            j = stack.pop()
            ans[j] = i - j             # days waited until warmer
        stack.append(i)
    return ans
Complexity
TIME O(n), SPACE O(n). When to use: "how far to the next strictly greater value" per position.

LeetCode 84 — Largest Rectangle in Histogram

A bar's maximal rectangle extends left/right until it meets a SHORTER bar. A monotonic INCREASING stack of indices lets us, when a shorter bar arrives, pop each taller bar and finalize its rectangle: the popped bar's height times the width between the new shorter bar and the new stack top (the first shorter bar on the left). A sentinel 0 at the end flushes everything still on the stack.

def largest_rectangle_area(heights: List[int]) -> int:
    stack = []                         # indices with increasing heights
    best = 0
    for i, h in enumerate(heights + [0]):    # trailing 0 forces a final flush
        while stack and heights[stack[-1]] >= h:
            height = heights[stack.pop()]
            # left boundary is the new top; width spans (top, i) exclusive
            left = stack[-1] if stack else -1
            width = i - left - 1
            best = max(best, height * width)
        stack.append(i)
    return best
Complexity
TIME O(n), SPACE O(n). When to use: max-area / span bounded by the nearest smaller on both sides.

LeetCode 42 — Trapping Rain Water (monotonic-stack formulation)

Water sits in "valleys" bounded by a taller bar on each side. Keep a DECREASING stack of indices. When a taller bar arrives, the popped bar is a valley floor; water above it is bounded by min(left wall, right wall) minus the floor height, times the width between the two walls. Each bar is pushed/popped once ⇒ O(n).

def trap(height: List[int]) -> int:
    stack = []                         # indices, decreasing heights
    water = 0
    for i, h in enumerate(height):
        while stack and height[stack[-1]] < h:
            floor = height[stack.pop()]
            if not stack:              # no left wall -> water spills out
                break
            left = stack[-1]
            width = i - left - 1
            bounded = min(height[left], h) - floor
            water += width * bounded
        stack.append(i)
    return water
Complexity
TIME O(n), SPACE O(n). When to use: accumulate area trapped between nearest-taller bounds.

Monotonic deque

LeetCode 239 — Sliding Window Maximum

Maintain a deque of indices whose values are DECREASING. Pushing a new value evicts smaller values from the back (they can never be the max while a larger, newer value is present). Evict the front when its index falls out of the window [i-k+1, i]. The front index is always the window's maximum. Each index enters and leaves once ⇒ O(n).

def max_sliding_window(nums: List[int], k: int) -> List[int]:
    dq = deque()                       # indices, nums[dq] decreasing
    out = []
    for i, x in enumerate(nums):
        while dq and nums[dq[-1]] < x:        # smaller-and-older values are useless
            dq.pop()
        dq.append(i)
        if dq[0] <= i - k:                    # front slid out of the window
            dq.popleft()
        if i >= k - 1:                        # window is full -> record its max
            out.append(nums[dq[0]])
    return out
Complexity
TIME O(n), SPACE O(k). When to use: running max/min over a fixed-width sliding window in O(n).

Spot-checks: next_greater_elements([2,1,2,4,3]) == [4,2,4,-1,-1], largest_rectangle_area([2,1,5,6,2,3]) == 10, trap([0,1,0,2,1,0,1,3,2,1,2,1]) == 6, and max_sliding_window([1,3,-1,-3,5,3,6,7], 3) == [3,3,5,5,6,7].

Takeaway
Reach for a stack when "most recently opened resolves first" — brackets, RPN, path navigation, nested decoding. The interview-grade move is the monotonic stack: store indices in monotonic order, pop while the newcomer dominates, and you get "nearest greater/smaller on each side" for free in O(n) amortized. Next-greater, daily temperatures, largest rectangle, and trapping rain are all the same skeleton with a different report (value vs distance vs area). The monotonic deque extends this to a windowed running max. Next, lesson 09 moves from contiguous arrays to pointer-linked structure: linked lists.