CrackedRuby CrackedRuby

Overview

The two pointers technique maintains two index variables that traverse an array or string according to specific movement rules. This approach transforms problems that would require nested loops into single-pass solutions, reducing algorithmic complexity.

The technique applies to problems involving searching, partitioning, or finding relationships between elements in ordered or unordered collections. Common applications include finding pairs with specific sums, detecting palindromes, partitioning arrays around pivot values, and removing duplicates from sorted sequences.

Two pointers work by establishing positions at different locations in the data structure and moving them based on conditions derived from the problem requirements. The pointers may start at opposite ends and converge, start together and move at different speeds, or maintain a window of variable size.

# Finding a pair that sums to target in sorted array
def two_sum(arr, target)
  left = 0
  right = arr.length - 1
  
  while left < right
    sum = arr[left] + arr[right]
    return [left, right] if sum == target
    
    sum < target ? left += 1 : right -= 1
  end
  
  nil
end

two_sum([1, 2, 3, 4, 6], 6)
# => [1, 3]  # arr[1] + arr[3] = 2 + 4 = 6

The technique originated from analysis of sorting and searching algorithms where maintaining multiple positions within a data structure proved more efficient than repeated iterations. The approach became formalized as a distinct pattern through competitive programming and algorithm optimization studies.

Key Principles

The two pointers technique operates on the principle that certain relationships between elements can be determined by comparing values at different positions and adjusting those positions based on the comparison results. This eliminates the need for examining every possible pair or combination.

Pointer Movement Rules

Pointer advancement follows deterministic rules derived from problem constraints. In sorted array problems, comparing the sum of pointed values against a target determines which pointer moves: if the sum exceeds the target, the right pointer decrements to reduce the sum; if below target, the left pointer increments to increase it. This guarantees that all valid pairs are examined without checking every combination.

Convergence Conditions

The algorithm terminates when pointers meet, cross, or satisfy problem-specific conditions. For opposite-end pointers, termination occurs when left >= right. For same-direction pointers, termination happens when the faster pointer reaches the boundary. The invariant—the condition that remains true throughout execution—defines correctness.

Search Space Reduction

Each pointer movement eliminates a portion of the search space. When searching for a pair sum in a sorted array, moving the left pointer right eliminates all pairs involving the previous left element, as those have been examined. Moving right pointer left similarly eliminates pairs with the previous right element. This property ensures O(n) time complexity.

# Demonstrating search space reduction
def find_pair_sum_verbose(arr, target)
  left = 0
  right = arr.length - 1
  iterations = 0
  
  while left < right
    iterations += 1
    sum = arr[left] + arr[right]
    
    if sum == target
      puts "Found in #{iterations} iterations (vs #{arr.length * (arr.length - 1) / 2} for brute force)"
      return [arr[left], arr[right]]
    end
    
    sum < target ? left += 1 : right -= 1
  end
  
  nil
end

Variants and Classifications

Three primary variants exist based on pointer positioning and movement:

Opposite-ends pointers start at array boundaries and converge toward the center. This variant works when the data structure has ordering properties that relate boundary elements to interior elements.

Same-direction pointers move in the same direction at different speeds or with different advancement rules. The fast-slow pointer pattern detects cycles, finds middle elements, or identifies specific positions. The sliding window pattern maintains a range of elements meeting certain criteria.

Partitioning pointers segregate elements into regions based on conditions, with one pointer tracking the boundary between regions and another scanning for elements to swap across the boundary.

Ruby Implementation

Ruby arrays provide zero-indexed access with negative indexing for end-relative positions. The two pointers technique uses these features along with Ruby's iteration methods and range operations.

Array Access Patterns

Ruby supports both positive indices (0 to length-1) and negative indices (-1 for last element, -2 for second-to-last). While negative indices offer convenience, two pointers implementations typically use positive indices with explicit length calculations for clarity and consistent behavior.

def reverse_array(arr)
  left = 0
  right = arr.length - 1
  
  while left < right
    arr[left], arr[right] = arr[right], arr[left]
    left += 1
    right -= 1
  end
  
  arr
end

reverse_array([1, 2, 3, 4, 5])
# => [5, 4, 3, 2, 1]

String Manipulation

Ruby strings require conversion to character arrays for in-place modification since strings are mutable but individual character replacement differs from array element replacement. The chars method converts strings to arrays, and join reconstructs the string.

def is_palindrome(str)
  chars = str.downcase.gsub(/[^a-z0-9]/, '').chars
  left = 0
  right = chars.length - 1
  
  while left < right
    return false if chars[left] != chars[right]
    left += 1
    right -= 1
  end
  
  true
