The Two-Pointers Pattern: Template & When to Use It
The two-pointers pattern uses two index variables that move across a sequence — either toward each other from both ends or together in the same direction — to solve in O(n) time problems that would otherwise need a nested O(n²) loop. It trades the second loop for a smarter walk, usually with O(1) extra space. This page recaps when to reach for it and gives a template you can adapt.
For the full topic treatment, see the two-pointers technique.
The signal: when to recognize it
Reach for two pointers when you see any of these in the problem statement:
- The input is a sorted array or string and you must find a pair, triplet, or range with some property (a target sum, a difference, a closest match).
- You must reverse, rotate, or partition in place with
O(1)extra space (e.g. move zeros, remove duplicates, Dutch-national-flag). - You compare a sequence with itself from both ends — palindrome checks.
- A brute-force pair scan is
O(n²)and the data is sorted (or you’re allowed to sort it).
For beginners: “Two pointers” just means two
intindices. The skill is the rule for moving them so that each move eliminates possibilities you’ll never need to revisit.
The two flavors
Converging pointers start at opposite ends (lo = 0, hi = n-1) and move inward. Fast/lead and slow/write pointers start together and move in the same direction, with one advancing the scan and the other marking where the next valid element goes.
Template: converging pointers
This is the canonical “pair sum on a sorted array,” the shape most converging two-pointer problems take. Adjust the comparison to fit your property.
#include <vector>
// Converging two pointers: does a sorted array contain a pair summing to target?
bool hasPairWithSum(const std::vector<int>& a, int target) {
int lo = 0, hi = (int)a.size() - 1;
while (lo < hi) {
int sum = a[lo] + a[hi];
if (sum == target) return true;
if (sum < target) lo++; // too small: raise the low end
else hi--; // too big: lower the high end
}
return false;
}
// Converging two pointers: does a sorted array contain a pair summing to target?
boolean hasPairWithSum(int[] a, int target) {
int lo = 0, hi = a.length - 1;
while (lo < hi) {
int sum = a[lo] + a[hi];
if (sum == target) return true;
if (sum < target) lo++; // too small: raise the low end
else hi--; // too big: lower the high end
}
return false;
}
// Converging two pointers: does a sorted array contain a pair summing to target?
function hasPairWithSum(a, target) {
let lo = 0, hi = a.length - 1;
while (lo < hi) {
const sum = a[lo] + a[hi];
if (sum === target) return true;
if (sum < target) lo++; // too small: raise the low end
else hi--; // too big: lower the high end
}
return false;
}
# Converging two pointers: does a sorted array contain a pair summing to target?
def has_pair_with_sum(a, target):
lo, hi = 0, len(a) - 1
while lo < hi:
s = a[lo] + a[hi]
if s == target:
return True
if s < target:
lo += 1 # too small: raise the low end
else:
hi -= 1 # too big: lower the high end
return False
Template: same-direction (read/write) pointers
A write pointer marks the next slot for a “kept” element while a read pointer scans everything. This is the in-place pattern behind move zeros, remove duplicates, and partition. Here it moves all zeros to the end while preserving order.
#include <vector>
// Same-direction pointers: move all zeros to the end, in place, stable.
void moveZeros(std::vector<int>& a) {
int write = 0;
for (int read = 0; read < (int)a.size(); read++)
if (a[read] != 0) a[write++] = a[read]; // keep non-zeros packed left
while (write < (int)a.size()) a[write++] = 0; // fill the rest with zeros
}
// Same-direction pointers: move all zeros to the end, in place, stable.
void moveZeros(int[] a) {
int write = 0;
for (int read = 0; read < a.length; read++)
if (a[read] != 0) a[write++] = a[read]; // keep non-zeros packed left
while (write < a.length) a[write++] = 0; // fill the rest with zeros
}
// Same-direction pointers: move all zeros to the end, in place, stable.
function moveZeros(a) {
let write = 0;
for (let read = 0; read < a.length; read++)
if (a[read] !== 0) a[write++] = a[read]; // keep non-zeros packed left
while (write < a.length) a[write++] = 0; // fill the rest with zeros
}
# Same-direction pointers: move all zeros to the end, in place, stable.
def move_zeros(a):
write = 0
for read in range(len(a)):
if a[read] != 0:
a[write] = a[read] # keep non-zeros packed left
write += 1
for i in range(write, len(a)):
a[i] = 0 # fill the rest with zeros
Complexity
Both flavors are O(n) time — each pointer makes at most n moves and never backtracks — and O(1) extra space. The converging flavor usually requires the input to be sorted first; if you have to sort it, the overall cost becomes O(n log n).
Common pitfalls
- Infinite loops: always make sure every branch moves a pointer.
- Off-by-one on the bound: use
while (lo < hi)for pairs (you don’t want a number paired with itself), butwhile (lo <= hi)if a single middle element is valid. - Forgetting the array must be sorted for the converging-sum flavor — the move rule is only correct on sorted data.
Going deeper: The 3-sum problem fixes one index, then runs the converging template on the rest — a two-pointer loop nested inside one outer loop gives
O(n²)instead of the naiveO(n³).
Practice
Drill this in the searching exercises and the array exercises. Then compare it with the closely related sliding window pattern, which also walks two indices but maintains a window between them.