Search…
DSA Foundations · Part 9

Two-pointer technique

In this series (15 parts)
  1. How computers store data
  2. Big-O and algorithm analysis
  3. Bits, bytes, and bitwise tricks
  4. Arrays: fixed-size and indexing
  5. Dynamic arrays: how lists grow
  6. 2D arrays and matrices
  7. Strings and character encoding
  8. String manipulation patterns
  9. Two-pointer technique
  10. Sliding window technique
  11. Prefix sums and difference arrays
  12. Fast and slow pointers
  13. Singly linked lists
  14. Doubly & circular linked lists
  15. Stacks (LIFO)

Opening: why this pattern matters

Imagine you have a sorted list of prices like [10, 20, 35, 40, 55, 70], and you need to find two items that add up to your exact budget of 75. The first idea many beginners try is: check every pair. Compare 10 with 20, 35, 40, 55, 70, then move to 20, and keep going.

That works, but it does a lot of repeated work. With n numbers, the nested-loop approach checks about combinations in the worst case, so its time complexity is O(n²).

The two-pointer technique asks a smarter question: if the array is sorted, can we use that order to skip huge groups of impossible answers at once? In this price example, the answer is yes. Instead of trying every pair, we can start at both ends and move inward, which cuts the work down to O(n).

That jump from quadratic time to linear time is why two pointers is one of the most important early patterns in data structures and algorithms.

The core insight

Two pointers is not one single algorithm. It is a pattern for scanning data with two positions that move according to a rule.

It works especially well when three things are true:

  • the data has some useful order, like a sorted array or a left-to-right sequence,
  • the current pointer positions let you eliminate many impossible answers,
  • each pointer only moves in one direction, so the total number of moves stays linear.

A good mental model is: every pointer move should teach you something. If moving a pointer does not rule out any possibilities, then two pointers is probably not the right tool.

Why does this stay O(n)? Because even though you may have a loop inside your head that feels like “compare, then move, then compare again,” each pointer never walks backward. If left moves from index 0 to index 5, that is only five moves total, not five moves per comparison.

Pattern 1: opposite-direction pointers

Problem: find a pair in a sorted array that sums to a target

Suppose the array is [1, 3, 5, 7, 9, 11] and the target is 12.

Step 0 of 0
Pointer visualization ready.
Algorithm
 

Why start at the ends? Because the ends give you the smallest and largest values available. That makes your next move obvious.

  • If the sum is too small, the only way to make it bigger is to move the left pointer right.
  • If the sum is too large, the only way to make it smaller is to move the right pointer left.
  • If the sum is exactly the target, you are done.

With this exact input, the first check is already enough:

  • left = 0 → value 1
  • right = 5 → value 11
  • 1 + 11 = 12 → found the pair immediately

That lucky first hit is nice, but the rule is more important than the luck. Imagine the same array with target 14:

  • 1 + 11 = 12, too small, so move left right
  • 3 + 11 = 14, exact match, so stop

Notice what happened. Once 1 + 11 was too small, we did not need to test 1 + 9, 1 + 7, or 1 + 5. Those would all be even smaller. Sorting lets us eliminate them in one decision.

Code

def two_sum_sorted(nums: list[int], target: int) -> list[int]:
    left, right = 0, len(nums) - 1

    while left < right:
        current = nums[left] + nums[right]

        if current == target:
            return [left, right]
        if current < target:
            left += 1
        else:
            right -= 1

    return [-1, -1]


print(two_sum_sorted([1, 3, 5, 7, 9, 11], 12))  # [0, 5]
import java.util.Arrays;

public class TwoPointersOpposite {
    public static int[] twoSumSorted(int[] nums, int target) {
        int left = 0;
        int right = nums.length - 1;

        while (left < right) {
            int current = nums[left] + nums[right];

            if (current == target) {
                return new int[]{left, right};
            }
            if (current < target) {
                left++;
            } else {
                right--;
            }
        }

        return new int[]{-1, -1};
    }

    public static void main(String[] args) {
        System.out.println(Arrays.toString(twoSumSorted(new int[]{1, 3, 5, 7, 9, 11}, 12)));
    }
}
#include <iostream>
#include <utility>
#include <vector>
using namespace std;

pair<int, int> twoSumSorted(const vector<int>& nums, int target) {
    int left = 0;
    int right = static_cast<int>(nums.size()) - 1;

    while (left < right) {
        int current = nums[left] + nums[right];

        if (current == target) {
            return {left, right};
        }
        if (current < target) {
            left++;
        } else {
            right--;
        }
    }

    return {-1, -1};
}

