DSA · Topic 2 of 21

Two Pointers

50 XP

The idea

A nested loop scanning all pairs is O(n²). Two pointers scanning an array from both ends — or at different speeds — is O(n). That’s the entire trade-off.

You maintain two indices and move them based on a condition until they meet or cross.

Pattern 1: Converging pointers

Used when the array is sorted and you’re looking for pairs that satisfy some condition.

function twoSumSorted(nums: number[], target: number): number[] {
  let left = 0, right = nums.length - 1
  while (left < right) {
    const sum = nums[left] + nums[right]
    if (sum === target) return [left, right]
    if (sum < target) left++   // need bigger sum → move left right
    else right--                // need smaller sum → move right left
  }
  return []
}

Why it works: because the array is sorted, you know exactly which direction to move. If the sum is too small, increasing the left pointer increases the sum. If too large, decreasing the right pointer decreases it. You never need to backtrack.

Template:

let left = 0, right = nums.length - 1
while (left < right) {
  // compute something from nums[left] and nums[right]
  // move left++ or right-- based on the result
}

Pattern 2: Slow/fast pointers (same direction)

Both pointers start at the left and move right, but at different speeds or with different conditions. Used for in-place operations on sorted arrays.

// Remove duplicates in sorted array — return new length
function removeDuplicates(nums: number[]): number {
  let slow = 0
  for (let fast = 1; fast < nums.length; fast++) {
    if (nums[fast] !== nums[slow]) {
      slow++
      nums[slow] = nums[fast]
    }
  }
  return slow + 1
}

slow is the write pointer. fast scans ahead. When fast finds something new, slow advances and we write. Classic in-place deduplication.

Template:

let slow = 0
for (let fast = 0; fast < arr.length; fast++) {
  if (/* some condition on arr[fast] */) {
    arr[slow] = arr[fast]
    slow++
  }
}
// slow is the new logical length

Pattern 3: Valid palindrome

A common variant — converge while skipping non-alphanumeric characters.

function isPalindrome(s: string): boolean {
  let left = 0, right = s.length - 1
  while (left < right) {
    while (left < right && !isAlphanumeric(s[left]))  left++
    while (left < right && !isAlphanumeric(s[right])) right--
    if (s[left].toLowerCase() !== s[right].toLowerCase()) return false
    left++
    right--
  }
  return true
}

function isAlphanumeric(c: string): boolean {
  return /[a-zA-Z0-9]/.test(c)
}

When to use

Reach for two pointers when you see:

  • Sorted array / sorted string — almost always two pointers
  • Find pairs with some sum/product — converging pointers
  • In-place modification — slow/fast pointers
  • Palindrome check — converging pointers

Don’t use two pointers on unsorted arrays where you need pairs — use a hashmap instead (like twoSum from the previous topic).

Complexity

VariantTimeSpace
ConvergingO(n)O(1)
Slow/fastO(n)O(1)

Both patterns do at most one full pass. The reduction from O(n²) to O(n) comes from the fact that after each comparison, at least one pointer moves — so the total work is bounded by the array length.