Two Pointers

Two indices walking an array — converging from the ends or chasing each other — turn a nested-loop pair search into a single linear sweep. The trick is almost always a sorted or symmetric structure that tells you which pointer to move.

Topic guide9 problems
The unlock

A nested loop over an array is really scanning an n × n grid of pairs. If the array is sorted, each comparison tells you an entire row or column can't hold the answer — so you delete it and never look again. Two indices walking inward do exactly that, turning the grid into a single O(n) walk.

MENTAL MODEL Two walls closing in — or a writer trailing a reader

Hold two pictures in your head, because two pointers comes in two flavors and you pick by asking one question.

  • Converging. Two walls stand at the ends of a sorted array and march toward each other. The sortedness is a compass: it tells you which wall to push so the measurement (a sum, an area) moves toward the target. A pushed wall never comes back.
  • Fast / slow. Both pointers start on the left and move the same direction. fast is a reader that touches every element; slow is a writer (or a trailing chaser) that only advances when something is worth committing.
The reframe →don't ask “how do I loop?” Ask “is this array sorted (or can I sort it), and does moving one end strictly help?” If yes, you have two walls. If it's an in-place edit or a chase, you have a reader and a writer.

SEE IT The pair grid collapses to a single walk

The brute force you're replacing literally inspects every cell of an n × n grid of pairs:

Brute force asks: of ALL pairs, which sums to the target?

        j→   2    7   11   15
    i↓     ┌────┬────┬────┬────┐
     2     │    │ ✓  │    │    │   n rows × n columns
     7     │    │    │    │    │   = n² cells to inspect.
    11     │    │    │    │    │
    15     │    │    │    │    │
           └────┴────┴────┴────┘

Two pointers never visits a cell twice. Each step throws away a
WHOLE row or column — so the n² grid collapses to one n-long walk.

On a sorted array, converging pointers throw away a whole row or column per step. Watch L and R squeeze toward the answer:

target = 13     too small → L→   |   too big → ←R

   [ 2,  4,  6,  9, 11, 15 ]
     L→                  ←R    2 + 15 = 17 > 13   move R in
   [ 2,  4,  6,  9, 11, 15 ]
     L→              ←R        2 + 11 = 13 = 13   FOUND ✓
                  ▲       ▲

WHY it never skips: arr is sorted, so arr[R] is the LARGEST partner
left for arr[L]. If even THAT overshoots, every other partner does
too — so L can't be in the answer. Discard it and step L up.
The span [L..R] shrinks by one each step → at most n steps.

The fast/slow flavor looks nothing like a grid — it's a write head trailing a read head, compacting in place:

Remove every 0, keep order, O(1) space.  s = write, f = read

   start   [ 1, 0, 2, 0, 0, 3 ]    s=0  f scans →
                                    f sees 1 (keep): write@s, s→1
   step    [ 1, 0, 2, 0, 0, 3 ]    f sees 0 (skip): s stays
            s
   ...                              f sees 2 (keep): write@s, s→2
           [ 1, 2, 2, 0, 0, 3 ]    f sees 3 (keep): write@s, s→3
               s
   result  [ 1, 2, 3 | _, _, _ ]   prefix [0..s) is the answer.

slow advances only when fast finds a keeper, so slow ≤ fast always:
read head leads, write head trails — one pass, nothing overwritten.

HOW TO THINK The cold-start ladder — run this on any new problem

When a problem smells like pairs, a window, or an in-place edit, climb these rungs in order. The flavor falls out by the third step:

  1. Is it about pairs / ends / a palindrome?Anything of the form “find two things that sum / multiply / bound something” or “compare from both ends” is a converging candidate.
  2. Is it sorted — or can I sort it without losing the answer? Converging needs order. If you only need values (Two Sum II, 3Sum, container area) you can sort. If you need original indicesand can't reorder, stop — that's a hash map, not two pointers.
  3. Does moving one end strictly help? Check the monotonic claim: does pushing L up only ever increase the measurement, and pushing R down only ever decrease it? If yes → converge from the ends.
  4. Else: in-place edit, or a chase? Removing / deduping / partitioning in O(1) space, or detecting a cycle, is fast / slow: a writer that trails a reader (or a tortoise that trails a hare).
  5. Write the move rule as one if/else.“Too small → move the left in. Too big → move the right in. Hit → record.” That three-way branch is the entire converging loop.
The one question that picks the flavor →“does sortedness tell me which way to move?” Yes → two walls. No, but I'm editing in place or chasing → reader and writer.

SAY IT The invariant you must be able to say out loud

Each flavor has one sentence that, if true at every step, proves the whole thing correct. Say it before you code:

  • Converging:“The answer, if it exists, always lies inside the still-open window [L..R].” Every move only discards pairs that are provably not the answer, so the window can shrink without fear.
  • Fast / slow: “Everything in [0..slow) is already finalized and correct, and slow ≤ fastalways.” The writer never passes the reader, so nothing is overwritten before it's read.
