The Core Idea
The two pointer technique is the simplest, most repeatedly useful trick for collapsing apparently-quadratic array problems to linear time. The idea is unreasonably small. Maintain two indices, call them and , walking over the array. At every step, compare the values at those indices, infer something about the answer's location, and advance whichever pointer the comparison tells you to. Each pointer moves only forward (or only inward). Each crosses the array at most once. Total work: .
Why does this beat the brute force? A brute force would consider every pair — that is pairs. The two-pointer trick exploits a monotone structure (often sortedness, sometimes a geometric envelope) to eliminate entire columns or rows of the pair matrix in a single move. We will see the precise elimination argument three times — for Two-Sum, for Container with Most Water, and for 3-Sum. Once you see the shape, you will recognize it the rest of your life.
Slogan: two pointers turn an search over pairs into an march, by pruning at least one candidate from the remaining search space at every comparison.
The Three Flavors
Two-pointer code in the wild comes in three distinct shapes. The differences look cosmetic but the invariants and proofs differ.
| Flavor | Setup | Movement | Canonical use |
|---|---|---|---|
| Opposite-end / converging | lo = 0, hi = n − 1 | Move toward each other; loop while lo < hi | Sorted-array search, palindrome check, container with most water, reversal |
| Same-direction / fast-slow | slow = 0, fast = 0 (or 1) | Both advance left-to-right, fast moves faster or conditionally | In-place compaction, partitioning, sliding window, cycle detection |
| Two-array merge pointers | i = 0 on A, j = 0 on B | Advance whichever side is smaller / matches a rule | Merge step of merge sort, intersection / union of sorted lists, sorted-list diff |
Visual schematic
Converging pointers shrink a window from the outside in. Fast/slow pointers grow a kept-prefix from the inside out. Merge pointers ride two parallel rails. The asymptotic complexity is the same — the invariant we prove is what differs.
Why It Works: The Invariant
Two-pointer code is short. It is also easy to get wrong. The discipline that protects you is the same one that protects binary search and induction: state the invariant, then prove every move preserves it.
The universal converging-pointer invariant
Termination. The window shrinks by exactly 1 cell per iteration, so after at most iterations it is empty and the loop exits.
For Two-Sum on a sorted array the proof of the invariant is a two-line case split:
- Case . For every , the pair has sum (because the array is sorted ascending so ). So index can never participate in a valid pair within this window. We may eliminate and the invariant is preserved.
- Case . Symmetric: can never participate, so we eliminate .
- Case equality. Found.
Each step permanently removes a row or a column from the implicit pair matrix. Out of pairs we end up examining only , because each pointer-move retires pairs in one shot. That is the algorithm in one sentence.
Engineering reflex
Worked Problem 1: Two-Sum on a Sorted Array
Problem. Given a sorted ascending array of length and a target , return indices i < j with , or report none.
Three solutions, three complexities
| Approach | Time | Extra space | Notes |
|---|---|---|---|
| Brute force: every pair | O(n²) | O(1) | Always correct, almost never the right call |
| Hash map: lookup T − A[i] | O(n) | O(n) | Works on UNsorted arrays; pays in memory |
| Two pointer (sorted) | O(n) | O(1) | Best when the array is already sorted |
The two-pointer version costs no extra memory and has the smallest constant factor — a single addition and one comparison per step. On a sorted input there is essentially no reason to use anything else.
Trace
, :
| Step | lo | hi | A[lo] + A[hi] | Decision |
|---|---|---|---|---|
| 0 | 0 | 7 | 1 + 14 = 15 | 15 > 13 → hi-- |
| 1 | 0 | 6 | 1 + 11 = 12 | 12 < 13 → lo++ |
| 2 | 1 | 6 | 3 + 11 = 14 | 14 > 13 → hi-- |
| 3 | 1 | 5 | 3 + 9 = 12 | 12 < 13 → lo++ |
| 4 | 2 | 5 | 4 + 9 = 13 | = target ✓ return (2, 5) |
Five iterations on a length-eight array. Brute force would have considered pairs.
Worked Problem 2: Reverse In Place
The textbook two-pointer task: , ; swap with ; converge.
1def reverse_in_place(A):
2 lo, hi = 0, len(A) - 1
3 while lo < hi:
4 A[lo], A[hi] = A[hi], A[lo]
5 lo += 1
6 hi -= 1Exactly swaps, time, extra space. The middle element of an odd-length array does not move — the loop simply exits when .
This three-line routine is also the building block for rotate-by-k via the three-reverses trick we saw in §5.4.
Worked Problem 3: Remove Duplicates from a Sorted Array
Problem (LeetCode 26). Given a sorted array, compact it in place so that the unique values occupy a prefix . Return . The remaining cells are scratch; the caller will not read them.
This is the canonical fast / slow shape:
- slow points at the next free position in the kept prefix — i.e. is duplicate-free.
- fast scans the unprocessed tail. Whenever it sees a new value, copy it into and advance .
Trace on a small example
:
| fast | A[fast] | A[slow] | Action | Array (kept-prefix bold) | slow |
|---|---|---|---|---|---|
| 1 | 1 | 1 | duplicate → fast++ | [**1**, 1, 2, 2, 3, 4, 4] | 0 |
| 2 | 2 | 1 | new → slow++, A[1]=2 | [**1**, **2**, 2, 2, 3, 4, 4] | 1 |
| 3 | 2 | 2 | duplicate → fast++ | [**1**, **2**, 2, 2, 3, 4, 4] | 1 |
| 4 | 3 | 2 | new → slow++, A[2]=3 | [**1**, **2**, **3**, 2, 3, 4, 4] | 2 |
| 5 | 4 | 3 | new → slow++, A[3]=4 | [**1**, **2**, **3**, **4**, 3, 4, 4] | 3 |
| 6 | 4 | 4 | duplicate → fast++ | [**1**, **2**, **3**, **4**, 3, 4, 4] | 3 |
Final length: . Both pointers traversed the array exactly once; reads, at most writes, no allocation. Compare against the "copy unique elements into a new list" approach: same time, but extra memory and worse cache behavior.
Worked Problem 4: Container with Most Water
Problem (LeetCode 11). Given an array of non-negative heights, choose two indices i < j to maximize the rectangular water area:
The brute force is . The two-pointer algorithm is . The core decision: always move the pointer at the SHORTER wall.
Why moving the shorter wall is correct: an exchange argument
Suppose H[\mathit{lo}] < H[\mathit{hi}] (the symmetric case is identical). The current candidate area is . Consider any pair with \mathit{lo} < k < \mathit{hi}:
- The width strictly decreases: (k - \mathit{lo}) < (\mathit{hi} - \mathit{lo}).
- The min height is at most (it cannot exceed either wall).
So — every pair involving the shorter wall and any k < \mathit{hi} is bounded by the current area. Therefore index is provably exhausted; advancing it loses no candidate. Conversely, advancing when it is the taller wall would discard the pair without justification — we might be skipping an optimum.
Worked Problem 5: 3-Sum
Problem (LeetCode 15). Given an array , return every triple of distinct indices i < j < k with . Triples must be unique by value.
The brute force is . The standard improvement: sort, then for each pivot run two-pointer on the suffix , looking for the pair that sums to . Sorting costs . The pivot loop runs times; each pivot costs work — total .
1def three_sum(A):
2 A = sorted(A)
3 n = len(A)
4 out = []
5 for i in range(n - 2):
6 if i > 0 and A[i] == A[i - 1]:
7 continue # skip duplicate pivots
8 lo, hi = i + 1, n - 1
9 target = -A[i]
10 while lo < hi:
11 s = A[lo] + A[hi]
12 if s == target:
13 out.append((A[i], A[lo], A[hi]))
14 lo += 1
15 hi -= 1
16 while lo < hi and A[lo] == A[lo - 1]:
17 lo += 1 # skip duplicate lo
18 while lo < hi and A[hi] == A[hi + 1]:
19 hi -= 1 # skip duplicate hi
20 elif s < target:
21 lo += 1
22 else:
23 hi -= 1
24 return outThe two duplicate-skip lines are the easiest place to introduce bugs. The standard pitfall is to skip duplicates only on one side, or before checking the equality case, leading to missed or repeated triples. The pattern above skips after the equality move, which is the only correct order.
3-Sum is the prototype: any "k-Sum" problem reduces to by sorting once and recursively fixing pivots, with two-pointer at the bottom. 4-Sum is ; 5-Sum is ; etc.
Worked Problem 6: Trapping Rain Water
Problem (LeetCode 42). Heights represent an elevation map, each bar of unit width. After it rains, how much water is trapped above the bars?
The water above index is
where / are the tallest bars at or to the left / right of . The naive solution precomputes both arrays — time but extra space.
The two-pointer beauty: maintain running maxima and on the fly, and process whichever side has the smaller running max. Reasoning: at index , if then the bar to the right is at least as tall as anything we have seen on the left — the water height at is fully determined by the left side. Compute and advance .
1def trap(H):
2 lo, hi = 0, len(H) - 1
3 left_max = right_max = 0
4 water = 0
5 while lo < hi:
6 if H[lo] < H[hi]:
7 left_max = max(left_max, H[lo])
8 water += left_max - H[lo]
9 lo += 1
10 else:
11 right_max = max(right_max, H[hi])
12 water += right_max - H[hi]
13 hi -= 1
14 return watertime, space. Eliminates the need for the maxL / maxR arrays. The same invariant — "the side with the smaller running max is decided" — drives the entire computation.
Worked Problem 7: Dutch National Flag
Problem (Dijkstra, 1976). Given an array of values in , sort it in a single linear pass with constant extra space. Equivalently: 3-way partition around a pivot.
Three pointers, not two: , , . Invariant:
- are all 0 (the "reds").
- are all 1 (the "whites").
- is the unprocessed region.
- are all 2 (the "blues").
1def dutch_flag(A):
2 lo, mid, hi = 0, 0, len(A) - 1
3 while mid <= hi:
4 if A[mid] == 0:
5 A[lo], A[mid] = A[mid], A[lo]
6 lo += 1
7 mid += 1
8 elif A[mid] == 1:
9 mid += 1
10 else: # A[mid] == 2
11 A[mid], A[hi] = A[hi], A[mid]
12 hi -= 1 # do NOT advance mid hereThe asymmetry on the last branch is the single most-confused detail: when we swap a 2 from the back to , the value we pulled in is unprocessed and might be 0, 1, or 2 — so stays put for re-examination on the next iteration. When we swap a 0 forward, the value pulled in from can only be a 1 (by the invariant), so we are safe to advance .
Quicksort's 3-way partition is exactly this routine, generalized from the constants 0/1/2 to less-than / equal / greater-than the pivot. It is the algorithmic heart of every fast sorting library (libstdc++, V8 TimSort fallback, Rust's pdqsort).
Interactive: Two-Pointer Lab
Pick a problem, edit the array, drag the target slider for Two-Sum, and step through the algorithm. The blue arrow is / ; the red arrow is / . The decision panel narrates the comparison being made; the running answer updates in green. For Container with Most Water, the cells are drawn as bars so you can see which wall is the binding constraint — and confirm visually that we always move the shorter one.
Two-Pointer Lab
Blue arrow = lo / slow pointer. Red arrow = hi / fast pointer. Pale-green cells lie inside the active window. Amber cells are part of the final answer. For Two-Sum and Remove Duplicates the array is sorted automatically.
1, 8, 6, 2, 5, 4, 8, 3, 7, and step through. The optimum is . Notice how often we discard the left side because the right wall happens to be tall — the algorithm is steered entirely by the local wall heights, never by lookahead.Quick Check
In Container with Most Water, why do we always move the pointer at the SHORTER wall?
Quick Check
Trace remove_duplicates on A = [2, 2, 2, 5, 5, 7]. After the loop finishes, what is (slow, A[0..slow])?
Reference Implementations: Python and TypeScript
Below are clean, production-quality versions of the three workhorse routines in two languages. Every meaningful line is annotated with the invariant it preserves and the cost it incurs. Read these side-by-side: the algorithms are identical, only the syntax differs — which is itself a useful lesson about how language-agnostic the two-pointer pattern is.
Real-World Patterns
- Merge step of merge sort. Two pointers, one per input run, advance whichever value is smaller. Output is sorted. The single most-implemented two-pointer routine in computing history — every external sort in every database depends on it.
- Database joins on sorted indexes. When two tables are pre-sorted on the join key, a sort-merge join reduces to two pointers walking in lockstep — total, with no hash-table memory. PostgreSQL, ClickHouse, and most columnar engines pick sort-merge over hash-join for very large inputs precisely because of this.
- Memory compaction in garbage collectors. A copying / mark-compact GC uses a slow / fast pair to slide live objects down over the dead ones — exactly the remove-duplicates pattern generalized to typed heap blocks.
- Network packet windows. Sliding-window receivers (TCP, QUIC) maintain head and tail pointers into a ring of recently-seen packets. Acknowledgements advance the head; arrivals advance the tail.
- DNA / RNA read alignment. Anchor extension algorithms (BWA-MEM, minimap2) seed an alignment then walk two pointers outward over the read and the reference, scoring matches and gaps.
- Audio DSP — sliding window of samples. Running mean / running max / FIR filter all maintain a head and tail into a circular buffer of recent samples.
- Best buy / best sell single-pass. Walk the price series with two pointers — left tracks the running min so far, right is the current day. Maximum profit in .
- Diff and merge of sorted log files. Pipe two sorted streams; advance whichever has the older timestamp; emit merged output. Used by every distributed log aggregator.
- Filter-and-pack in column databases. Apply a predicate over a column; use a slow pointer to write the surviving rows back into the same column, in place — removes a full pass and an extra column allocation.
- Edit-distance ladder for autocorrect. When two candidate words share a long common prefix and suffix, two pointers identify the differing core in linear time, which is then fed to a smaller dynamic-programming table.
Pattern Recognition Checklist
When a problem walks in, ask these four questions. If you answer "yes" to all four, two-pointer is almost certainly the right tool.
- Is the input an array or a sequence? Two-pointer fundamentally relies on indexable, bounded structure.
- Are you searching for a pair, a window, or a compacted subsequence? If the answer is a pair or a contiguous run, two-pointer has a chance. If the answer is a set or a complex subset, it probably does not.
- Is the brute force or worse? Otherwise you do not need the trick.
- Is there a monotone property that lets you discard at least one candidate per comparison? Sortedness is the most common; geometric envelopes (Container, Trapping Water) and duplicate-adjacency (LeetCode 26) are the others. If you cannot articulate the monotone property, you cannot prove the algorithm correct, so you should not write it.
Diagnostic question to ask yourself
Pitfalls
- Off-by-one at the boundary. The converging-pointer loop is
while lo < hi, notwhile lo <= hi. The two are different in palindrome checks, in container-with-most-water, and in 3-Sum (wherelo == hiwould mean choosing the same index twice). - Forgetting to skip duplicates in 3-Sum. Either skip both AND after a hit, or neither — the one-sided skip produces missed or duplicated triples. And always skip after the equality move, not before.
- Advancing both pointers on a Dutch-flag swap-with-hi. When you swap with , the value pulled in is unverified. Decrementing is correct; advancing would skip an unprocessed element.
- Mutating while iterating from both ends. If you remove or insert in the middle of the array while two pointers are live, the indices invalidate. Either mutate only at the endpoints (push / pop) or post-process after both pointers terminate.
- Wrong shift direction in fast / slow compaction. Always copy into , never the reverse. fast leads, slow follows.
- Confusing "sorted by value" with "sorted by original index". Two-Sum-with-indices returns original positions; if you sort the array in place, you destroy the index mapping. Either sort a list of
(value, index)pairs, or use the hash-map variant. - Assuming the input is sorted when it is not. The most common silent bug. Every two-pointer routine that requires sorting should either assert it or sort defensively at entry. The sort cost still beats the brute force for any n > 30 in practice.
The crossing-pointers question
The next section, Sliding Window, is two-pointer's sibling: same fast / slow shape, but the question changes from "find a pair" to "find a contiguous subarray with property ." Once you have the two patterns in muscle memory, an enormous fraction of array problems become routine.