int main() {
    vector<int> nums{1, 3, 5, 7, 9, 11};
    auto answer = twoSumSorted(nums, 12);
    cout << answer.first << ", " << answer.second << endl;
}

Complexity

This pattern runs in O(n) time because each pointer moves inward at most n times total. It uses O(1) extra space because we only store a few variables.

More examples of opposite-direction pointers

  • Container With Most Water: start with the two outer walls, compute the area, then move the shorter wall inward because the shorter side is the bottleneck.
  • Valid palindrome: compare characters from both ends of the string and move inward while they match.

In both cases, the current positions tell you which options can be safely discarded. That is the signature of a strong two-pointer problem.

Pattern 2: same-direction pointers

Problem: remove duplicates from a sorted array in place

Now consider a different kind of task. You are given [1, 1, 2, 2, 3, 4, 4, 5], and you want to keep only one copy of each number without creating a new array.

Step 0 of 0
Pointer visualization ready.
Algorithm
 

This version uses two pointers that both move from left to right, but they play different roles:

  • slow marks the position where the next unique value should be written,
  • fast scans ahead looking for the next new value.

Think of the array as having two regions. The left side up to slow is the cleaned-up unique prefix. Everything to the right is still unprocessed or irrelevant.

Walk through the concrete numbers:

  • Start with slow = 0, so the unique prefix is just [1].
  • fast = 1 sees another 1. That is a duplicate, so skip it.
  • fast = 2 sees 2, which is different from nums[slow] = 1. Move slow to 1 and write 2 there.
  • fast = 3 sees another 2. Skip it.
  • fast = 4 sees 3. Move slow to 2 and write 3 there.

By the end, the front of the array becomes [1, 2, 3, 4, 5, ...], and the answer length is 5. The values after index 4 do not matter anymore.

Code

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


nums = [1, 1, 2, 2, 3, 4, 4, 5]
length = remove_duplicates(nums)
print(length)        # 5
print(nums[:length]) # [1, 2, 3, 4, 5]
import java.util.Arrays;

public class RemoveDuplicates {
    public static int removeDuplicates(int[] nums) {
        if (nums.length == 0) {
            return 0;
        }

        int slow = 0;

        for (int fast = 1; fast < nums.length; fast++) {
            if (nums[fast] != nums[slow]) {
                slow++;
                nums[slow] = nums[fast];
            }
        }

        return slow + 1;
    }

    public static void main(String[] args) {
        int[] nums = {1, 1, 2, 2, 3, 4, 4, 5};
        int length = removeDuplicates(nums);
        System.out.println(length);
        System.out.println(Arrays.toString(Arrays.copyOf(nums, length)));
    }
}
#include <iostream>
#include <vector>
using namespace std;

int removeDuplicates(vector<int>& nums) {
    if (nums.empty()) {
        return 0;
    }

    int slow = 0;

    for (int fast = 1; fast < static_cast<int>(nums.size()); fast++) {
        if (nums[fast] != nums[slow]) {
            slow++;
            nums[slow] = nums[fast];
        }
    }

    return slow + 1;
}

int main() {
    vector<int> nums{1, 1, 2, 2, 3, 4, 4, 5};
    int length = removeDuplicates(nums);
    cout << length << endl;
    for (int i = 0; i < length; i++) {
        cout << nums[i] << (i + 1 == length ? '\n' : ' ');
    }
}

More examples of same-direction pointers

  • Move zeroes: let fast read every number and let slow track where the next non-zero should go.
  • Remove element by value: copy every value that is not equal to the forbidden target into the next write position.

These problems are really about compacting good values toward the front while scanning once through the array.

Pattern 3: three pointers (Dutch National Flag)

Problem: sort an array of 0s, 1s, and 2s in one pass

Suppose the array is [2, 0, 2, 1, 1, 0]. A counting approach works, but the Dutch National Flag algorithm is a beautiful pointer-based way to do it in one scan and O(1) space.

The idea is to maintain four regions at all times:

  • [0 .. low-1] contains only 0s
  • [low .. mid-1] contains only 1s
  • [mid .. high] is still unknown
  • [high+1 .. end] contains only 2s

Now look at nums[mid]:

  • If it is 0, swap it with nums[low], then move both low and mid forward.
  • If it is 1, it is already in the correct middle region, so just move mid forward.
  • If it is 2, swap it with nums[high], then move high backward.

The subtle part is the last case. After swapping with high, you do not move mid yet, because the value that just came in from the right side is still unknown.

Step through the algorithm on [2, 0, 2, 1, 1, 0] and watch the three zones grow:

Step 0 of 0
Pointer visualization ready.
Algorithm
 

Code

def sort_colors(nums: list[int]) -> None:
    low = 0
    mid = 0
    high = len(nums) - 1

    while mid <= high:
        if nums[mid] == 0:
            nums[low], nums[mid] = nums[mid], nums[low]
            low += 1
            mid += 1
        elif nums[mid] == 1:
            mid += 1
        else:
            nums[mid], nums[high] = nums[high], nums[mid]
            high -= 1


nums = [2, 0, 2, 1, 1, 0]
sort_colors(nums)
print(nums)  # [0, 0, 1, 1, 2, 2]
import java.util.Arrays;

public class SortColors {
    public static void sortColors(int[] nums) {
        int low = 0;
        int mid = 0;
        int high = nums.length - 1;

        while (mid <= high) {
            if (nums[mid] == 0) {
                int temp = nums[low];
                nums[low] = nums[mid];
                nums[mid] = temp;
                low++;
                mid++;
            } else if (nums[mid] == 1) {
                mid++;
            } else {
                int temp = nums[mid];
                nums[mid] = nums[high];
                nums[high] = temp;
                high--;
            }
        }
    }

    public static void main(String[] args) {
        int[] nums = {2, 0, 2, 1, 1, 0};
        sortColors(nums);
        System.out.println(Arrays.toString(nums));
    }
}
#include <iostream>
#include <vector>
using namespace std;

void sortColors(vector<int>& nums) {
    int low = 0;
    int mid = 0;
    int high = static_cast<int>(nums.size()) - 1;

    while (mid <= high) {
        if (nums[mid] == 0) {
            swap(nums[low], nums[mid]);
            low++;
            mid++;
        } else if (nums[mid] == 1) {
            mid++;
        } else {
            swap(nums[mid], nums[high]);
            high--;
        }
    }
}

int main() {
    vector<int> nums{2, 0, 2, 1, 1, 0};
    sortColors(nums);
    for (int x : nums) {
        cout << x << ' ';
    }
    cout << endl;
}

This three-pointer idea is also a nice bridge to 3Sum. In 3Sum, you first sort the array, fix one number, and then run the opposite-direction two-pointer pattern on the remaining suffix.

When to use two pointers

If you are not sure whether this pattern fits, run through this checklist.

Signal in the problem Pattern to try Why it fits
Sorted array + pair or triplet condition Opposite-direction pointers The ends let you grow or shrink the total predictably
Need in-place cleanup or compaction Same-direction pointers One pointer reads, one pointer writes
Three categories such as 0/1/2 Three pointers You can maintain three finished regions and one unknown region
Must use O(1) extra space Two pointers is a strong candidate Many pointer solutions reuse the input array instead of allocating another one

The strongest clue is not 'I see two variables.' The strongest clue is 'pointer movement discards many impossible states.'

A simple rule of thumb helps: if you can describe your algorithm as “start somewhere, learn something, and move one pointer without ever needing to move it back,” two pointers is worth trying.

Another clue is when a brute-force solution compares many overlapping pairs or repeatedly rescans the same data. Two pointers often removes that repetition.

Common pitfalls

Beginners usually do not struggle with the big idea. They struggle with the small pointer details.

Pitfall What goes wrong How to avoid it
Forgetting the sorted precondition The move rules stop being valid, so you may skip the right answer Only use the pair-sum version directly on sorted data, or sort first if allowed
Off-by-one errors You compare past the boundary or miss the last valid pair Write the loop condition carefully: left < right for pair scans, mid <= high for Dutch National Flag
Infinite loops A pointer never moves in some branch, so the loop repeats forever Check every branch and make sure at least one pointer changes
Wrong equality handling In variants like duplicate skipping or 3Sum, you may keep reusing the same values After processing a match, move pointers according to that specific problem's rules

One more subtle warning: sometimes you must sort before using two pointers, and sometimes you are not allowed to reorder the data. Always check the problem constraints before you sort.

Practice problems

Try these in order. They escalate nicely from direct pattern recognition to more layered reasoning.

When you practice, do not just memorize code. After each pointer move, ask yourself: what possibilities did this move eliminate? That question is what turns this from a trick into a reusable tool.

What’s next

Two pointers teaches you how to move through data efficiently with a small amount of state. The next step is learning what happens when those pointers define a range instead of just two positions.

Next up: Sliding window technique — post 10 in the series. It builds directly on the same pointer intuition, but now the moving region represents a subarray or substring whose size or contents matter.

Start typing to search across all content
navigate Enter open Esc close