Part I · Arrays, Strings and Pointers Pattern 2 4 problems

Two Pointers

Sorting gives you a direction. Two pointers converging from the ends turn that direction into an O(n) search over all n² pairs.

The sliding window walks two indices forward together. Two pointers, in the classic sense, sends them toward each other from opposite ends. Each step throws away a whole family of candidate pairs at once, which is why a doubly-nested loop collapses into a single pass.

Contents

  1. When to use
  2. Core idea
  3. The three templates
  4. Common mistakes
  5. Two Sum II — Input Array Is Sorted
  6. Remove Duplicates from Sorted Array
  7. 3Sum
  8. Container With Most Water
  9. Recap

When to use

The trigger. The data is sorted, or can be sorted without changing the answer, and you need a pair, a triplet, or a rearrangement in place. The tell-tale phrases are “sorted array”, “find two numbers that…”, “O(1) extra space”, and “modify the array in place”.
SignalWhat it looks like
SortednessThe array is given sorted, or the answer does not depend on order so you may sort it yourself.
A pairwise relation“two numbers summing to…”, “the widest pair”, “closest to a target”.
Space pressure“in place”, “O(1) extra memory”, “do not allocate a new array”.
PalindromesAnything comparing a string to itself reversed is the same converging motion.

When not to use it

Core idea

Put left at index 0 and right at index n-1. Evaluate the pair. Because the array is sorted, the comparison tells you which pointer cannot possibly be part of the answer with any remaining partner, so you move that one inward and never look at it again. Each step eliminates an entire row or column of the n×n pair grid.
target = 13 1 3 4 6 8 9 left right 1 + 9 = 10 < 13 9 is the largest partner 1 can ever get so discard 1 entirely, move left in 1 3 4 6 8 9 3 + 9 = 12, still small → move left again
Figure 2.1 — One comparison removes five candidate pairs. That is where the factor of n goes.

Why discarding is safe

This is the proof to have ready, because it is the only interesting thing to say about the pattern.

Exchange argument. Suppose nums[left] + nums[right] < target. Since the array is sorted, nums[right] is the largest value still available. So nums[left] paired with anything left in the range gives a sum no bigger than the one just computed, which is already too small. Therefore nums[left] is in no solution, and dropping it loses nothing. The symmetric argument covers the too-large case.

Each iteration moves exactly one pointer inward by one, and the pointers start n-1 apart, so the loop runs at most n-1 times. O(n) after the sort.

The three templates

Template A — converging from both ends
def converge(nums: list[int], target: int) -> tuple[int, int] | None:
    """Find a pair in a sorted array summing to target."""
    left, right = 0, len(nums) - 1

    while left < right:                 # strict: never pair an element with itself
        total = nums[left] + nums[right]
        if total == target:
            return left, right
        if total < target:
            left += 1                   # need a bigger sum
        else:
            right -= 1                  # need a smaller sum

    return None

Use left < right, not <=, whenever the two pointers must select two different elements.

Template B — read and write, same direction
def compact(nums: list[int]) -> int:
    """Filter in place. Returns the length of the kept prefix."""
    write = 0

    for read in range(len(nums)):
        if keep(nums[read]):
            nums[write] = nums[read]    # write never overtakes read, so this is safe
            write += 1

    return write

The in-place filter. write <= read always holds, so the write can never clobber an element that has not been read yet.

Template C — fix one, converge on the rest
def triplets(nums: list[int], target: int) -> list[list[int]]:
    """Reduce a 3-sum to n independent 2-sums."""
    nums.sort()
    out: list[list[int]] = []

    for i in range(len(nums) - 2):
        if i > 0 and nums[i] == nums[i - 1]:
            continue                    # skip duplicate anchors
        left, right = i + 1, len(nums) - 1
        # ... Template A on nums[left:right+1] for target - nums[i]

    return out

k-sum is a for loop wrapped around (k-1)-sum. 3Sum is O(n²), 4Sum is O(n³).

Common mistakes

The problems

1. Two Sum II — Input Array Is Sorted Easy

Problem

