DSA Trapping Rain Water
Trapping Rain Water counts the units of water held between bars after rain. Each position holds water up to the lower of the tallest walls on its two sides.
The problem
Trapping Rain Water counts the units of water held between bars after rain. Each bar has a height. Water settles in the dips between taller bars.
A position holds water up to a level set by the walls around it. Water above the two end positions drains away, so the ends themselves hold none.
The cases used in this article
cases = [
([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1], 6),
([4, 2, 0, 3, 2, 5], 9),
([2, 0, 2], 2),
([3, 3, 3], 0),
([], 0),
]
for heights, expected in cases:
print("heights", heights, "-> water", expected)The third case is the smallest real trap. One dip of depth two sits between two walls of height two. The flat case holds nothing, because no dip exists.
Intuition: each position is capped by its lower wall
A running maximum is the tallest bar seen so far from one side. Each position has a running maximum from the left and one from the right. The smaller of the two caps the water level at that position.
The water at a position is that cap minus the bar height. The formula is simple. The trap is its price when both maxima are rebuilt from scratch for every position.
The formula at work, and the rescan price
heights = [3, 1, 2, 0, 2]
scans = 0
total = 0
for i, h in enumerate(heights):
left_max = 0
for x in heights[:i + 1]:
scans += 1
left_max = max(left_max, x)
right_max = 0
for x in heights[i:]:
scans += 1
right_max = max(right_max, x)
total += min(left_max, right_max) - h
print("water:", total)
print("bar visits for maxima:", scans)Five bars already cost thirty bar visits. Each maximum scan repeats work the neighbouring position just did. The maxima only ever grow as a scan advances, and that fact is wasted here.
Brute force: rebuild both maxima at every position
The brute force writes the intuition as a function. Every position scans to the left end for its left maximum. Then it scans to the right end for its right maximum.
The brute force, with visits counted
def trap_brute(heights):
water = 0
visits = 0
for i, h in enumerate(heights):
left_max = 0
for x in heights[:i + 1]:
visits += 1
left_max = max(left_max, x)
right_max = 0
for x in heights[i:]:
visits += 1
right_max = max(right_max, x)
water += min(left_max, right_max) - h
return water, visits
print("main case:", trap_brute([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]))
print("second case:", trap_brute([4, 2, 0, 3, 2, 5]))
print("small case:", trap_brute([2, 0, 2]))The answers match the cases from the start. The visit counts expose the cost. Twelve bars cost well over a hundred visits, and doubling the bars roughly quadruples that count.
The optimal solution: two pointers carrying their running maxima
Two pointers start at the two ends and carry one running maximum each. The side with the shorter current bar is settled next, because a taller bar on the other side already guarantees its level is capped by its own side.
Settling a position adds its capped level minus its height to the total. Each move settles exactly one position. The pointers meet when every position is settled.
The pointer solution on every case
def trap(heights):
left, right = 0, len(heights) - 1
left_max = 0
right_max = 0
water = 0
moves = 0
while left < right:
moves += 1
if heights[left] < heights[right]:
left_max = max(left_max, heights[left])
water += left_max - heights[left]
left += 1
else:
right_max = max(right_max, heights[right])
water += right_max - heights[right]
right -= 1
return water, moves
cases = [
([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1], 6),
([4, 2, 0, 3, 2, 5], 9),
([2, 0, 2], 2),
([3, 3, 3], 0),
([], 0),
]
for heights, expected in cases:
water, moves = trap(heights)
print("heights", heights, "->", water, "match:", water == expected, "moves:", moves)All five cases pass. The move count stays below the bar count on every skyline. No table is built, and no position is visited twice.
Walkthrough: [3, 1, 2, 0, 2] step by step
The walk settles one position per line. The state is the two pointers, the two running maxima, and the water total so far.
The state after each move
heights = [3, 1, 2, 0, 2]
left, right = 0, len(heights) - 1
left_max = 0
right_max = 0
water = 0
while left < right:
if heights[left] < heights[right]:
left_max = max(left_max, heights[left])
gained = left_max - heights[left]
print("settle left index", left, "height", heights[left], "- gained", gained)
water += gained
left += 1
else:
right_max = max(right_max, heights[right])
gained = right_max - heights[right]
print("settle right index", right, "height", heights[right], "- gained", gained)
water += gained
right -= 1
print("total water:", water)The right wall of height 2 settles first and gains nothing. The low bar beside it then gains two units under that cap. The walk settles the middle bar for nothing and the bar of height 1 for one unit, and the total reads three.
Complexity
The brute force scans the whole skyline for each position, so its time is O(n^2) for n bars. The pointer walk settles each position once, so its time is O(n).
The pointer walk stores two maxima, two indexes, and the total, so its extra space is O(1). A precomputed table of left and right maxima would also run in O(n) time, at the price of O(n) space.
Counting the work instead of timing it
def visits_brute(n):
return sum((i + 1) + (n - i) for i in range(n))
for n in [5, 10, 20]:
print("bars", n, "- brute visits:", visits_brute(n), " pointer moves:", max(n - 1, 0))Brute visits grow with the square of the bar count. Pointer moves grow in a straight line. The gap widens with every bar added.
Edge cases and pitfalls
A rising skyline holds no water. Every dip needs a wall on both sides, and a rising run never closes a dip on the right. A falling skyline fails the same way in mirror image.
Edge cases, run
def trap(heights):
left, right = 0, len(heights) - 1
left_max = 0
right_max = 0
water = 0
moves = 0
while left < right:
moves += 1
if heights[left] < heights[right]:
left_max = max(left_max, heights[left])
water += left_max - heights[left]
left += 1
else:
right_max = max(right_max, heights[right])
water += right_max - heights[right]
right -= 1
return water, moves
print("empty:", trap([]))
print("one bar:", trap([5]))
print("two bars:", trap([2, 1]))
print("flat:", trap([3, 3, 3]))
print("rising:", trap([1, 2, 3, 4]))
print("falling:", trap([4, 3, 2, 1]))
print("deep single dip:", trap([5, 0, 5]))The classic pitfall is settling the taller side first. Its cap is not yet known, because a still taller wall may wait beyond the other pointer. The shorter current bar is always the safe side to settle.
Updating the running maximum before adding water matters too. A new tallest bar must add zero for itself. The maximum rises first, and the subtraction then gives zero naturally.
Exercise
The left running maximum is 2 and the right one is 5. The left bar is 1 tall and is processed now. How much water sits on it?