Two-Pointer Technique

The two-pointer technique is a pattern for scanning an array, string, or linked list using two index variables instead of one, so you can compare or combine elements from different positions in a single pass. Instead of nesting one loop inside another to check every pair of elements — which costs O(n^2) — you move two pointers through the data with a clear rule for when each one advances, cutting the work down to O(n) in most cases. It shows up constantly in coding interviews (Two Sum on a sorted array, removing duplicates in place, checking palindromes, merging sorted arrays) because it replaces brute-force pair-checking with a single, carefully reasoned sweep. Once you recognize the shape of the problem — sorted or orderable data, and a question about pairs, subarrays, or in-place rearrangement — the two-pointer technique is usually the fastest correct solution available.

Overview: How the Two-Pointer Technique Works

At its core, the two-pointer technique keeps two integer indices — commonly named left and right, or slow and fast — that walk through a sequence according to a rule tied to the problem, instead of using a single index compared against every other element. There are two dominant flavors, and recognizing which one a problem calls for is the real skill.

Opposite-direction (converging) pointers start one at each end of a sorted sequence and move toward the middle. On every step you look at the pair (numbers[left], numbers[right]) and use the fact that the array is sorted to decide which pointer to move: if the pair’s combined value is too small, moving left rightward can only increase it, because everything to the right of left is greater than or equal to numbers[left]; if the value is too large, moving right leftward can only decrease it. This is the pattern behind Two Sum on a sorted array and Container With Most Water.

Same-direction (fast/slow) pointers both start at the beginning and both move forward, but at different rates or under different conditions. fast scans every element; slow only advances when a condition is met (for example, "this element is different from the last one I kept"). This is the pattern behind removing duplicates in place, partitioning an array around a pivot, and the classic fast/slow cycle-detection trick on linked lists.

Why does this actually work, instead of being a lucky shortcut? The key idea is monotonicity. For opposite-direction pointers, sortedness guarantees that moving a pointer inward always changes the sum (or comparison) in a predictable direction, so you never have to backtrack — each of the n elements is visited by left or right at most once, giving O(n) instead of the O(n^2) of checking every pair. For same-direction pointers, the invariant is that everything slow has already passed satisfies the property you are building up, so slow never needs to revisit ground that fast has already covered.

The tradeoff to remember: the opposite-direction pattern usually requires sorted (or otherwise ordered) input. If your data isn’t sorted and sorting it would destroy information you need (like original indices), you either sort a copy that carries the indices along, or reach for a different tool such as a hash map.

Time and Space Complexity

Variant Time Complexity Space Complexity Why
Opposite-direction pointers on already-sorted data (Two Sum II, Container With Most Water) O(n) O(1) Each step moves left forward or right backward by one; the gap between them shrinks by at least one every iteration, so the loop runs at most n times total.
Same-direction pointers (fast/slow) for in-place compaction (remove duplicates, partitioning) O(n) O(1) fast visits every element exactly once; slow only ever moves forward and never passes fast.
Two-pointer technique when the input must be sorted first O(n log n) O(1) extra with an in-place sort, O(n) if a new sorted list is built The sort dominates the total running time; the two-pointer scan that follows is only O(n) on top of it.

In every variant, the crucial fact is that neither pointer ever moves backward past a position it has already left, and the two pointers stop as soon as they meet or cross. That bounds the total number of pointer moves across the entire run by n (or 2n for two independently moving pointers), never n^2. The space complexity is O(1) because you only ever store a constant number of index variables and running totals — no auxiliary array, hash map, or recursion stack is needed, which is exactly why two-pointer solutions are prized in interviews as "in-place" or "O(1) extra space" answers.

Examples

Example 1: Two Sum on a Sorted Array (opposite-direction pointers)

Given a sorted array, find the indices of two numbers that add up to a target value, without checking every pair.

def two_sum_sorted(numbers: list[int], target: int) -> tuple[int, int] | None:
    left, right = 0, len(numbers) - 1
    while left < right:
        current_sum = numbers[left] + numbers[right]
        if current_sum == target:
            return (left, right)
        elif current_sum < target:
            left += 1
        else:
            right -= 1
    return None


def main() -> None:
    numbers = [1, 3, 4, 6, 8, 11, 15]
    target = 10
    result = two_sum_sorted(numbers, target)
    print(f"Indices: {result}")
    if result:
        i, j = result
        print(f"Values: {numbers[i]} + {numbers[j]} = {target}")


main()

Output:

Indices: (2, 3)
Values: 4 + 6 = 10

