LC Patterns — Two / Three / N Pointers
The Master Insight (read before the patterns)
Checking every pair in an array is O(n²). Two pointers cuts that to O(n) by using order (the array is sorted, or has some other one-directional structure): each pointer only ever moves one way, so together they move at most 2n steps — and the structure guarantees that the pairs you skip could never have been the answer.
left could still pair with is even smaller, so left is done"). If you can't say that sentence, you don't have a two-pointer solution — you have two indices and a hope. Interviewers poke exactly here.There are only three ways the pointers can move; every pattern below is one of them:
- Converge — start at both ends, move toward each other (finding a pair, partitioning by comparison).
- Chase — both start on the left and move the same direction at different speeds/conditions (reader/writer, fast/slow, cycles).
- Parallel — one pointer per sequence, advance whichever you just consumed (merging, matching, subsequence checks).
"Three/N pointers" is never a new idea: it's either an outer loop pinning one element with a two-pointer converge inside (k-sum), or three chase pointers keeping a partition tidy (Dutch flag).
§0Templates & Universal Pitfalls
Template 1 — Converge
left, right = 0, n - 1
while left < right:
if condition(left, right): # e.g., sum == target → found / record
...
elif too_small(...): left += 1 # only the limiting side moves
else: right -= 1Template 2 — Chase (reader/writer form)
write = 0
for read in range(n): # read always advances; write only on keep
if keep(nums[read]):
nums[write] = nums[read]
write += 1
return write # new logical lengthTemplate 3 — Parallel
i = j = 0
while i < len(A) and j < len(B):
if A[i] <= B[j]: consume A[i]; i += 1
else: consume B[j]; j += 1
# drain the leftover of whichever remainsUniversal pitfalls
- Moving the wrong pointer (or both at once) without a reason — the classic silent wrong answer. Decide which pointer is provably used up and move only that one.
- Duplicate handling in k-sum: skip duplicates at every pointer level (
while nums[i] == nums[i-1]: i += 1) — and only after recording a hit, or you'll skip the valid first occurrence. left < rightvsleft <= right: finding a pair needs<(an element can't pair with itself); single-element checks (palindrome center) can use<=. Get this wrong and you either loop forever or miss the middle.- Forgetting to drain the leftover tail in parallel/merge patterns.
- Sorting when you mustn't: if the answer needs the original indices (LC 1 Two Sum), sorting throws them away — use a hash map instead. Say the trade-off out loud: two pointers cost O(n log n) to sort but O(1) space; a hash map is O(n) time and space.
- Linked-list null-safety: every
fast.next.nextneedsfast and fast.nextin the loop guard — recite the guard before you write the loop.
1Converging Pointers on a Sorted Array (pair with target)
What it is: The array is sorted (or you sort it), and you want a pair with some property — a sum equal to a target, or a mirror check like a palindrome. Put one pointer at each end; each comparison tells you which side is "too small" or "too big," and that side steps inward.
Intuition: Say sum = arr[l] + arr[r]. If it's too small, then no pair using arr[l] can ever reach the target — every partner still available is ≤ arr[r], and that already wasn't enough. So arr[l] is used up: move l right. Too big is the mirror case: move r left. Every step throws away one element for good → O(n) after sorting. It's the same "prune what can't help" idea as binary search, but for pairs.
Approach: Template 1. For palindrome checks, the condition is s[l] == s[r], and both pointers move on a match. For strings with junk characters (non-alphanumerics), skip past those inside the loop.
Challenges & pitfalls
saying the "this side is used up" argument out loud (see master insight); < vs <= in the loop; the sorted precondition — if the input isn't sorted and indices matter, this pattern doesn't apply (pitfall 5).
numbers = [2, 7, 11, 15], target 9. Return the two 1-based indices.l = 0(2),r = 3(15): sum17 > 9→ too big → moverleft.l = 0(2),r = 2(11): sum13 > 9→ too big → moverleft.l = 0(2),r = 1(7): sum9→ found! Return[1, 2](1-indexed). ✓ Each step permanently ruled out the biggest remaining value.
"aba" a palindrome?l = 0('a'),r = 2('a'): match → step both inward.- Now
l = 1,r = 1— pointers met, loop ends. Every compared pair matched → true. ✓ (For"abca":a==a, thenbvscmismatch → false.)
2Converging with a Greedy Invariant (move the limiting side)
What it is: Both ends still move inward, but the rule isn't a sorted sum — it's a bottleneck: the answer at any (l, r) is capped by the weaker side (shorter line, lower wall), so moving the stronger side can't possibly help, and you always advance the weaker one.
Intuition: Container With Most Water — the area is width × min(h[l], h[r]). If you move the taller side inward, the width shrinks and the height is still capped by the shorter side, so the area can only get worse. So the shorter side is the one holding you back: move it, hoping to find a taller wall. Trapping Rain Water extends this with running maxima: whichever side has the smaller max so far is fully decided ("water here = maxLeft − height"), so lock it in and advance it.
Approach: Template 1, where the move rule is "advance the pointer with the smaller height/value," usually while carrying best, max_left, max_right accumulators. For pairing problems (boats): greedily put the heaviest with the lightest; if they don't fit together, the heaviest goes alone.
Challenges & pitfalls
these need a real proof, not a vibe — rehearse the exchange argument for 11 and the "smaller-max side is decided" claim for 42 until each is two clean sentences; accumulator updates in the wrong order (update the max before or after computing water? trace one example); it's easy to hand-wave and get caught in the follow-up.
height = [1, 8, 6, 2, 5, 4, 8, 3, 7].l = 0(1),r = 8(7): area =min(1, 7) × 8 = 8. The left wall (1) is shorter → movel.l = 1(8),r = 8(7): area =min(8, 7) × 7 = 49. Best so far 49. Now the right wall (7) is shorter → mover.- Keep going; nothing beats 49 (every later width is smaller and no pair of tall walls is far enough apart). Answer 49. ✓
- Why moving the shorter side is safe: keeping the shorter wall and moving the taller one in only shrinks the width while the height stays capped — it can never improve.
height = [0,1,0,2,1,0,1,3,2,1,2,1], total trapped = 6.- Track
maxLeftandmaxRight. Whichever side's running max is smaller is "decided": the water above that bar issmallerMax − height. - Walking with that rule sums the trapped water bar by bar to 6. The key sentence: if
maxLeft < maxRight, the left bar's water depends only onmaxLeft(the right side already guarantees a taller wall), so you can commit it and move left. ✓
3K-Sum Family (pin + converge; the "three/N pointers" classic)
What it is: Find triplets/quadruplets that sum to a target: sort the array, pin the first element with an outer loop, and solve the remaining pair with Pattern 1's converge inside. 4Sum pins two elements, kSum recurses down to 2Sum — so "N pointers" is really "N−2 pinned loops + one converge."
Intuition: Sorting buys two things at once: the two-pointer sum walk and easy duplicate-skipping (equal values sit next to each other). The recursion: kSum(target) = for each pin i, solve (k−1)Sum(target − nums[i]) — the base case 2Sum is the only place pointers actually move.
Approach: sort → for i (skip dups: if i > 0 and nums[i] == nums[i-1]: continue) → converge l = i+1, r = n-1 → on a hit, record it and skip duplicates on both l and r, then move both. Prune: break when nums[i] * k > target (even the smallest possible sum is already too big). O(n^(k−1)).
Challenges & pitfalls
skipping duplicates at every level without skipping a legitimate combination (skip only relative to the previous same-level value); on a hit, move both pointers, not one; overflow in 4Sum for large values (Java/C++); "3Sum Closest" drops the dedup and tracks best-distance instead — don't blindly copy the exact-match skeleton.
nums = [-1, 0, 1, 2, -1, -4], find all triplets summing to 0.- Sort first →
[-4, -1, -1, 0, 1, 2]. - Pin
-4(need a pair summing to 4):l=1(-1),r=5(2) → −1+2 = 1, too small, and even the biggest remaining pair can't reach 4 → no triplet here. - Pin
-1(index 1, need pair summing to 1):l=2(-1),r=5(2) → sum 1 → triplet [-1, -1, 2]. Thenl=3(0),r=4(1) → sum 1 → triplet [-1, 0, 1]. - Pin
-1again (index 2): same value as the previous pin → skip, to avoid duplicate triplets. - Pin
0(need pair summing to 0):l=4(1),r=5(2) → sum 3, too big, no smaller pair left → done. - Answer
[[-1, -1, 2], [-1, 0, 1]]. ✓ Sorting made both the pair-walk and the dedup trivial.
4Reader/Writer (in-place compaction)
What it is: Remove or rearrange elements in place with O(1) extra space: one pointer reads every element, a second (always ≤ the reader) marks where the next "kept" element goes. Everything before the writer is the answer-so-far; the function returns the writer as the new length.
Intuition: The invariant does all the work: [0, write) is exactly the kept region, [write, read) is garbage you're free to overwrite. The reader advances every step, the writer only on keeps — so the writer never overtakes the reader and no kept data is ever clobbered. This is compaction, the same trick garbage collectors and log compaction use.
Approach: Template 2. Variants: "at most k duplicates" → keep if nums[read] != nums[write-k]; "move zeroes preserving order" → write the non-zeros, then either fill the tail with zeros or swap-on-write to keep the values.
Challenges & pitfalls
stating the invariant before coding (it's also exactly what the interviewer wants to hear); the write-k back-reference for at-most-k variants (compare against the written region, not the read region); stability (move-zeroes must preserve order — a swap-from-both-ends approach breaks it).
nums = [0, 0, 1, 1, 2, 3, 3], keep one of each, in place.- Keep index 0 as-is;
write = 1. Reader walks from index 1, and we write only when the value differs from the last kept one (nums[write-1]). - read
0(= last kept 0) → skip. read1(≠ 0) → write at index 1,write = 2. read1→ skip. read2(≠ 1) → write at index 2,write = 3. read3(≠ 2) → write at index 3,write = 4. read3→ skip. - Front of the array is now
[0, 1, 2, 3, ...], return length 4. ✓ Everything beforewriteis the deduped answer; the tail is leftover junk we ignore.
nums = [0, 1, 0, 3, 12], push zeros to the end, keep order.- Writer marks where the next non-zero goes. Walk: read
0skip; read1→ write at 0; read0skip; read3→ write at 1; read12→ write at 2. - Now
[1, 3, 12, 3, 12]; fill the tail (indices 2+... i.e. fromwrite) with zeros →[1, 3, 12, 0, 0]. ✓ Order of the non-zeros is preserved because the writer copies them left in the order it sees them.
5Fast & Slow: Cycle Detection (Floyd)
What it is: Two pointers race through a linked structure — slow moves 1 step, fast moves 2. If there's a cycle they must meet inside it; if not, fast runs off the end (null). A second phase (reset one pointer to the head, move both at speed 1) finds the exact spot where the cycle begins.
Intuition: Inside a cycle, fast gains exactly 1 node on slow every step, so it can't jump over slow — they're guaranteed to land on the same node within one lap. Phase 2 is a little algebra: at the meeting point, the distance from the head to the cycle start equals the distance from the meeting point to the cycle start (2(a+b) = a+b+c+b ⇒ a = c) — memorize that two-line derivation, interviewers ask for it. The big generalization: any repeated function x → f(x) on a finite set is secretly a linked list — that's how 287 (the array is i → nums[i]) and happy-number turn into cycle problems.
Approach: while fast and fast.next: → advance; if slow == fast: cycle found. Then reset one pointer to the head and walk both at speed 1 until they meet — that's the entry. For 287: f(i) = nums[i], and the duplicate value is the cycle entry.
Challenges & pitfalls
the loop guard (pitfall 6); start both at the head and check equality after moving (checking before you move gives a false positive); recognizing the disguised versions (287 is the real test — the array is the linked list); explaining why they must meet without waving hands ("gains 1 per step, so the gap shrinks to 0").
nums = [1, 3, 4, 2, 2]. Treat each index as a node pointing to nums[index]; the repeated value is where two arrows collide → a cycle entrance.- Phase 1 (slow +1, fast +2), starting from index 0:
- slow: 0 →
nums[0]=1→nums[1]=3→nums[3]=2→nums[2]=4 - fast: 0 →
nums[1]=3→nums[2]=4→nums[2]=4→nums[2]=4 - They meet at value 4.
- Phase 2: reset one pointer to index 0, move both by 1:
- ptr: 0 → 1 → 3 → 2; other: 4 → 2 → 4 → 2. They meet at 2.
- The duplicate is 2. ✓ No extra array, no modifying the input — the cycle math found it.
1 → 2 → 3 → 4 → 2 (the 4 points back to 2).- slow steps 1→2→3→4→2…, fast steps 1→3→2→4→3…; because fast closes the gap by one node each step, they eventually land on the same node → cycle exists. If instead fast ever reaches null, there's no cycle. ✓
6Linked-List Positioning (gap-K and midpoint)
What it is: Find a spot in a singly linked list you can't index into — the middle, or the k-th from the end — in a single pass. Either give one pointer a k-node head start (then move both until the leader hits the end), or use speeds 1 and 2 so the slow pointer lands in the middle when fast finishes.
Intuition: A fixed head start is preserved as both pointers move at the same speed — so when the leader reaches the end, the trailer is exactly k behind it, no length count needed. Speed 2-vs-1 means slow covers half of whatever fast covers: fast at the end ⇒ slow at the middle. These combine into recipes: "is this list a palindrome" = find the middle (this pattern) + reverse the second half + compare in parallel (Pattern 7).
Approach: gap-K: advance lead k steps (often from a dummy node, so "delete the k-th from end" leaves you just before the target); then move both to the end. Midpoint: while fast and fast.next: slow = slow.next; fast = fast.next.next.
Challenges & pitfalls
the even-length middle convention (with the guard above, slow lands on the second of the middle pair — check it against the problem's definition on a 4-node example); deleting requires stopping one node before (a dummy node is the standard fix — use it by default and say why); restoring the list if you reversed half (interviewers may require it).
1 → 2 → 3 → 4 → 5, remove the 2nd from the end (node 4).- Put a dummy before the head:
dummy → 1 → 2 → 3 → 4 → 5. Giveleada head start ofn + 1 = 3steps from the dummy:leadnow points at node3.trailstarts at the dummy. - Move both one at a time until
leadfalls off the end: - lead 3, trail dummy → lead 4, trail 1 → lead 5, trail 2 → lead null, trail 3.
trailsits just before the target, sotrail.next = trail.next.nextsplices out node 4 →1 → 2 → 3 → 5. ✓ The head start of exactlyn+1is what leavestrailone node early.
1 → 2 → 3 → 4 → 5.- slow +1, fast +2: slow 1→2→3, fast 1→3→5(end). slow stops at 3, the middle. ✓ For an even list
1→2→3→4, slow lands on3(the second of the two middles) with this guard.
7Parallel Pointers / Merging Two Sequences
What it is: One pointer per sequence; at each step, compare the two current elements and advance the pointer whose element you just used. This is the merge step of merge-sort, generalized: merging, intersecting, or comparing two sorted (or independently walkable) sequences in O(m+n).
Intuition: Sortedness means the smaller current element can't match or beat anything further along in the other sequence — so use it now and move on. The famous twist for in-place merge (88): merge backwards, largest first — the free space is at the end of the target array, so writing the biggest values there never overwrites an element you haven't read yet.
Approach: Template 3 + drain the leftover. Variants: intersection (advance the smaller, record equals), backspace-compare (walk both backwards, resolving #s with a skip counter before each comparison — going backwards turns "look ahead" into "look behind").
Challenges & pitfalls
forgetting to drain (pitfall 4); merging in place forwards (overwrites A — backwards is the whole trick; if you start forwards in an interview, recover with "wait — the free space is at the end, I should fill from the back"); duplicate policy in intersections (350 keeps counts, 349 doesn't).
nums1 = [1, 2, 3, 0, 0, 0] (first m=3 are real), nums2 = [2, 5, 6]. Merge into nums1 in place.- Fill from the back. Three pointers:
i=2(last real of nums1 = 3),j=2(last of nums2 = 6),k=5(last slot of nums1). - 6 > 3 → put 6 at slot 5.
j=1, k=4. - 5 > 3 → put 5 at slot 4.
j=0, k=3. - 3 > 2 → put 3 at slot 3.
i=1, k=2. - nums1[1]=2 vs nums2[0]=2 → put the nums2 one at slot 2.
j=-1, k=1. - nums2 exhausted; nums1's own
[1, 2]are already in place. - Result
[1, 2, 2, 3, 5, 6]. ✓ Filling largest-first from the end means we never step on an unread value.
8Expand From Center (diverging pointers)
What it is: The opposite of converge: the pointers start together and move outward. Every palindrome has a center, so try all 2n−1 centers (each character, and each gap between two characters) and expand l--, r++ while the characters match.
Intuition: A palindrome is fully described by its center + how far it reaches, so listing every center covers every palindrome exactly once, and expansion stops at the first mismatch (you get the longest one for free). 2n−1 centers × O(n) expansion = O(n²) with O(1) space — the sweet spot between brute force O(n³) and Manacher's O(n) (name-drop Manacher; never code it live).
Approach: for each i: expand from (i, i) (odd-length) and from (i, i+1) (even-length); track the best window, or count every successful expansion (647 counts each expansion step, not just the maximal one).
Challenges & pitfalls
forgetting the even centers (the #1 bug — "aa" has no single-char center); off-by-one when turning the final (l, r) into a substring (after the failed step, the answer is s[l+1:r] — trace it once); knowing when DP beats this (when later stages need all palindromic substructure, e.g. partitioning).
s = "babad".- Odd center at index 1 ('a'): expand →
s[0]='b'ands[2]='b'match → try to go further, butlfalls off the left end → stop. Palindrome"bab", length 3. - Odd center at index 2 ('b'):
s[1]='a',s[3]='a'match → nexts[0]='b',s[4]='d'mismatch → stop. Palindrome"aba", length 3. - Longest is length 3 (
"bab"—"aba"ties, either is accepted). ✓ - Even center example,
s = "cbbd": center between indices 1 and 2 →s[1]='b',s[2]='b'match → answer"bb". This is the case you lose if you forget even centers.
9Dutch National Flag (three-way partition, three chase pointers)
What it is: Split an array into three regions in one pass with three pointers: low (edge of the 0-region), mid (the scanner), high (edge of the 2-region). Classic form: sort an array of 0s/1s/2s in place, without counting.
Intuition: Keep four zones by invariant: [0, low) = 0s, [low, mid) = 1s, [mid, high] = unknown, (high, n) = 2s. Every scanner step shrinks the unknown zone by at least one, from one side or the other → O(n), one pass. This is quicksort's 3-way partition; knowing that connection answers "where would you use this in real life?" (sorting data with lots of equal keys).
Approach: mid scans: on 0 → swap into low, advance both; on 1 → just mid++; on 2 → swap into high, high--, and do not advance mid (the element that just arrived from high hasn't been examined yet). Two-way (parity/binary) partitions are the degenerate case (simple reader/writer or converge-swap).
Challenges & pitfalls
the don't-advance-mid-after-a-high-swap rule (the bug everyone writes — the element coming from high is unknown, while the one coming from low is a known 1, hence the asymmetry — be ready to explain why); the loop condition is mid <= high (not <); generalizing to "partition by a pivot into <, =, >".
nums = [2, 0, 2, 1, 1, 0], sort 0s/1s/2s in place. Start low=0, mid=0, high=5.- mid=0 sees 2 → swap with high(5):
[0, 0, 2, 1, 1, 2],high=4, mid stays (must re-check the swapped-in value). - mid=0 sees 0 → swap with low(0) (itself):
low=1, mid=1. - mid=1 sees 0 → swap with low(1) (itself):
low=2, mid=2. - mid=2 sees 2 → swap with high(4):
[0, 0, 1, 1, 2, 2],high=3, mid stays. - mid=2 sees 1 → just
mid=3. - mid=3 sees 1 →
mid=4. Nowmid > high(4 > 3) → stop. - Result
[0, 0, 1, 1, 2, 2]. ✓ The one subtlety: after a high-swap we don't advance mid, because that new value is still unexamined.
10Subsequence Matching Across Two Strings
What it is: Check whether string s is a subsequence of t (same characters in order, gaps allowed): one greedy pointer per string, always advance t's pointer, advance s's only on a match; if s runs out, it's a subsequence. Extends to "which of many candidate words fit inside t."
Intuition: Greedy matching is safe by an exchange argument: matching each character of s at the earliest place it can go in t never hurts — any later match could be swapped for the earlier one. This is Pattern 7's parallel topology, where one sequence is the filter and only one pointer's advance is conditional.
Approach: two pointers, O(m+n) per check. The follow-up that levels you up (asked in 392): many queries against the same t → preprocess t into "char → sorted list of positions," then each query character binary-searches the next usable position (binary search Pattern 2 — the series composes). For 524, run the check per candidate, tracking longest-then-lexicographically-smallest.
Challenges & pitfalls
the multi-query follow-up (know the preprocessing answer cold — it's the real interview question hiding in 392); tie-breaking in 524 (length first, then lexicographic order — read the statement precisely); resisting DP/regex overkill for what is a linear greedy scan.
s = "abc" a subsequence of t = "ahbgdc"?- Pointer
iintos, walktleft to right: - looking for
a:t[0]='a'matches →i→b. - looking for
b:t[1]='h'no,t[2]='b'yes →i→c. - looking for
c:t[3]='g'no,t[4]='d'no,t[5]='c'yes →iruns off the end ofs. sfully matched → true. ✓ Grabbing each letter at its earliest spot intis always safe.
s against the same t, don't re-scan t each time. Precompute, for t = "ahbgdc": a→[0], h→[1], b→[2], g→[3], d→[4], c→[5]. Then for each query letter, binary-search the position list for the smallest index greater than where you are — turning each check into O(|s| log |t|). ✓▤Summary Table — Recognition Cheat Sheet
| # | Pattern | Topology | Trigger phrase in problem | Key invariant/argument |
|---|---|---|---|---|
| 1 | Sorted pair converge | Converge | "sorted", "pair summing to", "palindrome check" | too-small side is used up |
| 2 | Greedy invariant converge | Converge | "container", "trap water", "pair heaviest/lightest" | bottleneck side is decided — move it |
| 3 | K-sum | Pin + converge | "all triplets/quadruplets summing to" | sort + dedup at every level |
| 4 | Reader/writer | Chase | "remove in place", "O(1) extra space" | [0,write) = kept region |
| 5 | Floyd cycle | Chase (2:1) | "cycle", "repeats forever", array as i→nums[i] | fast gains 1/step ⇒ must meet |
| 6 | List positioning | Chase (gap/2:1) | "k-th from end", "middle of list", one pass | equal speed preserves the head start |
| 7 | Parallel merge | Parallel | "two sorted arrays/lists", "merge", "compare" | smaller current element is final |
| 8 | Expand from center | Diverge | "longest/count palindromic substring" | every palindrome has a center (2n−1) |
| 9 | Dutch flag | 3-pointer chase | "sort 0s/1s/2s", "partition into three" | four-zone invariant; don't advance mid on high-swap |
| 10 | Subsequence match | Parallel | "is subsequence", "by deleting characters" | earliest greedy match is safe |