Search Tech Journey

Find topics, journeys and posts

back to blog
algorithmsintermediate 75m read

L07 · Two Pointers I — Opposite Ends

Two indices converging from the ends of a sorted array, and the monotonicity argument that proves you can discard a whole row of candidates with one comparison.

🧩DSAPhase 1 · Core patterns· Session 007 of 130 75 min

🎯 Own the converging two-pointer skeleton and, more importantly, the argument for why moving one pointer safely eliminates an entire set of candidate pairs.

Series: LeetCode — From Basics to Interview-Ready · Session 7 / 65 · Phase 1 · Core patterns

Why this session exists

Sorted input plus pair-finding equals two pointers. That cue alone solves dozens.

This is the first real pattern in the series and it is a good one to start with, because it is the clearest example of the thing all patterns do: it turns an O(n^2) search over pairs into an O(n) walk, and the reason it is allowed to is a short, provable argument rather than a trick.

Here is the argument in one sentence. If the array is sorted and you are looking at the pair (lo, hi), then the sum you see is the largest possible sum involving lo — because hi holds the largest remaining value. So if that sum is still too small, no pair involving lo can ever work, and you may discard lo entirely. One comparison eliminates a whole row of the pair matrix. Do it n times and you are done.

That structure — a comparison whose outcome licenses discarding a whole set of candidates — is what "monotonicity" means in practice, and it is the same reason binary search works in L13. Learn the argument here and you get several later sessions cheaper.

Checkpoint · you can now
  • Write the converging skeleton from a blank file, with the correct loop condition.
  • State why moving lo when the sum is too small is safe, in one sentence.
  • Explain why the loop condition is lo < hi rather than lo <= hi.
  • Handle duplicate skipping correctly when the problem requires unique results.

Blank-file warm-up

Five minutes, empty file. Write lo = 0, hi = n - 1, while lo &lt; hi and the body that follows. Then write, underneath, the discard argument: when the sum is too small, which pointer moves and what set of candidates does that eliminate?

The skeleton is four lines and you will get it. The argument is the part that decays, and it is the part that lets you adapt the skeleton to a variant you have not seen.

Pattern anatomy

The shape that summons this: the input is sorted (or can be sorted without losing what you need), and you are looking for a pair, or optimising over pairs, where moving an endpoint changes the objective in a predictable direction.

The invariant: every pair not yet examined lies entirely within the window [lo, hi]. Each pointer move must preserve that, which is exactly what the discard argument establishes.

def two_pointer_converge(a: list[int], target: int):
    """a MUST be sorted. Returns a pair summing to target, or None."""
    lo, hi = 0, len(a) - 1
    while lo < hi:                    # strict: lo == hi is a single
        s = a[lo] + a[hi]             #   element, not a pair
        if s == target:
            return (lo, hi)
        elif s < target:
            lo += 1                   # need bigger: only lo can grow it
        else:
            hi -= 1                   # need smaller: only hi can shrink it
    return None
 
# WHY each move is safe (this is the whole pattern):
#
#   a[hi] is the largest value still available.
#   So a[lo] + a[hi] is the LARGEST sum involving a[lo].
#   If that is still < target, then a[lo] paired with anything
#   else available is even smaller. a[lo] is dead. Discard it.
#
#   Symmetrically, a[lo] is the smallest available value, so
#   a[lo] + a[hi] is the SMALLEST sum involving a[hi]. If that
#   already exceeds target, a[hi] is dead.
#
# Each iteration kills exactly one element. n elements, so at
# most n iterations. That is the O(n).

Two variations on the skeleton worth having ready.

Palindrome / mirror check: same converging walk, but the comparison is a[lo] != a[hi] rather than a sum, and the array need not be sorted — the pairing is positional rather than value-based.

Skipping duplicates for problems requiring unique output: after recording a hit, advance lo past every equal value and retreat hi past every equal value. Getting this wrong is the single most common reason 3Sum fails.

The cue