Given a 1-indexed array numbers sorted in non-decreasing order, find the two numbers that add up to target and return their 1-based indices. Exactly one solution exists, and you may not use an element twice. Extra space must be O(1).

Approach

Solution

def two_sum_sorted(numbers: list[int], target: int) -> tuple[int, int]:
    """Indices of the two values in a sorted array that sum to target.

    Args:
        numbers: Non-decreasing integers.
        target: The required sum.

    Returns:
        The 1-based indices (i, j) with i < j and numbers[i-1] + numbers[j-1] == target.

    Raises:
        ValueError: If no such pair exists.

    Example:
        >>> two_sum_sorted([2, 7, 11, 15], 9)
        (1, 2)
    """
    left, right = 0, len(numbers) - 1

    while left < right:
        total = numbers[left] + numbers[right]

        if total == target:
            return left + 1, right + 1
        if total < target:
            left += 1        # numbers[left] is too small even with the largest partner
        else:
            right -= 1       # numbers[right] is too large even with the smallest partner

    raise ValueError("no pair sums to target")

Walkthrough

numbers = [2, 7, 11, 15], target = 18:

leftrightsumaction
0317too small, left++
1322too large, right--
1218match, return (2, 3)
TimeO(n)SpaceO(1)

Edge cases to raise

Say this out loud: “The O(1) space constraint is the signal. Without it I would use a hash map in one pass; with it, sortedness lets me converge.”

2. Remove Duplicates from Sorted Array Easy

Problem

Given a sorted array nums, remove duplicates in place so each value appears once, keeping the relative order. Return the number of unique elements k. The first k slots of nums must hold the result; what follows does not matter.

Approach

Solution

def remove_duplicates(nums: list[int]) -> int:
    """Compact a sorted list in place so each value appears once.

    Args:
        nums: Sorted integers. Mutated in place.

    Returns:
        k, the number of unique values. nums[:k] holds them in order.

    Example:
        >>> data = [0, 0, 1, 1, 1, 2, 2, 3]
        >>> k = remove_duplicates(data)
        >>> (k, data[:k])
        (4, [0, 1, 2, 3])
    """
    if not nums:
        return 0

    write = 1                      # nums[0] is always kept

    for read in range(1, len(nums)):
        # nums[write - 1] is the last value we decided to keep.
        if nums[read] != nums[write - 1]:
            nums[write] = nums[read]
            write += 1

    return write

Why compare against nums[write - 1] and not nums[read - 1]

Both work for this problem, because sorted duplicates are adjacent and the prefix mirrors the source. But nums[write - 1] is the honest expression of the intent: is this different from the last thing I kept. That version generalises without change to the common follow-up, allow each value at most twice, where you compare against nums[write - 2]. The read - 1 version does not generalise.

Walkthrough

nums = [1, 1, 2]:

readnums[read]nums[write-1]actionwrite
111equal, skip1
221write nums[1] = 22
TimeO(n)SpaceO(1)

The follow-up

def remove_duplicates_at_most_twice(nums: list[int]) -> int:
    """Same idea, but each value may survive twice."""
    write = 0

    for value in nums:
        # Keep it unless the last two kept values are already this value.
        if write < 2 or value != nums[write - 2]:
            nums[write] = value
            write += 1

    return write

One changed constant. This is why the write - 1 framing is worth the habit.

Edge cases to raise

Say this out loud:write never passes read, so writing into the prefix cannot destroy data I still need to read.”

3. 3Sum Medium

Problem

Given an array nums, return all unique triplets [a, b, c] with a + b + c == 0. The result must not contain duplicate triplets.

Approach

Solution