end

is_palindrome("A man, a plan, a canal: Panama")
# => true

Range and Slice Operations

Ruby ranges (start..end or start...end) represent contiguous sequences. Two pointers define dynamic ranges that change during iteration. The slice method or range indexing extracts subarrays between pointer positions.

def find_max_sum_subarray(arr, k)
  return nil if k > arr.length
  
  # Initialize window
  window_sum = arr[0...k].sum
  max_sum = window_sum
  max_start = 0
  
  # Slide window
  left = 0
  right = k
  
  while right < arr.length
    window_sum = window_sum - arr[left] + arr[right]
    if window_sum > max_sum
      max_sum = window_sum
      max_start = left + 1
    end
    left += 1
    right += 1
  end
  
  { sum: max_sum, subarray: arr[max_start, k] }
end

find_max_sum_subarray([1, 4, 2, 10, 23, 3, 1, 0, 20], 4)
# => {:sum=>39, :subarray=>[4, 2, 10, 23]}

Enumerable Integration

While two pointers typically use while loops for explicit pointer control, Ruby enumerables offer alternative approaches for specific patterns. The each_cons method provides sliding windows, and each_with_index tracks positions during iteration.

# Traditional two pointer approach
def remove_duplicates_sorted(arr)
  return 0 if arr.empty?
  
  write_pos = 1
  
  (1...arr.length).each do |read_pos|
    if arr[read_pos] != arr[read_pos - 1]
      arr[write_pos] = arr[read_pos]
      write_pos += 1
    end
  end
  
  write_pos
end

nums = [1, 1, 2, 2, 2, 3, 4, 4, 5]
length = remove_duplicates_sorted(nums)
nums[0...length]
# => [1, 2, 3, 4, 5]

In-Place Modification

Many two pointer algorithms modify arrays in-place to achieve O(1) space complexity. Ruby arrays support parallel assignment for swaps and direct index assignment for element updates.

def move_zeros_to_end(arr)
  write_pos = 0
  
  # Move all non-zeros to front
  arr.each_with_index do |val, read_pos|
    if val != 0
      arr[write_pos] = val
      write_pos += 1
    end
  end
  
  # Fill remaining positions with zeros
  (write_pos...arr.length).each { |i| arr[i] = 0 }
  
  arr
end

move_zeros_to_end([0, 1, 0, 3, 12])
# => [1, 3, 12, 0, 0]

Common Patterns

The two pointers technique manifests in distinct patterns, each suited to specific problem characteristics. Recognizing these patterns accelerates problem-solving and implementation.

Opposite Ends Convergence

This pattern places pointers at array boundaries and moves them toward each other based on comparison results. The pattern requires some ordering or symmetry property in the data.

def three_sum_closest(nums, target)
  nums.sort!
  closest_sum = Float::INFINITY
  min_diff = Float::INFINITY
  
  nums.each_with_index do |num, i|
    next if i > 0 && num == nums[i - 1]  # Skip duplicates
    
    left = i + 1
    right = nums.length - 1
    
    while left < right
      current_sum = num + nums[left] + nums[right]
      diff = (current_sum - target).abs
      
      if diff < min_diff
        min_diff = diff
        closest_sum = current_sum
      end
      
      current_sum < target ? left += 1 : right -= 1
    end
  end
  
  closest_sum
end

Fast-Slow Pointer

The fast-slow pattern uses two pointers advancing at different rates. This detects cycles, finds middle elements, or identifies positions at fractional distances through the structure.

def find_middle_node(linked_list_array)
  return nil if linked_list_array.empty?
  
  slow = 0
  fast = 0
  
  while fast < linked_list_array.length - 1 && 
        fast + 1 < linked_list_array.length
    slow += 1
    fast += 2
  end
  
  linked_list_array[slow]
end

find_middle_node([1, 2, 3, 4, 5])
# => 3

Sliding Window

The sliding window maintains a contiguous range of elements meeting specific criteria. Both pointers move in the same direction, with the window expanding or contracting based on conditions.

def longest_substring_without_repeating(str)
  char_positions = {}
  left = 0
  max_length = 0
  
  str.chars.each_with_index do |char, right|
    if char_positions.key?(char) && char_positions[char] >= left
      left = char_positions[char] + 1
    end
    
    char_positions[char] = right
    max_length = [max_length, right - left + 1].max
  end
  
  max_length
end

