Container With Most Water

water level = min(8, 7) = 7|area = 7 x 7 = 49

Imagine you're standing in a canyon, looking at a row of vertical walls rising out of the ground. Some are tall, some are short, some are middling. You want to pick two of these walls and fill the gap between them with water — like building a makeshift swimming pool.

The catch: water doesn't care about the taller wall. It spills over the shorter one. No matter how tall the right wall is, if the left wall is only 3 units high, the water level tops out at 3. The area of water you can hold is min(height[L], height[R]) * (R - L) — the shorter wall times the distance between them.

You already know how to squeeze two pointers inward. But this time there's no target sum. No “too big” or “too small.” The decision is different: which pointer do you move, and why?

That question — which pointer to sacrifice — is the entire lesson. By the end, you'll have a second movement rule in your two-pointer toolkit, and you'll know exactly when to reach for it.

Try some pairs

Heights: [1, 8, 6, 2, 5, 4, 8, 3, 7]. Pick two walls and see how much water they hold.

Don't strategize yet — just explore. Tap two bars to form a container, see the water fill, then try another pair. Pay attention to what makes some pairs better than others. Is it always the tallest walls? The widest gap? Something else?

Tap two walls to form a container0 pairs checked
best area

Predict the move

Four scenarios. For each one, the two pointers are already placed. Which one should move inward?

Watch what happens to the area when you choose wrong — the number drops immediately. The correct move isn't random. There's a principle hiding in these scenarios, and you'll feel it before we name it.

L is height 3, R is height 7. Which pointer should move?1/4
L
3
9
2
R
7
area = 9
min(3, 7) x 3

A different kind of squeeze

In the two-sum lesson, the movement rule came from arithmetic. Current sum too big? Move R leftward to decrease it. Too small? Move L rightward to increase it. There was always exactly one correct direction, dictated by comparing the sum against a target.

Here there's no target. The area isn't “too big” or “too small” relative to anything — you're trying to maximize it. So what drives the movement?

Before reading on, think about this: if you move the TALLER wall inward, can the area ever increase? The formula is min(height[L], height[R]) * (R - L). The width always shrinks by 1. What happens to the min() term when you abandon the tall wall but keep the short one? Hold your answer.

bottleneck37
L
R

min(3, 7) x 5 = 15

39w=8
min(3,9)=3×8=24

Look at the formula again. Two factors multiply together. The width (R - L) always decreases when you move a pointer inward — there's nothing you can do about that. Every step shrinks the width by exactly 1.

So the only hope for a bigger area is the other factor: min(height[L], height[R]). This is the water level. It equals whichever wall is shorter. That shorter wall is the bottleneck — it's the constraint that limits everything.

If you move the taller wall inward, the width drops by 1. And the bottleneck? It's still the same short wall — still limiting the water level to the same height. The min() term either stays the same or drops (if the new wall is even shorter). Area can only decrease or stay equal. You've gained nothing. (Did your prediction match?)

Now think about moving the shorter wall inward. The width still drops by 1 — that's unavoidable. But the new wall might be taller than the one you just abandoned. If it is, the min() term increases. The bottleneck rises. And a higher water level might more than compensate for the narrower width. Moving the shorter wall is the only move that has a chance of helping.

This is greedy logic at its purest: you can't control the width shrinking, so you focus on the one variable you can influence. Kill the bottleneck.

The bottleneck argument

This is worth saying precisely, because it's the entire correctness proof for the algorithm.

Before reading on, predict: suppose L is the shorter wall. You're about to abandon L and skip every pair (L, j) where j < R. Could any of those skipped pairs hold more water than the current pair (L, R)? Think about what constrains the water level in every pair that includes L. Hold your answer, then read on.

Here's the question to hold in your mind: when we move the shorter wall and skip all the pairs it could have formed with interior walls, how do we know none of those skipped pairs were the global maximum? If you can answer that, you understand why this algorithm is correct — not just fast.

When both pointers are at positions L and R, the area is min(height[L], height[R]) * (R - L). Suppose height[L] < height[R] — L is the shorter wall.

Consider every pair (L, j) where j < R. Each of these pairs has a smaller width than (L, R). And the water level is at most height[L] — because L is still in the pair, and it's still the bottleneck (or the new wall is even shorter). So every pair (L, j) where j < R has area less than or equal to height[L] * (R - L), which is the current area.

That means no unexplored pair involving L can beat the current area. We can safely discard L and move to L + 1. We haven't skipped any potential maximum.

1L86254837R

The same argument works symmetrically when R is the shorter wall. And when they're equal? Neither can improve — moving either is fine.

This is why the greedy choice is provably correct, not just a heuristic. Every pair we skip is guaranteed to be no better than the one we already measured.

Full walkthrough

Now run the whole algorithm on [1, 8, 6, 2, 5, 4, 8, 3, 7]. At each step, predict which pointer moves. Watch the code panel on the right — it highlights which branch of the if/else executes for your prediction, connecting the visual movement to the actual implementation.

Step 1/8max area so far: 8
L
1
8
6
2
5
4
8
3
R
7
area = 8
min(1, 7) x 8
1
function maxArea(height) {
2
  let l = 0, r = height.length - 1;
3
  let best = 0;
4
  while (l < r) {
5
    const area = Math.min(height[l], height[r]) * (r - l);
6
    best = Math.max(best, area);
7
    if (height[l] <= height[r]) l++;
8
    else r--;
9
  }
10
  return best;
11
}
L is height 1, R is height 7. Which is the bottleneck?

