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.
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]both2s claim the full subarray[2, 5, 2], double-counting it! - If both stop at smaller-or-equal ($\le A[m]$), neither
2claims[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
Single-Pass Sentinel-Flushed Histogram Algorithm
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)"]
std::stack<int> in Hot LoopsIn 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.
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.
Invariant & State
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;
}
r - m Instead of r - st.back() - 1 After PoppingIn 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).