longest_substring_without_repeating("abcabcbb")
# => 3  # "abc"

Partition Pattern

The partition pattern separates elements into regions based on conditions. One pointer tracks the boundary between regions while another scans for elements to move.

def partition_array(arr, pivot)
  left = 0
  right = arr.length - 1
  
  while left <= right
    # Find element on left that should be on right
    left += 1 while left <= right && arr[left] < pivot
    
    # Find element on right that should be on left
    right -= 1 while left <= right && arr[right] >= pivot
    
    if left < right
      arr[left], arr[right] = arr[right], arr[left]
      left += 1
      right -= 1
    end
  end
  
  arr
end

partition_array([3, 5, 2, 7, 1, 9, 4], 5)
# => [3, 4, 2, 1, 5, 9, 7]  # Elements < 5 before elements >= 5

Multiple Pointer Coordination

Some problems require coordinating more than two pointers simultaneously, each with distinct movement rules.

def remove_element(arr, val)
  write_pos = 0
  
  arr.each do |element|
    if element != val
      arr[write_pos] = element
      write_pos += 1
    end
  end
  
  arr[0...write_pos]
end

remove_element([3, 2, 2, 3, 4, 2, 5], 2)
# => [3, 3, 4, 5]

Practical Examples

Container With Most Water

Given heights representing vertical lines, find two lines that form a container holding maximum water. The area equals the minimum height multiplied by the distance between lines.

def max_area(heights)
  left = 0
  right = heights.length - 1
  max_water = 0
  
  while left < right
    # Calculate current area
    width = right - left
    height = [heights[left], heights[right]].min
    current_area = width * height
    max_water = [max_water, current_area].max
    
    # Move pointer at shorter height
    # Moving taller pointer cannot increase area
    if heights[left] < heights[right]
      left += 1
    else
      right -= 1
    end
  end
  
  max_water
end

max_area([1, 8, 6, 2, 5, 4, 8, 3, 7])
# => 49  # Lines at index 1 (height 8) and index 8 (height 7)

Trapping Rain Water

Calculate water trapped between elevation bars after rainfall. Water at each position equals the minimum of maximum heights to the left and right, minus the current height.

def trap_rain_water(heights)
  return 0 if heights.length < 3
  
  left = 0
  right = heights.length - 1
  left_max = 0
  right_max = 0
  water = 0
  
  while left < right
    if heights[left] < heights[right]
      if heights[left] >= left_max
        left_max = heights[left]
      else
        water += left_max - heights[left]
      end
      left += 1
    else
      if heights[right] >= right_max
        right_max = heights[right]
      else
        water += right_max - heights[right]
      end
      right -= 1
    end
  end
  
  water
end

trap_rain_water([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1])
# => 6

Sort Colors (Dutch National Flag)

Sort an array containing only 0s, 1s, and 2s in-place using a single pass. Three pointers maintain boundaries between color regions.

def sort_colors(nums)
  low = 0      # Boundary for 0s
  mid = 0      # Current element
  high = nums.length - 1  # Boundary for 2s
  
  while mid <= high
    case nums[mid]
    when 0
      nums[low], nums[mid] = nums[mid], nums[low]
      low += 1
      mid += 1
    when 1
      mid += 1
    when 2
      nums[mid], nums[high] = nums[high], nums[mid]
      high -= 1
      # Don't increment mid - need to examine swapped element
    end
  end
  
  nums
end

sort_colors([2, 0, 2, 1, 1, 0])
# => [0, 0, 1, 1, 2, 2]

Minimum Window Substring

Find the smallest substring containing all characters from a pattern string, including duplicates.

def min_window_substring(s, t)
  return "" if s.empty? || t.empty?
  
  # Build character frequency map for pattern
  pattern_freq = Hash.new(0)
  t.each_char { |c| pattern_freq[c] += 1 }
  
  required = pattern_freq.length
  formed = 0
  window_counts = Hash.new(0)
  
  left = 0
  min_len = Float::INFINITY
  min_left = 0
  
  s.each_char.with_index do |char, right|
    window_counts[char] += 1
    
    if pattern_freq.key?(char) && 
       window_counts[char] == pattern_freq[char]
      formed += 1
    end
    
    # Contract window while valid
    while formed == required && left <= right
      # Update minimum window
      if right - left + 1 < min_len
        min_len = right - left + 1
        min_left = left
      end
      
      # Remove leftmost character
      window_counts[s[left]] -= 1
      if pattern_freq.key?(s[left]) && 
         window_counts[s[left]] < pattern_freq[s[left]]
        formed -= 1
      end
      left += 1
    end
  end
  
  min_len == Float::INFINITY ? "" : s[min_left, min_len]
