GUIDE · Patterns
Two Pointers
two indices moving toward each other (or same direction) to replace a nested loop.
At a glance
- FIRST VISIT
- From Session 9.3, read the TELL row and section 1, two-sum on sorted input: the sort is the condition, and the one-element assert is the input that breaks a careless loop. Stop there. Session 9.4 sends you back for the last assert of each section, the inputs that break the pattern; the sections themselves are interview lookup.
- WHAT
- two indices moving toward each other (or same direction) to replace a nested loop.
- TELL
- "sorted array", "pair/triplet with sum", "in place", "remove duplicates", "container / water", "palindrome", "merge".
- SAY
- converging pointers (opposite ends)fast/slow (same direction)read/write pointerpartition (Lomuto / Hoare)skip duplicatesinvariant
- BIG-O
- O(n) per pass; 3-sum is O(n^2); sort first costs O(n log n).
The idea#
Picture a number line drawn on the sidewalk, chalk marks in sorted order. You put one finger on the leftmost mark and one finger on the rightmost. You want two marks that add up to a target. You look at the sum under your fingers. Too small, and the left finger is the problem, because every mark to its left is even smaller. You slide the left finger right. Too big, and the right finger is the problem. You slide it left. The fingers only move toward each other. When they meet, you have tried the pairs that could have worked, without ever writing a nested loop.
That converging pair is one shape. The other shape puts both fingers on the left and lets them move the same direction at different speeds. A slow finger writes, a fast finger reads. Or a left-max and a right-max inch inward, and you always process the side whose wall is shorter. The invariant is what stays true after each slide: everything behind the write finger is already the answer, or every pair you skipped could not hit the target.
Two fingers replace a loop inside a loop when the array is sorted, or when you can sort it first and live with that cost.
Why it matters#
You reach for this when the problem says sorted array, a pair or a triplet with a sum, in place, remove duplicates, container and water, palindrome, or merge. The nested loop is the habit. The fingers are the replacement. If the array is not sorted and you only need one pair, a hash map is the other tool. If it is sorted, the fingers use constant extra space.
One pass of the fingers is O(n). Three-sum, which fixes one index and runs the fingers on the rest, is O(n²). Sorting first, when you need it, is O(n log n) and usually cheaper than the quadratic part that follows.
A small example#
function twoSumSorted(nums, target) { let lo = 0; let hi = nums.length - 1; while (lo < hi) { const sum = nums[lo] + nums[hi]; if (sum === target) return [lo, hi]; if (sum < target) lo += 1; else hi -= 1; } return [-1, -1];} const pair = twoSumSorted([1, 3, 5, 8, 12], 11);3 + 8 is 11, so pair is [1, 3]. Each step throws away a whole end of the number line, because the array only increases as you walk right.
What the code below is doing#
Two-sum on a sorted input is the converging pair: sum too small moves the left finger, too big moves the right, linear time and constant space. Three-sum sorts, fixes i, and runs that same pair on the remainder, skipping equal neighbors so the same triplet is not recorded twice. Container with the most water treats the width between the fingers as one side and the shorter height as the other. You move the shorter side, because moving the taller one cannot raise the waterline.
Remove-duplicates on a sorted array is the read/write pair. The write finger advances only when the value changes, and the returned length is how far it got. Squares of a sorted array notice that the largest absolute value sits at one end or the other, so you fill the output from the back. Trapping rain water keeps leftMax and rightMax and processes the side with the smaller max, constant extra space. Valid palindrome II, at most one deletion, walks inward and, on the first mismatch, tries skipping either side.
Merging two sorted arrays when the first has slack at the end writes from the back, taking the larger value, so you never overwrite a number you still need. The last section is Lomuto's partition, the same idea inside quicksort: the pivot sits at the end, a boundary finger marks "everything here is smaller," and smaller values are swapped forward. One linear pass, constant extra space.
When you talk about it, say this: two fingers, an invariant, and which finger moves when the test fails.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');Two-sum II (sorted input)#
converging pointers: sum too small -> l++, too big -> r--
function twoSumSorted(nums, target) { let l = 0, r = nums.length - 1; while (l < r) { const s = nums[l] + nums[r]; if (s === target) return [l, r]; if (s < target) l++; else r--; } return [];}Verified by 3 asserts
assert.deepEqual(twoSumSorted([2, 7, 11, 15], 9), [0, 1]);assert.deepEqual(twoSumSorted([-3, -1, 0, 2, 4], -4), [0, 1]); // negativesassert.deepEqual(twoSumSorted([1], 1), []);Three-sum (sort + skip duplicates)#
fix i, two-pointer the rest; skip equal neighbors
function threeSum(nums) { nums = [...nums].sort((a, b) => a - b); const out = []; for (let i = 0; i < nums.length - 2; i++) { if (i > 0 && nums[i] === nums[i - 1]) continue; // skip dup anchor if (nums[i] > 0) break; // pruning: all positive from here let l = i + 1, r = nums.length - 1; while (l < r) { const s = nums[i] + nums[l] + nums[r]; if (s === 0) { out.push([nums[i], nums[l], nums[r]]); while (l < r && nums[l] === nums[l + 1]) l++; // skip dup l while (l < r && nums[r] === nums[r - 1]) r--; // skip dup r l++; r--; } else if (s < 0) l++; else r--; } } return out;}Verified by 3 asserts
assert.deepEqual(threeSum([-1, 0, 1, 2, -1, -4]), [[-1, -1, 2], [-1, 0, 1]]);assert.deepEqual(threeSum([0, 0, 0, 0]), [[0, 0, 0]]);assert.deepEqual(threeSum([1, 2]), []);Container with most water#
area = min(h[l], h[r]) * (r-l); move the shorter side
function maxArea(h) { let l = 0, r = h.length - 1, best = 0; while (l < r) { best = Math.max(best, Math.min(h[l], h[r]) * (r - l)); if (h[l] < h[r]) l++; else r--; } return best;}Verified by 3 asserts
assert.equal(maxArea([1, 8, 6, 2, 5, 4, 8, 3, 7]), 49);assert.equal(maxArea([1, 1]), 1);assert.equal(maxArea([5]), 0);Remove duplicates from sorted array in place#
read/write pointers; write when value changes
function removeDuplicates(nums) { if (nums.length === 0) return 0; let w = 1; for (let r = 1; r < nums.length; r++) if (nums[r] !== nums[w - 1]) nums[w++] = nums[r]; return w; // new length; nums[0..w) are the uniques}const rd = [1, 1, 2, 3, 3];Verified by 4 asserts
assert.equal(removeDuplicates(rd), 3);assert.deepEqual(rd.slice(0, 3), [1, 2, 3]);assert.equal(removeDuplicates([]), 0);assert.equal(removeDuplicates([1, 1, 1]), 1);Squares of a sorted array#
largest |x| is at one end; fill output from the back
function sortedSquares(nums) { const n = nums.length, out = new Array(n); let l = 0, r = n - 1; for (let k = n - 1; k >= 0; k--) { if (Math.abs(nums[l]) > Math.abs(nums[r])) out[k] = nums[l] * nums[l++]; else out[k] = nums[r] * nums[r--]; } return out;}Verified by 3 asserts
assert.deepEqual(sortedSquares([-4, -1, 0, 3, 10]), [0, 1, 9, 16, 100]);assert.deepEqual(sortedSquares([-3, -2, -1]), [1, 4, 9]);assert.deepEqual(sortedSquares([]), []);Trapping rain water#
leftMax/rightMax; process the side with the smaller max
function trap(h) { let l = 0, r = h.length - 1, lMax = 0, rMax = 0, water = 0; while (l < r) { if (h[l] < h[r]) { lMax = Math.max(lMax, h[l]); water += lMax - h[l]; l++; } else { rMax = Math.max(rMax, h[r]); water += rMax - h[r]; r--; } } return water;}Verified by 3 asserts
assert.equal(trap([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]), 6);assert.equal(trap([4, 2, 0, 3, 2, 5]), 9);assert.equal(trap([1, 2, 3]), 0);Valid palindrome II (at most one deletion)#
on first mismatch, try skipping either side
function validPalindrome(s) { const isPal = (i, j) => { while (i < j) if (s[i++] !== s[j--]) return false; return true; }; let i = 0, j = s.length - 1; while (i < j) { if (s[i] !== s[j]) return isPal(i + 1, j) || isPal(i, j - 1); i++; j--; } return true;}Verified by 3 asserts
assert.equal(validPalindrome('abca'), true);assert.equal(validPalindrome('abc'), false);assert.equal(validPalindrome(''), true);Merge sorted arrays from the back (nums1 has m valid + n slack)#
write pointer at m+n-1; take larger
function mergeInto(nums1, m, nums2, n) { let i = m - 1, j = n - 1, w = m + n - 1; while (j >= 0) nums1[w--] = i >= 0 && nums1[i] > nums2[j] ? nums1[i--] : nums2[j--]; return nums1; // leftover nums1 elements are already in place}Verified by 3 asserts
assert.deepEqual(mergeInto([1, 2, 3, 0, 0, 0], 3, [2, 5, 6], 3), [1, 2, 2, 3, 5, 6]);assert.deepEqual(mergeInto([0], 0, [1], 1), [1]);assert.deepEqual(mergeInto([1], 1, [], 0), [1]);Partition / Lomuto#
pivot = last; i = boundary of "< pivot"; swap smaller forward; place pivot at i
// SAY: Lomuto (single pointer, pivot at end, simple) vs Hoare (two converging pointers, fewer swaps).function lomuto(a, lo = 0, hi = a.length - 1) { const pivot = a[hi]; let i = lo; for (let j = lo; j < hi; j++) if (a[j] < pivot) { [a[i], a[j]] = [a[j], a[i]]; i++; } [a[i], a[hi]] = [a[hi], a[i]]; return i; // final pivot index; a[lo..i) < pivot <= a(i..hi]}const lp = [3, 8, 1, 9, 4, 5];const pIdx = lomuto(lp);Verified by 4 asserts
assert.equal(lp[pIdx], 5);assert.ok(lp.slice(0, pIdx).every((x) => x < 5) && lp.slice(pIdx + 1).every((x) => x >= 5));assert.equal(lomuto([7]), 0);assert.equal(lomuto([2, 2, 2]), 0); // with strict '<', equal elements stay right of pivot (Lomuto's weakness on duplicates)See also