Two indices moving with purpose turn an O(n²) nested loop into a single O(n) pass. The trick only works when the data is ordered — and that ordering is what tells each pointer which way to move.
Controls
target sum16
Pointers in Motion
step 0
Insight
Two pointers work when moving one of them reliably changes the answer in a known direction. On a sorted array, moving left rightwards can only increase the sum.
comparisons0
brute force0
best so far–
Complexity
Two pointersO(n)
Brute forceO(n²)
SpaceO(1)
Two Pointers
One pass instead of two nested loops — when the data lets you rule things out.
What this is
The two pointers technique uses two indices moving through a sequence in a coordinated way. It typically replaces a nested loop, turning O(n²) into O(n) with no extra memory.
The Classic: Pair Sum
Find two numbers in a sorted array that sum to a target. Brute force checks all pairs: O(n²). Two pointers starts one at each end:
Sum too small? Move the left pointer right — only larger values lie that way.
Sum too large? Move the right pointer left — only smaller values lie that way.
Exactly right? Done.
Each move eliminates an entire set of pairs from consideration without ever testing them. The pointers only ever move toward each other, so the whole search is one pass.
Why Sorting Is Essential
The technique depends on knowing what moving a pointer does. On a sorted array, moving left rightwards can only increase the sum — that guarantee is what makes it safe to discard everything you skipped.
On unsorted data no such guarantee exists, and the method is simply wrong. If your input is not sorted you must either sort first (O(n log n)) or use a hash set instead (O(n) time, O(n) space).
The three patterns
Pattern
Pointers
Typical use
Opposite ends
Start and end, moving inwards
Two-sum on sorted data, palindromes, container problems
Same direction
Both forward, at different speeds
Removing duplicates, partitioning in place
Fast and slow
One moves twice as fast
Cycle detection, finding the middle
Same direction is the in-place editing pattern. One pointer reads, the other writes:
def remove_duplicates(arr): # arr is sorted; edit in place
if not arr:
return 0
write = 1
for read in range(1, len(arr)):
if arr[read] != arr[write - 1]:
arr[write] = arr[read]
write += 1
return write # new length
The invariant: everything before write is the deduplicated result so far. That framing — naming what is true about the region behind each pointer — is how these are reasoned about and debugged.
Fast and slow is the cycle-detection pattern, and the reason it works is worth understanding rather than memorising.
Container With Most Water
A subtler case worth understanding. Given heights, pick two lines forming the largest water container. Area is min(height) × width.
Start wide and always move the shorter line inward. Why is that safe? Moving the taller one can only reduce the width while the area stays capped by the same shorter line — so it can never improve. Moving the shorter one at least gives a chance of a taller limit. That single argument is what makes the greedy pointer movement correct.
Two indices instead of two loops
The two-pointer technique replaces a nested loop with two indices moving through the data, turning O(n²) into O(n).
It applies when the data has structure — usually sortedness — that tells you which pointer to move.
The canonical problem: find two numbers in a sorted array that sum to a target.
Brute force checks every pair: O(n²). With two pointers, one at each end:
Array [2, 7, 11, 15], target 18:
left
right
Sum
Action
2
15
17
Too small — move left right
7
15
22
Too large — move right left
7
11
18
Found
Three steps instead of six comparisons, and the saving grows with n. The logic is what makes it correct: if the sum is too small, the only way to increase it is a larger left value; if too large, a smaller right value. No possibility is skipped.
def two_sum_sorted(arr, target):
lo, hi = 0, len(arr) - 1
while lo < hi:
s = arr[lo] + arr[hi]
if s == target:
return lo, hi
if s < target:
lo += 1
else:
hi -= 1
return None
Floyd's cycle detection
Move one pointer one step at a time and another two steps. If there is a cycle, the fast one laps the slow one and they meet. If there is no cycle, the fast one reaches the end.
def has_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False
Why they must meet: once both are inside the cycle, the gap between them closes by exactly one position per step, so it reaches zero. It cannot be jumped over.
The extension finds the cycle's start: after they meet, reset one pointer to the head and advance both one step at a time. They meet at the entry point. That result follows from the distances involved and is not obvious — it is worth trusting rather than deriving under time pressure.
The same technique finds the middle of a list in one pass: when the fast pointer reaches the end, the slow one is at the midpoint.
Why sorting is what makes the two pointers legal
The two-pointer pair-sum is short enough to memorise without understanding it, and then it gets applied to unsorted data and silently returns nothing. The step worth examining is the one where a pointer moves: that move throws away candidates, and sortedness is the only thing that makes throwing them away safe.
example_01.pyPython
def pair_sum(a, target, verbose=False):
lo, hi, checks = 0, len(a) - 1, 0
while lo < hi:
checks += 1
s = a[lo] + a[hi]
if verbose:
print(" a[%d]=%d + a[%d]=%d = %d %s" % (
lo, a[lo], hi, a[hi], s,
"found" if s == target else
("too small, move lo right" if s < target else
"too big, move hi left")))
if s == target:
return (a[lo], a[hi]), checks
if s < target:
lo += 1
else:
hi -= 1
return None, checks
data = [1, 3, 4, 6, 8, 10, 13]
print("sorted:", data, " target 17")
found, checks = pair_sum(data, 17, verbose=True)
print(" ->", found, "in", checks, "checks")
# Look at the first line: 1 + 13 = 14 is too small, so lo moves right and
# every pair involving the 1 is gone for good --
# not just that one. That is legal ONLY because a[hi] is the largest
# remaining value -- if the biggest partner available is not enough, no
# smaller partner will be either. Sortedness is what licenses the leap.
#
# Give the same function unsorted data and it fails:
unsorted = [1, 13, 10, 3, 4, 6, 8]
print()
print("same values unsorted:", unsorted, " target 23")
print(" ->", pair_sum(unsorted, 23)[0])
print(" and yet 10 + 13 = 23 -- the pair is sitting in the list.")
# It returns None with no error. The pointers moved on a comparison that
# means nothing about the values they skipped.
#
# The cost, against the obvious nested loop:
import math
print()
print("%8s %14s %18s" % ("n", "two pointers", "nested loops n^2/2"))
for n in (10, 100, 1000, 10000):
print("%8s %14d %18d" % (n, n, n * n // 2))
# Linear against quadratic -- but the two-pointer version needs sorted
# input, so the honest comparison on unsorted data includes the sort:
#
# nested loops: n^2/2
# sort + two pointers: n log n + n
#
# Still a win at any real size, and if the data arrives sorted the sort
# is free. The third option is a hash set, which is O(n) with no sort at
# all -- and the reason two pointers still wins in interviews is that it
# uses O(1) extra space and, unlike the hash version, generalises to
# problems where you need the ACTUAL closest pair rather than an exact hit:
def closest_pair(a, target):
lo, hi = 0, len(a) - 1
best, bestpair = None, None
while lo < hi:
s = a[lo] + a[hi]
if best is None or abs(s - target) < best:
best, bestpair = abs(s - target), (a[lo], a[hi])
if s < target:
lo += 1
else:
hi -= 1
return bestpair, best
print()
for t in (14, 15, 100):
pair, off = closest_pair(data, t)
print("closest pair to %3d: %s (off by %d)" % (t, pair, off))
# A hash set cannot answer that question -- it can only tell you whether
# an exact complement is present. The ordering the two pointers rely on
# is also what lets them measure "close".
Output
Guided tour
Run pair sum and step through. Watch each move discard a whole block of pairs the brute force would have tested.
Compare the two counters. Two pointers does roughly n comparisons where brute force does n²/2.
Change the target so no pair exists. The pointers still meet in one pass and correctly report failure.
Try the palindrome check — the same converging pattern, comparing characters instead of summing.
Run container with most water and watch it always move the shorter bar. That is the greedy choice the correctness argument justifies.
Where that leaves you
Two pointers replaces nested loops with a single coordinated pass, in O(1) extra space. It works only when moving a pointer changes the result predictably — usually because the data is sorted. Recognising that condition is the whole skill.
Where it applies, and where it does not
It applies when:
The array is sorted, or can be sorted cheaply.
The problem is about pairs or triplets satisfying a condition.
You are editing an array in place and want O(1) extra space.
The structure is a linked list and you need position without a length.
A monotonic condition tells you unambiguously which pointer to move.
It does not apply when:
The data is unsorted and sorting is not affordable.
Moving a pointer might skip a valid answer, because the condition is not monotonic.
The problem needs arbitrary lookups — a hash table is the right tool.
That last row is a real alternative worth naming. Unsorted two-sum is solved with a dictionary in O(n) and O(n) space:
def two_sum(arr, target):
seen = {}
for i, x in enumerate(arr):
if target - x in seen:
return seen[target - x], i
seen[x] = i
So the choice is: sorted input plus O(1) space (two pointers) against unsorted input plus O(n) space (hash table). Both are O(n) after sorting is accounted for.
Three-sum, and the pattern that generalises
The standard extension: find all triplets summing to zero. Sort, fix one element, and two-pointer the rest.
def three_sum(nums):
nums.sort()
out = []
for i in range(len(nums) - 2):
if i and nums[i] == nums[i-1]:
continue # skip duplicate first elements
lo, hi = i + 1, len(nums) - 1
while lo < hi:
s = nums[i] + nums[lo] + nums[hi]
if s < 0:
lo += 1
elif s > 0:
hi -= 1
else:
out.append((nums[i], nums[lo], nums[hi]))
lo += 1
while lo < hi and nums[lo] == nums[lo-1]:
lo += 1 # skip duplicate second elements
hi -= 1
return out
O(n²) rather than the O(n³) of three nested loops. The duplicate-skipping lines are what most implementations get wrong, and they matter whenever the output must contain no repeats.
That structure — fix k−2 elements with loops, two-pointer the last two — generalises to four-sum and beyond, at O(n^(k-1)).
Related techniques
Sliding window is the same idea with both pointers moving forward and a condition maintained between them — used for subarray and substring problems.
Merging two sorted arrays uses one pointer per array, which is exactly merge sort's combine step.
In-place partitioning in quick sort is a same-direction two-pointer scan.
Dutch national flag uses three pointers to partition into three regions in one pass.
Recognising the family matters more than memorising each: all of them replace nested iteration with coordinated single-pass movement, justified by an invariant about the regions the pointers bound.
Questions people ask
Does the array need to be sorted? For opposite-end patterns, yes — the monotonic relationship is what justifies moving a pointer. Same-direction and fast-slow patterns do not require it.
Is sorting first worth the cost? Sorting is O(n log n) and two pointers is O(n), so the total is O(n log n) — still far better than O(n²), and it uses O(1) extra space where a hash table uses O(n).
How do I know which pointer to move? From the condition: if the current value is too small, move the pointer that increases it. If that is ambiguous, two pointers is not the right technique.
Why does Floyd's algorithm work? The gap between the pointers shrinks by one each step inside the cycle, so it must reach zero.
What is the space complexity? O(1) — just the two indices. That is the technique's main advantage over a hash-table solution.
Can it be used on a linked list? Yes, and fast-slow is designed for it — no random access needed.
Recap in one screen
Two coordinated indices replace a nested loop, turning O(n²) into O(n) with O(1) extra space.
Opposite ends for sorted-pair problems, same direction for in-place editing, fast-slow for cycles and midpoints.
Correctness comes from an invariant: what is guaranteed true about the region each pointer bounds.
Unsorted pair problems are usually a hash-table job instead — O(n) time, O(n) space.
Three-sum is a loop plus two pointers at O(n²), and skipping duplicates is the part that is easy to get wrong.
Run it in Python
Three uses of the same idea — pair sum, in-place duplicate removal, and palindrome checking — each with its pointer positions printed, and each replacing a nested loop.
two_pointers.pyPython 3
# Two pointers: walk a sorted list from both ends, or at two speeds.
# --- 1. find a pair that sums to the target ----------------------------
def pair_sum(a, target):
lo, hi = 0, len(a) - 1
while lo < hi:
total = a[lo] + a[hi]
print(f" lo={lo} ({a[lo]:>2}) hi={hi} ({a[hi]:>2}) sum={total:>3}", end="")
if total == target:
print(" <- match")
return lo, hi
if total < target:
print(" too small, move lo right")
lo += 1 # only a bigger left value can help
else:
print(" too big, move hi left")
hi -= 1 # only a smaller right value can help
return None
data = [1, 3, 4, 6, 8, 10, 13]
print("sorted:", data, " target 14")
print("pair:", pair_sum(data, 14))
print(f"comparisons: at most {len(data)}, against {len(data) ** 2 // 2} for nested loops")
# --- 2. remove duplicates in place, O(1) extra memory ------------------
def dedupe(a):
if not a:
return 0
write = 1 # slow pointer: end of the kept region
for read in range(1, len(a)): # fast pointer: scans everything
if a[read] != a[write - 1]:
a[write] = a[read]
write += 1
return write
values = [1, 1, 2, 2, 2, 3, 4, 4, 5]
n = dedupe(values)
print()
print("deduped:", values[:n], f"(kept {n}, list not reallocated)")
print("tail left over:", values[n:])
# --- 3. palindrome, ignoring anything that is not a letter -------------
def is_palindrome(text):
lo, hi = 0, len(text) - 1
while lo < hi:
while lo < hi and not text[lo].isalnum():
lo += 1
while lo < hi and not text[hi].isalnum():
hi -= 1
if text[lo].lower() != text[hi].lower():
return False
lo, hi = lo + 1, hi - 1
return True
print()
for text in ["A man, a plan, a canal: Panama", "race a car"]:
print(f" {text!r:>34}: {is_palindrome(text)}")
Output
How the code works
while lo < hi:Strictly less than, so the two pointers never land on the same element — which would pair a value with itself. Every two-pointer loop lives or dies on this condition.
if total < target: lo += 1The move is justified, not guessed. The list is sorted, so with a[hi] as the largest available partner, a[lo] cannot be part of any solution — discarding it is safe.
lo += 1 / hi -= 1Each step eliminates a whole row or column of the pair table, so the n² candidates are covered in n steps. That is the trick, and it only works because the input is sorted.
write = 1; for read in range(1, len(a)):The other variant: both pointers move forwards, at different rates. write marks the end of the kept prefix and read scans ahead — nothing is allocated.
while lo < hi and not text[lo].isalnum():The inner skips also need the lo < hi guard, or a string of pure punctuation runs a pointer off the end. Nested pointer loops are where the index errors hide.
Change one thing
Shuffle data before calling pair_sum. It returns None for a pair that exists — sortedness is a precondition, not a nicety.
Print values in full after dedupe. The tail is stale data, which is why the function returns a length rather than a list.
Extend pair_sum to three numbers: fix one, two-pointer the rest. O(n²) instead of O(n³), and the standard answer to 3Sum.
Where this runs
Real CPython, compiled to WebAssembly and running on your own machine — nothing is uploaded. The first run takes a few seconds while the interpreter downloads; after that it is immediate. Need more room, or want to paste your own attempt? Use the Python compiler.
Check yourself
0 of 3
Answer without scrolling back up.
Pair-sum with two pointers requires the list to be:
Moving a pointer is only justified because sortedness proves the discarded element cannot be part of any solution. Shuffle the input and it returns None for a pair that exists.
When the sum is too small, why is it safe to move lo right rather than hi left?
Each step eliminates a whole row or column of the pair table, which is how n² candidates are covered in n steps.
In the in-place dedupe, why does the function return a length instead of a list?
The point of the technique is O(1) extra memory. The caller uses a[:n] and ignores whatever is past it.
Cheat sheet
Two Pointers
Two indices moving with purpose turn an O(n²) nested loop into a single O(n) pass. The trick only works when the data is ordered — and that ordering is what tells each pointer which way to move.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.