Failure mode →if you can't state why a discarded pair can't be the answer, you haven't proven the monotonic step — and the “move the smaller side” logic is just a guess.

WHY IT’S SAFE The monotonic argument — moving a pointer can’t skip the answer

This is the heart of converging two pointers, and the one thing interviewers probe. Suppose the array is sorted and arr[L] + arr[R] is too small.

arr[R] is the largest partner that exists for arr[L] — every other index between them holds a smaller value. So if even the biggest partner undershoots the target, no partner can reach it. arr[L] is hopeless; discard it forever and step L up. (Symmetrically, too big ⇒ shrink R.)

That's why one comparison eliminates a whole row or column of the pair grid, not a single cell — and why the span [L..R] shrinks by exactly one each step, giving O(n).

Where it breaks → the argument needs the measurement to be monotonic in pointer position. Container-with-most-water moves the shorterwall (height is the binding constraint); if no such monotone rule exists, two pointers can't safely discard, and the technique doesn't apply.

TWO SKELETONS Both flavors fit in a handful of lines

Strip away the specific problem and each flavor is a tiny fixed shape. Converging is a three-way branch inside a shrinking while:

# CONVERGING — the array must be sorted (or sort it first)
L = 0;  R = n - 1
while L < R:
    measure = f(arr[L], arr[R])     # sum, area, palindrome check ...
    if measure == target:  return hit
    if measure too SMALL:  L += 1   # only a bigger-left value helps
    else:                  R -= 1   # only a smaller-right helps
# each line of the loop retires one pointer → O(n) total

Fast / slow is a single forward for with a guarded write:

# FAST / SLOW — one pass, write head trails the read head
slow = 0                              # next slot to overwrite
for fast in 0 .. n-1:                 # reads EVERY element once
    if keep(arr[fast]):
        arr[slow] = arr[fast]         # commit a keeper
        slow += 1
return slow                           # length of kept prefix

The only things that change per problem are the measurement (sum vs. area vs. char-equality) and the keep predicate. The pointer choreography never changes — which is exactly why two pointers becomes reflexive once you've seen Two Sum II, container-with-most-water, valid palindrome, and remove-duplicates side by side.

MNEMONIC Sorted? Squeeze both ends.

Sorted? Squeeze both ends. Put lo on the smallest value and hi on the largest. The sum vs. the target tells you exactly which wall to move — and once a wall is moved, it never comes back.

Open the Visualize tab and step through it: watch the in-play span between lo and hishrink by one every single step. That's the whole O(n).

PATTERN What "two pointers" really means

You keep two indices into the same array (or two arrays) and move them with intent, never resetting them back. Because each pointer only ever moves forward, the whole array is processed in O(n) total — even though it looks like a double loop.

There are two dominant shapes:

  • Opposite ends (converging). lo starts left, hi starts right, they squeeze inward. Needs a sorted array (or a symmetric quantity like area/palindrome).
  • Same direction (fast/slow). Both start left; fast scans ahead while slow marks a boundary. Used for in-place filtering, dedup, and partitioning.

KEY IDEA Why moving one pointer is safe

The exchange argument → on a sorted array, if arr[lo] + arr[hi] is too small, then no pair using lo can ever reach the target — arr[hi] is already the biggest partner available. So you can discard lo forever and move it up. Symmetric logic shrinks hi.

That one observation is what collapses O(n²) into O(n): every comparison permanently eliminates an entire row or column of the pair matrix, not just one cell.

COST What it buys you

Brute force pairs
O(n²)
Check every (i, j).
Two pointers
O(n)
One sweep, O(1) extra space.

If the array isn't sorted yet, you usually pay O(n log n) to sort first — still a big win, and the sort is what unlocks the technique.

RUN IT Sorted? Squeeze both ends

step 0 / 4
STARTWant a pair summing to 9. First the prerequisite that makes the trick legal: sort. Sorted? Squeeze both ends.
unsorted20711121531485
lo (left wall)hi (right wall)still in playmatch
slowfast

TRIGGERS When you see ___ → reach for ___

Two pointers is the answer when the work is fundamentally about pairs or a window of positions in a linear structure, and either the data is sorted or you can sort it without losing the answer.

"find a pair / triplet that sums to X" + arraysort, then converge from both ends
"the array is sorted" and you want O(1) spaceopposite-ends two pointers
"remove / dedupe / partition in place"fast/slow (slow = write boundary)
"is it a palindrome?" / compare from both endsconverging pointers
"max area / container / most water"ends inward, move the limiting side
"merge two sorted arrays / lists"one pointer per array

