HARD Lab · Two Pointers · 06

15. 3Sum

Sort the array, fix one index i, and squeeze two pointers inward — the sorted order lets you steer toward zero and skip duplicates with a single comparison, yielding all unique zero-sum triplets in O(n²) time.

MediumTwo PointersSortingTypeScript

PROBLEM What we're solving

Given an integer array nums, return all unique triplets [a, b, c] such that a + b + c = 0. No duplicate triplets in the output. nums = [-1, 0, 1, 2, -1, -4] [[-1, -1, 2], [-1, 0, 1]]. The same three indices can't be reused, and the answer set must contain no duplicate tuples.

KEY IDEA Sorting turns the steering problem into pointer math

Insight → once the array is sorted, fixing the leftmost element nums[i] reduces the problem to “find two numbers in a sorted subarray that sum to -nums[i].” Two pointers from both ends either find a match or tell you unambiguously which direction to move — because the array is sorted, moving l right only increases the sum and moving r left only decreases it.

RECIPE Sort → fix i → squeeze l/r → skip duplicates

  • 0 · Sort. nums.sort() ascending. This enables pointer steering and lets duplicate detection be a single equality check with the neighbor.
  • 1 · Fix i. Iterate i from 0 to n-3. Skip if nums[i] === nums[i-1] (duplicate outer value). Break if nums[i] > 0 (no positive triple can sum to zero after sorting).
  • 2 · Two-pointer inner loop. Set l = i+1, r = n-1. While l < r: compute sum = nums[i]+nums[l]+nums[r]. If sum === 0 record it; if sum < 0 advance l; if sum > 0 retreat r.
  • 3 · Skip inner duplicates. After recording a triplet, advance l past repeats and retreat r past repeats before moving both pointers inward. This avoids recording the same triple again.
Classic confusion → forgetting to skip duplicates at both pointers after recording a hit. Skipping only l (or only r) still produces duplicates for inputs like [-2, 0, 0, 0, 2]. Both inner duplicate-skip loops are required.

COST Complexity & alternatives

Brute force (three loops)
O(n³)
Check every triple; deduplicate with a set afterward.
Sort + two pointers
O(n²)
O(n log n) sort + O(n²) inner; O(1) extra space ignoring output.

Space note

The in-place sort uses O(log n) call-stack space. The output list itself is O(k) for k results, which is unavoidable. A hash-set approach also achieves O(n²) but requires O(n) auxiliary space and is harder to deduplicate cleanly — the two-pointer approach handles duplicates structurally.

Pattern transfer → the same “fix one, squeeze two” template solves 4Sum (fix two, squeeze two — O(n³)), 3Sum Closest (track minDiff instead of exact zero), and 3Sum Smaller(count pairs below a target). Anywhere you see “k-sum, sorted input, unique tuples,” reach for nested pointers.

RUN IT Sort, fix i, squeeze l and r inward

step 0 / 14
STARTInput: -1, 0, 1, 2, -1, -4. Step 1: sort the array so two-pointer math works and duplicate-skipping is trivial.
original-10011223-14-45
i (fixed)l (left pointer)r (right pointer)
slowfast

TYPESCRIPT The solution, annotated

