Two-Pointer Bugs That Look Correct Until Submission

- Sorted input is a hard requirement for converging two-pointer; unsorted arrays produce silent wrong answers, not errors.
- Wrong pointer movement in Container with Most Water discards valid candidates. Always advance the shorter line, not the taller one.
- 3Sum duplicate skipping has three distinct failure points: the
i > 0outer guard, inner skip after finding a match, and boundary guards on inner skip loops. - Dutch National Flag: after swapping
nums[mid]withnums[high], never incrementmid. The swapped-in element is unexamined. - Nested advancement loops inside a convergence loop must include the outer
left < rightguard, or inner loops can advance past the other pointer. - Index vs value: sorting to enable two-pointer destroys original indices. Use a hash map when the problem returns indices (Two Sum I), not values.
You know the feeling. Sort, place one pointer at each end, move them toward each other based on some condition. You write it out in five minutes. The examples pass. You have already mentally composed the commit message.
Then you submit and get Wrong Answer on a test case that looks completely, embarrassingly normal. You stare at the code for ten minutes. You cannot find the bug. Because the bug is not in a line you wrote. It is in an assumption you made.
Two-pointer bugs are uniquely frustrating because they do not look like bugs. They look like correct logic. They come from misunderstanding why the technique works, so the same bug surfaces in different problems under different disguises.
This assumes you already know the pattern. If you want a refresher first, the two-pointer algorithm post has the mechanics.
Sorted Input Isn't Optional (Your Code Just Won't Say Anything)
The converging two-pointer (both pointers moving toward each other) requires sorted input to be correct. This sounds obvious. It is also the one requirement most commonly forgotten, because the code does not complain when you violate it. It just quietly produces wrong answers, like a coworker who watches you walk into a glass door and says nothing.
The technique works because sorted order creates monotonic search space elimination. When nums[left] + nums[right] < target, you know that every pair using nums[left] with any index smaller than right is also too small, since the array is sorted and those values are even smaller than nums[right]. Advancing left discards an entire column of candidates that are provably useless.
Remove sorted order, and that reasoning collapses. Try finding a pair that sums to 4 in [2, 1, 3, 4]:
def has_pair(nums, target): left, right = 0, len(nums) - 1 while left < right: s = nums[left] + nums[right] if s == target: return True elif s < target: left += 1 else: right -= 1 return False has_pair([2, 1, 3, 4], 4) # returns False # But nums[1] + nums[2] = 1 + 3 = 4. The answer exists.
The pair (1, 3) at indices 1 and 2 was never examined. The algorithm's pointer movement skipped over it because the sorted-order guarantees do not hold.
A related trap: sorting to enable two-pointer destroys original indices. Two Sum (LeetCode 1) asks you to return indices. If you sort the array and run two-pointer, you find the right values but return positions in the sorted copy, not the original. Two Sum II (LeetCode 167) avoids this problem by providing a pre-sorted input, which is precisely why two-pointer works there. When the problem asks for values, sorting is fine. When it asks for indices, use a hash map.
You're Moving the Wrong Pointer
For any converging two-pointer problem, each step must eliminate a set of candidates you can formally prove do not contain the answer. This is the invariant that makes the whole technique correct, and it breaks the moment you move the wrong pointer.
Container with Most Water (LeetCode 11) makes this concrete. The area between two lines is min(height[left], height[right]) * (right - left). The correct rule is to always advance the pointer at the shorter line.
Here is why that rule is the only valid one. When height[left] < height[right], the area for any pair (left, j) where j < right satisfies two conditions simultaneously: the height is bounded by height[left] (since that is still the shorter side), and the width (j - left) is strictly smaller than (right - left). Both factors are worse. You can safely eliminate all pairs involving this left with any right endpoint smaller than right and advance left. Zero information is lost.
If you advance right instead, you eliminate pairs (i, right) for all i > left. But some of those pairs might have a taller left side, producing a larger area. You just threw away candidates that could contain the answer. Your algorithm is now a coin flip wearing a lab coat.
# WRONG: moves the taller pointer if height[left] > height[right]: left += 1 else: right -= 1 # moves right even when height[left] < height[right] # CORRECT: always advance the shorter side if height[left] < height[right]: left += 1 else: right -= 1
The equal-height case (height[left] == height[right]) can advance either pointer. Any movement produces a container with strictly smaller width and a height bounded by the same value, so neither side is a candidate for a better answer.
The same logic powers Trapping Rain Water (LeetCode 42). When leftMax < rightMax, the water at left is bounded by leftMax regardless of the actual tallest bar to the right. The real right maximum is at least height[right], so min(leftMax, realRightMax) = leftMax. You can process the left bar without knowing the exact right max.
The 3Sum Duplicate Skip Has Three Failure Points
3Sum (LeetCode 15) is the canonical two-pointer problem where handling duplicates is mandatory. There are three distinct places where candidates go wrong. Not one. Not two. Three. Each one independently capable of sinking your solution.
Failure point one: the outer loop guard.
The standard check is:
for i in range(len(nums)): if i > 0 and nums[i] == nums[i - 1]: continue
The i > 0 guard is not optional. Some people write nums[i] == nums[i + 1] instead. That produces an IndexError when i is the last element, since nums[i + 1] goes out of bounds. The i - 1 variant with i > 0 is simpler and has no boundary issue.
Failure point two: forgetting to skip inner duplicates after a match.
After finding and recording a valid triplet, you must advance both inner pointers and skip any consecutive duplicates. Skipping this step means recording the same triplet multiple times:
if nums[left] + nums[right] == -nums[i]: result.append([nums[i], nums[left], nums[right]]) left += 1 right -= 1 # BUG: duplicate triplets possible on the next iteration
The correct sequence: record first, then skip, then move.
if nums[left] + nums[right] == -nums[i]: result.append([nums[i], nums[left], nums[right]]) while left < right and nums[left] == nums[left + 1]: left += 1 while left < right and nums[right] == nums[right - 1]: right -= 1 left += 1 right -= 1
Failure point three: the inner skip loop missing the outer boundary check.
The inner while loops must include left < right. Without it, on an array where all remaining elements are identical, the skip loop advances a pointer all the way past the other pointer, and the subsequent comparison reads invalid state.
Dutch National Flag: The Pointer You Cannot Advance
Sort Colors (LeetCode 75) uses three pointers: low, mid, and high. The invariant: everything before low is 0, everything from low to mid-1 is 1, everything after high is 2, and mid is the current element under examination. (Dutch National Flag deep dive for the full walkthrough.)
The most common bug: incrementing mid after swapping with high. It seems fine. Why wouldn't you advance past what you just placed? Here is why.
if nums[mid] == 2: nums[mid], nums[high] = nums[high], nums[mid] high -= 1 mid += 1 # BUG: the element at mid was never examined
When you swap nums[mid] with nums[high] and decrement high, the element that landed at mid came from the unprocessed region. It could be 0, 1, or 2. You have to examine it before advancing past it. Incrementing mid here marks an unknown element as a processed 1, which corrupts the invariant. Your sort now silently produces garbage on certain inputs.
Compare this to the 0-case:
if nums[mid] == 0: nums[low], nums[mid] = nums[mid], nums[low] low += 1 mid += 1 # SAFE
When you swap with low, the element coming to mid was sitting somewhere in the [low, mid) range, which is by invariant entirely occupied by 1s. The received element is guaranteed to be 1. Safe to advance.
Correct 2-case: hold mid in place and re-examine in the next iteration.
if nums[mid] == 2: nums[mid], nums[high] = nums[high], nums[mid] high -= 1 # mid stays; examine nums[mid] again next iteration
Nested Advancement Loops Need the Outer Boundary
Valid Palindrome (LeetCode 125) requires skipping non-alphanumeric characters while converging. The inner skip loops are where the boundary invariant breaks:
while left < right: while not s[left].isalnum(): # BUG: no left < right guard left += 1 while not s[right].isalnum(): # BUG: no left < right guard right -= 1 if s[left].lower() != s[right].lower(): return False left += 1 right -= 1
On input ".," (two non-alphanumeric characters), the buggy version crashes entirely. The inner loop for left runs without a boundary check: '.' is not alnum, left++ to 1. ',' is not alnum, left++ to 2. Now s[2] does not exist. IndexError.
The really maddening part: on valid input like "a.b", the buggy code works most of the time, which is exactly why this bug survives code reviews. It surfaces only when all remaining characters in the window are non-alphanumeric. The kind of input nobody thinks to test.
Any advancement loop nested inside a convergence loop must include the outer boundary condition:
while left < right: while left < right and not s[left].isalnum(): left += 1 while left < right and not s[right].isalnum(): right -= 1 if s[left].lower() != s[right].lower(): return False left += 1 right -= 1
The same rule applies to the 3Sum inner skip loops and to any problem where you skip over elements during convergence.
What Every Two-Pointer Bug Has in Common
None of these are random mistakes. They all trace back to one thing: two-pointer works only when each pointer movement provably eliminates candidates that cannot contain the answer. Sorted input makes elimination monotonic. Advancing the wrong pointer in Container with Most Water discards candidates that could be the answer. Not re-examining after a high-side swap in Dutch National Flag marks an unknown element as processed. Missing the boundary guard in palindrome loops lets inner advancement corrupt the outer invariant.
For each pointer movement you write, ask: what am I proving useless, and why? If you can answer that per branch, the solution is probably correct.
If your problem involves maintaining a window instead of eliminating pairs at the edges, Two Pointers vs Sliding Window covers where the patterns diverge.
If you want to practice these patterns in a setting that pushes back when your reasoning is fuzzy, SpaceComplexity runs voice-based mock interviews that score your problem-solving process, not just whether the final code compiles.
Recap
- Converging two-pointer requires sorted input. Unsorted arrays silently produce wrong answers.
- Sort + two-pointer destroys original indices. Use a hash map when the problem asks for indices (Two Sum I), not values.
- Every pointer movement must eliminate candidates that are provably not the answer. Moving the wrong pointer in Container with Most Water discards candidates that could contain the maximum.
- In 3Sum: use
i > 0 and nums[i] == nums[i-1]for the outer skip. Record triplets before skipping inner duplicates. Guard inner skip loops withleft < right. - In Dutch National Flag, swapping with
highreceives an unexamined element atmid. Do not incrementmid. - Nested advancement loops inside convergence loops need the outer boundary condition.
Further Reading
- Two Pointers Technique: GeeksforGeeks, complete overview with worked examples
- Dutch National Flag Problem: Wikipedia, original Dijkstra formulation
- Container With Most Water: LeetCode 11
- 3Sum: LeetCode 15
- Two Sum II: Input Array Is Sorted: LeetCode 167, the canonical two-pointer setup