DSA DSA In Depth
0%
design monotonic-stackamortizeddesign-pattern

Monotonic Stacks: Next Greater Element, Histogram & Subarray Mins

7 min read Module 1.3: Linked Structures, Monotonic Stacks/Queues & Cache-Conscious Lists Updated Oct 23, 2026
📄 Cheat Sheet
Largest Rectangle in Histogram — Monotonic Stack Invariants in O(N)
NeetCode Largest Rectangle in Histogram — Monotonic Stack Invariants in O(N) Watch on YouTube ↗

Deep-dive lecture covering Monotonic Stacks: Next Greater Element, Histogram & Subarray Mins.

0:00 / --:--

Dominance Pruning & Two-Sided Boundary Discovery

Suppose we want to find, for every bar $H[m]$ in a histogram $H[0 \dots N-1]$, the largest rectangle of height $H[m]$ that can be formed using $m$ as the shortest (limiting) bar. That rectangle extends left to the nearest index $L < m$ with $H[L] < H[m]$, and extends right to the nearest index $R > m$ with $H[R] < H[m]$, giving width $(R - L - 1)$ and area $H[m] \cdot (R - L - 1)$.

How does a Monotonic Increasing Stack find both $L$ and $R$ for every $m \in [0, N-1]$ in a single left-to-right scan? Maintain a stack of indices $i_0 < i_1 < \dots < i_k$ such that $H[i_0] \le H[i_1] \le \dots \le H[i_k]$. When scanning index $R$:

  • While the stack is non-empty and $H[\text{stack.top()}] > H[R]$, let $m = \text{stack.pop()}$.
  • Right Boundary ($R$): Because $m$ survived on the stack until $R$ arrived, no index in $(m, R)$ had height $< H[m]$; thus $R$ is the exact Next Smaller Element to the right of $m$!
  • Left Boundary ($L$): What is the new stack.top() sitting immediately below $m$? Every element originally between $L = \text{stack.top()}$ and $m$ was already popped before $m$ was pushed, meaning all of them were $\ge H[m]$ while $H[L] \le H[m]$. Thus $L$ is the exact Previous Smaller Element to the left of $m$!

By the aggregate amortized method, each of the $N$ indices is pushed onto the stack exactly once and popped at most once, bounding total stack operations to $\le 2N = O(N)$ time.

Combinatorial Invariant: Asymmetric Strict/Non-Strict Tie-Breaking for Duplicate Elements

Consider computing the Sum of Subarray Minimums: $\sum_{0 \le i \le j < N} \min(A[i \dots j])$. Instead of iterating over $O(N^2)$ subarrays, count how many subarrays have $A[m]$ as their minimum: if $L$ is the previous smaller index and $R$ is the next smaller index, there are $(m - L)$ choices for the subarray start and $(R - m)$ choices for the subarray end, so $A[m]$ contributes $A[m] \cdot (m - L) \cdot (R - m)$ to the total sum.

What if $A$ contains duplicate values (e.g., [2, 5, 2])?

  • If both left and right searches stop at strictly smaller ($< A[m]$), then for [2, 5, 2] both 2s claim the full subarray [2, 5, 2], double-counting it!
  • If both stop at smaller-or-equal ($\le A[m]$), neither 2 claims [2, 5, 2], under-counting it!
  • The Asymmetric Tie-Break Law: Define the left boundary as strictly smaller ($A[L] < A[m]$) and the right boundary as smaller or equal ($A[R] \le A[m]$), attributing every tied subarray uniquely to its leftmost minimum occurrence!

Monotonic Stack Variants & Boundary Semantics

Stack Order (Bottom $\to$ Top) Pop Condition at Incoming $A[R]$ When $m$ is Popped by $R$ New stack.top() ($L$) Below $m$ Canonical Applications
Monotonic Decreasing ($>$) A[top] < A[R] $R$ is Next Greater of $m$ $L$ is Previous Greater-or-Equal of $m$ Next Greater Element, Daily Temperatures, Stock Span
Monotonic Increasing ($<$) A[top] > A[R] $R$ is Next Smaller of $m$ $L$ is Previous Smaller-or-Equal of $m$ Largest Rectangle in Histogram, Maximal 0-1 Rectangle
Asymmetric Increasing ($<$) A[top] >= A[R] $R$ is Next Smaller-or-Equal of $m$ $L$ is Strictly Smaller ($A[L] < A[m]$) Sum of Subarray Minimums / Ranges (Exact Dedup)

Visualizing Histogram Rectangle Expansion $[L+1 \dots R-1]$ Upon Pop

When R=5 (height 2) Arrives, m=3 (height 5) & m=2 (height 5) Are Popped; L=1 (height 1) Bounds Left i=0: 2 L=1: 1 m=2: 5 i=3: 6 i=4: 5 R=5: 2 Popped m (height 5): - Right bound R = 5 (first bar < 5) - Left bound L = 1 (new stack.top() below m) - Valid Span = [L+1 .. R-1] = [2 .. 4] - Width = R - L - 1 = 5 - 1 - 1 = 3 - Area = 5 * 3 = 15 Sentinel Trick: Append virtual height 0 at index R = N to flush all remaining bars from the stack in 1 loop!

Single-Pass Sentinel-Flushed Histogram Algorithm