left starts at index 0 (value 1) and right at index 6 (value 15). Their sum, 16, is too big, so right moves left. This repeats — each time the sum is too big, right decreases; when it’s too small, left increases — until left reaches index 2 (value 4) and right reaches index 3 (value 6), whose sum is exactly 10.

Example 2: Remove Duplicates from a Sorted Array In Place (fast/slow pointers)

Given a sorted array with duplicates, compact it in place so each value appears once, and return the new logical length.

def remove_duplicates(nums: list[int]) -> int:
    if not nums:
        return 0
    slow = 0
    for fast in range(1, len(nums)):
        if nums[fast] != nums[slow]:
            slow += 1
            nums[slow] = nums[fast]
    return slow + 1


def main() -> None:
    nums = [1, 1, 2, 2, 2, 3, 4, 4, 5]
    new_length = remove_duplicates(nums)
    print(f"New length: {new_length}")
    print(f"Array: {nums[:new_length]}")


main()

Output:

New length: 5
Array: [1, 2, 3, 4, 5]

slow marks the boundary of the deduplicated region built so far. fast scans ahead looking for a value different from the one slow is currently pointing at. Every time it finds one, slow advances by one and copies that new value into place. By the end, the first slow + 1 positions hold each distinct value exactly once, in order.

Example 3: Container With Most Water (opposite-direction, greedy elimination)

Given a list of heights representing vertical lines, find the pair that, together with the x-axis, forms the container holding the most water.

def max_area(heights: list[int]) -> int:
    left, right = 0, len(heights) - 1
    best = 0
    while left < right:
        width = right - left
        current_area = width * min(heights[left], heights[right])
        best = max(best, current_area)
        if heights[left] < heights[right]:
            left += 1
        else:
            right -= 1
    return best


def main() -> None:
    heights = [1, 8, 6, 2, 5, 4, 8, 3, 7]
    print(f"Max water container area: {max_area(heights)}")


main()

Output:

Max water container area: 49

The area between two lines is limited by the shorter one, so at each step we move the pointer at the shorter line inward — keeping the taller line can never hurt, since the shorter one is always the bottleneck. Starting at width 8 with heights 1 and 7 gives area 8; moving to heights 8 and 7 (width 7) gives area 49, which turns out to be the maximum across the whole scan.

How It Works Step by Step

Tracing Example 1 — numbers = [1, 3, 4, 6, 8, 11, 15], target = 10 — step by step:

Step left right numbers[left] numbers[right] Sum Action
1 0 6 1 15 16 16 > 10, move right left
2 0 5 1 11 12 12 > 10, move right left
3 0 4 1 8 9 9 < 10, move left right
4 1 4 3 8 11 11 > 10, move right left
5 1 3 3 6 9 9 < 10, move left right
6 2 3 4 6 10 Match — return (2, 3)

Notice that the window between left and right shrinks on every step, and it shrinks in a direction the sortedness of the array guarantees is correct — that is exactly why the loop is guaranteed to terminate within n steps and never needs to check a pair twice.

Common Mistakes

Mistake 1: Applying opposite-direction pointers to unsorted data

The opposite-direction pattern only knows which pointer to move because sortedness guarantees the direction of the sum’s change. On unsorted data, that guarantee is gone, and the algorithm silently returns a wrong answer instead of crashing.

def two_sum_unsorted(nums: list[int], target: int) -> tuple[int, int] | None:
    left, right = 0, len(nums) - 1
    while left < right:
        current = nums[left] + nums[right]
        if current == target:
            return (left, right)
        elif current < target:
            left += 1
        else:
            right -= 1
    return None


nums = [8, 3, 1, 4, 6]  # not sorted!
print(two_sum_unsorted(nums, 10))  # misses the valid pair (4, 6)

Even though 4 + 6 == 10 exists in the array, the function returns None because the pointer-movement logic assumes a sorted order that isn’t there. The fix is to sort first (and, if the original indices matter, sort pairs of (value, index) instead of bare values):

def two_sum_sorted(numbers: list[int], target: int) -> tuple[int, int] | None:
    left, right = 0, len(numbers) - 1
    while left < right:
        current_sum = numbers[left] + numbers[right]
        if current_sum == target:
            return (left, right)
        elif current_sum < target:
            left += 1
        else:
            right -= 1
    return None


def main() -> None:
    nums = [8, 3, 1, 4, 6]
    sorted_nums = sorted(nums)
    print(f"Sorted: {sorted_nums}")
    print(two_sum_sorted(sorted_nums, 10))