end

min_window_substring("ADOBECODEBANC", "ABC")
# => "BANC"

Performance Considerations

The two pointers technique achieves time complexity improvements by reducing nested iterations to single passes. Understanding when these improvements apply determines appropriate usage.

Time Complexity Analysis

Most two pointer algorithms execute in O(n) time, where n represents the input size. Each element is examined at most twice—once when a pointer reaches it and possibly once when the other pointer crosses it. This contrasts with brute force approaches requiring O(n²) time to check all pairs.

# Brute force pair sum: O(n²)
def two_sum_brute_force(arr, target)
  arr.each_with_index do |num1, i|
    ((i + 1)...arr.length).each do |j|
      return [i, j] if num1 + arr[j] == target
    end
  end
  nil
end

# Two pointers pair sum: O(n)
def two_sum_optimized(arr, target)
  left = 0
  right = arr.length - 1
  
  while left < right
    sum = arr[left] + arr[right]
    return [left, right] if sum == target
    sum < target ? left += 1 : right -= 1
  end
  nil
end

For sorted input, the preprocessing sort operation dominates at O(n log n). When input is already sorted or when sorting is required anyway, two pointers add only O(n) to the total complexity.

Space Complexity Optimization

Two pointers implementations typically use O(1) auxiliary space, storing only pointer variables and temporary values. This advantage becomes significant when processing large datasets where O(n) space for hash tables or additional arrays proves prohibitive.

In-place modifications using two pointers avoid allocating new arrays. The partition and removal patterns rearrange elements within the original array, returning new length or boundary indices.

Cache Performance

Sequential access patterns in two pointers algorithms exhibit good cache locality. When both pointers move in the same direction, memory access remains sequential. Opposite-end convergence patterns access both ends but still maintain predictable access patterns beneficial for prefetching.

When Two Pointers Underperforms

Two pointers requires specific problem properties to function correctly. For unsorted data where sorting is not acceptable, hash-based solutions often prove faster. When finding all pairs rather than one pair, the O(n²) enumeration becomes unavoidable, though two pointers might still reduce constant factors.

Problems requiring backtracking or exploring multiple paths simultaneously do not fit the two pointers pattern. The technique assumes greedy or deterministic pointer movement rules; when decisions depend on future unknown values, dynamic programming or other approaches apply.

Common Pitfalls

Off-by-One Errors

Incorrect loop termination conditions cause off-by-one errors. The condition left < right versus left <= right depends on whether checking the same element twice is valid.

# Wrong: Misses middle element in odd-length arrays
def buggy_palindrome(arr)
  left = 0
  right = arr.length - 1
  
  while left < right  # Should be left < right for palindrome
    return false if arr[left] != arr[right]
    left += 1
    right -= 1
  end
  true
end

# Correct: Handles all elements
def correct_palindrome(arr)
  left = 0
  right = arr.length - 1
  
  while left < right  # Correct - no need to check middle element
    return false if arr[left] != arr[right]
    left += 1
    right -= 1
  end
  true
end

Infinite Loops

Failing to advance pointers in all code paths creates infinite loops. Each iteration must move at least one pointer closer to termination.

# Buggy: Infinite loop when sum equals target but condition wrong
def buggy_find_pair(arr, target)
  left = 0
  right = arr.length - 1
  
  while left < right
    sum = arr[left] + arr[right]
    if sum == target
      # BUG: No pointer movement
      return [left, right]
    elsif sum < target
      left += 1
    # BUG: Missing else clause for sum > target
    end
  end
  nil
end

Incorrect Pointer Initialization

Starting pointers at wrong positions causes incorrect results or missed elements. Boundary conditions require careful consideration.

# Wrong: Starts second pointer too early
def buggy_pair_sum(arr, target)
  left = 0
  right = arr.length - 2  # BUG: Should be length - 1
  # Misses pairs involving last element
end

# Correct initialization
def correct_pair_sum(arr, target)
  left = 0
  right = arr.length - 1
  # Examines all possible pairs
end

Modifying Array During Iteration

Removing or inserting elements while iterating with indices invalidates pointer positions. Two pointers work with fixed-size arrays or use logical rather than physical removal.

# Dangerous: Physical removal during iteration
def buggy_remove_element(arr, val)
  i = 0
  while i < arr.length
    if arr[i] == val
      arr.delete_at(i)  # BUG: Shifts all elements, invalidates indices
    else
      i += 1
    end
  end
  arr
