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.
What you'll be able to do
- Prove that the opposite-direction two-pointer scan never skips the answer, using an exchange argument
- Choose between opposite-direction, same-direction, and fast/slow pointers from the shape of the prompt
- Implement in-place partitioning and deduplication with a write pointer
- Derive why Floyd's cycle detection finds the cycle start, rather than memorising the second phase
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.
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.
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▾
// 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
# Sorted input. Returns [lo, hi] indices, or None.
def two_sum_sorted(nums, target):
lo = 0
hi = len(nums) - 1
# `lo < hi`, not `<=`: an element may not pair with itself.
while lo < hi:
total = nums[lo] + nums[hi]
if total == target:
return [lo, hi]
if total < target:
lo += 1 # nums[lo]'s best partner was too small
else:
hi -= 1 # nums[hi]'s best partner was too large
return None
print(two_sum_sorted([2, 7, 11, 15, 19, 22], 26)) # [1, 4] -> 7+19
print(two_sum_sorted([2, 7, 11, 15], 100)) # None
print(two_sum_sorted([3, 3], 6)) # [0, 1] -> duplicates are fine
print(two_sum_sorted([3], 6)) # None -> single element
[1, 4] None [0, 1] None
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▾
// 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)}`);
import json
# Remove duplicates from a sorted list, in place. Returns the new length.
def dedupe_sorted(nums):
if len(nums) == 0:
return 0
# INVARIANT: nums[0..write-1] holds the distinct values seen so far, in order.
write = 1
for read in range(1, len(nums)):
if nums[read] != nums[write - 1]:
nums[write] = nums[read]
write += 1
return write
a = [1, 1, 2, 2, 2, 3, 4, 4]
n = dedupe_sorted(a)
# `separators=(",", ":")` makes json.dumps print like JSON.stringify; the
# default adds a space after every comma.
compact = lambda x: json.dumps(x, separators=(",", ":"))
print(f"length {n}, prefix {compact(a[:n])}, full array {compact(a)}")
length 4, prefix [1,2,3,4], full array [1,2,3,4,2,3,4,4]
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.
// 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
# Build a list with a cycle, then locate its entrance.
class Node:
def __init__(self, v):
self.v = v
self.next = None
def make_list(values, cycle_start_index):
nodes = [Node(v) for v in values]
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
if cycle_start_index >= 0:
nodes[-1].next = nodes[cycle_start_index]
return nodes[0]
def cycle_start(head):
slow = head
fast = head
# Phase one: find a meeting point inside the cycle, if there is one.
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast: # `is` compares identity, like `===` on JS objects
# Phase two: mu = n*lambda - k, so head and meeting point are
# equidistant from the entrance.
probe = head
while probe is not slow:
probe = probe.next
slow = slow.next
return probe.v
return None
print(cycle_start(make_list([1, 2, 3, 4, 5, 6, 7], 2))) # 3 -> entrance value
print(cycle_start(make_list([1, 2, 3], -1))) # None -> no cycle
print(cycle_start(make_list([1], 0))) # 1 -> self-loop
3 None 1
Choosing between the three
| Prompt shape | Variant | Precondition |
|---|---|---|
| Sorted input, find a pair or triple with a property | Converging | Sortedness — the exchange argument needs it |
| Filter, reorder, or partition in place, no extra space | Read/write | Output is a rearrangement of the input |
| Cycle, midpoint, or nth-from-end in a linked structure | Fast/slow | Single-pass traversal, no random access |
| Contiguous range with a validity condition | Sliding window | See the previous lesson — this is the fourth cousin |