When can you replace a nested loop with two pointers, and why is it correct?

Asked constantlyjunior0–8 yrs10 min read

When each comparison lets you rule out a whole candidate rather than one pair. On a sorted array, a sum that is too small proves no pair using the current left element can work — so the pointer advances past it for good. That turns n²/2 comparisons into n. Measured on 4,000 elements: 7,998,000 comparisons against 3,999.

The Answer

  • Two pointers works when one comparison eliminates a candidate, not just a pair. That is the whole precondition.
  • On a sorted array searching for a pair summing to a target: if the sum is too small, no pair using the current left element can reach the target — because the right element is already the largest available. So the left pointer moves on permanently.
  • Each pointer only ever moves one way, so the total work is at most n steps: O(n) instead of O(n²).
  • Sortedness is what licenses the deduction. On unsorted data a small sum tells you nothing, and the technique is simply wrong.
  • A sliding window is the same idea for contiguous ranges: grow the right edge, and move the left edge forward only when the window becomes invalid.
  • The giveaway that it applies: you are looking for a pair or a range, and the data is sorted or the validity condition is monotonic.

Understand It

The deduction that makes it correct

The loop everyone writes first compares every pair:

for i in 0..n:
    for j in i+1..n:
        if a[i] + a[j] == target: found      # n²/2 comparisons

Each comparison rules out exactly one pair. Two pointers rules out a whole row:

lo ← 0;  hi ← n - 1                  # requires a SORTED array

while lo < hi:
    sum ← a[lo] + a[hi]

    if sum == target: found
    if sum < target:  lo ← lo + 1     # a[hi] is the largest left; if it is
                                      #   not enough for a[lo], nothing is.
                                      #   Discard a[lo] entirely.
    else:             hi ← hi - 1      # symmetric: a[lo] is the smallest left,
                                      #   so a[hi] is too big for anything

Read the comment on the sum < target branch again, because it is the entire justification. We are not skipping a pair; we are proving that no pair containing a[lo] can work, because it has already been tested against the largest partner available. That is a statement about n/2 pairs, established by one addition.

The cost of that reasoning is the precondition: it is only true if a[hi] is really the largest remaining value. Sorted.

The difference, counted

Compiled and run on this buildEdit and run
for (int n : new int[] {1000, 2000, 4000}) {
    int[] a = evens(n);
    int target = a[n - 2] + a[n - 1];          // the last pair: worst case for brute force

    twoSumBrute(a, target);
    long brute = ops;
    twoSumPointers(a, target);
    long pointers = ops;

    System.out.printf("n=%-5d brute force: %,10d comparisons   two pointers: %,5d%n",
        n, brute, pointers);
}
Output
n=1000  brute force:    499,500 comparisons   two pointers:   999
n=2000  brute force:  1,999,000 comparisons   two pointers: 1,999
n=4000  brute force:  7,998,000 comparisons   two pointers: 3,999

Double n: the brute force quadruples, the two-pointer version doubles. And look at the two-pointer numbers — n - 1 exactly. Every iteration moves one pointer one step, the pointers start n - 1 apart, and they meet. There is no slack in it.

Why each pointer must move only one way

That monotonicity is the proof of the O(n) bound, and it is also the thing people accidentally break. If any branch moved lo backwards, the loop could revisit pairs and the argument collapses — both the correctness argument (a discarded element was proven useless) and the complexity one.

So when writing a two-pointer loop, the check is: does every branch advance exactly one pointer, in its one permitted direction? If a case needs to move a pointer back, the problem is not a two-pointer problem.

Sliding window: the same idea for ranges

When the answer is a contiguous range rather than a pair, both pointers move the same direction and the invariant is about the window's contents:

start ← 0
for end in 0..n:                     # the window is a[start..end]
    include a[end]

    while window is invalid:          # shrink from the left until it is valid
        exclude a[start]
        start ← start + 1

    best ← max(best, end - start + 1)

It looks like a nested loop and is not: start never decreases, so across the whole run each index is entered once by end and left once by start. At most 2n moves, however much the inner while spins on any single iteration.

Compiled and run on this buildEdit and run
for (String s : new String[] {"abcabcbb", "bbbbb", "pwwkew", "abcdef"}) {
    int answer = longestUnique(s);
    System.out.printf("%-10s longest unique run = %d   (characters visited: %d)%n",
        "\"" + s + "\"", answer, ops);
}
Output
"abcabcbb" longest unique run = 3   (characters visited: 8)
"bbbbb"    longest unique run = 1   (characters visited: 5)
"pwwkew"   longest unique run = 3   (characters visited: 6)
"abcdef"   longest unique run = 6   (characters visited: 6)

The visit count equals the string length every time, including "bbbbb" where the window collapses on every character. One pass, no matter how much the left edge has to catch up.

Reference

The three shapes, and when each applies:

// 1. Converging — a pair in a SORTED array
int lo = 0, hi = a.length - 1;
while (lo < hi) {
    int sum = a[lo] + a[hi];
    if (sum == target) return new int[] { lo, hi };
    if (sum < target) lo++; else hi--;
}

// 2. Same direction, fast and slow — cycle detection, or dedupe in place
int write = 0;
for (int read = 0; read < a.length; read++)
    if (read == 0 || a[read] != a[read - 1])
        a[write++] = a[read];            // removes duplicates from a sorted array
                                         // in O(n) with no extra memory

// 3. Sliding window — a contiguous range satisfying a monotonic condition
int start = 0;
for (int end = 0; end < a.length; end++) {
    include(a[end]);
    while (!valid()) exclude(a[start++]);
    best = Math.max(best, end - start + 1);
}

Does it apply? The checklist:

QuestionIf no
Is the data sorted, or can it be?converging pointers are not valid
Does one comparison rule out a whole element?you are just writing a nested loop with extra variables
Does each pointer move in only one direction?it is not a two-pointer problem
For a window: is validity monotonic — once invalid, does adding more keep it invalid?shrinking from the left is not enough

That last row is the one that quietly breaks. "Longest subarray with sum ≤ k" is a window problem only for non-negative numbers: with negatives, adding an element can make an invalid window valid again, so the left edge would need to move backwards. Use a prefix-sum map instead.

When sorting first is worth it:

// Unsorted input, and you need the VALUES not the original indices:
Arrays.sort(a);                     // O(n log n), then O(n) — beats O(n²)

// Unsorted input, and you need the ORIGINAL indices: sorting destroys them.
Map<Integer, Integer> seen = new HashMap<>();
for (int i = 0; i < a.length; i++) {
    Integer j = seen.get(target - a[i]);
    if (j != null) return new int[] { j, i };
    seen.put(a[i], i);              // O(n) time, O(n) space, order preserved
}

Scenarios

Merging two sorted result sets. One pointer per list, advance whichever holds the smaller value. This is the merge step of merge sort, and it is why merging sorted data is O(n + m) rather than O(n log n) — no comparison is ever repeated.

Removing duplicates from a sorted array without allocating. The fast/slow form: read scans, write trails behind placing keepers. O(n) time, O(1) extra space, and the array is compacted in place. The same pattern is how ArrayList.removeIf avoids shifting repeatedly.

Rate limiting over a rolling minute. A queue of timestamps with the left edge dropping anything older than 60 seconds. A genuine sliding window, and monotonic because time only moves forward.

Where it does not apply: "longest subarray with sum exactly k" on data with negatives. Adding an element can move the sum back toward the target, so validity is not monotonic and the left edge cannot be advanced safely. Prefix sums with a hash map handle it in O(n); a window silently returns the wrong answer.

Interviewer's Next Move

1. "Why does the array have to be sorted?" Because the deduction depends on a[hi] being the largest remaining value. If it is, then a sum that is too small proves nothing containing a[lo] can reach the target, and a[lo] can be discarded. Unsorted, a small sum tells you nothing about any other pair, so there is no justification for advancing.

2. "Sorting is O(n log n) — hasn't that eaten the gain?" It has changed O(n²) to O(n log n), which is still a large win; and if the array arrives sorted, or you make several queries against it, the sort is paid once. But sorting destroys original indices, so if the answer must be indices into the input, a hash map at O(n) is the better trade.

3. "The inner while in a sliding window looks like a nested loop. Why is it O(n)?" Because start never moves backwards. Across the entire run it advances at most n times in total, no matter how those moves are distributed between iterations. Amortised over the loop, each element is included once and excluded once.

4. "When is a window not the right tool?" When validity is not monotonic — when adding an element can turn an invalid window valid. Sums with negative numbers are the standard example. The window assumes that once you must shrink, growing further will not help.

5. "Two pointers or binary search?" Both exploit sortedness, and they answer different shapes. Binary search finds one element in O(log n). Two pointers examines a relationship between two positions in O(n). Searching for each of n elements with binary search is O(n log n), which is worse than one two-pointer pass.

Check Yourself

Why can the left pointer be advanced permanently rather than just skipping that pair? Because it was compared against the largest remaining value. If even that partner gives too small a sum, every smaller partner does too — so no pair containing that element can succeed, and it is eliminated, not deferred.

A two-pointer pass on 4,000 elements used 3,999 comparisons. Why exactly that? The pointers start 3,999 apart and each iteration closes the gap by exactly one, until they meet. The bound is not approximate — it is n - 1 in the worst case, with no repeated work.

"Longest subarray with sum ≤ k." Is that a sliding window? Only if the values are non-negative. Then adding an element can never reduce the sum, so validity is monotonic and shrinking from the left is sufficient. With negatives, an invalid window can become valid by growing, and the window approach gives a wrong answer.

Where this question goes next

Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-09-11.