Five tells that this is the pattern:

  1. The word "sorted" in the input description. It is never mentioned decoratively. If the input is sorted, the setter is telling you that order carries information you are meant to use.
  2. The input is not sorted but the answer does not depend on original positions. Then you may sort it yourself and the cue applies — this is what turns 3Sum into a two-pointer problem.
  3. "Find a pair such that ..." with a comparison against a target. Sum, difference, product against a threshold — anything where moving an endpoint moves the objective monotonically.
  4. A palindrome, reversal, or any symmetric property. Positional pairing from the two ends, sorting irrelevant.
  5. "Maximise something over a pair of indices" where the objective involves both the distance between them and their values — Container With Most Water is the archetype, and it is the one where the discard argument is genuinely subtle.

Guided solve

Valid Palindrome. Given a string, return true if it is a palindrome considering only alphanumeric characters and ignoring case.

Run L06's framework briefly. Understand: filter to alphanumeric, lowercase everything, then check that it reads the same forwards and backwards. Clarify: is the empty string a palindrome — yes, vacuously; are digits included — yes, alphanumeric; what about unicode — constraints say ASCII printable.

Match: symmetric property, so positional two pointers from the ends. Sortedness is irrelevant here; what matters is that position i from the left must match position i from the right.

The tempting one-liner first, because you should know it and then reject it:

def is_palindrome_naive(s: str) -> bool:
    t = "".join(c.lower() for c in s if c.isalnum())
    return t == t[::-1]          # correct, but O(n) EXTRA space

That is correct and it is what I would write in production. It builds a filtered copy and then a reversed copy, so it is O(n) time and O(n) space. The interview version removes the space:

def is_palindrome(s: str) -> bool:
    lo, hi = 0, len(s) - 1
    while lo < hi:
        while lo < hi and not s[lo].isalnum():   # skip junk from left
            lo += 1
        while lo < hi and not s[hi].isalnum():   # skip junk from right
            hi -= 1
        if s[lo].lower() != s[hi].lower():
            return False
        lo += 1
        hi -= 1
    return True

Three details worth narrating.

The inner skip loops need their own lo < hi guard. Without it, a string of pure punctuation walks lo off the end of the string and raises IndexError. This is the classic bug in this problem and it only shows on an input like ",." — which is exactly why L06's Review step insists on tracing an edge example.

The outer condition is strict. When lo == hi you are looking at the single middle character of an odd-length string, which is trivially equal to itself. Comparing it is harmless but pointless; walking past it is wrong. Strict inequality handles both parities with no special case.

Each character is visited at most once by each pointer. The inner while loops look like they might make this quadratic, but the pointers only ever move toward each other and never back, so the total work across the entire run is bounded by n. This "amortised across the whole loop, not per iteration" argument is worth stating explicitly — it is the same reasoning that makes sliding windows linear in L10, and interviewers do ask.

Cost: O(n) time, O(1) space. Compare honestly against the one-liner: same time class, strictly better space, more lines and more places to be wrong. On a real codebase I would ship the one-liner.

Solo timed

Fifteen minutes each. Before writing, state which pointer moves under which condition and why that is safe.

  • Two Sum II — input is already sorted and the problem says so loudly. The only trap is the return format; read it twice.
  • Container With Most Water — you always move the pointer at the shorter line. Prove to yourself why moving the taller one can never help before you write anything; that proof is the entire problem.
  • 3Sum — sort, fix one index, then run the converging skeleton on the remainder. The difficulty is not the algorithm, it is deduplication. Decide your dedup strategy during Plan, not during debugging.

Common failure modes

  • lo <= hi instead of lo < hi. Lets an element pair with itself, which silently produces wrong answers on inputs where an element is exactly half the target.
  • Missing the guard inside the skip loops. IndexError on all-punctuation or all-duplicate inputs.
  • Applying the pattern to unsorted input. The discard argument depends entirely on a[hi] being the largest remaining value. Without sortedness the whole thing is unfounded, and it will pass the sample tests by luck.
  • 3Sum deduplication done by post-filtering. Collecting all triples and then deduping with a set works but is slower and is not what the pattern is for. Skip duplicates as you advance the pointers instead.
  • Moving both pointers when the sum matches, in a counting problem. If the array contains a run of equal values, the correct handling depends on whether a[lo] == a[hi]; handle that case separately or you will double count.
