Trapping Rain Water
Introduction
The problem is deceptively simple. A non-negative integer array describes the silhouette of a series of concrete pillars of unit width. Pour water from above and count the volume that stays trapped between them.
There’s an obvious solution that runs in . There’s also a much faster answer with constant extra memory, and reaching it requires the right move at the right time. This chapter walks the four approaches an interviewer might want to see — a brute force, a dynamic-programming refinement, a two-pointer sweep, and a monotonic-stack reframing — and the lessons each one teaches transfer well beyond this single problem.
The interview signal isn’t writing the brute force. It’s the explicit move from “recompute” to “precompute” to “eliminate.”
If you’re new to histogram problems, read linearly — each approach builds on the previous one. If you’ve coded the optimal answer before, jump to its correctness invariant — that’s where candidates stumble when pressed to prove the algorithm.
What the interviewer is actually testing
- Articulating the per-column structure. A candidate who dives straight into code without naming what determines the water level at each column usually writes wrong code. Say the relationship out loud first.
- Seeing past the brute force. is the obvious solution. The signal is whether you recognize that the helper data the brute force recomputes can be precomputed, and then further eliminated.
- A correctness invariant. The optimal approach is short but its correctness is non-obvious. Stating the invariant explicitly is the senior signal.
- Knowing the alternatives exist. A monotonic-stack reframing is another lens on the same problem. Mentioning it shows you see histogram problems as a family.
Problem statement
Given a non-negative integer array of size representing an elevation map where each bar has width , compute how many units of water can be trapped between the bars after rain.
Water can pool above a column whenever there are taller bars on both sides; it pools no higher than the lower of those two surrounding walls (otherwise it would spill over).
H = [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1] 6 H = [3, 2, 0, 0, 4, 4, 1, 2, 5, 4, 0, 2, 1] 14 H = [5, 4, 3, 2, 1] 0 Clarifying questions — and why each one matters
Clarification questions
- Q Are the heights always non-negative integers?
- A Per the standard problem, yes. If negatives are allowed the formula still works (water is above the base, clamped at zero) but the semantics become less physical; confirm before assuming.
- Q Can the input be empty or have fewer than three bars?
- A With 0, 1, or 2 bars, no water can be trapped — there isn't a valid 'middle' to pool water above. Guard once at the top.
- Q Is the array mutable?
- A Doesn't matter for any of the approaches here; all of them treat
heightas read-only. Worth asking if the interviewer expects in-place tricks. - Q What's the upper bound on n and on H[i]?
- A n up to 10⁵ is typical; heights to 10⁴ or so. These bounds permit O(n) solutions comfortably. If n is enormous but the array is sparse, we'd reach for a different representation — ask.
- Q Do you want the total water, or a per-column array?
- A Default is the scalar total. If the interviewer asks for the per-column profile, use the prefix-max approach — it already computes each column's contribution; just return the array instead of summing.
A mental framework — the column-by-column formula
Visualize the histogram one column at a time. For column i, water can only settle in the region between height[i] and whatever ceiling the surrounding bars create. The ceiling is the lower of the two tallest bars on either side — because water spills over the shorter side.
That gives the one formula that drives the entire chapter:
where and are the max of in the prefix and suffix including . The total is .
Every approach below is a different strategy for computing or avoiding computing and :
- Brute force recomputes and from scratch on every column.
- Prefix-max arrays precompute both in linear time, then scan.
- Two-pointer sweep eliminates the array by observing that we only need one bound at a time.
- Monotonic stack reframes the sum as a sequence of rectangles discovered on-the-fly.
Discussion
Brute force — recompute maxes at every column
For each inner column, scan the entire prefix and suffix for the max. Apply the formula. Correct, O(n²), your starting line.
The interview narration
Say this out loud:
“For each column
iin the interior, the water above it ismin(leftMax, rightMax) − height[i], clamped at zero. Brute force: for everyi, linear-scan the prefix and suffix to find those two maxes. total, space. Correct but slow — I’ll use it as a baseline.”
How it works, step by step
The outer loop ranges over interior columns ( from to ); the first and last columns can never trap water because one of their sides has no bars to bound the water.
For each :
- Compute
leftMax = max(H[0..i-1])viastd::max_element. Cost: . - Compute
rightMax = max(H[i+1..n-1])similarly. Cost: . - Apply the formula: if
min(leftMax, rightMax) > H[i], add the difference to the running total. - Otherwise the current bar is at least as tall as one of the bounds — no water.
The outer loop runs times; each iteration does work for the two max scans. Total: time, extra space.
The code
#include <algorithm>#include <vector>
int trap(const std::vector<int>& height) { const int n = static_cast<int>(height.size()); if (n < 3) return 0; int total = 0; for (int i = 1; i < n - 1; ++i) { const int leftMax = *std::max_element(height.begin(), height.begin() + i); const int rightMax = *std::max_element(height.begin() + i + 1, height.end()); const int level = std::min(leftMax, rightMax); if (level > height[i]) total += level - height[i]; } return total;}One loop, two std::max_element calls per iteration. The `std::max(0, ...)` clamp is the formula in one line.
The std::max_element calls return iterators; dereferencing gives the actual max value. This is more idiomatic than hand-rolling two inner loops — interviewers notice.
Why it’s rejected
gives operations, which is roughly a minute of wall time. Production code would never ship this, and no interviewer accepts it as the final answer. But it establishes the formula clearly, and every faster approach is a way to avoid recomputing the maxes.
What it gets right
- The formula is correct and direct. Every other approach builds on this same per-column sum; the brute force just computes it the slowest possible way.
- O(1) extra space. No auxiliary arrays or stacks. Until the next approach, this is the space-optimal baseline.
- Easy to verify on small inputs. If you’re ever unsure whether your optimized code is correct, run both on a 10-element input and compare.
Prefix-max arrays — precompute both bounds
Two linear passes compute leftMax and rightMax for every index. A third pass sums the formula. O(n) time, O(n) space.
The insight
The in the brute force came from recomputing the same maxes over and over. Both leftMax[i] and rightMax[i] have overlapping structure — each one extends the previous:
That’s a one-line DP. Two linear passes give us the full arrays; a third pass sums the formula. Everything becomes at the cost of two additional arrays of size .
How it works, step by step
The figure below shows the two ceilings as dashed lines on top of the bars — green for leftMax, red for rightMax. The water at each column is the gap between the lower of the two dashed lines and the bar itself.
leftMax[i] and the dashed red line marks rightMax[i]. Water fills the band between the bar top and the lower of the two dashed lines.- Forward pass for
leftMax. InitializeleftMax[0] = height[0]. For from to ,leftMax[i] = max(leftMax[i-1], height[i]). - Backward pass for
rightMax. InitializerightMax[n-1] = height[n-1]. For from to ,rightMax[i] = max(rightMax[i+1], height[i]). - Summing pass. For each , add
min(leftMax[i], rightMax[i]) − height[i]to the total. (This is always ≥ 0 becausemin(leftMax[i], rightMax[i]) ≥ height[i]— both ceilings includeheight[i]in their maximum.)
The code
#include <algorithm>#include <vector>
int trap(const std::vector<int>& height) { const int n = static_cast<int>(height.size()); if (n < 3) return 0;
std::vector<int> leftMax(n); std::vector<int> rightMax(n);
leftMax[0] = height[0]; for (int i = 1; i < n; ++i) { leftMax[i] = std::max(leftMax[i - 1], height[i]); }
rightMax[n - 1] = height[n - 1]; for (int i = n - 2; i >= 0; --i) { rightMax[i] = std::max(rightMax[i + 1], height[i]); }
int total = 0; for (int i = 0; i < n; ++i) { total += std::min(leftMax[i], rightMax[i]) - height[i]; } return total;}Three linear passes: two to build the prefix-max arrays, one to apply the formula. Clear, correct, easy to argue complexity on.
Why this is a substantial improvement
The two prefix-max arrays are computed once, each in time. The formula sum is another pass. Total: time, auxiliary space. From being a minute of compute (brute) to milliseconds (prefix max).
The code also reads like the math — two DP recurrences followed by a sum. Interviewers can verify correctness at a glance, which matters when time is tight.
Why we can still do better
We pay extra space for the two arrays. Every entry is read exactly once in the summing pass, then discarded. That’s wasteful — at any given column, the algorithm only needs one element from each array. If we can reconstruct those two values on the fly, we can reduce space to . That’s the two-pointer sweep.
Complexity
- Time: — three linear passes.
- Space: for
leftMaxandrightMax.
Two-pointer sweep — eliminate the arrays Recommended
Run two pointers inward from the ends, tracking running maxes. Always advance the side with the smaller current bar; it's bounded by the other side. O(n) time, O(1) space.
The insight, stated cleanly
Look at the formula again: water at column i equals min(leftMax, rightMax) − height[i]. We only need leftMax when it is the smaller of the two, and similarly for rightMax. If we’re standing at a column whose side has the smaller confirmed max, we already know that side is the ceiling — the other side is at least that tall.
Put two pointers l = 0 and r = n-1 at the ends. Track two running values: leftMax (the max of H[0..l]) and rightMax (the max of H[r..n-1]). At each step:
- If
H[l] ≤ H[r]: the left side is the smaller end of the range.leftMaxis the correct ceiling for columnl(we’ve confirmed from the right side that the ceiling is at leastH[r] ≥ H[l], which at worst equalsleftMax). Compute water atl, advancel. - Otherwise: symmetric on the right.
The key move: we never need the absolute rightMax to compute water at l, only the guarantee that rightMax ≥ leftMax. The comparison H[l] ≤ H[r] gives us that guarantee without storing an array.
Start. l = 0, r = 11. leftMax and rightMax both 0.
Three transitions worth naming
The widget above lets you step through every iteration. Three moments are particularly instructive — pause on each and the algorithm’s logic clicks into place.
The first water added. For the first two steps the left side advances over the leading zero and the rising edge [0, 1]; leftMax rises to 1 but the bar is at the same height, so no water accrues. On step 3 the cursor lands on a zero with leftMax = 1, and the formula adds the first unit of water.
The first side switch. Through step 3 the left side has been advancing because H[l] ≤ H[r]. On step 4, H[l] = 2 > H[r] = 1 for the first time, and the right side starts moving. This is the comparison kicking in — the algorithm rebalances which side carries the water-adding work based on which end is currently lower.
The deepest pour. The middle of the array contains the lowest cells (H[5] = 0) under a confirmed left ceiling of 2. Step 7 deposits 2 units in a single pour — the largest single-step contribution in this trace.
After step 7 the algorithm continues until the pointers meet at index 7, accumulating one more unit on the right side. The widget plays this out fully; the table at the heart of every two-pointer trace is just these three rhythms repeating: advance on the smaller side, update its running max, deposit the gap.
The code
#include <algorithm>#include <vector>
int trap(const std::vector<int>& height) { const int n = static_cast<int>(height.size()); if (n < 3) return 0;
int l = 0; int r = n - 1; int leftMax = 0; int rightMax = 0; int total = 0;
while (l < r) { if (height[l] <= height[r]) { leftMax = std::max(leftMax, height[l]); total += leftMax - height[l]; ++l; } else { rightMax = std::max(rightMax, height[r]); total += rightMax - height[r]; --r; } } return total;}Ten-line while loop with symmetric branches. Two running maxes, no auxiliary arrays. The clean C++20-ish idiom is to prefer one `while` with an `if/else` over two loops.
Correctness, precisely — why advancing the smaller side is safe
This is the subsection interviewers probe hardest, and candidates fail most often.
Invariant. When we’re about to process column l with H[l] ≤ H[r], we have:
leftMax ≥ H[k]for allk ≤ l(from the way we updatedleftMax).H[r'] ≥ H[l]for somer' > l— specificallyr' = ritself, becauseH[r] ≥ H[l].
The ceiling at column l is . We know:
- after the update.
- . We don’t know its exact value, but we know , which implies is possible. More importantly, means the right side has at least one bar tall enough that water can be supported from that side up to height
H[l].
Wait — the argument is subtler. The water at l is min(leftMax, R_l) − H[l]. We want to show this equals leftMax − H[l] when H[l] ≤ H[r].
Claim. R_l ≥ leftMax whenever H[l] ≤ H[r].
Proof sketch. R_l is the maximum of H[l..n-1], so R_l ≥ H[r] ≥ H[l]. If leftMax > H[l], then some earlier column in H[0..l-1] already had height ≥ leftMax. But wait — that doesn’t directly give us R_l ≥ leftMax. The argument is actually:
The full rightMax at any time is max(H[r..n-1]). Since we initialized r = n-1 and only decremented r after confirming H[r] > H[l] (i.e., the right side had a taller bar than the left), the running rightMax tracks the tallest right-hand bar we’ve stepped past. Every time we advance r past a bar, that bar’s height is ≤ the H[r'] that “knocked it down” — which itself ≥ the H[l] at the time. Chained, this means the right side always has a bar of height ≥ max(leftMax, H[l]) still waiting (at the new r).
Therefore , and the water is simply leftMax − H[l]. We don’t need R_l’s true value.
The invariant inductively holds: the side we advance has a certified ceiling from the other side, so its water contribution uses only the running max on its own side.
Edge cases
- Strictly increasing (
[1, 2, 3, 4, 5]): every step hasH[l] ≤ H[r];leftMaxrises to matchH[l]on every step; water added = 0 each time. Total = 0. Correct. - Strictly decreasing (
[5, 4, 3, 2, 1]): every step hasH[l] > H[r]; symmetric, same conclusion. Total = 0. - Single valley (
[3, 0, 3]): step 1 hasH[l] = 3 ≤ H[r] = 3; leftMax = 3, water at l=0 = 0, advance. Step 2:H[l] = 0 ≤ H[r] = 3, leftMax still 3, water = 3. Step 3: pointers meet. Total = 3. - Plateau (
[2, 2, 2, 2]): every comparison is ≤, leftMax = 2 throughout, water = 0 on every column. Total = 0.
Complexity
- Time: . Each iteration advances exactly one pointer; total iterations ≤ .
- Space: . Two integers plus two counters.
Monotonic stack — rectangles on the fly
Maintain a stack of bar indices in strictly decreasing height order. When a taller bar arrives, pop and compute the bounded rectangle it just closed. O(n) time, O(n) space.
The insight
The three previous approaches think column-by-column. The stack approach thinks rectangle-by-rectangle. Every time a taller bar arrives to the right of some shorter bar, the shorter bar has just been enclosed between two taller ones (the left neighbor already on the stack and the new bar on the right). The area of the bounded rectangle is min(left, right) − bottom times the horizontal distance.
By processing bars left to right and maintaining a decreasing monotonic stack, every bar is pushed once and popped once. When a pop happens, we’re closing a rectangle — and summing those closed rectangles exactly equals the trapped water.
How it works, step by step
Maintain a stack of bar indices (not heights — we need the distance for the rectangle width) where heights strictly decrease from bottom to top. For each new bar height[i]:
- While the stack is non-empty and
height[i] > height[stack.top()]:- Pop the top index (call it
bottom). This bar is about to be “closed” by a taller bar on its right. - If the stack is empty after the pop, no left bound exists — exit the inner loop.
- Otherwise: peek the new top (call it
left). The bar atleftis the closest taller bar onbottom’s left. - Compute the rectangle:
width = i − left − 1,heightBounded = min(height[i], height[left]) − height[bottom], and addwidth × heightBoundedto the total.
- Pop the top index (call it
- Push
ionto the stack.
At the end, any bars still on the stack are never “closed” (no taller bar arrives to their right), so they contribute nothing — which is correct, because water needs a taller bar on each side.
The code
#include <algorithm>#include <stack>#include <vector>
int trap(const std::vector<int>& height) { std::stack<int> stk; int total = 0; const int n = static_cast<int>(height.size());
for (int i = 0; i < n; ++i) { while (!stk.empty() && height[i] > height[stk.top()]) { const int bottom = stk.top(); stk.pop(); if (stk.empty()) break; const int left = stk.top(); const int width = i - left - 1; const int bounded = std::min(height[i], height[left]) - height[bottom]; total += width * bounded; } stk.push(i); } return total;}A monotonic stack of indices. The while loop is where the real work happens; each bar is pushed once, popped at most once.
Amortized argument — why it’s
The outer for loop runs times. The inner while loop looks like it could blow up, but each iteration pops exactly one element from the stack. Across the full run, the total number of pops is bounded by the total number of pushes — which is . So the total inner-loop work, summed over the whole algorithm, is .
This is the same amortized pattern as the chain-head filter in Longest Consecutive Sequence and the sliding-window maximum deque. A nested loop that looks is actually because each element is touched a bounded number of times.
When to use the stack approach
- When other histogram problems surround this one. Monotonic stacks solve Largest Rectangle in Histogram, Next Greater Element, Maximal Rectangle, and more. If your interview is walking through that family, the stack is a unifying tool.
- When you want to compute per-column rectangles and not just per-column water. The stack processes geometric regions; the two-pointer processes heights. Some follow-ups (e.g., “return the largest single puddle”) are easier from the stack view.
- When the two-pointer invariant feels shaky. Some candidates simply find the stack formulation easier to prove — one rectangle, one pop, visible correctness.
Complexity
- Time: amortized. Each bar pushed once, popped at most once.
- Space: for the stack. In the worst case (strictly decreasing heights), all bars are stacked.
Approach comparison
| Approach | Time | Space | When to reach for it |
|---|---|---|---|
| Brute force | Baseline in narration; never in production. | ||
| Prefix-max arrays | When clarity > memory. Reads like the formula. | ||
| Two-pointer sweep | Default. Constant space, single pass. | ||
| Monotonic stack | When the problem sits inside a histogram-family interview. |
Default answer: two-pointer. State the “always advance the smaller side” invariant out loud when you close.
Follow-ups to anticipate
| The interviewer asks… | What to do |
|---|---|
| ”Return the water level at each column, not just the total.” | Switch to prefix-max: already computes per-column contributions. Return the array instead of the sum. |
| ”What about a 2D grid — water trapped on a 2D elevation map?” | Different problem. Requires a min-heap BFS starting from the boundary (Trapping Rain Water II, LC 407). The 1D formula doesn’t generalize. |
| ”Return the largest single puddle.” | Use the stack approach — each pop yields one rectangle. Track the maximum rectangle as you go. |
| ”What if bars have varying widths?” | Replace width in every formula with the actual bar width. Same algorithm otherwise. |
| ”Heights arrive as a stream — compute water incrementally.” | The stack approach extends naturally: each new bar triggers the pop-and-compute loop. The two-pointer doesn’t fit streams. |
| ”Can we do O(1) space AND O(n) time without the pointer comparison trick?” | No general-purpose method known. The two-pointer sweep is tight on both. |
| ”Heights in floats, not ints.” | All four approaches work unchanged — comparisons and arithmetic only. Watch for floating-point precision near the clamp-at-zero boundary. |
| ”What’s the worst-case stack depth in the monotonic approach?” | on a strictly decreasing input: every bar is pushed and none is popped until the algorithm ends (and in that case, no water is trapped — consistent with correctness). |
Pattern recognition — min-bound aggregation
The move this chapter teaches — value at position i is bounded by the minimum of two aggregates over the prefix and suffix — is a recurring shape:
- Largest Rectangle in Histogram. Same stack framework, different geometry (max rectangle instead of trapped area).
- Container With Most Water. Two-pointer with a similar “advance the smaller side” invariant. The key comparison is on heights, not on running maxes.
- Sliding Window Maximum. A monotonic deque, not a stack, but the amortized-linear-touches-per-element argument is the same.
- Minimum in Every Window. Same deque idea, inverted.
- Candy. Two linear passes (left-to-right and right-to-left) reminiscent of the prefix-max arrays.
- Shortest Unsorted Continuous Subarray. Prefix-max + suffix-min to locate the two boundaries.
The reflex: when a problem asks for a quantity at each index bounded by aggregates over the prefix and suffix, think prefix/suffix arrays first, then look for a two-pointer elimination. Add monotonic-stack if the problem is naturally geometric.
What to say out loud — the delivery script
Ten beats:
- “Let me restate: given an array of non-negative heights, compute the total water trapped between the bars.”
- Ask: “non-negative only? any constraints on n? total or per-column?”
- “The core formula: water at column i equals min(leftMax[i], rightMax[i]) minus height[i], clamped at zero.”
- “Brute force: recompute leftMax and rightMax at every i. That’s . Name it, move on.”
- “Prefix-max arrays: precompute both in two linear passes, then sum the formula. time, space.”
- “Two-pointer sweep: we don’t need both bounds at once. Always advance the side with the smaller current bar — that side’s running max is the true ceiling because the other side has at least one bar taller than our current.”
- Write the two-pointer code — 10 lines. Symmetric if/else branches.
- Trace the canonical example on the whiteboard. Point at a step where
H[l] > H[r]and say: “we advancerbecause the left is confirmed higher —rightMaxis the ceiling atr.” - State the invariant explicitly: “at every step, the side we advance has a confirmed ceiling from the other side. That’s why
running max on this sideequalsmin(leftMax, rightMax)for that column.” - ” time, space. If the interviewer wants per-column output or a 2D grid I’d switch to prefix-max or min-heap BFS. For the standard problem, this is what I’d ship.”
Summary
Trapping Rain Water is the canonical histogram problem. Four approaches walk a careful arc — recompute the helpers, precompute them, eliminate them, then reframe the problem as rectangles — and each step buys an order-of-magnitude improvement in either time or space.
The interview signal isn’t the final code. It’s recognizing that the obvious answer can be improved, naming what’s expensive about it, and walking the optimization staircase deliberately rather than guessing the optimal answer up front. The broader lesson — the “min-bound aggregation” pattern — appears across the histogram-problem family (largest rectangle, container with most water, sliding-window maximum) and any setting where a per-index quantity depends on aggregates from both directions.