Two Pointer Techniques
Learn efficient traversal patterns using multiple pointers to solve problems in linear time
SPACED REPETITION Β· 19 practice questions
Make this lesson stick.
Try 3 questions now. No account needed. Sample answers aren't saved.
or sign in to practice all 19Stop checking pairs you have already ruled out
A sorted array holds 30,000 integers, which is the most that LeetCode 167: Two Sum II - Input Array Is Sorted allows. Do two of them add up to a target? Checking every pair means 30,000 Γ 29,999 / 2 = 449,985,000 sums. The method in this lesson needs at most 29,999.
The speed-up does not come from a faster comparison. It comes from reading each comparison more carefully. Suppose the smallest value plus the largest value falls short of the target. Then the smallest value is useless: it just failed with the biggest partner it could ever get. One comparison has ruled out 29,999 pairs, and the brute force goes on to check every one of them anyway.
That is the big idea: a pointer may move only when you can say why the positions it leaves behind can never matter again. Within one scan, no pointer ever moves backward, so the work of that scan is bounded by how far its pointers can travel. Each technique below is a different reason for moving.
| # | Smell | Move | Signature problem |
|---|---|---|---|
| 1 | Sorted input; find a pair that hits a target | Squeeze from both ends | Two Sum II |
| 2 | Sorted input; drop repeats in place | Compare with the last value kept | Remove Duplicates from Sorted Array |
| 3 | A linked list and a question about its shape | Tortoise and hare | Linked List Cycle |
| 4 | Triplets whose values hit a target | Sort, fix one, squeeze the rest | 3Sum |
| 5 | Three kinds of values to separate in one pass | Four regions, three pointers | Sort Colors |
This lesson builds on Foundation: Arrays & Strings. You should know its output contracts, the read/write pointers from its Move 3 (Remove Element) and its Move Zeroes challenge, and the opposite-end swap loop that reverses an array in its Move 4. Here those pointers get new jobs, and each job comes with a proof.
Move 1: Squeeze from both ends
Smell: the input is sorted and you want a pair whose sum hits a target.
The contract
LeetCode 167 gives you numbers, sorted in non-decreasing order, and a target. Exactly one pair of different positions adds up to it. Return the two positions 1-based, smaller first, using O(1) extra space.
The version below returns 0-based indices, and None when no pair exists, which makes it easy to test on any input. For LeetCode, return [left + 1, right + 1] instead.
Predict first: nums = [2, 7, 11, 15, 18, 24, 30], target 33. Put one finger on each end: 2 + 30 = 32, one short. Which finger moves? How many pairs does that single move rule out?
Check your answer
The left finger moves off the 2. The 30 is the largest partner the 2 will ever get, and even that falls short, so 2 plus anything else falls shorter still. The move rules out all six pairs that contain the 2, and only one of them was ever added up.
The slow version tries all 7 Γ 6 / 2 = 21 pairs. Look at what it repeats: for each left value it tries partner after partner, even after the largest partner has already failed.
The fast version keeps one index at each end and moves exactly one of them per step:
def two_sum_sorted(nums, target):
"""nums is sorted ascending. Return 0-based (i, j) with i < j, or None."""
left, right = 0, len(nums) - 1
while left < right:
s = nums[left] + nums[right]
if s == target:
return (left, right)
if s < target:
left += 1 # even the largest partner left is too small for nums[left]
else:
right -= 1 # even the smallest partner left is too big for nums[right]
return None
| step | left | right | values | sum | action |
|---|---|---|---|---|---|
| 1 | 0 | 6 | 2 + 30 | 32 | left += 1 |
| 2 | 1 | 6 | 7 + 30 | 37 | right -= 1 |
| 3 | 1 | 5 | 7 + 24 | 31 | left += 1 |
| 4 | 2 | 5 | 11 + 24 | 35 | right -= 1 |
| 5 | 2 | 4 | 11 + 18 | 29 | left += 1 |
| 6 | 3 | 4 | 15 + 18 | 33 | found |
Six sums instead of 21. The function returns (3, 4), and LeetCode would want [4, 5].
Why one comparison rules out a whole row
Lay every possible sum out in a grid. Row i is the left index, column j is the right index, and only i < j counts. Because the array is sorted, sums grow from left to right along each row and from top to bottom down each column. The bracketed cells are the six sums the loop computed:
j=1 j=2 j=3 j=4 j=5 j=6
(7) (11) (15) (18) (24) (30)
i=0 ( 2) 9 13 17 20 26 [32]
i=1 ( 7) 18 22 25 [31] [37]
i=2 (11) 26 [29] [35] 41
i=3 (15) [33] 39 45
i=4 (18) 42 48
i=5 (24) 54
The search starts in the top-right corner, cell (0, 6).
- Too small (32 at
(0, 6)): every cell to its left in row 0 is smaller still. The whole row is dead, andleft += 1moves down one row. - Too big (37 at
(1, 6)): every cell below it in column 6 is bigger still. The whole column is dead, andright -= 1moves one column left.
Each step deletes a row or a column, which gives the loop its invariant: if a valid pair exists, both of its indices lie in [left, right]. Here is the proof for the too-small case. Every index j with left < j β€ right has nums[j] β€ nums[right], so nums[left] + nums[j] β€ s < target. The value at left has no partner inside the window, and partners outside the window were ruled out by earlier steps. The too-big case is the mirror image.
Each iteration shrinks right β left by exactly one, so the loop runs at most n β 1 times. That is O(n) time and O(1) extra space, where n is len(nums).
π§ It is tempting to say the loop "considers every pair". It computed 6 of 21. The accurate claim is that every pair was either checked or proved useless.
The grid also handles counting. Suppose the question is how many pairs have a sum below the target. A too-small sum at (left, right) means every pair (left, j) with left < j β€ right is below the target too. That is right β left pairs, counted in one step before the row is dropped.
Sorting buys correctness, not speed
Run the same function on the unsorted [3, 5, 1, 9] with target 10. 3 + 9 = 12 is too big, so right drops the 9. That move assumes 3 is the smallest partner the 9 could ever get. But 1 is smaller, and 1 + 9 = 10 was the answer. The loop goes on to check 3 + 1 = 4 and 5 + 1 = 6, then returns None.
It doesn't crash and it doesn't slow down, because the loop is O(n) on any input. What breaks is the proof behind each move, so you get a wrong answer that looks plausible.
Sorting first repairs the proof, but check the contract before you sort. LeetCode 1: Two Sum asks for original positions in an unsorted array, and sorting scrambles them. Here are your options:
| Approach | Time | Extra space | Catch |
|---|---|---|---|
| Check every pair | O(nΒ²) | O(1) | too slow for large n |
| Hash map of values seen so far | expected O(n) | O(n) | uses memory |
| Sort (value, index) pairs, then squeeze | O(n log n) | O(n) | you must carry the indices along |
| Squeeze an array that is already sorted | O(n) | O(1) | needs sorted input |
The hash-map version belongs to Hash Maps & Sets.
Traps
- Every branch must move a pointer. Delete the
elseand try[2, 3, 4, 8]with target 7. 2 + 8 = 10 is too big, nothing moves, and the loop computes the same sum forever. <or<=? The guardleft < rightmeans "two different positions". Withleft <= right, the input[2, 3, 4]with target 8 walks up toleft = right = 2and reports(2, 2), which uses the 4 twice. Some loops do need<=. Move 5's loop does, because there the cell where the pointers meet has not been examined yet. So ask: does the stateleft == rightstill have work to do?- Equal values are fine.
[3, 3]with target 6 returns(0, 1). Pointers are positions, not values. - Short input. An empty or one-element array skips the loop and returns
None. But code that readsnums[0]before the loop, for example to set up a "best so far", needs a length check first. - Overflow. In Java or C++,
nums[left] + nums[right]can overflow a 32-bitint. Python integers never overflow. LeetCode 167 keeps values within Β±1000, so this only bites in a general version with large values.
Your turn: closest instead of exact
Change the problem: return the pair sum closest to the target. Use nums = [10, 22, 28, 29, 30, 40] with target 54.
- What must you remember that the exact version didn't?
- When may you stop early?
- Is the pointer rule still safe?
Check your answer
Remember the best sum so far and keep squeezing after a miss. Stop early only on an exact hit, because a distance of 0 can't be beaten.
def closest_pair(nums, target):
"""nums sorted ascending. Return (best_sum, (i, j)), or None if fewer than 2 values."""
if len(nums) < 2:
return None
left, right = 0, len(nums) - 1
best_sum, best_pair = None, None
while left < right:
s = nums[left] + nums[right]
if best_sum is None or abs(s - target) < abs(best_sum - target):
best_sum, best_pair = s, (left, right)
if s == target:
break # distance 0 cannot be beaten
if s < target:
left += 1
else:
right -= 1
return best_sum, best_pair
| left | right | sum | distance | best after | move |
|---|---|---|---|---|---|
| 0 (10) | 5 (40) | 50 | 4 | 50 | left += 1 |
| 1 (22) | 5 (40) | 62 | 8 | 50 | right -= 1 |
| 1 (22) | 4 (30) | 52 | 2 | 52 | left += 1 |
| 2 (28) | 4 (30) | 58 | 4 | 52 | right -= 1 |
| 2 (28) | 3 (29) | 57 | 3 | 52 | right -= 1 |
The answer is 52 = 22 + 30, at distance 2. The rule is still safe. When s is below the target, every other partner of nums[left] inside the window gives an even smaller sum, which is even further away. The length check only makes the function return None, as its docstring promises; without it, [] and [5] skip the loop and return (None, None). It becomes essential in the common variant that seeds best_sum = nums[0] + nums[-1] before the loop: that version crashes on [] and reports the 5 paired with itself on [5].
Move 2: Compare with the last value kept
Smell: the input is sorted, and you must remove repeats in place and report how many distinct values are left.
In the final round of Foundation: Arrays & Strings you kept first occurrences in an unsorted list, and you needed a set to answer "have I kept this value already?". Sorted input answers that question for free. All copies of a value sit next to each other, so a value is new exactly when it differs from the last value you kept.
LeetCode 26: Remove Duplicates from Sorted Array has the same contract shape as Remove Element. Put the distinct values in the first k cells, in their original order, and return k. Whatever sits after cell k doesn't matter.
Predict first: nums = [0, 0, 1, 1, 1, 2, 2, 3, 3, 4]. What is k, what are the first k cells, and what does the whole list look like afterward?
Check your answer
k = 5, and the front is [0, 1, 2, 3, 4]. The whole list is [0, 1, 2, 3, 4, 2, 2, 3, 3, 4]. The last five cells are leftovers from the input, and the contract lets them stay.
def remove_duplicates(nums):
if not nums:
return 0
write = 1 # nums[0] is always kept
for read in range(1, len(nums)):
if nums[read] != nums[write - 1]: # differs from the last kept value
nums[write] = nums[read]
write += 1
return write
Trace [1, 1, 2, 3, 3]:
| read | value | last kept nums[write - 1] |
action | front afterward | write |
|---|---|---|---|---|---|
| 1 | 1 | 1 | skip | [1] |
1 |
| 2 | 2 | 1 | copy to 1 | [1, 2] |
2 |
| 3 | 3 | 2 | copy to 2 | [1, 2, 3] |
3 |
| 4 | 3 | 3 | skip | [1, 2, 3] |
3 |
It returns 3, and the whole list is now [1, 2, 3, 3, 3].
Why it works. The invariant is: before each iteration, nums[:write] holds each distinct value of nums[:read] exactly once, in order. Because the input is sorted, a value can only be followed by copies of itself or by something larger. So "differs from nums[write - 1]" means the same thing as "never kept before". The writes are safe for the reason you saw in the Foundation lesson: write <= read, so nothing unread gets overwritten. That is O(n) time and O(1) extra space.
Two edges deserve a look:
- The empty guard is load-bearing. Starting at
write = 1assumes a first value exists. Withoutif not nums, an empty list returns 1. - Sortedness is doing real work. On the unsorted
[1, 2, 1], the function returns 3 and keeps both 1s. The read/write shape itself doesn't need order (Remove Element didn't), but this comparison does.
Plot twist: keep at most two of each
LeetCode 80: Remove Duplicates from Sorted Array II allows each value up to twice. A value may be kept unless the last two kept values already equal it. In sorted input, that is one comparison: nums[read] != nums[write - 2].
Your turn: write it. Then a teammate suggests comparing with nums[read - 2] instead, "because it's the same thing in the input". Find an input where the teammate's version fails, and explain why.
Check your answer
def remove_duplicates_keep2(nums):
write = 0
for read in range(len(nums)):
if write < 2 or nums[read] != nums[write - 2]:
nums[write] = nums[read]
write += 1
return write
[1, 1, 1, 2, 2, 3] breaks the teammate's version. The correct one returns 5 with front [1, 1, 2, 2, 3]. The teammate's returns 4 with front [1, 1, 2, 3], so a 2 has vanished.
At read = 3, the first 2 is copied into cell 2, over the third 1. At read = 4, the teammate compares the second 2 with nums[2], which used to be a 1 but is now a 2, so the second 2 is wrongly skipped. Cells behind read may already be overwritten; the kept region nums[:write] never changes behind your back. Look back into the output, never into input cells that writes may have changed.
The general rule for "at most k copies" is write < k or nums[read] != nums[write - k]. For k = 1, nums[read - 1] happens to work too, because the only cell it could see overwritten is nums[write - 1] itself. Don't build on that luck.
Move 3: The tortoise and the hare
Smell: you have a linked list, or any "next step" rule, and a question about its shape: does it loop, and where is its middle?
A linked list gives you a head node and a .next link on each node. You can't index into it and you don't know its length, so all you can do is walk forward.
class ListNode:
def __init__(self, val, next=None):
self.val = val
self.next = next
LeetCode 141: Linked List Cycle asks whether following .next ever comes back to a node you have visited. The follow-up asks for O(1) memory.
The simple answer is the Foundation lesson's set of seen items, applied to nodes. Walk the list, store each node, and report a cycle when you meet a stored one. That is expected O(n) time but also O(n) memory. Without any memory, a plain walk around a cycle never ends.
Floyd's algorithm uses two pointers instead. The tortoise moves one node per iteration and the hare moves two. With no cycle, the hare falls off the end. With a cycle, the hare can never leave it, and it eventually lands on the tortoise.
def has_cycle(head):
slow = fast = head
while fast is not None and fast.next is not None:
slow = slow.next # tortoise: one step
fast = fast.next.next # hare: two steps
if slow is fast: # same node, not just the same value
return True
return False
Predict first: in the list below, E links back to C. Both pointers start at A. After which iteration do they meet, and on which node?
A β B β C β D β E
β β
βββββββββ
Check your answer
They meet on D after iteration 3.
| iteration | slow | fast | same node? |
|---|---|---|---|
| start | A | A | (not checked) |
| 1 | B | C | no |
| 2 | C | E | no |
| 3 | D | D | yes |
In iteration 3 the hare goes from E to C and then to D, taking the back link, and lands on the tortoise.
Why they must meet
Once the tortoise enters the loop, the hare is already inside it, because it is ahead and can't get out. Measure the gap as the number of steps the hare must walk forward around the loop to reach the tortoise. Each iteration the tortoise moves 1 and the hare moves 2, so the gap shrinks by exactly one. A gap that shrinks by one per step can't jump over zero, so they meet no later than the end of the tortoise's first lap. For a list of n nodes the loop runs at most n iterations: O(n) time, O(1) extra space.
Why two steps and not three? The proof needs the gap to shrink by exactly one. A three-step hare shrinks it by two, which can jump over zero every time: on a 4-node loop, a gap of 1 goes 1, 3, 1, 3, and so on. Pointers that start on the same node never get into that state, though: the gap on an even-length loop is then always even, so shrinking it by two still lands on zero. A three-step hare does detect every cycle. What it breaks is the loop-start trick below.
β οΈ Compare nodes, not values. slow is fast asks whether both pointers are on the same node. With slow.val == fast.val, the acyclic list 5 β 7 β 5 β 9 β 5 reports a cycle: after two iterations the tortoise is on the middle 5 and the hare on the last 5.
Guarding the hare's jump
fast.next.next needs both fast and fast.next to exist. Guards that look almost right fail on specific lengths:
| Loop guard | What happens |
|---|---|
fast is not None and fast.next is not None |
correct on every list |
fast is not None |
an acyclic list with an odd number of nodes crashes: the hare stops on the last node, and fast.next.next reads None.next |
fast.next.next is not None |
crashes on an empty list and on every acyclic list of odd length, where the hare sits on the last node and fast.next is None |
Non-empty acyclic lists of even length and all lists with a cycle pass all three guards, so tests that only use even lengths never catch the bug. The order inside the correct guard matters too: and checks fast before it reads fast.next.
Same loop, different question: the middle
When the hare reaches the end, the tortoise has gone half as far. That answers LeetCode 876: Middle of the Linked List in one pass:
def middle_node(head):
slow = fast = head
while fast is not None and fast.next is not None:
slow = slow.next
fast = fast.next.next
return slow
On A β B β C β D β E it returns C. On A β B β C β D it also returns C, the second of the two middles, which is the convention LeetCode 876 asks for. To split a list into halves you often want the first middle. Start the hare one node ahead with fast = head.next, and A β B β C β D gives B.
A third variant keeps a fixed gap instead of a speed ratio. Walk fast k nodes ahead first, then move both pointers one node at a time. When fast falls off the end, slow is on the k-th node from the end.
Your turn: what does middle_node return for lists of 1, 2 and 6 nodes, and what does the fast = head.next variant return for 6?
Check your answer
For 1 node, the loop never runs and the answer is A. For 2 nodes, one iteration gives B. For 6 nodes (A to F), three iterations give D. The variant stops one node earlier on even lengths and returns C. On odd lengths both versions agree.
Boss level: where does the loop start?
LeetCode 142: Linked List Cycle II wants the first node of the loop. After the two pointers meet, put one pointer back at head and move both one node at a time. They meet again exactly at the loop's first node.
def meeting_node(head):
slow = fast = head
while fast is not None and fast.next is not None:
slow = slow.next
fast = fast.next.next
if slow is fast:
return slow
return None
def cycle_start(head):
meet = meeting_node(head)
if meet is None:
return None
a, b = head, meet
while a is not b: # both walk one node at a time
a = a.next
b = b.next
return a
On the A to E list, the pointers first meet at D. Then a and b go through (A, D), (B, E) and finally (C, C). The loop starts at C.
Why it works. Say the tail has ΞΌ nodes before the loop and the loop has Ξ» nodes. At the meeting, the tortoise has taken t steps and the hare 2t. The hare's extra t steps were whole laps of the loop, so t is a multiple of Ξ». The tortoise stands t β ΞΌ steps past the loop's first node, so ΞΌ more steps take it t steps past that node. That is a whole number of laps, which puts it back on the first node. The pointer that restarts at head also needs ΞΌ steps to get there. Before that, one pointer is on the tail and the other is inside the loop, so they can't meet early. In the example, ΞΌ = 2 (A and B), Ξ» = 3 (C, D, E) and t = 3.
This is where the hare's speed matters. A three-step hare has walked 3t, so its extra 2t steps are whole laps, and t itself can be half a lap. Take A β B β C β D β E β F β C, a 2-node tail and a 4-node loop. The three-step pointers meet at C after t = 2. In the second phase, the restarted pointer needs two steps to reach C, and by then the other one has moved on to E. They stay two nodes apart forever.
Move 4: Sort, fix one, squeeze the rest
Smell: you want triplets whose values hit a target, and the answer lists values, not positions.
LeetCode 15: 3Sum asks for every triplet [a, b, c], taken from three different positions, with a + b + c = 0. The output must not contain the same triplet twice, and any order is fine.
Read the contract first. The answer is values, so sorting is allowed.
Predict first: nums = [-1, 0, 1, 2, -1, -4]. How many different triples of positions sum to zero, and how many triplets belong in the answer?
Check your answer
Three triples of positions sum to zero: positions (0, 1, 2), (0, 3, 4) and (1, 2, 4). They give only two answer triplets, [-1, 0, 1] and [-1, -1, 2]. The two β1s produce [-1, 0, 1] twice. Removing duplicates is half of this problem.
The slow version runs three nested loops and throws the results into a set of sorted tuples. That is O(nΒ³). At LeetCode's limit of n = 3,000 it means C(3000, 3) = 4,495,501,000 triples.
The fast version sorts, then treats each nums[i] as a fixed value. For each fixed value it runs Move 1's squeeze on the part after i, looking for a pair that sums to -nums[i]:
def three_sum(nums):
nums = sorted(nums) # values only, so sorting is allowed
n = len(nums)
result = []
for i in range(n - 2):
if nums[i] > 0:
break # everything from i on is positive
if i > 0 and nums[i] == nums[i - 1]:
continue # same fixed value as last time
left, right = i + 1, n - 1
while left < right:
s = nums[i] + nums[left] + nums[right]
if s == 0:
result.append([nums[i], nums[left], nums[right]])
left += 1
right -= 1
while left < right and nums[left] == nums[left - 1]:
left += 1 # skip copies of the value just used
while left < right and nums[right] == nums[right + 1]:
right -= 1
elif s < 0:
left += 1
else:
right -= 1
return result
On the example, the sorted list is [-4, -1, -1, 0, 1, 2]. The table shows each pair tried as (left value, right value) and the triple sum s:
| i | fixed | pairs tried β s | found |
|---|---|---|---|
| 0 | β4 | (β1, 2) β β3, (β1, 2) β β3, (0, 2) β β2, (1, 2) β β1 | none |
| 1 | β1 | (β1, 2) β 0, (0, 1) β 0 | [-1, -1, 2], [-1, 0, 1] |
| 2 | β1 | skipped: same fixed value as i = 1 | none |
| 3 | 0 | (1, 2) β 3 | none |
The result is [[-1, -1, 2], [-1, 0, 1]]. At i = 0 the pair (β1, 2) appears twice because left steps from the first β1 to the second.
Duplicates without a set
Two skips keep the output clean.
- The outer skip compares
nums[i]withnums[i - 1], so each fixed value gets exactly one turn, at its first occurrence. The first copy is the one that can still pair with its own twins: at i = 1, the fixed β1 finds[-1, -1, 2]by using the second β1 as a partner. Compare withnums[i + 1]instead and you skip the first copy and keep the last one. The last β1 has no β1 after it, so[-1, -1, 2]vanishes, and that version returns only[[-1, 0, 1]]. - The inner skip runs after a match. With the fixed value a, any other pair that uses the same left value b would need the same c = βa β b, so it would repeat the triplet. Skip past the copies of both. Without the inner skip,
[-2, 0, 0, 2, 2]outputs[-2, 0, 2]twice, even with the outer skip in place.
β οΈ The skip loops need their own left < right check. On [0, 0, 0], an unguarded while nums[left] == nums[left - 1] walks past the end of the list and raises IndexError.
Why no triplet repeats. Each triplet comes out in sorted order a β€ b β€ c. The outer skip gives each value of a one turn. Within that turn, left only moves right and jumps past every copy of b once b is used, so each b appears once. And c is fixed by a and b. You could also dedupe with a set of tuples, since the triplets come out sorted. The skips are better because they never build the duplicates in the first place.
The early exit if nums[i] > 0: break is safe because every value from i on is positive, so no three of them can sum to zero. It must be > 0, not >= 0: [0, 0, 0] is a valid answer.
What sorting costs
Sorting is O(n log n). Then the outer loop runs up to n times and each squeeze is O(n), which gives O(nΒ²) overall, because nΒ² outgrows n log n. At n = 3,000 the squeezes take at most (n β 1)(n β 2)/2 = 4,495,501 steps, exactly 1,000 times fewer than the 4.5 billion triples. 4Sum adds one more fixed loop around the same squeeze, which makes it O(nΒ³).
Extra space is less obvious. sorted(nums) makes an O(n) copy. nums.sort() avoids the copy but reorders the caller's list, and Python's sort (Timsort) can still use about n/2 temporary slots while it merges. So in Python, 3Sum uses O(n) extra space either way. That does not count the output, which can hold O(nΒ²) triplets.
Your turn: 3Sum Closest
LeetCode 16: 3Sum Closest asks for the sum of three values that is closest to a target, and guarantees exactly one best answer. Combine this move with the closest-pair loop from Move 1. Which parts of three_sum can you delete? Predict the answer for [-1, 2, 1, -4] with target 1.
Check your answer
def three_sum_closest(nums, target):
nums = sorted(nums)
best = nums[0] + nums[1] + nums[2] # LeetCode guarantees len(nums) >= 3
for i in range(len(nums) - 2):
left, right = i + 1, len(nums) - 1
while left < right:
s = nums[i] + nums[left] + nums[right]
if abs(s - target) < abs(best - target):
best = s
if s == target:
return s
if s < target:
left += 1
else:
right -= 1
return best
Both skips and the early exit go. You return a single number, so repeated triplets do no harm, and nums[i] > 0 only meant something for target 0 and exact hits. The answer is 2 = β1 + 1 + 2. It is still O(nΒ²) time.
Move 5: Four regions, three pointers
Smell: the values come in three kinds, and you must rearrange them in place in one pass.
LeetCode 75: Sort Colors gives you only 0s, 1s and 2s and asks you to sort them in place without the library sort. The simple answer uses two passes: count each value, then overwrite the list. The follow-up asks for one pass with constant extra space.
Keep four regions and three pointers:
0 β¦ low-1 low β¦ mid-1 mid β¦ high high+1 β¦ n-1
[ all 0s | all 1s | unknown | all 2s ]
def sort_colors(nums):
low, mid, high = 0, 0, len(nums) - 1
while mid <= high:
if nums[mid] == 0:
nums[low], nums[mid] = nums[mid], nums[low]
low += 1
mid += 1
elif nums[mid] == 1:
mid += 1
else: # nums[mid] == 2
nums[mid], nums[high] = nums[high], nums[mid]
high -= 1 # mid stays: the value that arrived is unchecked
return nums
Predict first: on [2, 0, 2, 1, 1, 0] the first nums[mid] is a 2, so it is swapped with nums[high]. What are (low, mid, high) afterward, and why doesn't mid move?
Check your answer
(0, 0, 4). The value that arrived from index 5 is a 0 that nobody has examined yet, so mid has to look at it next.
| step | nums[mid] |
action | array afterward | low | mid | high |
|---|---|---|---|---|---|---|
| start | [2, 0, 2, 1, 1, 0] |
0 | 0 | 5 | ||
| 1 | 2 | swap 0 β 5 | [0, 0, 2, 1, 1, 2] |
0 | 0 | 4 |
| 2 | 0 | swap 0 β 0 (itself) | [0, 0, 2, 1, 1, 2] |
1 | 1 | 4 |
| 3 | 0 | swap 1 β 1 (itself) | [0, 0, 2, 1, 1, 2] |
2 | 2 | 4 |
| 4 | 2 | swap 2 β 4 | [0, 0, 1, 1, 2, 2] |
2 | 2 | 3 |
| 5 | 1 | mid += 1 |
[0, 0, 1, 1, 2, 2] |
2 | 3 | 3 |
| 6 | 1 | mid += 1 |
[0, 0, 1, 1, 2, 2] |
2 | 4 | 3 |
mid has passed high, so the unknown region is empty and the loop stops.
Why it works. Every iteration either moves mid up or high down, so the unknown region shrinks by exactly one each time. That means exactly n iterations: O(n) time, O(1) extra space. The pointer moves are licensed by what each swap brings back:
- A 0 at
midis swapped tolow. What comes back fromlow? Iflow < mid, it comes from the 1s region, so it is a 1 and already classified. Iflow == mid, it is the same cell. Either waymidmay advance. - A 2 at
midis swapped tohigh. What comes back is from the unknown region, so nobody has checked it.midstays. - The guard is
mid <= high, because whenmid == highthat cell is still unknown. Withmid < high, the input[1, 0]comes back unchanged.
β οΈ Advancing mid after the swap with high is the classic bug. On [1, 2, 0] it produces [1, 0, 2], with a 0 stranded among the 1s. Whatever arrives from high is unchecked, whether it is a 0 or a 2.
The swaps don't keep equal values in their original order: the partition is not stable. For bare numbers nobody can tell. If the values are records with other fields, or the problem demands the original order within each group, this method is the wrong tool.
Your turn: any pivot
Generalize to partition3(nums, pivot): values below the pivot first, then values equal to it, then values above it. Three-way quicksort uses exactly this partition to cope with many repeated values. Predict partition3([5, 1, 7, 5, 3, 9], 5).
Check your answer
def partition3(nums, pivot):
low, mid, high = 0, 0, len(nums) - 1
while mid <= high:
if nums[mid] < pivot:
nums[low], nums[mid] = nums[mid], nums[low]
low += 1
mid += 1
elif nums[mid] == pivot:
mid += 1
else:
nums[mid], nums[high] = nums[high], nums[mid]
high -= 1
return nums
It returns [1, 3, 5, 5, 9, 7]. The three groups are right, but inside the last group 9 now comes before 7, although 7 came first in the input. Nothing guarantees sorted order or input order within a group.
Choosing: what does each move prove?
Every move in this lesson has a license, a fact that proves the positions behind the pointer are finished:
| Move | License to move |
|---|---|
| Squeeze from both ends | sorted input: the best partner has already failed |
| Compare with the last value kept | sorted input: copies of a value are adjacent |
| Tortoise and hare | the gap shrinks by exactly one per step |
| Sort, fix one, squeeze | the sort creates the order the squeeze needs |
| Four regions | every region except the unknown one holds a single known value |
The opposite-effects test
A squeeze from both ends needs a quantity that one move pushes up and the other pushes down. For a sum on sorted input, left += 1 raises the sum and right -= 1 lowers it. So "too small" has exactly one fix and "too big" has exactly one fix.
Predict first: in the sorted [1, 4, 6, 7, 14, 19], find two values whose difference is 7. Start at the ends: 19 β 1 = 18, too big. Which pointer do you move?
Check your answer
Neither choice has a proof. Moving left gives 19 β 4 = 15, and moving right gives 14 β 1 = 13. Both moves shrink the difference, so "too big" has two candidate fixes with no reason to prefer either, and "too small" has none at all. Both fixed rules fail here: "always move left" and "always move right" each miss the pair 7 and 14.
The fix is to start both pointers at the left, with j ahead of i. Now j += 1 widens the gap and i += 1 narrows it, so the moves have opposite effects again:
def has_pair_with_difference(nums, k):
"""nums sorted ascending, k >= 0. Is there i != j with nums[j] - nums[i] == k?"""
i, j = 0, 1
while j < len(nums):
gap = nums[j] - nums[i]
if i == j or gap < k:
j += 1 # widen the gap
elif gap > k:
i += 1 # narrow the gap
else:
return True
return False
| i | j | values | gap | move |
|---|---|---|---|---|
| 0 | 1 | 1, 4 | 3 | j += 1 |
| 0 | 2 | 1, 6 | 5 | j += 1 |
| 0 | 3 | 1, 7 | 6 | j += 1 |
| 0 | 4 | 1, 14 | 13 | i += 1 |
| 1 | 4 | 4, 14 | 10 | i += 1 |
| 2 | 4 | 6, 14 | 8 | i += 1 |
| 3 | 4 | 7, 14 | 7 | found |
Neither pointer moves backward and each moves at most n times, so this is O(n) time and O(1) extra space. The i == j check handles k = 0, where a value may not pair with itself. Counting the distinct pairs instead is LeetCode 532: K-diff Pairs in an Array.
Products pass the test when every value is positive, because then both factors grow to the right. With mixed signs the test fails. On [-5, -1, 2, 3] with target product 5, the squeeze checks β15, β3 and 6, then gives up, but (β5) Γ (β1) = 5. Once signs mix, moving left no longer pushes the product in one known direction.
Converging without sorting
Some converging loops need no sorted input, because their license comes from somewhere else. A palindrome check compares the two ends: a mismatch ends the search, and a match finishes both ends at once. Container With Most Water moves the shorter wall, which has its own proof. Each has a lesson: Palindrome Validation and Container With Most Water.
When pointers are the wrong tool
- Unsorted input, and the answer needs original positions. Sorting destroys them. Use a hash map, as in Hash Maps & Sets.
- A contiguous range that must satisfy a condition, such as the longest substring without repeats. That is a same-direction pair with a job of its own: Sliding Window Patterns.
Final round: no label on the problem
Real problems don't say which move they want. Read the contract, find the smell, and name the license before you write a loop.
Challenge 1: Squares of a Sorted Array
LeetCode 977: Squares of a Sorted Array gives you nums sorted in non-decreasing order, possibly with negatives. Return the squares, also sorted. [-4, -1, 0, 3, 10] becomes [0, 1, 9, 16, 100]. Squaring and then sorting is O(n log n); do it in O(n).
- Where in the input can the largest square be?
- Which move applies?
- In which order do you fill the output?
Hint
The squares are large at both ends and small in the middle. The smallest square hides somewhere inside, but the largest is always at an end.
Check your answer
The largest square sits at one of the two ends, since those hold the most negative and the most positive values. Squeeze from both ends and fill the output from the back:
def sorted_squares(nums):
n = len(nums)
out = [0] * n
left, right = 0, n - 1
for write in range(n - 1, -1, -1): # fill from the largest square down
if abs(nums[left]) > abs(nums[right]):
out[write] = nums[left] * nums[left]
left += 1
else:
out[write] = nums[right] * nums[right]
right -= 1
return out
The license: the larger of the two end squares is at least every square in between, so it is safe to place it now. Comparing nums[left] > nums[right] without abs is the classic bug: on [-5, 3], β5 is the smaller value but has the larger square, and the buggy version returns [25, 9]. This is O(n) time, plus the O(n) output list.
Challenge 2: Happy Number
LeetCode 202: Happy Number: replace a positive integer by the sum of the squares of its digits, and repeat. The number is happy if this reaches 1. So 19 β 82 β 68 β 100 β 1 is happy. And 2 β 4 β 16 β 37 β 58 β 89 β 145 β 42 β 20 β 4 β β¦ repeats forever without reaching 1. Decide whether n is happy using O(1) extra memory.
Check your answer
The sequence is a linked list whose "next" is the digit-square function. Every number determines its successor, so once a value repeats, the sequence loops. A 1 maps to itself, which is a loop of one. Run the tortoise and hare, and check where they meet:
def is_happy(n):
def step(x):
return sum(int(d) ** 2 for d in str(x))
slow, fast = n, step(n)
while fast != 1 and slow != fast:
slow = step(slow)
fast = step(step(fast))
return fast == 1
Here comparing values is correct, because the value is the node: equal numbers have equal successors. In a linked list, equal values can live in different nodes, which is why has_cycle compares with is.
Why must the sequence loop at all? A number with d β₯ 4 digits maps to at most 81d, which is less than the number itself, and anything below 1,000 maps to at most 243. So the values end up trapped below 1,000, and a sequence with finitely many possible values must eventually repeat. A set of seen values also works, at the cost of O(number of steps) memory.
Challenge 3: Two Sum, unsorted
LeetCode 1: Two Sum: nums = [3, 2, 4], target 6. Return the original positions of the two values. A teammate sorts the array and calls two_sum_sorted. What do they return, and what is the right answer?
Check your answer
The sorted list is [2, 3, 4], and the squeeze returns (0, 2) for 2 + 4. But in the original list, position 0 holds 3 and position 2 holds 4, which add up to 7. The right answer is [1, 2]. Sorting was allowed in 3Sum because that contract asks for values. This one asks for positions, so use a hash map of values already seen:
def two_sum_unsorted(nums, target):
seen = {} # value -> index where it appeared
for i, x in enumerate(nums):
if target - x in seen:
return [seen[target - x], i]
seen[x] = i
return []
That is expected O(n) time with O(n) space. Sorting (value, index) pairs and squeezing also works, in O(n log n) time.
Cheat sheet
| Clue | Move | Invariant or license | Cost |
|---|---|---|---|
| sorted; a pair hits a target | squeeze from both ends | a valid pair lies in [left, right] |
O(n), O(1) |
| sorted; drop repeats in place | compare with nums[write - 1], or nums[write - k] |
nums[:write] holds each value of nums[:read], at most once (or k times) |
O(n), O(1) |
| linked list: cycle, middle, loop start | tortoise (1 step) and hare (2 steps) | the gap shrinks by 1 per iteration | O(n), O(1) |
| triplets of values hit a target | sort, fix one, squeeze the rest | skip copies of values already used | O(nΒ²), O(n) in Python |
| three kinds of values, one pass | low / mid / high |
four regions: 0s, 1s, unknown, 2s | O(n), O(1) |
| sorted; a target difference | both pointers start at the left | j widens the gap, i narrows it |
O(n), O(1) |
| unsorted; original positions needed | not pointers: a hash map | the map holds the earlier values | expected O(n), O(n) |
Before moving on, pick one move and explain it aloud from a blank editor. Say what the smell is, what license each pointer move has, why the loop guard is < or <=, and which input breaks the code once the license is missing: unsorted input, mixed signs, a three-step hare in the loop-start trick, a lookback into overwritten cells. If you can do that for each move, you can rebuild the rest.
Next: Palindrome Validation and Container With Most Water take converging pointers further, Sliding Window Patterns gives same-direction pointers a range to maintain, and Hash Maps & Sets covers pair problems where sorting is not allowed.