def three_sum(nums: list[int]) -> list[list[int]]:
    """All unique triplets from nums that sum to zero.

    Args:
        nums: Integers, in any order. Sorted in place as a side effect.

    Returns:
        A list of triplets, each sorted ascending, with no duplicates.

    Example:
        >>> three_sum([-1, 0, 1, 2, -1, -4])
        [[-1, -1, 2], [-1, 0, 1]]
    """
    nums.sort()
    n = len(nums)
    triplets: list[list[int]] = []

    for i in range(n - 2):
        # Sorted, so once the anchor is positive the three smallest remaining
        # values are all positive and no triplet can reach zero.
        if nums[i] > 0:
            break
        # Dedup 1: an anchor value already used produces the same triplets.
        if i > 0 and nums[i] == nums[i - 1]:
            continue

        left, right = i + 1, n - 1
        while left < right:
            total = nums[i] + nums[left] + nums[right]

            if total < 0:
                left += 1
            elif total > 0:
                right -= 1
            else:
                triplets.append([nums[i], nums[left], nums[right]])
                left += 1
                right -= 1
                # Dedup 2 and 3: slide past repeats of the values just used.
                while left < right and nums[left] == nums[left - 1]:
                    left += 1
                while left < right and nums[right] == nums[right + 1]:
                    right -= 1

    return triplets

Walkthrough of the deduplication

nums = [-2, 0, 0, 2, 2] after sorting. With anchor -2 at i = 0, the pointers find (0, 2) and record [-2, 0, 2]. Both pointers move, landing on the second 0 and the second 2, which are repeats, so both dedup loops advance past them and the pointers cross. Without those loops the same triplet is recorded twice.

Why not use a set of tuples instead

Collecting into a set of sorted tuples also produces the right answer and is easier to write under pressure. It costs extra memory and hashing, and it hides the fact that you understand where the duplicates come from. Write the explicit skips if you can; mention the set as the fallback you would use if short on time.
TimeO(n²)SpaceO(1) extraSortO(n log n)

The outer loop runs n times, each inner converge is O(n). Output space is not counted; the sort is in place.

Edge cases to raise

Say this out loud: “Sorting costs O(n log n) but the main loop is O(n²) anyway, so the sort is free, and it gives me both the converging scan and adjacent duplicates.”

4. Container With Most Water Medium

Problem

height[i] is the height of a vertical line at position i. Pick two lines so that the container they form with the x-axis holds the most water. Return that maximum area. The area for a pair is (j - i) * min(height[i], height[j]).

Approach

Solution

def max_area(height: list[int]) -> int:
    """Largest area of water trapped between two vertical lines.

    Args:
        height: Non-negative line heights, indexed by position.

    Returns:
        The maximum area. 0 if fewer than two lines.

    Example:
        >>> max_area([1, 8, 6, 2, 5, 4, 8, 3, 7])
        49
    """
    left, right = 0, len(height) - 1
    best = 0

    while left < right:
        span = right - left
        best = max(best, span * min(height[left], height[right]))

        # Only moving the shorter line can raise the min, so only that move
        # has any chance of beating the current area.
        if height[left] < height[right]:
            left += 1
        else:
            right -= 1

    return best

The correctness argument, stated properly

Suppose height[left] < height[right]. Consider any pair (left, j) with j < right. Its width is smaller than right - left, and its height is at most height[left], which is the current minimum. So its area is strictly less than the area just computed. Every remaining pair that uses left is therefore dominated, and discarding left cannot lose the optimum. That is exactly the exchange argument from the top of the page, with width in place of sortedness.

Walkthrough

height = [1, 8, 6, 2, 5, 4, 8, 3, 7]:

leftrightspanmin heightareabest
088188
18774949
17631849
16584049

The scan continues but never beats 49.

TimeO(n)SpaceO(1)

Edge cases to raise

Do not confuse this with Trapping Rain Water. That problem asks how much water sits on top of a terrain profile, uses all the bars, and needs prefix maxima from both sides. It is also solvable with two pointers, but the state and the reasoning are different. Interviewers ask them back to back.
Say this out loud: “The area is limited by the shorter line, so moving the taller one strictly shrinks the answer. That makes moving the shorter line the only candidate move, and gives a one-pass solution.”

Recap

The six things to carry forward

Where this goes next

Pattern 3, Fast and Slow Pointers, keeps two pointers moving in the same direction but at different speeds. That difference in speed is what detects a cycle in a structure you cannot index into.


1 — Sliding Window 3 — Fast and Slow Pointers