Common misconception
✗ What most people think
Two pointers is just an optimisation of the nested loop — same idea, less work.
✓ What is actually true
It is a different algorithm justified by a different argument. The nested loop examines every pair; two pointers examines n of them and proves the rest cannot be answers. Without that proof the technique is simply wrong.
Why the myth is so sticky
Because the code looks like a stripped-down nested loop and produces the same answers on the examples, so it reads as an optimisation rather than as a distinct claim about the input.
Prove it to yourself
Run the converging skeleton on an unsorted array. It will return a wrong answer on inputs where the discarded region contained the true pair — and it will still pass a handful of random tests, which is exactly what makes the mistake dangerous.
From first principles
Start with the question
Why is discarding a pointer safe, and why does that give O(n)?
  1. 1
    The array is sorted, so a[hi] is the largest value in the unexamined window and a[lo] the smallest.
    forced by · sortedness is precisely the statement that position encodes rank.
  2. 2
    Therefore a[lo] + a[hi] is the maximum achievable sum involving a[lo].
    forced by · no remaining partner exceeds a[hi].
  3. 3
    So if that sum is below target, every pair containing a[lo] is below target.
    forced by · we already paired it with the best available partner and fell short.
  4. 4
    Hence a[lo] can be removed from consideration entirely, not merely skipped this round.
    forced by · its best case has been evaluated and rejected.
  5. 5
    Each iteration removes exactly one element from a set of size n.
    forced by · one pointer always moves, and neither ever moves back.
⇒ Therefore
Testable prediction: the number of iterations is exactly hi minus lo at the start, so at most n-1, regardless of the data. Instrument the loop with a counter and confirm it never exceeds n on any input — including adversarial ones.
Mental modelClosing the accordion
Two hands on the ends of an accordion, squeezing inward. The note you hear is the current sum. Too flat, push the left hand in; too sharp, push the right. Hands only ever move toward each other, so the instrument closes in at most n squeezes.
  • Neither hand ever moves back outward — that is what bounds the work.
  • Which hand moves is dictated by the comparison, never by a guess.
  • If the note does not change predictably when a hand moves, this is not the right instrument.
🔔 Fires when you see
The word 'sorted' in the constraints, or any symmetric property like palindrome or mirror.
The tradeoff
Sort then two pointers, versus a hash map, for pair-finding on unsorted input.
Sort, then converge
+ you gain O(1) extra space beyond the sort, and it extends naturally to 3Sum and k-sum where the sorted order enables both the inner walk and clean deduplication.
− you pay O(n log n) time, and the original indices are destroyed unless you sort (value, index) pairs, which reintroduces O(n) space.
pick when The answer is stated in terms of values rather than indices, or the problem is a k-sum variant needing dedup.
Hash map, one pass
+ you gain O(n) time and original indices preserved for free.
− you pay O(n) memory, and it degrades badly for k-sum — the bookkeeping for triples with dedup gets genuinely unpleasant.
pick when The problem asks for indices into the original array, or the input is already unsorted and k is exactly 2.
What a senior engineer actually does
Let the requested return type decide. 'Return the indices' means hash map; 'return the values' or 'return all unique triplets' means sort and converge. If the input is already sorted, converge — you have been handed the precondition for free.

Complexity

For the converging skeleton: time O(n) — each iteration advances exactly one pointer toward the other and neither ever reverses, so the total number of iterations is bounded by the initial gap, n - 1. Space O(1) — two indices.

When you must sort first, the total becomes O(n log n) dominated by the sort, with the walk vanishing under the max rule. Space is O(1) if sorting in place, O(n) if you use sorted() or must preserve indices.

For 3Sum specifically: an outer loop over the fixed element times an inner O(n) walk gives O(n^2) time, plus the O(n log n) sort which disappears. Space is O(1) beyond the output. Note that O(n^2) is not a failure here — with n up to about 3000, L01's table says quadratic is exactly the intended budget.

Recall · L07 · click to reveal
★ = stretch question
Try itProve before you code

For each of the four problems in this session, write one sentence of the form: "when CONDITION, I move POINTER, which eliminates SET, because REASON." Four sentences. If you cannot write one for a problem, you do not yet have the pattern for it — and that is a far more useful finding than a passing submission.

💡 Hint · Write the sentence before writing the loop.

Spaced queue

Due today: anything from L05 marked failed comes back at plus two days, so Two Sum may return — and note the deliberate contrast, since today's Two Sum II is the same problem with sortedness granted. Solve them back to back and articulate why the technique differs.

Status ladder: cold +60d, warm +21d, hint +7d, failed +2d then +7d.

Key points