end

# Safe: Logical removal with write pointer
def safe_remove_element(arr, val)
  write = 0
  arr.each do |element|
    if element != val
      arr[write] = element
      write += 1
    end
  end
  arr[0...write]
end

Handling Empty or Single-Element Arrays

Edge cases with empty or minimal-size inputs require explicit handling or careful boundary checks.

def robust_two_pointer(arr, target)
  return nil if arr.length < 2  # Explicit edge case handling
  
  left = 0
  right = arr.length - 1
  
  while left < right
    # Normal logic
  end
  
  nil
end

Skipping Duplicate Logic

When dealing with duplicates, forgetting to skip them causes incorrect results in problems requiring unique combinations.

# Handles duplicates correctly
def three_sum_no_duplicates(nums)
  nums.sort!
  result = []
  
  nums.each_with_index do |num, i|
    next if i > 0 && num == nums[i - 1]  # Skip duplicate first element
    
    left = i + 1
    right = nums.length - 1
    
    while left < right
      sum = num + nums[left] + nums[right]
      
      if sum == 0
        result << [num, nums[left], nums[right]]
        
        # Skip duplicates for second element
        left += 1 while left < right && nums[left] == nums[left - 1]
        # Skip duplicates for third element
        right -= 1 while left < right && nums[right] == nums[right + 1]
        
        left += 1
        right -= 1
      elsif sum < 0
        left += 1
      else
        right -= 1
      end
    end
  end
  
  result
end

Reference

Pattern Classification

Pattern Pointer Start Movement Direction Typical Use Case Time Complexity
Opposite Ends Both boundaries Converge inward Pair sum in sorted array, palindrome check O(n)
Fast-Slow Same position Same direction, different speeds Cycle detection, finding middle O(n)
Sliding Window Adjacent positions Same direction, variable gap Substring problems, subarray sums O(n)
Partition Boundary and scanner One fixed, one scanning Array partitioning, element removal O(n)
Multiple Pointer Various positions Problem-specific Dutch flag, complex partitioning O(n)

When to Apply Two Pointers

Problem Characteristic Applicability Alternative Approach
Sorted input required High - pair/triplet sums, container problems Hash table if sorting not allowed
Finding pairs/triplets High - reduces from O(n²) to O(n) Hash table for unsorted input
Palindrome checking High - natural fit for convergence pattern String reversal comparison
In-place modification High - O(1) space advantage New array allocation
Array partitioning High - single pass solution Multiple pass filters
Substring with conditions Medium - sliding window variant Dynamic programming for complex conditions
Unsorted pair finding Low - requires O(n²) or hash table Hash table preferred
All pairs enumeration Low - still requires O(n²) Explicit nested loops

Common Termination Conditions

Condition Interpretation Typical Pattern
left < right Pointers have not met or crossed Opposite ends convergence
left <= right Include case where pointers meet Partition where middle element matters
fast reaches end Fast pointer exhausted search space Fast-slow pointer
window valid Sliding window satisfies constraints Sliding window expansion
all elements processed Read pointer reached end Read-write pointer pattern

Complexity Analysis

Problem Type Brute Force Two Pointers Space Requirements
Pair sum O(n²) O(n) O(1) Sorted array
Triplet sum O(n³) O(n²) O(1) Sorted array
Remove duplicates O(n) O(n) O(1) vs O(n) In-place modification
Palindrome check O(n) O(n) O(1) String/array access
Container problem O(n²) O(n) O(1) Greedy choice property
Substring search O(n·m) O(n+m) O(m) Hash table for pattern

Ruby Implementation Idioms

Operation Ruby Pattern Notes
Initialize pointers left = 0; right = arr.length - 1 Explicit index arithmetic
Swap elements arr[i], arr[j] = arr[j], arr[i] Parallel assignment
Window sum sum = arr[left..right].sum Range slicing - O(k) operation
Character comparison str[i] == str[j] Direct index access on strings
Skip duplicates left += 1 while left < right && arr[left] == arr[left-1] Compound while condition
Partition in-place arr[0...write_pos] Range slice for result

Debugging Checklist

Issue Check
Infinite loop Verify pointer advancement in all branches
Wrong result Confirm termination condition matches problem
Off-by-one Check boundary initialization and loop condition
Missing pairs Ensure all valid pointer positions examined
Duplicate handling Verify skip logic for duplicate elements
Empty input Add explicit checks for length < 2
Modification bugs Confirm no element insertion/deletion during iteration