Reading

Two Pointers: One Search, Two Boundaries

Learn what pointers represent, why ordering makes movement safe, and how two boundaries avoid repeated work.

65 min 4 objectives
Learning objectives
  • Explain what a pointer represents in an array or string
  • Derive inward movement from a palindrome comparison
  • Use sorted order to eliminate impossible pairs
  • State the invariant that makes pointer movement correct

My notes & review

Begin with the search, not the technique

Imagine checking whether a word reads the same from both directions. A direct comparison starts with the first and last characters, then the second and second-to-last characters. You are not doing this because an interview pattern says “use two pointers.” You are doing it because the unanswered part of the word has two meaningful boundaries.

A pointer here is simply a variable containing an index. It does not have the lower-level memory meaning that the word has in languages such as C or C++.

Code
let left = 0
let right = text.length - 1

The two variables describe the part of the input that still needs attention:

Code
R A C E C A R
↑           ↑
left      right

After confirming that the two boundary characters match, move both pointers inward. The unchecked region becomes smaller without revisiting characters that are already settled.

The invariant: what remains true during the loop

Suppose we have compared the two outer Rs in RACECAR and moved inward to the As. We want to continue without checking those Rs again. What lets us forget about them? We already know they match. After checking the As, we will know the same thing about two pairs. Each step adds to the part of the answer we have established.

We can describe that knowledge in one sentence that stays true as the pointers move. Such a statement is called an invariant. It explains why the loop can leave processed characters behind without losing something it still needs to check.

For a palindrome scan, the invariant is:

Everything outside the interval from left through right has already been checked and matches its mirrored character.

Initially, nothing lies outside the interval, so the statement is true. When the boundary characters match, moving inward preserves it. When they differ, the string cannot be a palindrome, so the function can stop immediately.

Code
function isSimplePalindrome(text: string): boolean {
  let left = 0
  let right = text.length - 1

  while (left < right) {
    if (text[left] !== text[right]) return false
    left += 1
    right -= 1
  }

  return true
}

The loop uses left < right, not left <= right, because a middle character has no different partner to compare. An empty string and a one-character string therefore return true without entering the loop.

When the input contains punctuation and mixed case

Our first function compares every character exactly as written. Now suppose the input is "A, a" and the question says to ignore punctuation, spaces, and letter case. The expected answer is true: the meaningful characters are just "aa". Comparing the raw characters would wrongly reject the uppercase A and lowercase a.

How can we apply the same inward scan without constructing a cleaned copy? Before comparing a pair, move past anything the question says to ignore, then compare both letters in the same case. In this example, alphanumeric means English letters a–z or digits 0–9.

At each side:

  1. Skip characters outside the allowed set.
  2. Normalize the two remaining characters to the same case.
  3. Compare them.
  4. If they match, move both boundaries inward.
Code
function isAlphaNumeric(character: string): boolean {
  return /[a-z0-9]/i.test(character)
}

function isPalindrome(text: string): boolean {
  let left = 0
  let right = text.length - 1

  while (left < right) {
    while (left < right && !isAlphaNumeric(text[left])) left += 1
    while (left < right && !isAlphaNumeric(text[right])) right -= 1

    if (text[left].toLowerCase() !== text[right].toLowerCase()) return false

    left += 1
    right -= 1
  }

  return true
}

Trace: "A man, a plan, a canal: Panama"

Code
left sees A, right sees a  → compare a with a, then move both
left skips the space
left sees m, right sees m  → match
right skips punctuation and spaces whenever encountered
...
pointers meet              → every required pair matched

No cleaned copy of the string is required. Each character is visited at most once by a pointer, so the scan takes O(n) time and O(1) auxiliary space.

Would creating a cleaned string be wrong?

No. Normalizing first is often simpler and still takes O(n) time, but it uses O(n) extra space. The two-pointer version demonstrates that the same comparison can be performed without storing a second string.

Sorted order turns comparison into elimination

The palindrome problem tells us exactly which characters to compare: mirrored positions. Now change the task. Given a sorted array and a target, we want two different positions whose values add up to that target. The matching values need not be mirrored, so moving both pointers together would skip possible answers.

We could try every pair. Can sorted order instead tell us which pointer to move after one comparison? Start with the smallest and largest values and a target of 10:

Code
[1, 2, 4, 7, 9]
 ↑           ↑
left       right

The first pair sums to 10, so it is the answer. Now consider a different target, 8:

Code
1 + 9 = 10, which is too large

Because 9 is the largest remaining value, pairing it with any value to the right of 1 would make an equal or larger sum. Keeping 9 cannot fix the problem. Decrease the sum by moving right leftward.

If the sum were too small, 1 would be the problem: pairing the smallest remaining value with anything below 9 could only make the sum equal or smaller. Increase the sum by moving left rightward.

Code
function twoSumSorted(numbers: number[], target: number): [number, number] | null {
  let left = 0
  let right = numbers.length - 1

  while (left < right) {
    const sum = numbers[left] + numbers[right]

    if (sum === target) return [left, right]
    if (sum < target) left += 1
    else right -= 1
  }

  return null
}

Worked trace: [2, 3, 4, 6, 9], target 10

Goal: Find two values at different indices that add up to 10. Follow how each comparison eliminates one boundary. Here left and right are zero-based indices; the pair column shows the values at those positions.

Code
left  right  pair   sum  conclusion
0     4      2,9    11   too large; discard 9
0     3      2,6     8   too small; discard 2
1     3      3,6     9   too small; discard 3
2     3      4,6    10   found

We found values 4 and 6, at zero-based indices 2 and 3. If a particular problem requests one-based positions, convert these to 3 and 4 when returning.

Each movement rules out all remaining pairs that use the discarded endpoint, not just the pair we compared. That is why the algorithm needs at most n - 1 movements instead of testing roughly n² / 2 combinations.

How to recognize—and reject—the pattern

We have used the same two index variables for two different reasons: matching mirrored characters and ruling out impossible sums. Seeing two ends of an array is not enough to justify the technique. When a new problem arrives, ask: what could one comparison tell me about the values I would leave behind?

Two pointers are worth considering when:

  • the answer compares elements at two boundaries;
  • sorted order lets one comparison eliminate many candidates;
  • a contiguous region grows, shrinks, or is partitioned;
  • elements should be processed without allocating another full array.

Do not force converging pointers onto an unsorted pair-sum problem. If the sum is too small in an unsorted array, moving either pointer has no predictable effect. You would first need to sort, use a lookup structure, or find another property that justifies movement.

Complexity vocabulary

The punctuation-skipping function has small while loops inside another while loop. Does that mean it repeats a full scan for every character? No: the pointers never restart. Once a character is passed, that pointer does not visit it again. To measure the work, count those movements rather than multiplying the visible loops.

Both examples move each pointer in only one direction. Across the whole algorithm, each pointer advances at most n positions, so the work is O(n), not O(n²). A loop inside another loop is not automatically quadratic; what matters is the total number of movements.

The palindrome code uses O(1) auxiliary space. The sorted two-sum scan also uses O(1) extra space when the input is already sorted. Sorting an unsorted input first would usually add O(n log n) time and may affect whether original indices can be returned.

Checkpoint

You should now be able to explain a pointer move without quoting a memorized rule. Choose either the palindrome or sorted-pair example, hide the code, and describe how you would continue from the first comparison.

Before writing a two-pointer loop, answer three questions:

  1. What does each pointer represent?
  2. What remains true outside or inside the pointers?
  3. What fact proves that moving one pointer cannot discard a valid answer?

If the third answer is missing, the movement is a guess rather than an algorithm.