main()

Output:

Sorted: [1, 3, 4, 6, 8]
(2, 3)

Mistake 2: Forgetting to move a pointer in every branch (infinite loop)

Every branch of the pointer-movement logic must move at least one pointer, or the loop can spin forever on inputs that never hit the "too small" branch.

def has_pair_with_sum(nums: list[int], target: int) -> bool:
    left, right = 0, len(nums) - 1
    while left < right:
        current = nums[left] + nums[right]
        if current == target:
            return True
        if current < target:
            left += 1
        # BUG: missing the else branch. When current is greater
        # than target, neither pointer moves and the loop spins forever.
    return False

If current is ever greater than target and never becomes smaller before left catches up, neither pointer advances and the while loop never terminates. The fix is to make the branches exhaustive with an explicit else:

def has_pair_with_sum(nums: list[int], target: int) -> bool:
    left, right = 0, len(nums) - 1
    while left < right:
        current = nums[left] + nums[right]
        if current == target:
            return True
        elif current < target:
            left += 1
        else:
            right -= 1
    return False


def main() -> None:
    nums = [2, 4, 6, 8, 10]
    print(has_pair_with_sum(nums, 12))
    print(has_pair_with_sum(nums, 5))


main()

Output:

True
False

A closely related mistake is using left <= right instead of left < right in the loop condition for the opposite-direction pattern: with <=, when the pointers meet at the same index, the code compares an element against itself, which is usually not the intended check and can produce a false match.

Best Practices

  • Sort first when the technique needs monotonic order, and remember the overall complexity becomes O(n log n) because of the sort, not O(n).
  • Reach for two pointers whenever a brute-force solution is O(n^2) nested loops over a sorted or orderable sequence, or when you need to rearrange elements in place with O(1) extra space.
  • Pick the right variant deliberately: opposite-direction (converging) for sorted-array pair-sum and container-style problems; same-direction (fast/slow) for in-place deduplication, partitioning, and linked-list cycle detection.
  • Make sure every branch of your pointer logic advances at least one pointer — this is the single most common source of infinite loops in two-pointer code.
  • Use left < right (not <=) for converging pointers unless you have a specific reason to let them meet, since <= can cause an element to be compared against itself.
  • Prefer two pointers over nested loops for merging two already-sorted sequences — it’s O(n + m) instead of O(n * m).
  • If the array must stay unsorted (because you need original indices) and sorting isn’t an option, consider a hash map instead — that trades the O(1) space of two pointers for O(n) space but keeps indices intact.

Practice Exercises

  • Remove Element: Given an array of integers nums and a value val, remove all occurrences of val in place using two pointers and return the new length (order of the remaining elements doesn’t need to be preserved). For nums = [3, 2, 2, 3, 5] and val = 3, a valid result is a new length of 3. Hint: reuse the fast/slow shape from the duplicate-removal example, but keep an element when nums[fast] != val instead of comparing to the previous kept value.
  • Valid Palindrome: Given a string s, determine whether it reads the same forward and backward after ignoring case and non-alphanumeric characters, using two pointers starting at each end and moving inward. For "A man, a plan, a canal: Panama", the expected result is True. Hint: use an inner while loop to skip non-alphanumeric characters before comparing s[left].lower() to s[right].lower().
  • Merge Two Sorted Arrays: Given two sorted lists a and b, produce one sorted list containing all their elements using two pointers, one into each list, in O(len(a) + len(b)) time. For a = [1, 4, 5] and b = [2, 3, 6], the expected output is [1, 2, 3, 4, 5, 6]. Hint: compare the current elements of each pointer, append the smaller one, advance that pointer, then append whatever is left over once one list is exhausted.

Summary

  • The two-pointer technique replaces nested-loop pair checking (O(n^2)) with a single coordinated sweep (O(n)) using O(1) extra space.
  • Opposite-direction (converging) pointers rely on sorted or ordered data and suit pair-sum, container, and palindrome-style problems.
  • Same-direction (fast/slow) pointers suit in-place compaction, partitioning, and cycle detection.
  • If the input isn’t sorted and order-sensitive information (like original indices) matters, sort a copy that preserves that information, or use a different tool such as a hash map.
  • Every branch of the pointer-movement logic must advance at least one pointer, and the loop condition should be left < right unless you have a specific reason to let the pointers meet.
  • Complexity recap: O(n) time for a single two-pointer scan (O(n log n) if a sort is required first), and O(1) additional space.