Recognize the shape
before you write the loop.
A rebuild from first principles, tuned for pattern-recognition speed under interview pressure. Not what an array is — how the shape of a two-pointer problem announces itself, and how to state the invariant out loud before the first keystroke.
Four ideas carry the whole topic
None of them are syntax. You already know how a for-loop works; the depth budget goes entirely on why a technique works and how to spot it under disguise.
prefix[i] = a[0]+…+a[i-1]; then any range sum [l,r] is prefix[r+1] − prefix[l] in O(1). The antidote to a nested loop that recomputes the same running sum. Two-pointer-adjacent: same instinct, different mechanism.for i: for j>i: over the same array, stop and ask the question that derives every two-pointer solution.What information is the brute force throwing away and recomputing on each pass? If the answer is “ordering it already knows” or “a sum it already had,” the inner loop usually collapses into a moving pointer.
Start from the brute force
Canonical problem: given a sorted array, is there a pair summing to a target? The brute force checks every pair and ignores the one fact handed to us for free — that the array is sorted.
for i in 0..n: for j in i+1..n: if a[i] + a[j] == target: return (i, j)
Here’s the waste: when a sum comes out too small, the brute force never eliminates anything — it re-derives “too small / too big” from scratch every time. Put one pointer at each end instead, and sortedness lets you retire a whole row or column of the pair-matrix per step:
a[lo]+a[hi] < target: the sum is too small, and a[hi] is the largest partner available — so a[lo] with anything else is even smaller. Eliminate a[lo], lo++.a[lo]+a[hi] > target: symmetric. a[hi] is doomed. hi--.At every step, no pair that could sum to the target lies outside the window [lo, hi]. Everything stepped past has been proven unable to participate.
Say that sentence out loud in the interview and you pass. Pattern-match “pointers at both ends, move toward the middle” without it and you fall apart the moment the problem is disguised — Container With Most Water, Trapping Rain Water — because you won’t know which pointer to move or why it’s safe.
Why moving the smaller side is always safe
Abstract arguments don’t survive pressure, so make it numeric. Array [1, 4, 6, 8, 11, 15], target 19:
lo=0 (1), hi=5 (15) → 1+15 = 16 < 19. The biggest partner available to 1 is 15, and even that only reached 16. Every other partner is smaller, so every sum with 1 is below target. Drop it — we lose nothing. lo++.lo=1 (4), hi=5 (15) → 4+15 = 19 ✓.We never checked (1,11), (1,8), (4,11) — but we proved none of the 1-pairs could win before discarding, and the answer survived. Proof-before-discard is the whole game.
- Sorted or sortable input + asking for a pair or triplet hitting a target.
- “Remove / dedupe / compact in place” with an O(1)-space constraint.
- “Partition by a predicate” — split into two or three categories without allocating.
- “Is this a palindrome?” or any symmetric, compare-from-both-ends check.
- Merge two sorted structures without extra space.
- Process from the end backward — when a later element changes how you read an earlier one.
Brute force vs two pointers, racing
Same sorted array, same target. Step through it, or hit Play, and watch the comparison counters diverge. The right one barely moves while the left grinds through rows — that’s O(n) vs O(n²) as a count you watched accumulate, not a Big-O claim.
The three shapes of two pointers
There are three distinct geometries, and recognizing which one a problem wants is half the battle.
[lo, hi].”slow/write marks the boundary of the finalized region; fast/read scans ahead for the next keeper. Trigger: “remove / dedupe / compact in place.” Invariant: “everything left of slow is the final answer so far.”The simulator steps through one variant of each of the first two shapes. Pick a problem, hit Step, and watch the color-coded regions — finalized (teal), the active window, and the pointer cells (amber) with their role labels. The same visual vocabulary describes structurally different problems.
Three things to notice. In two-sum, cells outside [L, R] dim out — the invariant erasing proven-dead candidates. In remove-duplicates, the teal prefix is the entire answer; everything past it is scratch space you’re allowed to clobber. In valid-palindrome, outer cells turn matched-teal as the pointers march inward — the converging frontier is the only place work happens.
The Dutch national flag
Dijkstra’s problem: given elements of three kinds — red / white / blue, or <pivot / ==pivot / >pivot — sort them in a single pass with O(1) space. Three pointers maintain four regions at all times. Memorize it as a picture:
[ 0 .. low ) < pivot reds, finalized [ low .. mid ) == pivot whites, finalized [ mid .. high ] unknown not yet examined ( high .. n ) > pivot blues, finalized
mid is the scanner. The logic falls straight out of the invariant: a value below pivot swaps into the red zone (low++ mid++); a value equal to pivot is already placed (mid++); a value above pivot swaps to the blue zone (high--) and — the one rule everyone gets wrong — does not advance mid, because the element pulled in from high is unexamined.
The element you just received from the high side has never been examined — so you must look at it next. Advance mid there and you skip a value, breaking the partition. That single restraint is the difference between a correct one-pass sort and a subtle bug.
Two pointers vs sliding window
The two are constantly confused. Two pointers works on a static structure: pointers converge from opposite ends or chase each other forward, and the answer is a pair, a partition boundary, or an in-place rearrangement. Movement is driven by a comparison against a fixed target or predicate.
Sliding window maintains a contiguous subarray with a property — longest substring without repeats, smallest subarray summing to ≥ K. Both edges move forward only; the right expands to admit elements, the left contracts to restore the property. The window’s contents are the answer.
Answer is “a pair of indices” or “a rearranged array” → two pointers. Answer is “a contiguous run whose contents satisfy something” → sliding window. The fast/slow variant is sliding window’s mechanical cousin, but it tracks a write boundary, not a property-constrained window.
Where this lives in real systems
Two pointers is the in-place discipline that shows up wherever you move structured data through a pipeline without paying for copies. Consider a command-line tool that ingests tabular row-and-column data and runs a query pipeline over it: filter rows by a predicate, select columns, sort the result — minimizing copying as data flows through each stage.
Engineers building that kind of tabular-data-query CLI reach for two pointers in two concrete places. First, in-place partitioning: splitting rows into “passes the filter” and “fails the filter” before a sort step is structurally identical to the Dutch-flag partition — track region boundaries and swap rows into place rather than allocating a new collection. Second, boundary-tracking instead of reallocating: filtering rows by walking a write pointer over the same backing buffer mirrors the exact “don’t recompute, just advance a boundary” discipline the interview problems test.
The discipline of minimizing clones while passing data through a filter→sort pipeline (ownership / borrowing in a systems language) is the systems-level cousin of the same two-pointer in-place mutation discipline tested in interviews — same invariant, different language guarantees enforcing it. In an interview you assert and defend the invariant out loud; under a borrow checker the compiler refuses to build until you’ve respected it.
Problem set — 10
Skewed toward custom-bank favorites at research-lab-tier loops: heavy on partition and greedy-proof problems, light on filler. Tap any card to expand. Four are flagged with the widget above that re-parameterizes onto them — reuse the visual, don’t relearn the picture.
Pseudocode templates
Runnable-logic-correct, no hand-waving. The three shapes, one block each.
function converge(a, target): # a is sorted lo = 0 hi = length(a) - 1 while lo < hi: s = a[lo] + a[hi] # or any monotonic condition if s == target: return (lo, hi) else if s < target: lo = lo + 1 # smaller side proven doomed else: hi = hi - 1 # larger side proven doomed return NONE
function compact(a): n = length(a) if n == 0: return 0 slow = 0 # last index of finalized region for fast from 1 to n - 1: if keep(a[fast], a[slow]): # e.g. a[fast] != a[slow] slow = slow + 1 a[slow] = a[fast] # in-place write at the boundary return slow + 1 # length of finalized region
function three_way_partition(a, pivot): low = 0 # a[0 .. low) < pivot mid = 0 # a[low .. mid) == pivot high = length(a) - 1 # a[mid .. high] unknown while mid <= high: if a[mid] < pivot: swap(a[low], a[mid]); low += 1; mid += 1 else if a[mid] == pivot: mid += 1 else: # a[mid] > pivot swap(a[mid], a[high]); high -= 1 # do NOT advance mid