Coding & data structures · Foundations — reading the problem, and the three pointer patterns

Two pointers

The exchange argument that proves opposite-direction two pointers correct — why discarding a candidate is safe — plus the same-direction and fast/slow variants, and the cycle-detection result that looks like a magic trick until you see the algebra.

20 min read Free to read Patterns: two-pointers, fast-slow, in-place-write-pointer

What you'll be able to do

Before this: complexity-honestly

"Two pointers" names three genuinely different techniques that happen to share an implementation shape. Learning them as one thing is why people apply the wrong one. They are: pointers that converge from opposite ends, pointers that chase in the same direction at different rates, and a read/write pair used to compact an array in place.

Watchtake U forward · 23:09

The reason to watch a 23-minute video for a problem you can solve in ten lines is that it walks the *whole* progression — brute force, then the fix, then the fix to the fix — and narrates why each step is safe. That progression is the thing an interviewer is listening for, and it is invisible in a finished solution.

Opposite direction: the exchange argument

The classic setup is a sorted array and a target sum. Start at both ends. If the sum is too large, move the right pointer left; too small, move the left pointer right; equal, done.

Pointers converging on a sorted array
2711151922
lohi

Each step eliminates one element permanently. After at most n steps the pointers meet, so the scan is O(n) — but the reason it is CORRECT is the argument below, not the speed.

The speed is obvious; each step moves a pointer inward, so there are at most n steps. What candidates usually cannot do is say why it is correct — why moving a pointer inward does not discard the pair you were looking for. This is the exact follow-up interviewers use to separate "I've seen this" from "I understand this".

Here is the argument, and it is short.

Suppose nums[lo] + nums[hi] < target. Consider nums[lo] paired with anything still in range: the largest partner available to it is nums[hi], because the array is sorted and hi is the rightmost live index. So nums[lo]'s best possible sum is already too small. nums[lo] cannot be part of any solution within the live range, and advancing lo discards nothing. The mirror argument covers the too-large case.

Notice what the proof depends on: sortedness, and the ability to say "the best partner available". Break sortedness and the argument evaporates — which is why unsorted two-sum is a hash-map problem, not a pointer one.

1The scan, with the boundary cases that break it
JavaScript
// Sorted input. Returns [lo, hi] indices, or null.
function twoSumSorted(nums, target) {
  let lo = 0;
  let hi = nums.length - 1;

  // `lo < hi`, not `<=`: an element may not pair with itself.
  while (lo < hi) {
    const sum = nums[lo] + nums[hi];
    if (sum === target) return [lo, hi];
    if (sum < target) lo++;   // nums[lo]'s best partner was too small
    else hi--;                // nums[hi]'s best partner was too large
  }
  return null;
}

console.log(twoSumSorted([2, 7, 11, 15, 19, 22], 26));  // [1,4] -> 7+19
console.log(twoSumSorted([2, 7, 11, 15], 100));         // null
console.log(twoSumSorted([3, 3], 6));                   // [0,1] -> duplicates are fine
console.log(twoSumSorted([3], 6));                      // null -> single element
2Recognise the family

Once the exchange argument is in hand, a surprising number of problems are the same scan:

  • Container with most water. Two walls, area = min(height) × distance. Move the pointer at the shorter wall, because that wall caps the area and any other partner for it is closer, hence strictly worse. Same argument shape, different quantity.
  • Three-sum. Fix one element, two-pointer the rest. O(n²) total, and the sort is free because you needed sortedness anyway.
  • Valid palindrome. Converge from both ends comparing characters; skip non-alphanumerics on the way in.
  • Merging two sorted arrays. One pointer per array, and it is the merge step of merge sort.
  • Trapping rain water. Converge, tracking the running max from each side. The insight is identical: the shorter side bounds the water level, so that side can be resolved now.

Same direction: the read/write pointer

Different technique, same shape. Here both pointers move forward, one reading and one writing, and the invariant is about the region behind the write pointer.

3Compact in place, with an invariant
JavaScript
// Remove duplicates from a sorted array, in place. Returns the new length.
function dedupeSorted(nums) {
  if (nums.length === 0) return 0;

  // INVARIANT: nums[0..write-1] holds the distinct values seen so far, in order.
  let write = 1;
  for (let read = 1; read < nums.length; read++) {
    if (nums[read] !== nums[write - 1]) {
      nums[write] = nums[read];
      write++;
    }
  }
  return write;
}

const a = [1, 1, 2, 2, 2, 3, 4, 4];
const n = dedupeSorted(a);
console.log(`length ${n}, prefix ${JSON.stringify(a.slice(0, n))}, full array ${JSON.stringify(a)}`);

Two things to say out loud about this one. First, the invariant — state it as a comment, as above, and the code is self-evidently correct. Second, write never overtakes read, so you are never writing over data you have not yet read; that is what makes in-place safe rather than lucky.

The same skeleton with a different predicate gives you: move all zeroes to the end, partition an array around a pivot (the core of quicksort), remove all instances of a value, and the three-way Dutch national flag partition that sorts an array of three distinct values in one pass.

Fast and slow: cycle detection

The third variant moves two pointers forward at different rates. Its famous application is Floyd's cycle detection, and the reason to derive it rather than memorise it is that the second phase looks arbitrary until you do the algebra — and interviewers know that.

4Phase one — do they meet?

Advance slow one node per step and fast two. If the list ends, there is no cycle. If there is a cycle, both pointers eventually enter it, and then fast gains exactly one position on slow per step. A gap that shrinks by exactly one every step inside a finite ring must reach zero, so they must meet — they cannot leapfrog past each other.

5Phase two — where does the cycle start?

Let the distance from the head to the cycle entrance be μ, the cycle length be λ, and let them meet k steps into the cycle.

When they meet, slow has travelled μ + k and fast has travelled 2(μ + k). fast's extra distance is μ + k, and that extra distance must be a whole number of laps:

μ + k = n·λ   for some integer n ≥ 1
so   μ = n·λ - k

Read that last line as a statement about walking. From the meeting point, walking μ steps means walking n·λ - k steps: enough to finish the current lap (λ - k steps) and then (n-1) more full laps — landing exactly on the cycle entrance. And a pointer restarted at the head reaches the entrance in exactly μ steps too.

So: reset one pointer to the head, advance both one step at a time, and they meet at the entrance. Not a trick — a consequence of fast having walked a whole number of laps extra.

JavaScript
// Build a list with a cycle, then locate its entrance.
function makeList(values, cycleStartIndex) {
  const nodes = values.map((v) => ({ v, next: null }));
  for (let i = 0; i < nodes.length - 1; i++) nodes[i].next = nodes[i + 1];
  if (cycleStartIndex >= 0) nodes[nodes.length - 1].next = nodes[cycleStartIndex];
  return nodes[0];
}

function cycleStart(head) {
  let slow = head;
  let fast = head;

  // Phase one: find a meeting point inside the cycle, if there is one.
  while (fast && fast.next) {
    slow = slow.next;
    fast = fast.next.next;
    if (slow === fast) {
      // Phase two: mu = n*lambda - k, so head and meeting point are
      // equidistant from the entrance.
      let probe = head;
      while (probe !== slow) {
        probe = probe.next;
        slow = slow.next;
      }
      return probe.v;
    }
  }
  return null;
}

console.log(cycleStart(makeList([1, 2, 3, 4, 5, 6, 7], 2)));  // 3 -> entrance value
console.log(cycleStart(makeList([1, 2, 3], -1)));             // null -> no cycle
console.log(cycleStart(makeList([1], 0)));                    // 1 -> self-loop

Choosing between the three

Prompt shapeVariantPrecondition
Sorted input, find a pair or triple with a propertyConvergingSortedness — the exchange argument needs it
Filter, reorder, or partition in place, no extra spaceRead/writeOutput is a rearrangement of the input
Cycle, midpoint, or nth-from-end in a linked structureFast/slowSingle-pass traversal, no random access
Contiguous range with a validity conditionSliding windowSee the previous lesson — this is the fourth cousin