Two Pointers Algorithm Mastery: The Complete Intensive Guide
Master the Two Pointers technique with this intensive guide. Covers all four variants — Opposite Direction, Slow-Fast, Floyd's Cycle Detection, and Sliding Window — with 15 classic problems, complexity analysis, edge cases, and top interview questions.
Introduction
If you've ever solved a problem by brute-forcing two nested loops and wondered "there must be a better way" — there is. The Two Pointers technique is one of the most powerful and elegant patterns in competitive programming and coding interviews.
The idea is deceptively simple: use two index variables to traverse a data structure simultaneously, eliminating the need for a second loop and reducing O(n²) solutions to O(n). Yet this single concept unlocks solutions to dozens of classic problems: palindrome checking, pair sum finding, container with most water, sliding windows, merging sorted arrays, and much more.
This guide is your complete, intensive reference to the Two Pointers pattern. We'll cover every variant, build intuition with diagrams and analogies, and walk through real interview problems step by step.
What Are Two Pointers?
A pointer in this context is simply an index variable. Two Pointers means you maintain two index variables and move them based on logic — toward each other, in the same direction, or at different speeds.
let left = 0;
let right = array.length - 1;
while (left < right) {
// Make a decision based on arr[left] and arr[right]
// Move one or both pointers
}
Why Does It Work?
On a sorted (or otherwise structured) array, when the element at left and right doesn't satisfy your condition, you have enough information to know which direction to move, eliminating the need for a second loop.
💜 important[!IMPORTANT] Two Pointers typically requires the input to have some structure (sorted order, specific property) that allows you to make a smart decision each step. Without structure, you'd still need brute force.
Time and Space Complexity
| Approach | Time Complexity | Space Complexity |
|---|---|---|
| Brute Force (nested loops) | O(n²) | O(1) |
| Two Pointers | O(n) | O(1) |
| Hash Map (some problems) | O(n) | O(n) |
Two Pointers wins on both time and space compared to the hash map approach for many problems.
The Three Core Variants
The Two Pointers technique has three main variants. Understanding which variant to apply is the key skill.
Variant 1: Opposite Direction Pointers
Also called the Converging Pointers pattern. Here, left starts at the beginning and right starts at the end. They move toward each other until they meet.
The Decision Rule
if condition is met: → answer found! (or process and shrink window)
if need bigger value: → move left pointer right (left++)
if need smaller value: → move right pointer left (right--)
Problem 1: Two Sum in Sorted Array
Problem Statement:
Given a 1-indexed sorted (ascending) integer array numbers and an integer target, find two numbers that add up to target and return their indices as [index1, index2] where index1 < index2.
You may not use the same element twice. There is guaranteed to be exactly one solution.
Constraints: 2 ≤ numbers.length ≤ 3 × 10⁴, sorted in non-decreasing order.
Examples:
Input: numbers = [2, 7, 11, 15], target = 9
Output: [1, 2] ← numbers[1] + numbers[2] = 2 + 7 = 9 ✅
Input: numbers = [2, 3, 4], target = 6
Output: [1, 3] ← numbers[1] + numbers[3] = 2 + 4 = 6 ✅
Input: numbers = [-1, 0], target = -1
Output: [1, 2] ← numbers[1] + numbers[2] = -1 + 0 = -1 ✅
How to Think About It:
Imagine you have a number line. You place a left hand at the smallest number and right hand at the largest. Add them:
- Too small? The left value is too tiny — slide left hand right to a bigger number.
- Too big? The right value is too large — slide right hand left to a smaller number.
- Exact? You found it!
Because the array is sorted, every move of left++ increases the sum, and every move of right-- decreases it. This guarantees we'll find the answer without checking every pair.
Brute Force: O(n²) — check every pair.
Two Pointer Approach:
function twoSumSorted(numbers, target) {
let left = 0;
let right = numbers.length - 1;
while (left < right) {
const sum = numbers[left] + numbers[right];
if (sum === target) {
return [left + 1, right + 1]; // 1-indexed
} else if (sum < target) {
left++; // Need a larger sum → move left right
} else {
right--; // Need a smaller sum → move right left
}
}
return []; // No solution found
}
// Example
const numbers = [2, 7, 11, 15];
console.log(twoSumSorted(numbers, 9)); // [1, 2]
Why it works:
sum < target: The smallest unused element (left) is too small, so move left right.sum > target: The largest unused element (right) is too big, so move right left.- Each step eliminates at least one candidate, guaranteeing termination.
Step-by-step trace:
| Step | left | right | arr[left] | arr[right] | sum | Action |
|---|---|---|---|---|---|---|
| 1 | 0 | 3 | 2 | 15 | 17 | 17 > 9 → right-- |
| 2 | 0 | 2 | 2 | 11 | 13 | 13 > 9 → right-- |
| 3 | 0 | 1 | 2 | 7 | 9 | 9 = 9 → ✅ Found! |
Problem 2: Valid Palindrome
Problem Statement:
A phrase is a palindrome if, after converting all uppercase letters to lowercase and removing all non-alphanumeric characters (spaces, punctuation, etc.), it reads the same forward and backward.
Given a string s, return true if it is a palindrome, or false otherwise.
Examples:
Input: s = "A man, a plan, a canal: Panama"
Output: true
Reason: After cleaning → "amanaplanacanalpanama" → same forwards & backwards ✅
Input: s = "race a car"
Output: false
Reason: After cleaning → "raceacar" → 'e' ≠ 'r' at the midpoint ❌
Input: s = " "
Output: true
Reason: After cleaning → "" (empty) → empty string is a palindrome ✅
How to Think About It:
Picture the string like a piece of paper. Fold it in half from both ends toward the middle. If every pair of characters on top of each other match (ignoring case), it's a palindrome. The two pointers simulate this folding — left pointer from the front, right pointer from the back.
The tricky part: punctuation and spaces must be skipped. We simply advance the pointer past any invalid character before comparing.
function isPalindrome(s) {
let left = 0;
let right = s.length - 1;
while (left < right) {
// Skip non-alphanumeric from left
while (left < right && !isAlphanumeric(s[left])) {
left++;
}
// Skip non-alphanumeric from right
while (left < right && !isAlphanumeric(s[right])) {
right--;
}
if (s[left].toLowerCase() !== s[right].toLowerCase()) {
return false;
}
left++;
right--;
}
return true;
}
function isAlphanumeric(char) {
return /[a-zA-Z0-9]/.test(char);
}
// Examples
console.log(isPalindrome("A man, a plan, a canal: Panama")); // true
console.log(isPalindrome("race a car")); // false
console.log(isPalindrome(" ")); // true
Key insight: Skip invalid characters within the main loop to avoid index-out-of-bound issues.
Problem 3: Container With Most Water
Problem Statement:
You are given an integer array height of length n. There are n vertical lines drawn such that the two endpoints of the ith line are at (i, 0) and (i, height[i]).
Find two lines that, together with the x-axis, form a container that holds the most water. Return the maximum amount of water a container can store.
Key Formula:
water = min(height[left], height[right]) × (right - left)
↑ shorter wall limits height ↑ distance = width
Example:
Input: height = [1, 8, 6, 2, 5, 4, 8, 3, 7]
Output: 49
Visualized (heights as bars):
8 8
| 6 | 7
| | | 5 4 | | 3 |
| | | | | | | | |
1 2 3 4 5 6 7 8 9 ← index
Best pair: index 1 (height=8) and index 8 (height=7)
water = min(8, 7) × (8 - 1) = 7 × 7 = 49 ✅
How to Think About It:
Start with the widest possible container (left=0, right=end) — that gives maximum width. The volume is limited by the shorter bar. The only way to possibly find more water is to find a taller short bar, even though the width will shrink. So we always move the pointer pointing at the shorter bar inward, hoping to find something taller. If moving the taller bar, we'd be guaranteed to either maintain or decrease the water (shorter bar still limits AND width decreases).
function maxArea(height) {
let left = 0;
let right = height.length - 1;
let maxWater = 0;
while (left < right) {
const width = right - left;
const h = Math.min(height[left], height[right]);
const water = width * h;
maxWater = Math.max(maxWater, water);
// Move the shorter bar inward — the taller bar might find a better partner
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return maxWater;
}
console.log(maxArea([1, 8, 6, 2, 5, 4, 8, 3, 7])); // 49
Why move the shorter bar?
The width decreases when we move either pointer. To potentially increase water volume, we need a taller bar — moving the taller bar inward can only decrease or maintain the height, so it's never beneficial. Always move the shorter bar.
Problem 4: 3Sum (Three Sum to Zero)
Problem Statement:
Given an integer array nums, return all unique triplets [nums[i], nums[j], nums[k]] such that:
i,j,kare different indicesnums[i] + nums[j] + nums[k] == 0- The solution set must not contain duplicate triplets
Examples:
Input: nums = [-1, 0, 1, 2, -1, -4]
Output: [[-1, -1, 2], [-1, 0, 1]]
Explanation:
nums[0] + nums[1] + nums[2] = -1 + 0 + 1 = 0 → [-1, 0, 1] ✅
nums[0] + nums[3] + nums[4] = -1 + 2 + (-1) = 0 → not new (same values)
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 → [-1, 0, 1] — duplicate!
Input: nums = [0, 0, 0]
Output: [[0, 0, 0]]
Input: nums = [0, 1, 1]
Output: []
How to Think About It:
If you needed to find TWO numbers that sum to a target, Two Pointers on a sorted array does it in O(n). For THREE numbers:
- Sort the array.
- Fix one number at a time with a loop (
nums[i]). - For the remaining subarray to the right, run a standard Two Sum with target =
0 - nums[i].
This gives O(n²) instead of the brute force O(n³). Handling duplicates is the tricky bit — since we sorted, identical values are adjacent, so we just skip them.
Strategy:
Sort: [-4, -1, -1, 0, 1, 2]
↑i ↑left ↑right
Fix i=-4: find pair summing to 4 in remaining array
Fix i=-1: find pair summing to 1 in remaining array → finds [-1,0,1] and [-1,-1,2]
Fix i=-1: skip! (same as previous i value)
Fix i=0: find pair summing to 0 in remaining → nothing new
function threeSum(nums) {
const result = [];
nums.sort((a, b) => a - b); // IMPORTANT: Sort first!
for (let i = 0; i < nums.length - 2; i++) {
// Skip duplicate values for the fixed element
if (i > 0 && nums[i] === nums[i - 1]) continue;
let left = i + 1;
let right = nums.length - 1;
while (left < right) {
const sum = nums[i] + nums[left] + nums[right];
if (sum === 0) {
result.push([nums[i], nums[left], nums[right]]);
// Skip duplicates from both sides
while (left < right && nums[left] === nums[left + 1]) left++;
while (left < right && nums[right] === nums[right - 1]) right--;
left++;
right--;
} else if (sum < 0) {
left++;
} else {
right--;
}
}
}
return result;
}
console.log(threeSum([-1, 0, 1, 2, -1, -4]));
// [[-1, -1, 2], [-1, 0, 1]]
Pattern: Fix one element (i), then use Two Pointers for the remaining two. Sort first to enable Two Pointers AND easy duplicate skipping.
Problem 5: Trapping Rain Water
Problem Statement:
Given n non-negative integers representing an elevation map where the width of each bar is 1, compute how much water it can trap after raining.
Examples:
Input: height = [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]
Output: 6
Visualized:
■
■ ■ ■ ■
■ ■ ■ ■ ■ ■ ■ ■ ■ ■
0 1 0 2 1 0 1 3 2 1 2 1
↑ ↑ ↑ ↑
water=1 water=2 water=1 (total=6)
Input: height = [4, 2, 0, 3, 2, 5]
Output: 9
Visualized:
■ ■
■ ■
■ ■ ■
■ ■ ■ ■
■ ■ ■ ■ ■
4 2 0 3 2 5
↑↑↑↑↑↑↑↑← water trapped (9 units total)
How to Think About It:
Water at any single position i is bounded by:
water[i] = min(max_height_to_left, max_height_to_right) - height[i]
If this value is negative, no water is stored there (it would overflow).
The naive O(n²) approach computes these max values freshly for every position. The Two Pointers trick: maintain leftMax and rightMax running variables, and process from the side with the smaller maximum. Why? Because min(leftMax, rightMax) = the smaller side, which we already know exactly — we don't need to look further.
function trap(height) {
let left = 0;
let right = height.length - 1;
let leftMax = 0;
let rightMax = 0;
let totalWater = 0;
while (left < right) {
if (height[left] < height[right]) {
// Left side is the bottleneck
if (height[left] >= leftMax) {
leftMax = height[left]; // Update left max wall
} else {
totalWater += leftMax - height[left]; // Trap water
}
left++;
} else {
// Right side is the bottleneck
if (height[right] >= rightMax) {
rightMax = height[right]; // Update right max wall
} else {
totalWater += rightMax - height[right]; // Trap water
}
right--;
}
}
return totalWater;
}
console.log(trap([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1])); // 6
console.log(trap([4, 2, 0, 3, 2, 5])); // 9
Intuition: Water at any position is bounded by min(tallest wall on left, tallest wall on right) - height[i]. Start from both ends and process whichever side has the smaller maximum — that determines the water level.
Variant 2: Same Direction Pointers (Slow-Fast)
Here, both pointers move from left to right, but at different speeds or different roles. One pointer (slow) tracks where to write valid results; the other (fast) scans through the array.
slow →
fast →→
Problem 6: Remove Duplicates from Sorted Array
Problem Statement:
Given a sorted integer array nums, remove duplicates in-place so each unique element appears only once. Return k — the number of unique elements. The first k elements of nums should contain the result; the rest do not matter.
You must use O(1) extra space (no creating a new array).
Examples:
Input: nums = [1, 1, 2]
Output: k = 2, nums = [1, 2, _]
Explain: 2 unique elements. First 2 slots hold them.
Input: nums = [0, 0, 1, 1, 1, 2, 2, 3, 3, 4]
Output: k = 5, nums = [0, 1, 2, 3, 4, _, _, _, _, _]
Explain: 5 unique elements packed to the front.
How to Think About It:
Think of slow as a "writer" and fast as a "reader."
fastreads every element in the array.- When
fastfinds a value different from whatslowlast wrote,slowmoves forward one spot and writes it. - Duplicate values?
fastjust keeps moving,slowstays put.
Since the array is sorted, all duplicates are consecutive — so comparing nums[fast] to nums[slow] always catches them.
function removeDuplicates(nums) {
if (nums.length === 0) return 0;
let slow = 0; // Points to last unique element
for (let fast = 1; fast < nums.length; fast++) {
if (nums[fast] !== nums[slow]) {
slow++;
nums[slow] = nums[fast]; // Write unique element
}
}
return slow + 1; // Length of unique portion
}
const nums = [1, 1, 2, 3, 3, 4];
const k = removeDuplicates(nums);
console.log(k); // 4
console.log(nums.slice(0, k)); // [1, 2, 3, 4]
Step-by-step trace:
Initial: [1, 1, 2, 3, 3, 4]
↑slow ↑fast
fast=1: nums[1]=1 === nums[0]=1 → skip
fast=2: nums[2]=2 !== nums[0]=1 → slow=1, write 2
fast=3: nums[3]=3 !== nums[1]=2 → slow=2, write 3
fast=4: nums[4]=3 === nums[2]=3 → skip
fast=5: nums[5]=4 !== nums[2]=3 → slow=3, write 4
Result: [1, 2, 3, 4, 3, 4] → k=4 (first 4 elements)
Problem 7: Remove Element
Problem Statement:
Given an integer array nums and an integer val, remove all occurrences of val from nums in-place. Return k — the count of elements that are not equal to val. The first k elements of nums should contain those values; the rest do not matter.
You must do it with O(1) extra space.
Examples:
Input: nums = [3, 2, 2, 3], val = 3
Output: k = 2, nums = [2, 2, _, _]
Input: nums = [0, 1, 2, 2, 3, 0, 4, 2], val = 2
Output: k = 5, nums = [0, 1, 3, 0, 4, _, _, _]
How to Think About It:
Same writer/reader idea as Problem 6, but the filter condition changes. The writer (slow) only accepts values that are not equal to val. Every time the reader (fast) finds a "good" value, it hands it to the writer.
Unlike Problem 6, the array doesn't need to be sorted — this works on any array.
function removeElement(nums, val) {
let slow = 0;
for (let fast = 0; fast < nums.length; fast++) {
if (nums[fast] !== val) {
nums[slow] = nums[fast];
slow++;
}
}
return slow;
}
const nums = [3, 2, 2, 3];
console.log(removeElement(nums, 3)); // 2 (nums becomes [2, 2, ...])
Problem 8: Move Zeroes
Problem Statement:
Given an integer array nums, move all 0s to the end of it while maintaining the relative order of the non-zero elements. Do it in-place with O(1) extra space.
Examples:
Input: nums = [0, 1, 0, 3, 12]
Output: [1, 3, 12, 0, 0]
Explain: Non-zeros (1, 3, 12) stay in order at the front; zeros fill the back.
Input: nums = [0]
Output: [0]
Input: nums = [1]
Output: [1]
How to Think About It:
This is identical to "Remove Element" with val = 0 — just write all non-zeros to the front using the slow-fast trick, then fill remaining slots with zeros. The two-pass approach is the clearest; the swap approach avoids the second fill-with-zeros loop by swapping in-place.
function moveZeroes(nums) {
let slow = 0; // Insert position for non-zero elements
for (let fast = 0; fast < nums.length; fast++) {
if (nums[fast] !== 0) {
nums[slow] = nums[fast];
slow++;
}
}
// Fill remaining positions with zeros
while (slow < nums.length) {
nums[slow] = 0;
slow++;
}
}
const nums = [0, 1, 0, 3, 12];
moveZeroes(nums);
console.log(nums); // [1, 3, 12, 0, 0]
Alternative (swap approach):
function moveZeroesSwap(nums) {
let slow = 0;
for (let fast = 0; fast < nums.length; fast++) {
if (nums[fast] !== 0) {
[nums[slow], nums[fast]] = [nums[fast], nums[slow]]; // Swap
slow++;
}
}
}
The swap approach preserves the relative order and avoids the second loop.
Variant 3: Fast and Slow Pointers (Floyd's Algorithm)
This variant uses pointers moving at different speeds — slow moves one step at a time, fast moves two steps. This is ideal for linked list problems and cycle detection.
Slow: → → → (1 step)
Fast: →→ →→ (2 steps)
Problem 9: Detect Cycle in Linked List
Problem Statement:
Given the head of a linked list, determine if the linked list has a cycle in it. A cycle exists if some node's next pointer points back to a previous node, creating an infinite loop.
Return true if there is a cycle, false otherwise. Use O(1) memory (no visited set/hash map).
Examples:
Input: 3 → 2 → 0 → -4
↑_______↑ (tail connects back to node with val=2)
Output: true
Input: 1 → 2
↑___↑ (tail connects back to head)
Output: true
Input: 1
Output: false (single node, no cycle)
How to Think About It:
Imagine two runners on a track. If the track is a straight road (no cycle), the faster runner reaches the end first. If the track is circular (a cycle exists), the faster runner will eventually lap the slower one — they'll both be at the same point at the same time.
Translated to linked lists:
slowmoves 1 node at a time (the tortoise)fastmoves 2 nodes at a time (the hare)- If they ever point to the same node → cycle exists
- If
fastreachesnull→ no cycle (it hit the end of the road)
class ListNode {
constructor(val, next = null) {
this.val = val;
this.next = next;
}
}
function hasCycle(head) {
let slow = head;
let fast = head;
while (fast !== null && fast.next !== null) {
slow = slow.next; // Move 1 step
fast = fast.next.next; // Move 2 steps
if (slow === fast) {
return true; // Cycle detected!
}
}
return false; // No cycle (fast reached end)
}
Imagine a tortoise (slow) and a hare (fast) on a circular track. If there's a cycle, the faster hare will eventually lap the tortoise (they'll be at the same position). If there's no cycle, the hare reaches the end of the road.
💡 tip[!TIP] If
hasCyclereturnstrue, you can find the start of the cycle by resetting one pointer toheadand moving both one step at a time until they meet again.
Problem 10: Find Middle of Linked List
Problem Statement:
Given the head of a singly linked list, return the middle node. If there are two middle nodes (even length), return the second middle node.
Examples:
Input: 1 → 2 → 3 → 4 → 5
Output: Node with value 3
Explain: Middle of [1,2,3,4,5] is 3.
Input: 1 → 2 → 3 → 4 → 5 → 6
Output: Node with value 4
Explain: Middle of [1,2,3,4,5,6] is nodes 3 and 4. Return the second (4).
How to Think About It:
If we could see the full length, we'd just count to n/2. But with a linked list we'd need two passes. With fast & slow pointers, we do it in one:
fastmoves twice as fast asslow- When
fastreaches the end of the list,slowhas traveled exactly half as far →slowis at the middle
For even-length lists, fast falls off the end (null) when slow is at the second middle, which is exactly what we want.
function findMiddle(head) {
let slow = head;
let fast = head;
while (fast !== null && fast.next !== null) {
slow = slow.next;
fast = fast.next.next;
}
return slow; // Slow is at the middle when fast reaches end
}
Why does this work?
By the time fast travels the full length n, slow has traveled n/2. So slow is at the midpoint.
| List Length | Fast stops at | Slow is at |
|---|---|---|
| 5 (odd) | node 5 (end) | node 3 (middle) |
| 6 (even) | null (after node 6) | node 4 (second middle) |
Problem 11: Linked List Cycle II (Find Cycle Start)
Problem Statement:
Given the head of a linked list, return the node where the cycle begins. If there is no cycle, return null.
You must solve it with O(1) memory (no storing visited nodes).
Example:
Input: 3 → 1 → 0 → -4
↑__________↑ (tail connects back to node index 1, val=1)
Output: Node with val = 1 (the cycle start)
Input: 1 → 2
↑___↑
Output: Node with val = 1
Input: 1 (no cycle)
Output: null
How to Think About It:
This is Phase 2 of Problem 9. Once we know there's a cycle (slow and fast met inside the cycle), we need to backtrack to find where the cycle starts.
The math works out beautifully: the distance from head to the cycle start equals the distance from the meeting point back to the cycle start (going forward in the cycle). So:
- Reset one pointer to
head - Move both pointers one step at a time
- Where they meet = the cycle start node
function detectCycleStart(head) {
let slow = head;
let fast = head;
// Phase 1: Detect cycle
while (fast !== null && fast.next !== null) {
slow = slow.next;
fast = fast.next.next;
if (slow === fast) {
// Phase 2: Find cycle start
let finder = head;
while (finder !== slow) {
finder = finder.next;
slow = slow.next;
}
return finder; // This is the start of the cycle
}
}
return null; // No cycle
}
Mathematical proof sketch:
- Let
F= distance from head to cycle start - Let
C= cycle length - When slow and fast meet, slow has traveled
F + asteps (a = steps into cycle) - Fast has traveled
2(F + a)steps, which also equalsF + a + C(one full cycle ahead) - Therefore:
F = C - a = distance from meeting point back to cycle start - Reset one pointer to head → both reach cycle start in
Fsteps ✅
Variant 4: Sliding Window (Extended Two Pointers)
The Sliding Window is a natural extension of Two Pointers where you maintain a subarray/substring "window" that grows and shrinks based on conditions.
[ window ]
left right
→→→→→→
ℹ️ note[!NOTE] Sliding Window is so common that many engineers treat it as a separate pattern. At its core, however, it is Same-Direction Two Pointers where both
leftandrightmove forward and the window between them is the focus.
Problem 12: Longest Substring Without Repeating Characters
Problem Statement:
Given a string s, find the length of the longest substring that contains no repeating characters.
A substring is a contiguous sequence of characters within the string (not to be confused with a subsequence).
Examples:
Input: s = "abcabcbb"
Output: 3
Explain: "abc" is the longest with all unique chars (length 3).
After that, 'a' repeats.
Input: s = "bbbbb"
Output: 1
Explain: "b" — every single char repeats immediately.
Input: s = "pwwkew"
Output: 3
Explain: "wke" — not "pw" (too short) or "wkew" ('w' repeats).
Input: s = ""
Output: 0
How to Think About It:
Maintain a window [left, right] that is always a valid substring (no repeating chars). Expand right character by character:
- If the new character is not in the window → valid, update max length
- If it is already in the window → shrink
leftpast the previous occurrence of that character
We use a Map to remember each character's most recent index, so shrinking is O(1) instead of scanning.
function lengthOfLongestSubstring(s) {
const seen = new Map(); // char → last index
let left = 0;
let maxLen = 0;
for (let right = 0; right < s.length; right++) {
const char = s[right];
// If char was seen and it's inside the current window
if (seen.has(char) && seen.get(char) >= left) {
left = seen.get(char) + 1; // Shrink window
}
seen.set(char, right);
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
console.log(lengthOfLongestSubstring("abcabcbb")); // 3 ("abc")
console.log(lengthOfLongestSubstring("bbbbb")); // 1 ("b")
console.log(lengthOfLongestSubstring("pwwkew")); // 3 ("wke")
Problem 13: Minimum Window Substring
Problem Statement:
Given two strings s and t, return the minimum window substring of s such that every character in t (including duplicates) is included in the window. If no such window exists, return "".
Examples:
Input: s = "ADOBECODEBANC", t = "ABC"
Output: "BANC"
Explain: "BANC" contains A, B, and C. It's the shortest such window.
"ADOBEC" also works but is longer.
Input: s = "a", t = "a"
Output: "a"
Input: s = "a", t = "aa"
Output: ""
Explain: 't' requires two 'a's but 's' only has one.
How to Think About It:
Expand-then-contract. Use two pointers left and right to define a window:
- Expand
rightuntil the window contains all characters fromtat the required frequencies - Record the current window size if it's the minimum so far
- Shrink
leftone step to try to find a smaller valid window - Repeat until
rightreaches the end
Track how many unique character requirements are fully satisfied using a formed counter — this avoids scanning the entire need map every iteration.
function minWindow(s, t) {
const need = new Map();
for (const char of t) {
need.set(char, (need.get(char) || 0) + 1);
}
let left = 0;
let formed = 0;
const required = need.size;
const window = new Map();
let minLen = Infinity;
let minLeft = 0;
for (let right = 0; right < s.length; right++) {
const char = s[right];
window.set(char, (window.get(char) || 0) + 1);
// Check if this character's frequency is satisfied in the window
if (need.has(char) && window.get(char) === need.get(char)) {
formed++;
}
// Try to shrink window while all required chars are present
while (formed === required) {
if (right - left + 1 < minLen) {
minLen = right - left + 1;
minLeft = left;
}
const leftChar = s[left];
window.set(leftChar, window.get(leftChar) - 1);
if (need.has(leftChar) && window.get(leftChar) < need.get(leftChar)) {
formed--;
}
left++;
}
}
return minLen === Infinity ? "" : s.substring(minLeft, minLeft + minLen);
}
console.log(minWindow("ADOBECODEBANC", "ABC")); // "BANC"
Merging With Two Pointers
Two pointers also excel at merging operations — combining two separate data structures.
Problem 14: Merge Two Sorted Arrays
Problem Statement:
You are given two integer arrays nums1 and nums2, sorted in non-decreasing order, and two integers m and n representing the number of elements in each array. nums1 has a size of m + n (extra space at the end for merging).
Merge nums2 into nums1 in-place so the result is sorted. Do not return anything; modify nums1 in-place.
Examples:
Input: nums1 = [1, 2, 3, 0, 0, 0], m = 3
nums2 = [2, 5, 6], n = 3
Output: nums1 = [1, 2, 2, 3, 5, 6]
Input: nums1 = [1], m = 1
nums2 = [], n = 0
Output: nums1 = [1]
Input: nums1 = [0], m = 0
nums2 = [1], n = 1
Output: nums1 = [1]
How to Think About It:
Naïve approach: merge from the front, but this overwrites unprocessed elements in nums1.
Smart approach: merge from the back. The largest values go in last. Compare the largest unprocessed element from each array (via p1 and p2) and place the bigger one at the tail position p. Since we write into the extra space at the end of nums1, we never corrupt unprocessed data.
function merge(nums1, m, nums2, n) {
// Start from the END to avoid overwriting unprocessed elements
let p1 = m - 1; // Last valid element in nums1
let p2 = n - 1; // Last element in nums2
let p = m + n - 1; // Last position in merged array
while (p1 >= 0 && p2 >= 0) {
if (nums1[p1] > nums2[p2]) {
nums1[p] = nums1[p1];
p1--;
} else {
nums1[p] = nums2[p2];
p2--;
}
p--;
}
// If nums2 has remaining elements, copy them
while (p2 >= 0) {
nums1[p] = nums2[p2];
p2--;
p--;
}
}
const nums1 = [1, 2, 3, 0, 0, 0];
const nums2 = [2, 5, 6];
merge(nums1, 3, nums2, 3);
console.log(nums1); // [1, 2, 2, 3, 5, 6]
Why merge from right to left? If we placed from left, we'd overwrite unprocessed elements in nums1. Starting from the largest values at the end is safe.
Problem 15: Squares of a Sorted Array
Problem Statement:
Given an integer array nums sorted in non-decreasing order, return an array of the squares of each number, also sorted in non-decreasing order.
Examples:
Input: nums = [-4, -1, 0, 3, 10]
Output: [0, 1, 9, 16, 100]
Explain: Squares are [16, 1, 0, 9, 100]. Sorted → [0, 1, 9, 16, 100].
Input: nums = [-7, -3, 2, 3, 11]
Output: [4, 9, 9, 49, 121]
How to Think About It:
Squaring removes the sign, so negatives like -4 become 16 — potentially larger than positive numbers. The naive approach: square everything, then sort → O(n log n).
With Two Pointers: we know the largest squares must come from either the most negative (leftmost) or most positive (rightmost) element. So:
- Compare
nums[left]²vsnums[right]² - Place the larger one at the end of the result array (fill from back to front)
- Move the corresponding pointer inward
This gives O(n) time — one pass through the array.
function sortedSquares(nums) {
const result = new Array(nums.length);
let left = 0;
let right = nums.length - 1;
let insertPos = nums.length - 1; // Fill from the end
while (left <= right) {
const leftSq = nums[left] * nums[left];
const rightSq = nums[right] * nums[right];
if (leftSq > rightSq) {
result[insertPos] = leftSq;
left++;
} else {
result[insertPos] = rightSq;
right--;
}
insertPos--;
}
return result;
}
console.log(sortedSquares([-4, -1, 0, 3, 10])); // [0, 1, 9, 16, 100]
console.log(sortedSquares([-7, -3, 2, 3, 11])); // [4, 9, 9, 49, 121]
Insight: The largest squares come from either the leftmost (most negative) or rightmost (most positive) element. Two pointers compare both ends and fill from largest to smallest.
When to Use Two Pointers
Recognizing the right pattern in an interview is half the battle.
| Problem Signal | Likely Pattern |
|---|---|
| Sorted array, find pair with sum X | Opposite Direction |
| Check palindrome | Opposite Direction |
| Remove duplicates in-place | Same Direction (slow-fast) |
| Longest/shortest subarray/substring | Sliding Window |
| Linked list cycle / middle | Fast & Slow |
| Two sorted arrays to merge | Two separate pointers |
Common Mistakes and Edge Cases
Mistake 1: Forgetting to Sort
Three-Sum, Two-Sum-on-sorted-array, and most opposite-direction patterns require the array to be sorted first.
// ❌ WRONG — will give incorrect results on unsorted input
function twoSumWrong(nums, target) {
let left = 0, right = nums.length - 1;
while (left < right) { ... } // Only works if sorted!
}
// ✅ CORRECT
function twoSumRight(nums, target) {
nums.sort((a, b) => a - b); // Sort first!
let left = 0, right = nums.length - 1;
while (left < right) { ... }
}
Mistake 2: Wrong Loop Termination Condition
// ❌ WRONG — misses the middle element for odd-length arrays in some problems
while (left < right) { ... }
// ✅ Use left <= right when you need to process every element
while (left <= right) { ... }
Mistake 3: Infinite Loop — Forgetting to Move Pointers
// ❌ WRONG — forgot to move left and right inside the while body
while (left < right) {
if (sum === target) return [left, right];
// Missing pointer movements!
}
// ✅ CORRECT — always advance at least one pointer
while (left < right) {
if (sum === target) return [left, right];
else if (sum < target) left++;
else right--;
}
Mistake 4: Off-by-One in Palindrome Check
// ❌ WRONG — uses wrong condition for even-length strings
while (left < right - 1) { ... }
// ✅ CORRECT
while (left < right) { ... }
Mistake 5: Not Handling Duplicates in 3Sum
// ❌ WRONG — produces duplicate triplets
result.push([nums[i], nums[left], nums[right]]);
left++;
right--;
// ✅ CORRECT — skip duplicate values after finding a triplet
while (left < right && nums[left] === nums[left + 1]) left++;
while (left < right && nums[right] === nums[right - 1]) right--;
left++;
right--;
Complexity Analysis Summary
| Problem | Time | Space | Pattern |
|---|---|---|---|
| Two Sum (sorted) | O(n) | O(1) | Opposite |
| Valid Palindrome | O(n) | O(1) | Opposite |
| Container With Most Water | O(n) | O(1) | Opposite |
| 3Sum | O(n²) | O(1)* | Opposite + Outer Loop |
| Trapping Rain Water | O(n) | O(1) | Opposite |
| Remove Duplicates | O(n) | O(1) | Same Direction |
| Move Zeroes | O(n) | O(1) | Same Direction |
| Detect Cycle | O(n) | O(1) | Fast & Slow |
| Find Middle | O(n) | O(1) | Fast & Slow |
| Longest Substring | O(n) | O(k) | Sliding Window |
| Merge Sorted Arrays | O(m+n) | O(1) | Two Separate |
*O(k) for result storage
Interview Preparation: Common Questions
Here are the most frequently asked Two Pointers interview questions at top tech companies, with key hints:
Easy Level
Q: How do you check if a string is a palindrome?
Use opposite direction pointers — compare
s[left]ands[right], move inward. Skip non-alphanumeric characters. Time: O(n), Space: O(1).
Q: Given a sorted array and a target, find two numbers that sum to the target.
Classic opposite-direction: if sum < target, move left right; if sum > target, move right left. Time: O(n), Space: O(1).
Q: How do you move all zeroes to the end without changing non-zero element order?
Slow-fast pointers: slow tracks the write position for non-zeros. After placing all non-zeros, fill remaining positions with zeros. Time: O(n), Space: O(1).
Medium Level
Q: How does the Container With Most Water solution guarantee it finds the global maximum?
At each step, we move the shorter bar because: width decreases by 1, and moving the taller bar can only maintain or reduce height. The only hope for more water is a taller short bar. The greedy choice is provably optimal.
Q: How do you avoid duplicate triplets in 3Sum?
Sort the array first. For the fixed element
i, skip ifnums[i] === nums[i-1]. After finding a valid triplet, skip duplicates for both left and right before advancing.
Q: Explain the Sliding Window expansion and contraction logic.
Expand
rightto include elements until the window satisfies the condition. Then contractleftas long as the condition holds, recording the minimum/maximum window size. This gives O(n) amortized because each pointer moves at most n times.
Hard Level
Q: How does Floyd's cycle detection algorithm work mathematically?
When fast and slow meet inside the cycle: if slow traveled
F + asteps (F = distance to cycle start, a = steps inside cycle), fast traveled2(F + a) = F + a + nC(n full cycles), soF = nC - a. Resetting one pointer to head and advancing both one step at a time will make them meet at the cycle start after exactlyFmore steps.
Q: How do you solve the Trapping Rain Water problem in O(n) time and O(1) space?
Use opposite-direction pointers with running maximums. The water at any position is
min(leftMax, rightMax) - height[i]. Process the side with the smaller maximum — we already know water is bounded by that side, so we can compute it exactly.
Q: Can Two Pointers solve problems on unsorted arrays?
Not the classic opposite-direction variant. However, the sliding window and slow-fast variants work on unsorted data since they don't rely on order to make decisions — they rely on the condition of the window. Examples: longest substring without repeating characters, minimum window substring.
Advanced Patterns
Triplet Problems Template
// Template for ANY "find triplets" problem with Two Pointers
function findTriplets(nums, condition) {
nums.sort((a, b) => a - b);
const result = [];
for (let i = 0; i < nums.length - 2; i++) {
if (i > 0 && nums[i] === nums[i - 1]) continue; // Skip outer duplicates
let left = i + 1;
let right = nums.length - 1;
while (left < right) {
if (condition(nums[i], nums[left], nums[right])) {
result.push([nums[i], nums[left], nums[right]]);
while (left < right && nums[left] === nums[left + 1]) left++;
while (left < right && nums[right] === nums[right - 1]) right--;
left++;
right--;
} else if (/* need bigger */ false) {
left++;
} else {
right--;
}
}
}
return result;
}
Linked List Kth From End
function kthFromEnd(head, k) {
let fast = head;
let slow = head;
// Advance fast by k steps
for (let i = 0; i < k; i++) {
fast = fast.next;
}
// Move both until fast reaches end
while (fast !== null) {
slow = slow.next;
fast = fast.next;
}
return slow; // slow is kth from end
}
Why: When fast reaches the end (null), it has traveled n steps. Slow has traveled n - k steps, placing it at the kth node from the end.
Conclusion
The Two Pointers technique is one of the most versatile and efficient tools in a programmer's arsenal. What starts as a simple concept — just two index variables — expands into a family of patterns that solves an impressive range of problems with optimal O(n) time and O(1) space.
Key takeaways:
- Opposite Direction: Sorted arrays, pair/triplet sums, palindromes, max area problems
- Same Direction (Slow-Fast): In-place modification, removing elements, compaction
- Fast & Slow: Linked lists, cycle detection, finding midpoints
- Sliding Window: Subarray/substring extremes with a condition
The real skill is pattern recognition — seeing a problem and immediately knowing which Two Pointers variant applies. Practice the 15 problems in this guide until the thought process is instinctive, and you'll have a powerful advantage in any technical interview.
💡 tip[!TIP] When stuck on a problem involving arrays or linked lists, always ask yourself: "Can I maintain two pointers and make a smart decision about which one to move?" If yes, you've likely found an O(n) solution.
🧠 Test Your Knowledge
Now that you've learned the concepts, let's see if you can apply them! Take this quick quiz to test your understanding.