flowchart LR A["For R = 0 .. N
cur_h = (R == N ? 0 : H[R])"] --> B{"!st.empty() &&
H[st.back()] >= cur_h"} B -- "Yes (Dominated)" --> C["h = H[st.back()]; st.pop_back()
L = st.empty() ? -1 : st.back()
max_area = max(max_area, h * (R - L - 1))"] C --> B B -- "No" --> D["st.push_back(R)"]
Hardware Reality: Why You Should Never Use std::stack<int> in Hot Loops

In the C++ Standard Template Library, std::stack<T> is a container adapter whose default underlying container is std::deque<T>—not std::vector<T>! As we saw in Module 2, std::deque allocates segmented heap chunks through a pointer-map directory. In a monotonic stack where indices are pushed and popped millions of times inside a tight $O(N)$ loop, using std::vector<int> st; st.reserve(N); (with st.back(), st.push_back(), and st.pop_back()) keeps the entire stack in a single L1-resident contiguous buffer with zero allocations and single-cycle register indexing.

Monotonic Stack (Histogram) & Monotonic Queue (Sliding Window Max)
O(N) Single Pass

Every index is pushed once and popped at most once. Inspect how maintaining a strict monotonic invariant resolves Next Greater/Smaller boundaries and sliding window extrema in O(N) time.

Step 1 / 7
Custom Input:
Timeline

Invariant & State

Stack heights strictly non-decreasing: [2]
🔗 Live Pointer & State Transition Graph — Monotonic Stack (Histogram) & Monotonic Queue (Sliding Window Max) Active Pointer Jump/Transition Matched/Optimal
curr 2 [0] 1 [1] 5 [2] 6 [3] 2 [4] 3 [5]
[0]2curr
[1]1
[2]5
[3]6
[4]2
[5]3
Monotonic Increasing Stack: [0(h=2)]
Scan Index i
0 (h = 2)
Stack Depth
1
Popped Rectangles
0
Max Rectangle Area
0 (0)
[Step 1] h[0]=2 maintains increasing order; pushed index 0 onto stack.

Production Implementation: Largest Histogram Rectangle & Subarray Minimums Sum

#include <algorithm>
#include <cstdint>
#include <vector>

// O(N) Single-Pass Largest Rectangle in Histogram with virtual R = N sentinel (height 0)
std::int64_t largest_rectangle_area(const std::vector<int>& heights) {
  const std::size_t n = heights.size();
  std::vector<std::size_t> st;
  st.reserve(n + 1);
  std::int64_t max_area = 0;

  for (std::size_t r = 0; r <= n; ++r) {
    const int cur_h = (r == n) ? 0 : heights[r];
    while (!st.empty() && heights[st.back()] >= cur_h) {
      const std::int64_t h = heights[st.back()];
      st.pop_back();
      const std::size_t width = st.empty() ? r : (r - st.back() - 1);
      max_area = std::max(max_area, h * static_cast<std::int64_t>(width));
    }
    st.push_back(r);
  }
  return max_area;
}

// O(N) Single-Pass Sum of Subarray Minimums using Asymmetric Tie-Breaking
// Pop condition arr[st.back()] >= cur means:
//   - Right boundary r is the first element <= arr[m]
//   - Left boundary l (new st.back()) is strictly < arr[m]
std::int64_t sum_of_subarray_minimums(const std::vector<int>& arr) {
  const std::size_t n = arr.size();
  std::vector<std::size_t> st;
  st.reserve(n + 1);
  std::int64_t total_sum = 0;

  for (std::size_t r = 0; r <= n; ++r) {
    const int cur = (r == n) ? -2147483648 : arr[r];
    while (!st.empty() && (r == n || arr[st.back()] >= cur)) {
      const std::size_t m = st.back();
      st.pop_back();
      const std::size_t left_count = st.empty() ? (m + 1) : (m - st.back());
      const std::size_t right_count = r - m;
      const std::int64_t ways = static_cast<std::int64_t>(left_count) * static_cast<std::int64_t>(right_count);
      total_sum += static_cast<std::int64_t>(arr[m]) * ways;
    }
    st.push_back(r);
  }
  return total_sum;
}
Production Pitfall: Using Width r - m Instead of r - st.back() - 1 After Popping

In Largest Rectangle in Histogram, a classic mistake is computing the rectangle width for popped bar $m$ as r - m. Why is that wrong? Because taller bars located to the left of $m$ (between the new st.back() and $m$) were already popped earlier when $m$ (or an intermediate bar) arrived! Those popped left bars were all $\ge H[m]$, so $m$'s rectangle extends leftward all the way to st.back() + 1 (or index 0 if the stack is now empty), making the true width st.empty() ? r : (r - st.back() - 1).

Check yourself Question 1 of 4
Concept Check Amortized Complexity of Monotonic Stack recall

A monotonic stack algorithm has an outer `for` loop running $N$ times and an inner `while` loop that pops elements from the stack. In the worst case, a single iteration of the outer loop can execute $N - 1$ pops. Why is the total time complexity across all $N$ iterations strictly $O(N)$ rather than $O(N^2)$?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

How does maintaining the 'right spine' of the Cartesian Tree in a monotonic increasing stack insert element $A[i]$ in $O(1)$ amortized time?

Key Takeaways

A monotonic stack runs in O(N) time because each of the N indices is pushed once and popped at most once across the entire scan (<= 2N operations).
When incoming index R pops index m from a monotonic stack, R is m's nearest dominating boundary on the right, and the new stack top L below m is m's nearest dominating boundary on the left.
To count subarray minimums or maximums without double-counting duplicate values, use asymmetric comparisons: strictly smaller (<) on the left and smaller-or-equal (<=) on the right.
Appending a virtual sentinel at index R = N flushes all remaining unpopped elements from the monotonic stack in a single unified loop.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue