Two Pointers (Opposite Ends)
You're given numbers, a 1-indexed array of integers already sorted in non-decreasing order, plus a target value. Locate two distinct positions index_1 < index_2 whose values add up to target.
Return those two positions, 1-indexed, as [index_1, index_2].
Exactly one valid pair is guaranteed to exist, and you can't reuse the same element twice.
Do this using only constant extra space.
Converging Pointers
left -> <- right (Opposite Boundary Contraction)| 1 | from typing import Callable, List, Optional, Tuple |
| 2 | Â |
| 3 | Â |
| 4 | def converge(n: int, compare: Callable[[int, int], int]) -> Optional[Tuple[int, int]]: |
| 5 | """Converging Pointers: squeeze a pair of indices (left, right) inward from both ends of a sorted sequence of length n. |
| 6 | compare(left, right) returns 0 to stop at this pair, below 0 to move left, and above 0 to move right. |
| 7 | Precondition: each step can rule out the element at left or the element at right for good, and compare says which one. |
| 8 | For a pair sum on a sorted sequence, move left when the pair is too small and move right when it is too big. |
| 9 | compare may also record an answer and return a nonzero value to keep going, as in Container With Most Water and 3Sum. |
| 10 | In Valid Palindrome, a match moves both pointers, so that loop moves left and right together. |
| 11 | T = the cost of one compare call. |
| 12 | Time: O(N * T) | Space: O(1) |
| 13 | """ |
| 14 | left, right = 0, n - 1 # left and right only ever move toward each other |
| 15 | Â |
| 16 | # 1. Squeeze Inward While Not Met: |
| 17 | # compare rules out the element at left or at right for good on every step, so only the ruled-out end moves and the pair found, if any, is always still between left and right. |
| 18 | while left < right: |
| 19 | order = compare(left, right) |
| 20 | if order == 0: |
| 21 | return (left, right) |
| 22 | elif order < 0: |
| 23 | left += 1 # The pair is too small: the element at left cannot pair with any remaining element |
| 24 | else: |
| 25 | right -= 1 # The pair is too big: the element at right cannot pair with any remaining element |
| 26 | Â |
| 27 | return None |
[left, right] has been ruled out: it cannot sum to target.Start one pointer at each end of a sorted array. If the pair's sum is too small, only moving left right can raise it; if it is too big, only moving right left can lower it.
Each step rules out one element for good: when curr_sum < target, numbers[left] is already paired with the largest value left, so no remaining partner can reach target (and the mirror holds for right). This works with negative numbers too, because it relies on sorted order, not on signs.
The trap is unsorted input: there, moving a pointer no longer raises or lowers the sum, so the squeeze can walk past the answer; sort first, and keep the original indices if the problem asks for them.
See it run
Converging Pointers, step by step
left 0right 5curr_sum -
Each box is one entry of numbers, with its index above it. The answer uses positions that start at 1.
leftthe pointer that only moves rightrightthe pointer that only moves leftouta value ruled out for goodmatcha pair whose sum equals the target
Start: left is at index 0 and right is at index 5. The array is sorted, so one comparison can rule out one end.
Gotcha 1 of 5
- The trap
Gotcha 1: Running the squeeze on an unsorted array
MistaketwoSum(nums, target)FixtwoSum(sorted(nums), target)What happens: When
curr_sum < target, the loop movesleftone step right to raise the sum. A larger value atleftis certain only whennumsis sorted. On unsorted input, a move can lower the sum, and the loop walks past the pair. Sorting first finds the pair, but it returns positions in the sorted list. Two Sum (LC 1) wants the original indices, so sort(value, index)pairs instead.Example: nums = [1, 4, 2, 3], target = 5Mistake: returns []Fix: returns [1, 4]
- The answer is a contiguous stretch whose order must be kept, as in Fruit Into Baskets (LC 904), so you can't sort and there are no two ends to pair. See Longest Window
- The target is the sum of a contiguous subarray, not of two chosen elements, and values can be negative, as in Subarray Sum Equals K (LC 560). See Prefix Sum Map
Theory
The question we build
What we know
numbers is sorted in ascending order.
A pair's sum only goes up when its smaller index moves right, and only goes down when its larger index moves left.
What the problem gives us
The input is numbers and target.
Return the 1-indexed positions of the two entries that sum to target.
LeetCode guarantees exactly one such pair.
The question we build
We ask one question of the pair standing at the two ends: "is numbers[left] + numbers[right] equal to, below, or above target?"
When the sum is too small, is there any remaining partner for numbers[left]?
numbers[left] is already paired with numbers[right], the largest value not yet ruled out, and the sum is still too small.
Narrowing down to the template
Which element of the current pair can never be part of a better pair?
When the sum is too small, numbers[left] paired with the largest remaining value still falls short, so no remaining numbers[right] can save it: drop left.
When the sum is too big, the mirror argument drops right.
So one comparison always rules out one end for good, and the two pointers only move toward each other.
That is exactly Converging Pointers: squeeze [left, right] inward until they meet or the pair is found.
Step 1: Every pair
Check every pair (i, j) with i < j.
Correct for any array, sorted or not.
States the contract in one listing: a pair whose sum equals target.
| 1 | def two_sum_brute(numbers, target): |
| 2 | n = len(numbers) |
| 3 | for i in range(n): |
| 4 | for j in range(i + 1, n): |
| 5 | if numbers[i] + numbers[j] == target: |
| 6 | return [i + 1, j + 1] # 1-indexed, as LeetCode 167 asks |
| 7 | return [] |
Cost: O(N^2) time, O(1) extra space.
Step 2: Squeeze from both ends
On sorted input, one comparison at the two ends rules out one end for good.
If the sum is too small, numbers[left] cannot pair with anything smaller than numbers[right], so no remaining partner reaches target: move left right.
If the sum is too big, the mirror argument moves right left.
Run on unsorted input, this reasoning breaks: a pointer move no longer raises or lowers the sum in one direction, so the squeeze can walk straight past the answer.
| 1 | New line: def twoSum(numbers: List[int], target: int) -> List[int]: |
| 2 | New line: left, right = 0, len(numbers) - 1 # left and right only ever move toward each other |
| 3 | |
| 4 | New line: # 1. Squeeze Inward While Not Met: |
| 5 | New line: # numbers is sorted, so moving left right only raises curr_sum and moving right left only lowers it; one comparison always rules out the end that cannot reach target. |
| 6 | New line: while left < right: |
| 7 | New line: curr_sum = numbers[left] + numbers[right] |
| 8 | New line: if curr_sum == target: |
| 9 | New line: return [left + 1, right + 1] # LeetCode 167 wants 1-indexed positions |
| 10 | New line: elif curr_sum < target: |
| 11 | New line: left += 1 # numbers[left] cannot pair with any remaining elements; discard left |
| 12 | New line: else: |
| 13 | New line: right -= 1 # numbers[right] cannot pair with any remaining elements; discard right |
| 14 | |
| 15 | return [] |
Cost: O(N) time, O(1) extra space.
Step 3: The named template
The loop does not change between problems.
Only compare and what it returns change.
| 1 | New line: from typing import Callable, Optional, Tuple |
| 2 | |
| 3 | New line: def converge(n: int, compare: Callable[[int, int], int]) -> Optional[Tuple[int, int]]: |
| 4 | New line: """Converging Pointers: squeeze a pair of indices (left, right) inward from both ends of a sorted sequence of length n. |
| 5 | New line: compare(left, right) returns 0 to stop at this pair, below 0 to move left, and above 0 to move right. |
| 6 | New line: Precondition: each step can rule out the element at left or the element at right for good, and compare says which one. |
| 7 | New line: For a pair sum on a sorted sequence, move left when the pair is too small and move right when it is too big. |
| 8 | New line: compare may also record an answer and return a nonzero value to keep going, as in Container With Most Water and 3Sum. |
| 9 | New line: In Valid Palindrome, a match moves both pointers, so that loop moves left and right together. |
| 10 | New line: T = the cost of one compare call. |
| 11 | New line: Time: O(N * T) | Space: O(1) |
| 12 | New line: """ |
| 13 | New line: left, right = 0, n - 1 # left and right only ever move toward each other |
| 14 | |
| 15 | # 1. Squeeze Inward While Not Met: |
| 16 | New line: # compare rules out the element at left or at right for good on every step, so only the ruled-out end moves and the pair found, if any, is always still between left and right. |
| 17 | while left < right: |
| 18 | New line: order = compare(left, right) |
| 19 | New line: if order == 0: |
| 20 | New line: return (left, right) |
| 21 | New line: elif order < 0: |
| 22 | left += 1 # The pair is too small: the element at left cannot pair with any remaining element |
| 23 | else: |
| 24 | right -= 1 # The pair is too big: the element at right cannot pair with any remaining element |
| 25 | |
| 26 | New line: return None |
Cost: O(N * T), where T is the cost of one compare call.
| Before | After | What changed |
|---|---|---|
curr_sum = numbers[left] + numbers[right] | order = compare(left, right) | The sum and the comparison with target move inside compare. |
if curr_sum == target: return [left + 1, right + 1] | if order == 0: return (left, right) | compare returns 0 for a match; the caller adds 1 for LeetCode's 1-indexed answer. |
elif curr_sum < target: left += 1 | elif order < 0: left += 1 | Same move, named by the sign compare returns. |
else: right -= 1 | else: right -= 1 | Identical. |
Practice
Two Pointers (Opposite Ends)
You're given numbers, a 1-indexed array of integers already sorted in non-decreasing order, plus a target value. Locate two distinct positions index_1 < index_2 whose values add up to target.
Return those two positions, 1-indexed, as [index_1, index_2].
Exactly one valid pair is guaranteed to exist, and you can't reuse the same element twice.
Do this using only constant extra space.
Converging Pointers
left -> <- right (Opposite Boundary Contraction)| 1 | from typing import Callable, List, Optional, Tuple |
| 2 | Â |
| 3 | Â |
| 4 | def converge(n: int, compare: Callable[[int, int], int]) -> Optional[Tuple[int, int]]: |
| 5 | """Converging Pointers: squeeze a pair of indices (left, right) inward from both ends of a sorted sequence of length n. |
| 6 | compare(left, right) returns 0 to stop at this pair, below 0 to move left, and above 0 to move right. |
| 7 | Precondition: each step can rule out the element at left or the element at right for good, and compare says which one. |
| 8 | For a pair sum on a sorted sequence, move left when the pair is too small and move right when it is too big. |
| 9 | compare may also record an answer and return a nonzero value to keep going, as in Container With Most Water and 3Sum. |
| 10 | In Valid Palindrome, a match moves both pointers, so that loop moves left and right together. |
| 11 | T = the cost of one compare call. |
| 12 | Time: O(N * T) | Space: O(1) |
| 13 | """ |
| 14 | left, right = 0, n - 1 # left and right only ever move toward each other |
| 15 | Â |
| 16 | # 1. Squeeze Inward While Not Met: |
| 17 | # compare rules out the element at left or at right for good on every step, so only the ruled-out end moves and the pair found, if any, is always still between left and right. |
| 18 | while left < right: |
| 19 | order = compare(left, right) |
| 20 | if order == 0: |
| 21 | return (left, right) |
| 22 | elif order < 0: |
| 23 | left += 1 # The pair is too small: the element at left cannot pair with any remaining element |
| 24 | else: |
| 25 | right -= 1 # The pair is too big: the element at right cannot pair with any remaining element |
| 26 | Â |
| 27 | return None |
[left, right] has been ruled out: it cannot sum to target.Start one pointer at each end of a sorted array. If the pair's sum is too small, only moving left right can raise it; if it is too big, only moving right left can lower it.
Each step rules out one element for good: when curr_sum < target, numbers[left] is already paired with the largest value left, so no remaining partner can reach target (and the mirror holds for right). This works with negative numbers too, because it relies on sorted order, not on signs.
The trap is unsorted input: there, moving a pointer no longer raises or lowers the sum, so the squeeze can walk past the answer; sort first, and keep the original indices if the problem asks for them.
See it run
Converging Pointers, step by step
left 0right 5curr_sum -
Each box is one entry of numbers, with its index above it. The answer uses positions that start at 1.
leftthe pointer that only moves rightrightthe pointer that only moves leftouta value ruled out for goodmatcha pair whose sum equals the target
Start: left is at index 0 and right is at index 5. The array is sorted, so one comparison can rule out one end.
Gotcha 1 of 5
- The trap
Gotcha 1: Running the squeeze on an unsorted array
MistaketwoSum(nums, target)FixtwoSum(sorted(nums), target)What happens: When
curr_sum < target, the loop movesleftone step right to raise the sum. A larger value atleftis certain only whennumsis sorted. On unsorted input, a move can lower the sum, and the loop walks past the pair. Sorting first finds the pair, but it returns positions in the sorted list. Two Sum (LC 1) wants the original indices, so sort(value, index)pairs instead.Example: nums = [1, 4, 2, 3], target = 5Mistake: returns []Fix: returns [1, 4]
- The answer is a contiguous stretch whose order must be kept, as in Fruit Into Baskets (LC 904), so you can't sort and there are no two ends to pair. See Longest Window
- The target is the sum of a contiguous subarray, not of two chosen elements, and values can be negative, as in Subarray Sum Equals K (LC 560). See Prefix Sum Map
Theory
The question we build
What we know
numbers is sorted in ascending order.
A pair's sum only goes up when its smaller index moves right, and only goes down when its larger index moves left.
What the problem gives us
The input is numbers and target.
Return the 1-indexed positions of the two entries that sum to target.
LeetCode guarantees exactly one such pair.
The question we build
We ask one question of the pair standing at the two ends: "is numbers[left] + numbers[right] equal to, below, or above target?"
When the sum is too small, is there any remaining partner for numbers[left]?
numbers[left] is already paired with numbers[right], the largest value not yet ruled out, and the sum is still too small.
Narrowing down to the template
Which element of the current pair can never be part of a better pair?
When the sum is too small, numbers[left] paired with the largest remaining value still falls short, so no remaining numbers[right] can save it: drop left.
When the sum is too big, the mirror argument drops right.
So one comparison always rules out one end for good, and the two pointers only move toward each other.
That is exactly Converging Pointers: squeeze [left, right] inward until they meet or the pair is found.
Step 1: Every pair
Check every pair (i, j) with i < j.
Correct for any array, sorted or not.
States the contract in one listing: a pair whose sum equals target.
| 1 | def two_sum_brute(numbers, target): |
| 2 | n = len(numbers) |
| 3 | for i in range(n): |
| 4 | for j in range(i + 1, n): |
| 5 | if numbers[i] + numbers[j] == target: |
| 6 | return [i + 1, j + 1] # 1-indexed, as LeetCode 167 asks |
| 7 | return [] |
Cost: O(N^2) time, O(1) extra space.
Step 2: Squeeze from both ends
On sorted input, one comparison at the two ends rules out one end for good.
If the sum is too small, numbers[left] cannot pair with anything smaller than numbers[right], so no remaining partner reaches target: move left right.
If the sum is too big, the mirror argument moves right left.
Run on unsorted input, this reasoning breaks: a pointer move no longer raises or lowers the sum in one direction, so the squeeze can walk straight past the answer.
| 1 | New line: def twoSum(numbers: List[int], target: int) -> List[int]: |
| 2 | New line: left, right = 0, len(numbers) - 1 # left and right only ever move toward each other |
| 3 | |
| 4 | New line: # 1. Squeeze Inward While Not Met: |
| 5 | New line: # numbers is sorted, so moving left right only raises curr_sum and moving right left only lowers it; one comparison always rules out the end that cannot reach target. |
| 6 | New line: while left < right: |
| 7 | New line: curr_sum = numbers[left] + numbers[right] |
| 8 | New line: if curr_sum == target: |
| 9 | New line: return [left + 1, right + 1] # LeetCode 167 wants 1-indexed positions |
| 10 | New line: elif curr_sum < target: |
| 11 | New line: left += 1 # numbers[left] cannot pair with any remaining elements; discard left |
| 12 | New line: else: |
| 13 | New line: right -= 1 # numbers[right] cannot pair with any remaining elements; discard right |
| 14 | |
| 15 | return [] |
Cost: O(N) time, O(1) extra space.
Step 3: The named template
The loop does not change between problems.
Only compare and what it returns change.
| 1 | New line: from typing import Callable, Optional, Tuple |
| 2 | |
| 3 | New line: def converge(n: int, compare: Callable[[int, int], int]) -> Optional[Tuple[int, int]]: |
| 4 | New line: """Converging Pointers: squeeze a pair of indices (left, right) inward from both ends of a sorted sequence of length n. |
| 5 | New line: compare(left, right) returns 0 to stop at this pair, below 0 to move left, and above 0 to move right. |
| 6 | New line: Precondition: each step can rule out the element at left or the element at right for good, and compare says which one. |
| 7 | New line: For a pair sum on a sorted sequence, move left when the pair is too small and move right when it is too big. |
| 8 | New line: compare may also record an answer and return a nonzero value to keep going, as in Container With Most Water and 3Sum. |
| 9 | New line: In Valid Palindrome, a match moves both pointers, so that loop moves left and right together. |
| 10 | New line: T = the cost of one compare call. |
| 11 | New line: Time: O(N * T) | Space: O(1) |
| 12 | New line: """ |
| 13 | New line: left, right = 0, n - 1 # left and right only ever move toward each other |
| 14 | |
| 15 | # 1. Squeeze Inward While Not Met: |
| 16 | New line: # compare rules out the element at left or at right for good on every step, so only the ruled-out end moves and the pair found, if any, is always still between left and right. |
| 17 | while left < right: |
| 18 | New line: order = compare(left, right) |
| 19 | New line: if order == 0: |
| 20 | New line: return (left, right) |
| 21 | New line: elif order < 0: |
| 22 | left += 1 # The pair is too small: the element at left cannot pair with any remaining element |
| 23 | else: |
| 24 | right -= 1 # The pair is too big: the element at right cannot pair with any remaining element |
| 25 | |
| 26 | New line: return None |
Cost: O(N * T), where T is the cost of one compare call.
| Before | After | What changed |
|---|---|---|
curr_sum = numbers[left] + numbers[right] | order = compare(left, right) | The sum and the comparison with target move inside compare. |
if curr_sum == target: return [left + 1, right + 1] | if order == 0: return (left, right) | compare returns 0 for a match; the caller adds 1 for LeetCode's 1-indexed answer. |
elif curr_sum < target: left += 1 | elif order < 0: left += 1 | Same move, named by the sign compare returns. |
else: right -= 1 | else: right -= 1 | Identical. |