What just happened

You walked through 8 steps. The array has 9 elements, meaning there are C(9,2) = 36 possible pairs. The two-pointer approach checked only 8 of them and found the global maximum.

Quick check before the explanation: did the running maximum increase at every step, or were some steps "wasted"? Think back to what you just observed. If some steps produced a smaller area, why was that okay?

8 / 36 pairs checked

78% of work skipped

At each step, the bottleneck argument let you prove that an entire batch of pairs couldn't beat the current best. When L was the shorter wall at height 1 (step 1), you discarded every pair involving that wall — 7 pairs eliminated in one move. When R was shorter, same thing.

The running maximum doesn't increase at every step. Sometimes you move a pointer and the new area is smaller. That's fine — the algorithm isn't trying to increase area at every step. It's trying to not skip the maximum. The greedy choice guarantees that the global max will appear at some step, even if most steps are locally worse.

This is a fundamentally different flavor of two-pointer movement than the sum-based approach. With sums, you steer toward a target. With bottlenecks, you eliminate the limiting factor. Both squeeze the search space from O(n^2) to O(n), but for different reasons.

Equal heights

One edge case deserves attention. What if both walls are the same height?

Think about it before reading on: if both walls are height 6, does it matter which one you move? Could you miss the global maximum by picking the wrong side?

Neither wall is “the bottleneck” — they're both equally limiting. Moving either one is valid. The width shrinks regardless, and either side might find a taller wall next.

Some implementations move L. Some move R. Some move both. It doesn't matter — when the heights are equal, no unexplored pair involving either wall at the current width can improve on the current area. Both pointers are safe to advance.

You'll see height[l] <= height[r] in most implementations. The <= means “when tied, move L.” But < would also work (ties would move R instead). The algorithm is correct either way.

The wrong intuition

Someone wrote Container With Most Water but their code produces the wrong answer. The movement logic is backwards — it moves the taller pointer instead of the shorter one.

Think about why this breaks things: if you move the taller wall, you keep the bottleneck in place. Width shrinks, water level can't rise, area can only drop. The algorithm degrades to something worse than brute force — it actively avoids the tall walls that could form the best container.

Run it, see it fail, then tap the buggy line.

Running on [1, 8, 6, 2, 5, 4, 8, 3, 7]
L
1
8
6
2
5
4
8
3
R
7
area = 8 | max = 8
Find the bug

The speed gap

How much faster is the two-pointer approach? Same array, same problem. Brute force checks every pair — O(n^2) comparisons — while two pointers uses the bottleneck rule to skip entire families of pairs in one step.

Before you watch the race: brute force checks every pair — how many is that for 14 walls? And how many steps do you think two pointers will need?

14 walls — how many steps does each approach need?
Brute force checks all 91 pairs. How many steps will two pointers need?

Brute Force

steps

Two Pointers

steps

Sum rule or bottleneck rule?

The two-pointer pattern now has two movement rules in your toolkit.

Sum rule (from the two-sum lesson): compare the current aggregate against a target. Overshoot? Move the pointer that's contributing too much. Undershoot? Move the pointer that's contributing too little. The decision comes from comparison with a target.

Bottleneck rule (this lesson): the answer is limited by a minimum or maximum. Move the pointer that's currently limiting — it's the only one that could help, because the other pointer's contribution is already capped. The decision comes from which side is the constraint.

How to tell them apart in a new problem: ask yourself, “Is there a target I'm comparing against?” If yes, sum rule. “Is there a min/max that caps the answer?” If yes, bottleneck rule.

Can you classify these problems?

Problem 1/4
Find two numbers in a sorted array that sum to a target.

The code

Before you see the implementation, test your understanding. The loop body needs three decisions: when to update the running maximum, which pointer to move inward, and what to do when both heights are equal. Can you name the condition for each?

Think about it: the max update happens on every iteration (you always check the current pair). The pointer movement depends on which wall is shorter. And equal heights? You saw earlier that either side is safe — most implementations break the tie by moving the left pointer.

Now see how those three decisions map to code. Twelve lines. The bottleneck rule lives in one if statement.

1
function maxArea(height: number[]): number {
2
  let l = 0, r = height.length - 1;
3
  let best = 0;
4
  while (l < r) {
5
    const area = Math.min(height[l], height[r]) * (r - l);
6
    best = Math.max(best, area);
7
    if (height[l] <= height[r]) l++;
8
    else r--;
9
  }
10
  return best;
11
}

Time: O(n) — each pointer moves at most n - 1 times, and they never move backward.

Space: O(1) — three variables regardless of input size.

The pattern: when the answer depends on a min() or max() of two pointer values, move the pointer that's limiting. That's the bottleneck rule.

You now have two distinct movement heuristics for the two-pointer technique. The sum rule steers toward a target. The bottleneck rule eliminates the weakest link. Both reduce O(n^2) search spaces to O(n), but they apply to fundamentally different problem shapes. When you see a new two-pointer candidate, ask: “Am I chasing a target, or removing a constraint?” The answer tells you which rule to reach for.