threeSum.ts
function threeSum(nums: number[]): number[][] {
  nums.sort((a, b) => a - b);
  const result: number[][] = [];
  const n = nums.length;

  for (let i = 0; i < n - 2; i++) {
    if (nums[i] > 0) break;          // sorted: smallest value > 0 => no solution
    if (i > 0 && nums[i] === nums[i - 1]) continue;  // skip dup i

    let l = i + 1, r = n - 1;
    while (l < r) {
      const sum = nums[i] + nums[l] + nums[r];
      if (sum === 0) {
        result.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 (sum < 0) {
        l++;   // sum too small: larger l needed
      } else {
        r--;   // sum too large: smaller r needed
      }
    }
  }
  return result;
}

Reading it block by block

Line 2 — sort. Sorting is the prerequisite for everything else. It allows pointer steering (moving a pointer has a predictable effect on the sum) and reduces duplicate-skipping to a neighbor check instead of a set lookup.
Lines 6–8 — outer loop with early exits. if (nums[i] > 0) break works because the array is sorted: if the smallest remaining element is positive, no triple can sum to zero. if (i > 0 && nums[i] === nums[i-1]) continue skips a duplicate outer value so we never generate the same triplet from two different positions of the same number.
Lines 10–22 — two-pointer inner loop. l starts just right of i; r starts at the end. A sum less than zero means we need a larger value, so l++; a sum greater than zero means we need a smaller value, so r--. This is the core steering logic that makes the inner pass O(n) instead of O(n²).
Lines 15–19 — record and skip inner duplicates. On a zero-sum hit we push the triplet, then advance l past any duplicate values and retreat r past any duplicate values before moving both inward. Both loops are necessary — missing either one causes repeated triplets when two of the three values repeat.
Complexity → O(n²) time overall: O(n log n) for sorting plus O(n²) for the outer loop × O(n) inner two-pointer pass. O(1) auxiliary space (ignoring the output array and the sort call stack).

INTERVIEWFollow-ups they'll ask

  • “What about 4Sum?” Add one more outer loop — fix two indices, then run the two-pointer inner pass. Total time O(n³). The same duplicate-skip pattern applies at each level.
  • “3Sum Closest?” Replace the sum === 0 branch with tracking minDiff = Math.abs(sum - target). Update a running best when you find a closer sum.
  • “Return indices instead of values?”You'd need to keep the original indices while sorting — sort an index array by value, then emit the original positions. Deduplication gets trickier.
  • “What if duplicates were allowed in the output?” Remove both inner skip loops and the outer continue. That's simpler but produces repeats for inputs with repeated values.
  • “Can you solve it without sorting?” Yes — with a hash set for each pair. But you still need deduplication logic, and O(n²) time is the same; sorting is cleaner and uses less space.

MNEMONIC The one-liner

"Sort first; fix the anchor, squeeze the wings — skip twins at every level."

TRIGGERS When you see ___ → reach for ___

"k unique integers summing to target"sort + nested two pointers
no duplicate tuples in outputskip-neighbor check at each pointer level
find pair in sorted subarray summing to Xl/r squeeze from both ends
"3Sum Closest / 3Sum Smaller"same skeleton, change termination condition

SKELETON The reusable shape

skeleton.ts
nums.sort((a, b) => a - b);
const result: number[][] = [];
for (let i = 0; i < nums.length - 2; i++) {
  if (nums[i] > 0) break;
  if (i > 0 && nums[i] === nums[i - 1]) continue;
  let l = i + 1, r = nums.length - 1;
  while (l < r) {
    const sum = nums[i] + nums[l] + nums[r];
    if (sum === 0) {
      result.push([nums[i], nums[l], nums[r]]);
      while (l < r && nums[l] === nums[l + 1]) l++;
      while (l < r && nums[r] === nums[r - 1]) r--;
      l++; r--;
    } else if (sum < 0) { l++; } else { r--; }
  }
}
return result;

FLASHCARDS Tap to flip

Why sort before running two pointers?
Sorting makes pointer steering deterministic: l++ always increases the sum, r-- always decreases it. It also makes duplicate-skipping a single neighbor check.
tap to flip
1 / 7
Answer to reveal explanations. No penalty for retries.Score: 0 / 7
QUESTION 01
What is the time complexity of the sort + two-pointer 3Sum solution?
QUESTION 02
Why is the early break when nums[i] > 0 correct?
QUESTION 03
Trace nums = [-2, 0, 0, 2, 2]. How many unique triplets are in the answer?
QUESTION 04
What happens if you skip only the l duplicate-skip loop (but keep the r skip)?
QUESTION 05
Which of these is NOT a valid extension of the 3Sum two-pointer pattern?
QUESTION 06
For nums = [-1, 0, 1, 2, -1, -4] (the default example), what are the two unique triplets?
QUESTION 07
The outer duplicate-skip "if (i > 0 && nums[i] === nums[i-1]) continue" must use i > 0 because: