Two Pointers Problems

Six classic interview problems — Valid Palindrome, Two Sum II on a sorted list, 3Sum, Container With Most Water, Trapping Rain Water and Remove Duplicates in place — solved from zero with one idea: keep two positions in the list and move them by a rule. For each problem you will see the slow obvious idea, the reason each pointer move is safe, the pseudocode, the Dart code, and an animation you can feed your own input. By the end you will know when two pointers apply, why they never miss the answer, and when sorting first is worth its cost.

What "two pointers" means and why it works

Two friends search a long bookshelf sorted by price for two books that together cost exactly ₹500. One starts at the cheap end, the other at the expensive end. If the pair costs too much, the friend at the expensive end steps toward the middle — that expensive book is too pricey even with the cheapest book on the shelf, so it is useless. If the pair costs too little, the friend at the cheap end steps in. Each step throws away a whole book for good, so they meet in the middle after at most one walk along the shelf.

Some words first:

Why a pointer move never loses the answer

The brute force for a pair problem tries every pair (i, j) with i < j: n(n − 1)/2 of them. Picture them as a triangle-shaped grid: row i, column j, and in each box the sum nums[i] + nums[j]. When the list is sorted, the sums in that grid grow as you go right along a row and grow as you go down a column.

The two pointers always test the top-right box that is still alive, (left, right). Then:

Each test removes a full row or a full column, and nothing removed could have been the answer. That is the whole proof idea, and every opposite-ends problem on this page has the same shape: "the item I move away from can never be part of a better answer than the ones I have already checked." Since there are only n − 1 rows and columns to remove, the loop does at most n − 1 tests: O(n) instead of O(n²). Watch the grid shrink:

When is sorting first worth O(n log n)?

The proof above needs order: "nums[right] is the biggest partner left" is only true in a sorted list. If the list is not sorted, you can sort it first. Sorting n items costs O(n log n) — for n = 105 that is about 1.7·106 steps, tiny next to the 5·109 pair checks of the brute force. How sorting works is in C02 · Insertion & Merge Sort and C07 · Quicksort.

SituationSort, then two pointers?Why
The list already arrives sortedYes, alwaysO(n) time and O(1) space; a hash map would waste memory.
The answer is values (3Sum, 4Sum, closest sum)YesThe best known method is O(n²) anyway, so the O(n log n) sort is free; sorting also puts repeats side by side, which makes "no duplicate answers" easy.
The answer is original positions (classic Two Sum)Usually noSorting moves the items, so you must also carry the old index. The one-pass map of Step 13.1 · Two Sum is O(n).
Memory must stay O(1)YesAn in-place sort plus two pointers needs no map at all.
The pattern of this whole page: find a rule that tells you which end can be thrown away, and prove that the thrown-away item cannot be in a better answer. If you can say that sentence for a problem, two pointers will solve it in one pass.

Valid Palindrome

The task. A palindrome reads the same forwards and backwards, like "level". You get a piece of text s that may contain spaces, punctuation, capital letters and digits. Ignore everything that is not a letter (a–z, A–Z) or a digit (0–9), treat capital and small letters as equal, and answer true if what is left is a palindrome, otherwise false. Text with no letters or digits at all counts as a palindrome (nothing reads the same both ways).

Example 1
Input: s = "Step on no pets!"
Output: true
Why: keeping only letters, in lower case, gives "steponnopets", which is the same reversed.
Example 2
Input: s = "Level 2 up"
Output: false
Why: the cleaned text is "level2up"; its first letter l does not match its last letter p.
Example 3
Input: s = ", . !"
Output: true
Why: nothing is left after cleaning, and empty text is a palindrome.
Constraints
Speed you need: any method that looks at each character a few times is fine → aim for O(n) time and, as interviewers ask, O(1) extra space (no cleaned copy).
Two proofreaders check a banner. One starts at the first character, the other at the last, and they walk toward each other. Whenever one of them is standing on a space or a comma, they simply step past it. When both stand on real letters, they call them out: same letter (ignoring capitals) — both step in; different letter — the banner is not a palindrome, stop.

Brute force. Build a cleaned, lower-case copy, build its reverse, and compare the two. That is O(n) time but O(n) extra memory for the two copies. Correct, and a fine first answer.

Key insight. A palindrome check only ever compares character k from the front with character k from the back. Two pointers can make exactly those comparisons directly on the original text, skipping the characters that do not count — no copy needed.

Why each move is safe. Skipping a space or symbol is safe because the rules say it does not count. When two real characters match, they are a finished pair: no later comparison involves them, so both pointers may move in. When they differ, the answer is decided — a palindrome needs them equal.

Optimal approach. left at 0, right at the end. While left < right: skip a non-letter/digit on either side; otherwise compare the two in lower case and either stop with false or move both inward. If the pointers meet, answer true.

An edge case: text made only of symbols. Neither pointer ever finds a real character, so the loop just skips until the pointers meet.

Complexity. Time O(n) — every step moves at least one pointer one place inward, and they meet after at most n steps. Space O(1) — two whole numbers, no copy of the text.

Common mistakes. (1) Forgetting that digits count: "a1" is not a palindrome, because 1 is a real character. (2) Comparing before skipping: if s[left] is a comma, comparing it with s[right] gives a wrong false. (3) Writing the skip as an inner while without left < right inside it: on text like "!!!" the pointer runs off the end of the string. (4) Using toLowerCase() on the whole string inside the loop — that builds a new string every time, turning O(n) into O(n²).

Follow-ups interviewers ask.

The idea underneath: strings are lists of character codes, read by index — see D04 · Strings and D24 · String methods (codeUnitAt).

Two Sum II on a sorted list

The task. You get a list of whole numbers nums that is already sorted from smallest to largest (repeats allowed), and a whole number target. Find two different positions i < j with nums[i] + nums[j] = target and return [i, j] (positions counted from 0). If no pair works, return an empty list. If several pairs work, any one is accepted; this version returns the first one the pointers meet. Use only O(1) extra memory.

Example 1
Input: nums = [1, 3, 4, 6, 9], target = 10
Output: [0, 4]
Why: 1 + 9 = 10, found on the very first test.
Example 2
Input: nums = [-4, -1, 2, 7, 11], target = 6
Output: [1, 3]
Why: −1 + 7 = 6. Negative numbers follow the same rule.
Example 3
Input: nums = [2, 2, 5], target = 4
Output: [0, 1]
Why: the same value at two different positions is allowed.
Constraints
Speed you need: all pairs is 4.5·108 checks (several seconds), and a hash map breaks the memory rule → O(n) time, O(1) extra space.
This is the bookshelf from the start of the page: one friend at the cheap end, one at the expensive end. Too expensive → the expensive book goes. Too cheap → the cheap book goes. Exactly right → done.

Brute force. Try every pair (the panel in the first section): O(n²) time, O(1) space. It ignores the fact that the list is sorted.

Compare with Step 13.1. The map version of Step 13.1 · Two Sum works on any list in O(n) time, but stores up to n numbers in a Map: O(n) extra space. When the list is sorted, two pointers give the same O(n) time with O(1) space. When the list is not sorted and the original positions are wanted, the map wins, because sorting would scramble the positions.

Key insight and why each move is safe. Look at the current pair (left, right). If the sum is too small, then nums[left] plus any value still in range is at most nums[left] + nums[right], still too small — so nums[left] can never be in the answer and left moves right. If the sum is too big, nums[right] plus any value still in range is at least the current sum, still too big — so right moves left. Values that were thrown away can never come back into the answer, so nothing is missed.

An edge case with no answer: the pointers keep throwing values away until they meet, and the result is an empty list.

Complexity. Time O(n) — every test moves one pointer one step inward, so at most n − 1 tests. Space O(1) — two indexes and a sum.

Common mistakes. (1) Using while (left <= right): when they are equal you would pair a position with itself, e.g. "find 4" in [2] would wrongly use the 2 twice. (2) Running it on an unsorted list: the "too small → move left" reasoning is only true when the list is sorted, and the pointers can walk straight past the answer (question p02-q4 shows one). (3) Returning 1-based positions when 0-based were asked, or the other way round — read the task. (4) In languages with 32-bit integers, nums[left] + nums[right] can overflow near 2·109; Dart's native int is 64-bit, so it is safe here.

Follow-ups interviewers ask. Count all pairs whose sum is at most a limit (when a pair fits, every partner between the pointers fits too: add right − left at once). Count pairs whose sum lies in a range (two such counts subtracted, question p02-q31). Find the pair whose sum is closest to the target (keep the best while the pointers move).

The ideas underneath: sorting (C02 · Insertion & Merge Sort) and the hash-map alternative (Step 13.1 · Two Sum, C11 · Hash Tables).

3Sum

The task. Given a list of whole numbers, return every triplet of values [a, b, c] taken from three different positions with a + b + c = 0. Each triplet is written smallest value first, and the same set of three values must appear only once in the answer, even if the list holds it several times. This version lists the triplets in increasing order of a, then b.

Example 1
Input: nums = [-2, 0, 1, 1, 2]
Output: [[-2, 0, 2], [-2, 1, 1]]
Why: −2 + 0 + 2 = 0 and −2 + 1 + 1 = 0; the two 1s are different positions, so [−2, 1, 1] is allowed.
Example 2
Input: nums = [0, 0, 0, 0]
Output: [[0, 0, 0]]
Why: four zeros can form [0, 0, 0] in four ways, but it is listed once.
Example 3
Input: nums = [1, 2, -5]
Output: []
Why: the only triplet sums to −2.
Constraints
Speed you need: all triples is n³/6 ≈ 4.5·109 (far too slow) → O(n²) = 9·106 steps, plus O(n) for a sorted copy.
Three friends want to split a bill so that their wallets balance to zero (some are owed money, some owe). Line everyone up from "owes the most" to "is owed the most". Pick the first person as the fixed member, then send one scout from just after them and one from the far end, exactly like Two Sum II, looking for two people who balance the first. Then fix the next person and repeat. If the next person has the same amount as the one you just fixed, skip them — every group they could join was already found.

Brute force. Three nested loops over all triples, sort each triplet and put it in a Set to drop repeats: O(n³) time.

Key insight. Fix the first number nums[i]. Then you need two numbers to its right whose sum is −nums[i] — that is Two Sum II on the rest of a sorted list, O(n). Doing it for every i gives O(n²). Sorting first costs only O(n log n), which is smaller than O(n²), so it is free here.

Why each move is safe. Inside one fixed i the moves are the Two Sum II moves, with the same proof. The duplicate skipping is safe too: in a sorted list equal values sit side by side. If nums[i] equals nums[i − 1], every triplet starting with that value was already found in the previous round, so skipping cannot lose a new one. After a triplet is found, moving left past copies of the same middle value stops the same triplet from being added again.

The edge case where every number is the same: the triplet is found once, and the repeated first numbers are skipped.

Complexity. Time O(n²) — sorting is O(n log n), then for each of the n choices of i the two pointers make at most n steps. Space O(n) for the sorted copy (O(1) extra if you may sort the caller's list in place), not counting the answer.

Common mistakes. (1) Skipping with nums[i] == nums[i + 1] instead of nums[i] == nums[i − 1]: that throws away the first copy before it is used, losing triplets such as [−1, −1, 2] (debug question p02-q24). (2) Forgetting to skip repeated middle values after a match, so [−2, 1, 1] can be added twice when there are three 1s. (3) De-duplicating with a Set<List<int>>: two Dart lists with the same numbers are different objects, so the Set keeps both. (4) Sorting the caller's list without being asked — this version sorts a copy.

Follow-ups interviewers ask. The sum closest to a target (keep the best sum while the pointers move — p02-q14). Count triplets with sum smaller than a target (p02-q23). 4Sum: fix two numbers, then two pointers, O(n³) (p02-q20). Can 3Sum be done in O(n)? Nobody knows a way much faster than O(n²) (p02-q30).

The ideas underneath: sorting (C07 · Quicksort) and the pair search of Two Sum II above. The unsorted-list pair search with a map is in Step 13.1 · Two Sum.

Container With Most Water

The task. height[i] is the height of a thin vertical wall standing at position i. Pick two walls; together with the floor they make a container. It holds min(height[i], height[j]) × (j − i) units of water: the water level is set by the shorter wall, and the width is the distance between them. The walls in between do not get in the way. Return the most water any pair of walls can hold.

Example 1
Input: height = [2, 7, 3, 8, 4, 6]
Output: 24
Why: walls 1 and 5: min(7, 6) × (5 − 1) = 6 × 4 = 24.
Example 2
Input: height = [5, 5]
Output: 5
Why: the only pair: min(5, 5) × 1 = 5.
Example 3
Input: height = [1, 2, 1]
Output: 2
Why: the two outer walls: min(1, 1) × 2 = 2, which beats either pair that uses the tall middle wall (1 × 1).
Constraints
Speed you need: all pairs is 5·109 (about 50 s) → O(n) time, O(1) extra space. The biggest area is 104 × 105 = 109, which fits easily.
Think of a water trough you can build from two fence posts. The water spills over the shorter post, so a tall post next to a short one is wasted height. If you want more water, keeping the short post and bringing the other post closer can only make the trough narrower — and it can never be taller than the short post. So the short post is finished: drop it and try the next one inward.

Brute force. Measure every pair of walls: O(n²) time, O(1) space.

Key insight — why moving the shorter wall is safe. Start with the widest container, walls left = 0 and right = n − 1. Suppose height[left] < height[right]. Every other container that uses wall left pairs it with some wall between the two pointers. That container is narrower, and its water level is at most height[left] (the level can never be above the shorter wall). Narrower and no taller means no more water than what we just measured. So wall left has nothing better to offer, and we can drop it for good: left moves right. Moving the taller wall instead would be a mistake — the level would stay capped by the short wall while the width shrinks, and the best pair might be skipped.

Optimal approach. Measure the current pair, keep the best, move the shorter wall inward (on a tie, either one; this version moves right), and repeat until the pointers meet.

An edge case: every wall has the same height. Every move is a tie, and the widest pair turns out to be the best.

Complexity. Time O(n) — one wall is dropped per step, so n − 1 steps. Space O(1).

Common mistakes. (1) Moving the taller wall, or always moving left: on [1, 8, 6, 2, 5, 4, 8, 3, 7]-style inputs this skips the best pair. (2) Using j − i + 1 as the width: the width is the distance between the walls, j − i. (3) Mixing this problem up with Trapping Rain Water: here only two walls hold water and the walls in between are ignored; in rain water every bar holds water above it.

Follow-ups interviewers ask. Prove that the tie case is safe (with equal heights, both walls are "the shorter one", so either may go). Return the two positions, not just the area (remember them when best improves). What if walls have thickness and block water? Then it becomes Trapping Rain Water, the next problem.

The idea underneath: a greedy choice that is proved safe by an "exchange" argument — the same style of proof as in C16 · Greedy Algorithms.

Trapping Rain Water

The task. height[i] is the height of a bar of width 1 at position i, side by side like a city skyline. After heavy rain, water collects in the dips between bars. Return how many units of water are trapped in total. The water above bar i rises to the lower of "the tallest bar on its left (including itself)" and "the tallest bar on its right (including itself)", so bar i holds min(tallest left, tallest right) − height[i].

Example 1
Input: height = [3, 0, 2, 0, 4]
Output: 7
Why: the level is 3 everywhere between the bars of height 3 and 4, so the bars hold 3 + 1 + 3 = 7.
Example 2
Input: height = [1, 2, 3, 4]
Output: 0
Why: a staircase has no dip, so all water runs off the left side.
Example 3
Input: height = [5, 1, 1, 5]
Output: 8
Why: a pool of level 5 over two bars of height 1: 4 + 4 = 8.
Constraints
Speed you need: for each bar, scanning both sides for the tallest is n² = 4·108 (several seconds) → O(n) time; the two-pointer version also gets O(1) extra space.
Imagine you walk in from both ends of the skyline carrying two notebooks: "tallest bar I have passed on the left" and "tallest bar I have passed on the right". Always step from the side whose current bar is lower. Why? The other side already has a bar at least that tall, so for the bar you step onto, the lower wall is certainly on your side — and your notebook already holds it. You can pour that bar's water right away, without ever looking at the far side again.

Brute force. For each bar, scan left for the tallest bar and scan right for the tallest bar, then add min − height[i]: O(n²) time, O(1) space.

Better: prefix-max arrays. The brute force repeats the same scans. Precompute leftMax[i] (tallest bar in height[0..i]) in one left-to-right pass and rightMax[i] (tallest in height[i..n−1]) in one right-to-left pass; then each bar's water is one subtraction. O(n) time, but two extra lists: O(n) space.

Key insight for O(1) space. Each bar only needs the smaller of its two maxima. Walk two pointers inward, keeping leftMax and rightMax for the parts already passed. One fact makes it work: the bar at the pointer that is not moving is always the tallest bar seen so far on either side. So if height[left] < height[right], the right side certainly has a wall at least as tall as leftMax, and the water above left is exactly leftMax − height[left] — the true right maximum can be even taller, but it no longer matters, because the lower wall decides. The mirror image holds for the right side.

Why each move is safe. We only settle a bar when we know which of its two walls is the lower one, and we know that lower wall's exact height. So every bar's water is final when the pointer leaves it, and nothing is ever recounted.

An edge case: a rising staircase. Every bar is a new tallest bar, so no water is ever poured.

Complexity. Two-pointer version: time O(n) (one step per bar), space O(1) (four numbers). Prefix-max version: time O(n) (three passes), space O(n) (two lists). The prefix version is easier to explain and to get right; the two-pointer version is what interviewers ask for when they say "constant extra space".

MethodTimeExtra spaceIdea
Scan both sides for every barO(n²)O(1)Recompute both maxima each time
Prefix-max arraysO(n)O(n)Precompute leftMax[i] and rightMax[i] once
Two pointersO(n)O(1)Settle the lower side; its own max is the limit
Common mistakes. (1) Updating leftMax after adding water: a bar taller than every bar before it would add a negative amount. Update first, then add leftMax − height[left] (which is 0 for a new tallest bar). (2) Moving the pointer on the taller side: then the far side's maximum is not known to be the limit, and the water is wrong. (3) Counting water above the first and last bars: they have no wall on one side, so they always hold 0 — the formula gives 0 automatically, no special case needed. (4) Confusing it with Container With Most Water (two walls, inner walls ignored).

Follow-ups interviewers ask. Solve it with a stack (pour water layer by layer whenever a taller bar closes a dip). Rain water on a 2-D height map (a priority queue that always grows from the lowest border cell). Return how much water each bar holds, not just the total.

The ideas underneath: prefix maxima are precomputed answers to repeated questions, the same spirit as C15 · Dynamic Programming; the prefix-product trick is in Step 13.1 · Product of Array Except Self.

Remove Duplicates in place (slow and fast pointers)

The task. You get a list of whole numbers sorted from smallest to largest. Rearrange it in place (no second list) so that each different value appears once at the front, in the original order, and return how many different values there are, k. Whatever is left in the boxes after position k − 1 does not matter.

Example 1
Input: nums = [1, 1, 2, 3, 3, 3, 7]
Output: 4 (front becomes [1, 2, 3, 7])
Why: four different values: 1, 2, 3 and 7.
Example 2
Input: nums = [5, 5, 5]
Output: 1 (front becomes [5])
Why: all equal, so one value is kept.
Example 3
Input: nums = []
Output: 0
Why: an empty list has no values.
Constraints
Speed you need: calling removeAt for every repeat shifts the rest of the list each time (O(n²) in the worst case) → O(n) time, O(1) extra space.
A librarian tidies a shelf of sorted books where some titles appear several times. She walks along the shelf reading every book (the fast pointer) and keeps a bookmark at the end of the "tidy" part (the slow pointer). When she reads a title different from the last tidy one, she moves the bookmark one place and puts that book there. Repeats are simply walked past. One walk, and the tidy part at the front holds each title once.

Brute force. Each time a value equals the one before it, delete it with removeAt, which shifts everything after it one place left: up to O(n²) time. Or copy the unique values to a new list: O(n) time but O(n) extra space, which the task forbids.

Key insight. In a sorted list, repeats sit side by side, so "is this a new value?" only needs a comparison with the last value kept. The kept values form a growing block at the front, and the place to write the next kept value is just after that block. Two pointers moving the same way do exactly this.

Why each move is safe. fast is always ahead of or level with slow, so writing into nums[slow] only ever overwrites a box that fast has already read — no unread value is destroyed. And because the list is sorted, a value equal to nums[slow] is a repeat of something already kept, so skipping it loses nothing.

An edge case: every value is the same, so nothing is ever written and the answer is 1.

Complexity. Time O(n) — fast visits each box once. Space O(1) — two indexes.

Common mistakes. (1) Comparing nums[fast] with nums[fast − 1] instead of with the last kept value nums[slow]: it happens to work for this exact task, but breaks for the "keep at most two copies" version (p02-q17). (2) Returning slow instead of slow + 1: slow is the index of the last kept value, and the count is one more. (3) Forgetting the empty list: nums[0] does not exist, so return 0 first. (4) Removing items while looping over the same list with for (final x in nums) — Dart throws a "concurrent modification" error.

Follow-ups interviewers ask. Move every 0 to the end while keeping the order of the others (Move Zeroes, p02-q5). Remove every copy of a given value (p02-q9). Keep at most two copies of each value (p02-q17). Detect a loop in a linked list: the same "slow and fast" idea, but fast moves two steps for every one step of slow (p02-q21) — linked lists are built in C10 · Elementary Data Structures.

The ideas underneath: in-place list updates and why removeAt is O(n) — D26 · List methods.

Quiz

Interview questions

Variations and follow-ups of the six problems above — the questions interviewers move to once you have solved the classic version. Every solution is tested in Dart on its examples and on hundreds of random inputs against a slow reference.

Cheat sheet

ProblemPointersTimeSpaceWhy the move is safe
Valid PalindromeOpposite endsO(n)O(1)Symbols do not count; a matched pair is finished; a mismatch decides
Two Sum II (sorted)Opposite endsO(n)O(1)Too small → nums[left] fails with every partner; too big → nums[right] does
3SumFix i + opposite endsO(n²)O(n) sorted copySort first; skip nums[i] = nums[i − 1] and repeated middles
Container With Most WaterOpposite endsO(n)O(1)The shorter wall can only get narrower and no taller: drop it
Trapping Rain WaterOpposite ends + two maximaO(n)O(1)Settle the lower side; its own running max is the water level
Remove Duplicates (sorted)Slow + fast, same directionO(n)O(1)Write only over boxes fast has already read; repeats are side by side

Use two pointers when the input is sorted (or sorting is affordable) and a rule tells you which end is useless; use a hash map instead when the original positions matter and the list is unsorted (see Step 13.1 · Arrays & Hashing).