RED FLAGSWhen it's NOT this pattern

  • Order matters / you need original indices and can't sort. Sorting scrambles indices — reach for a hash mapinstead (that's why plain Two Sum uses a map, not two pointers).
  • You need a contiguous run with a running total. That's a sliding window, not converging pointers — the window grows and shrinks from the same side.
  • The relationship isn't monotonic.If moving a pointer doesn't reliably increase or decrease the quantity you compare against, you can't prove it's safe to discard — the technique breaks.

TEMPLATE Opposite-ends squeeze

When → The array is sorted and you want a pair (or to compare ends). Move the pointer that brings the comparison closer to the target.

opposite-ends-squeeze.ts
function squeeze(arr: number[], target: number): [number, number] | null {
  let lo = 0, hi = arr.length - 1;       // start at both ends
  while (lo < hi) {
    const sum = arr[lo] + arr[hi];
    if (sum === target) return [lo, hi];
    if (sum < target) lo++;              // too small → grow the left value
    else hi--;                           // too big   → shrink the right value
  }
  return null;
}
Loop condition lo < hi stops the two pointers from crossing or landing on the same index (which would reuse one element).

TEMPLATE Fast/slow write boundary

When → In-place filtering, dedup, or partition. slow marks where the next kept element goes; fast scans every element.

fast-slow-write-boundary.ts
function partition(arr: number[]): number {
  let slow = 0;                          // boundary of the "kept" region
  for (let fast = 0; fast < arr.length; fast++) {
    if (keep(arr[fast])) {               // some predicate
      [arr[slow], arr[fast]] = [arr[fast], arr[slow]];
      slow++;
    }
  }
  return slow;                           // length of the kept prefix
}

TEMPLATE Fix one + squeeze the rest

When → k-sum style problems (3Sum, 4Sum). Sort, fix an outer anchor, then run opposite-ends on the remaining suffix. Skip duplicate anchors and duplicate landings to keep results unique.

fix-one-squeeze-the-rest.ts
function triples(nums: number[]): number[][] {
  nums.sort((a, b) => a - b);
  const out: number[][] = [];
  for (let i = 0; i < nums.length - 2; i++) {
    if (i > 0 && nums[i] === nums[i - 1]) continue;   // skip dup anchor
    let lo = i + 1, hi = nums.length - 1;
    while (lo < hi) {
      const sum = nums[i] + nums[lo] + nums[hi];
      if (sum === 0) {
        out.push([nums[i], nums[lo], nums[hi]]);
        while (lo < hi && nums[lo] === nums[lo + 1]) lo++;  // skip dups
        while (lo < hi && nums[hi] === nums[hi - 1]) hi--;
        lo++; hi--;
      } else if (sum < 0) lo++;
      else hi--;
    }
  }
  return out;
}
The duplicate skips are the whole difficulty → skip a repeated anchor, and after a hit, skip repeated lo/hi values before stepping both inward.

PITFALL Forgetting to sort first

Converging pointers rely on order. If the input isn't guaranteed sorted, sort it (or confirm the problem hands you sorted data). On an unsorted array the "move the smaller side" logic is meaningless.

PITFALL Off-by-one in the loop condition

Use lo < hi when the two pointers must stay distinct (pairs), and lo <= hi only when a single middle element is itself valid. Mixing these double-counts or skips the center.

PITFALL Duplicate results in k-sum

The most common 3Sum bug: emitting [−1,−1,2] twice. After recording a triplet, advance past all equal values on both sides before moving on, and skip a repeated outer anchor entirely.

PROBLEMS

#153SumFix one + squeeze: sort, anchor i, converge lo/hi, skip duplicates on all three.#11Container With Most WaterOpposite ends; area = min(height) × width, so always move the shorter wall inward.#167Two Sum II - Input Array Is SortedThe canonical converge: the sorted guarantee lets lo/hi find the pair in O(n), O(1) space.#42Trapping Rain WaterTwo pointers carrying running leftMax/rightMax; process the shorter side first.#88Merge Sorted ArrayMerge nums2 into nums1 in place by writing from the BACK with three pointers, so the largest remaining value lands in the last open slot and you never overwrite an unread element. O(m+n) time, O(1) space.#26Remove Duplicates from Sorted ArrayA slow write-pointer and a fast scan-pointer compact a sorted array in place: whenever the fast value differs from the last kept one, write it forward. Returns the count of unique values in O(n) time, O(1) space.#75Sort ColorsSort an array of 0s, 1s, and 2s in a single pass with Dijkstra's Dutch National Flag: low/mid/high pointers partition the array into three regions, swapping 0s to the front and 2s to the back as mid scans.#283Move ZeroesPush every zero to the end while keeping the non-zeroes in order using a slow insert pointer: swap each non-zero into the next open slot in one O(n) pass with O(1) extra space.#31Next PermutationRearrange numbers into the lexicographically next permutation in place: find the rightmost ascent, swap its pivot with the next-larger value to its right, then reverse the suffix. O(n) time, O(1) space.