Trapping Rain Water
TWO POINTERS · HARD
The problem
Section titled “The problem”You’re given an array of non-negative integers representing an elevation map: each index is a bar of width 1, and its value is the bar’s height. After it rains, water pools in the low spots between taller bars — picture the whole array as a bumpy skyline, then flood it until no more water can be added without spilling off one of the two open ends. Compute the total volume of water trapped, in unit squares (one bar-width × one unit of height). A bar only holds water above it if there’s a bar at least as tall somewhere to its left and somewhere to its right — a bar with nothing but shorter bars on one side (the tallest bar in the whole array, or either end bar) holds nothing, because water would just run off that open side. Example: height = [0,1,0,2,1,0,1,3,2,1,2,1] → 6 units trapped. height = [4,2,0,3,2,5] → 9 units trapped.
Solution 1: Brute force — rescan left and right from scratch for every index
Section titled “Solution 1: Brute force — rescan left and right from scratch for every index”The most literal reading of the problem: for every single bar, ask “what’s the tallest bar to my left, and what’s the tallest bar to my right?” and answer that by actually looking — scanning outward from the current index in both directions, every time, throwing away whatever the previous index’s scan already found. The water above index i is min(maxLeft, maxRight) - height[i], clamped to 0 when that comes out negative (a bar taller than at least one of its walls holds nothing). This is correct — it directly implements the problem’s own definition — but it’s wasteful: index 5’s left-scan and index 6’s left-scan overlap almost entirely, and neither one remembers what the other found.
height = [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1] — total water trapped: 6 units
Press play to run the brute force solution.
Step 0 / 38
The code
Section titled “The code”function trapBrute(height) { const n = height.length; let total = 0;
for (let i = 0; i < n; i++) { let maxLeft = 0; for (let l = i; l >= 0; l--) { maxLeft = Math.max(maxLeft, height[l]); }
let maxRight = 0; for (let r = i; r < n; r++) { maxRight = Math.max(maxRight, height[r]); }
const water = Math.min(maxLeft, maxRight) - height[i]; if (water > 0) { total += water; } }
return total;}Line by line:
- The outer loop visits every index
iexactly once — that part alone is O(n). - The first inner loop walks from
idown to0, tracking the tallest bar seen — this ismaxLeft, starting fresh at0for everyi, with no memory of any previous index’s scan. - The second inner loop walks from
iup ton - 1the same way formaxRight. Both inner loops includeheight[i]itself, which is harmless — a bar can never be taller than itself, so including it never changes the max. Math.min(maxLeft, maxRight) - height[i]is the water level abovei: water can only rise as high as the shorter of the two walls, since anything higher would spill over that shorter side.- The
if (water > 0)guard clamps negative results to nothing added — a bar taller than at least one of its walls (including the tallest bar in the whole array, which is taller than everything) holds zero water above it. - Each of the two inner loops is O(n) in the worst case, run inside an O(n) outer loop — that’s the O(n²) this solution’s name implies.
Complexity
Section titled “Complexity”| Time | Space | Approach |
|---|---|---|
| O(n²) | O(1) | Fresh left/right scan from every index |
Solution 2: Optimal — two pointers, resolve whichever side has the smaller max
Section titled “Solution 2: Optimal — two pointers, resolve whichever side has the smaller max”Maintain left/right pointers starting at the two ends of the array, and leftMax/rightMax tracking the tallest bar seen so far from each side. At each step, fold the current pointer’s own height into its max (leftMax = Math.max(leftMax, height[left]), symmetrically for the right), then compare the two running maxima — always leftMax against rightMax, never height[left] against height[right]. Whichever side has the smaller max gets resolved and its pointer moves inward:
- If
leftMax <= rightMax, indexleftis safe to resolve. Its true left wall is exactlyleftMax(every bar from0tolefthas already been folded in), and its true right wall is at leastrightMax— becauserightMaxis only the max of the portion the right pointer has already visited, a subset of everything actually to the right ofleft, so the real right wall can only be taller. SinceleftMax <= rightMax <= (true right wall), the smaller wall is provablyleftMax— water aboveleftis exactlyleftMax - height[left], no matter what the unvisited middle of the array looks like. - Symmetrically, if
rightMax < leftMax, indexrightis safe to resolve the same way, usingrightMax - height[right].
Each index gets folded into a max and resolved exactly once, by whichever pointer reaches it first — the loop runs until left === right, at which point every index has been accounted for.
height = [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1] — watch the pointers close in from both ends
Press play to run the optimal two-pointer solution.
Step 0 / 35
The code
Section titled “The code”function trapOptimal(height) { let left = 0; let right = height.length - 1; let leftMax = 0; let rightMax = 0; let total = 0;
while (left < right) { leftMax = Math.max(leftMax, height[left]); rightMax = Math.max(rightMax, height[right]);
if (leftMax <= rightMax) { total += leftMax - height[left]; left++; } else { total += rightMax - height[right]; right--; } }
return total;}Line by line:
left = 0,right = height.length - 1— the pointers start at the two open ends, since either end could turn out to be the array’s tallest bar.leftMax/rightMaxstart at0— bar heights are never negative in this problem, so0is a safe floor that never accidentally beats a real height (see the Interview Corner trick question below for why this specific choice of seed value matters more than it looks).while (left < right)— the moment the pointers meet, every index has already been resolved by whichever side reached it; there’s nothing left to compare.- Folding
height[left]intoleftMax(andheight[right]intorightMax) happens before the comparison, every iteration — this is the detail that makes the “compare the maxes” rule actually correct. Deciding based on stale maxima that haven’t yet accounted for the current pointer’s own height is a real bug; see the Interview Corner below for a concrete case where it silently returns the wrong answer. leftMax <= rightMaxis the entire routing decision: the side with the provably-smaller wall gets resolved and its water added, then that pointer moves one step inward. The<=(not strict<) means ties resolve left — it doesn’t matter which side wins a tie, sinceleftMax === rightMaxgives the same water contribution either way.- When the loop exits,
totalalready holds the full answer — there’s no separate finishing step, because the pointers meeting means the last remaining index was already folded into both maxima on a previous iteration.
Complexity
Section titled “Complexity”| Time | Space | Approach |
|---|---|---|
| O(n) | O(1) | Two pointers, always resolving the side with the smaller max |
Complexity comparison
Section titled “Complexity comparison”| Solution | Time | Space |
|---|---|---|
| Brute force (rescan from every index) | O(n²) | O(1) |
| Optimal (two pointers) | O(n) | O(1) |
The real story here is purely about time, not space. Both solutions use only a constant handful of scalar variables — the brute force never allocates an array proportional to n, and neither does the two-pointer version, so both are honestly O(1) auxiliary space. The win going from brute force to optimal is a genuine asymptotic time improvement: O(n²) because every index triggers two fresh full-array scans, down to O(n) because the two-pointer version folds each bar into a running max and resolves it exactly once, never revisiting work it already did. (A middle-ground solution — precomputing leftMax[] and rightMax[] arrays in one forward and one backward pass — would also reach O(n) time, but at the cost of O(n) space for those two arrays; the two-pointer approach gets the same O(n) time and keeps the O(1) space the brute force already had, which is what actually makes it strictly better rather than just “different.”)
Interview corner
Section titled “Interview corner”Q: Why is it always safe to resolve the side with the smaller max, rather than the side with the smaller current height?
- Say
leftMax <= rightMaxat some step. The true wall on the left ofleftis exactlyleftMax— every bar from index0tolefthas already been folded into it, so there’s nothing left unseen on that side. - The true wall on the right of
leftmight be taller than the trackedrightMax, becauserightMaxonly reflects the portion the right pointer has visited so far — the unvisited middle of the array could still hide a taller bar. But it can never be shorter thanrightMax, sincerightMaxis itself a real bar that’s part of that same right-hand region. - So the true right wall is at least
rightMax, which is at leastleftMaxin this branch — meaning the true right wall is guaranteed >= the true left wall. The shorter of the two walls is therefore provablyleftMax, regardless of what’s hiding in the unscanned middle, soleftMax - height[left]is exactly correct without ever looking further.
Q: What actually breaks if you decide which side to resolve using the maxima before folding in the current pointer’s own height, instead of after?
- Take
height = [1, 3, 2, 6]. The correct answer is1(index 2 holds one unit, bounded by the wall of 3 on its left and 6 on its right). - The buggy version compares
leftMax/rightMaxfirst, then only updates whichever side it already decided to move — so on the very first comparison, both are still0, and a tie (0 <= 0) always routes left without that decision ever having accounted forheight[0] = 1orheight[3] = 6at all. - Tracing it through: the buggy version ends up returning
0for this array — silently wrong, with no error or crash. The correct version, which foldsheight[left]/height[right]into the maxima before comparing, correctly returns1. - The rule to remember: the comparison that decides direction has to reflect the current pointer’s own bar, not last iteration’s leftover state — otherwise you’re routing based on information that’s already out of date.
- ↳ Common follow-up: “Could you catch this bug with a single test case?” Yes — any array where the very first comparison is a tie (both maxima at their starting value of
0) exposes it immediately, which is exactly why[1, 3, 2, 6]works as a minimal counterexample: it’s tiny, and the bug shows up on iteration one.
- ↳ Common follow-up: “Could you catch this bug with a single test case?” Yes — any array where the very first comparison is a tie (both maxima at their starting value of
Q: The algorithm processes all n bars — why is it still described as O(1) space instead of O(n)?
- Big-O space complexity conventionally measures extra (auxiliary) memory the algorithm allocates beyond the input it was already given — not the size of the input itself, which the caller already paid for before the function was ever called.
- This solution only ever holds five scalar variables —
left,right,leftMax,rightMax,total— no matter how largeheightis. Doubling the input size doesn’t add a sixth variable. - Contrast that with an approach that precomputes
leftMax[]/rightMax[]arrays: those genuinely grow withn, so that version is honestly O(n) space, even though it also reaches O(n) time. The two-pointer version is the one that keeps both dimensions minimal at once.
Q: Trick question — what does this log?
console.log(Math.max());console.log(Math.max(...[]));console.log(Math.min(...[]));- Answer: all three log
-Infinity,-Infinity, andInfinityrespectively — not0, and not an error. Math.maxwith zero arguments (including spreading an empty array into it) returns-Infinity— the mathematical identity element for “maximum,” since any real number is >=-Infinity.Math.min()mirrors it with+Infinity.- This page’s
leftMax/rightMax/maxLeft/maxRightall deliberately start at the literal0, not atMath.max()’s built-in “empty” value — a safe choice here because bar heights in this problem are guaranteed non-negative, so0is a true floor no real height can go below. - The trap: it’s tempting to rewrite a running-max loop as a one-liner like
Math.max(...height.slice(0, i + 1)). That’s fine for every actual index in this problem (the slice always has at least the bar atiitself, so it’s never empty) — but the moment that same “just spread it into Math.max” habit gets reused somewhere the collection genuinely can be empty, it silently produces-Infinityinstead of throwing or returning a sane default, and that-Infinitycan propagate through arithmetic for a long time before anything visibly breaks.