The Duplicate Problem

Find all unique triplets in [-2, -1, 0, 0, 1, 2] that sum to zero.

The word “unique” is doing a lot of work in that sentence. Without it, LC 15 (Three Sum) is a straightforward extension of two-pointer search — fix one element, run converging pointers on the rest. You already know how to do that. With the uniqueness constraint, though, you need skip logic. And getting that logic wrong is the single most common Three Sum bug in interviews.

Here's the trap. Consider a slightly different array: [-4, -1, -1, 0, 1, 2]. The naive approach — loop over each index i, then run Two Sum on the remaining elements — produces [-1, -1, 2] as a valid triplet when i = 1 (the first -1). Then when i = 2 (the second -1), the inner Two Sum finds the exact same triplet again. Same values, same sum, different indices. The problem says unique values, not unique indices.

The brute-force fix? Collect every triplet into a Set of stringified tuples and deduplicate at the end. That works, but it's inelegant and hides the real question: where in the algorithm can you prevent duplicates from ever being generated?

The fix requires changes in TWO places — but where? That's what we need to figure out. And there's a comparison direction that trips up almost everyone. Miss any one of the three details, and duplicates leak through.

Find the triplets

Go ahead — tap any three cells that sum to zero. Find as many triplets as you can.

Pay attention to what happens when you use both 0s.

Target sum: 0Tap three cells
0
1
2
3
4
5

Where duplicates come from

Think back to the moment you found the same triplet twice. What was different about the two discoveries? Same values, same sum — but you arrived at them through different paths.

Before reading on, ask yourself: how many separate ways can duplicates sneak in? Is it one source, or more than one? And could they each need a different fix?

Here's what makes it structural. The array is sorted, which means duplicate values sit right next to each other. That adjacency creates two distinct entry points for repeated triplets:

The first is in the outer loop. When i sits on 0 at index 2, the inner search finds (-1, 0, 1). Then i steps to index 3 — another 0. The inner search runs on the remaining elements and finds... the same triplet. Same fixed value, same sub-problem, same result.

The second is inside the inner loop itself. After finding (-2, 0, 2), both pointers advance inward. But if the left pointer lands on another 0, the algorithm “discovers” the same triplet again without the outer value ever changing.

Outer source-2-10i=20i=312(-2, 0, 2)(-2, 0, 2)=Same value at different iInner source-2i-10L0L12R(-2, 0, 2)(-2, 0, 2)=Same value at adjacent L

Two sources. Two locations in the code. And — as you'll discover shortly — a comparison direction that trips up almost everyone. Miss any one of the three details, and duplicates leak through.

Outer loop skipping

The outer for loop picks each element as the “fixed” value. If two adjacent elements are the same, the second one would produce all the same triplets as the first. So the fix looks deceptively simple:

1
for (let i = 0; i < nums.length - 2; i++) {
2
  if (i > 0 && nums[i] === nums[i - 1]) continue; // skip duplicate fixed values
3
  // ... run Two Sum on nums[i+1..end]
4
}

The condition checks: "Is the element I'm about to fix the same as the one I just finished processing?" If so, skip it — the previous iteration already found every triplet involving this value.

Notice the i > 0 guard. Without it, the first element would compare against nums[-1], which is undefined in JavaScript (and an out-of-bounds read in most languages). This guard isn't about correctness of the skip — it's about not crashing on the first iteration.

Here's the question: in our array [-2, -1, 0, 0, 1, 2], the outer pointer is about to move from index 2 (the first 0) to index 3 (the second 0). What happens if you don't skip?

Walk through the array below. At each position, predict whether the algorithm should process or skip.

for (let i = 0; i < nums.length - 2; i++) {
if (i > 0 && nums[i] === nums[i-1]) continue;
// ... two-pointer inner loop
}
Step 1/5i = 0, nums[i] = -2
i
-2
0
-1
1
0
2
0
3
1
4
2
5
What should happen at i = 0?

Inner pointer skipping

The outer skip handles one source of duplicates — repeated fixed values. But there's another hiding inside the inner loop.

Picture this: i is fixed at -2. The left pointer L is at index 1 (value 0), and the right pointer R is at index 5 (value 2). The sum is 0 — a match! You record the triplet (-2, 0, 2) and advance both pointers inward.

But what if L lands on another 0? The converging pointers would compute the same sum, find the same match, and record (-2, 0, 2) a second time. The outer skip can't help here — the fixed value -2 didn't change. The duplicate was born entirely within the inner loop.

The fix: after recording a match, skip L past all copies of its current value, and skip R past all copies of its current value. In code:

1
// After finding a match:
2
results.push([nums[i], nums[left], nums[right]]);
3
while (left < right && nums[left] === nums[left + 1]) left++;
4
while (left < right && nums[right] === nums[right - 1]) right--;
5
left++;
6
right--;

Wait — did you catch something odd about those comparisons? We'll come back to that. First, step through the inner skip mechanism yourself.

i
-2
0
-1
1
L
0
2
0
3
1
4
R
2
5
[-2, 0, 2] = 0
We found (-2, 0, 2). Sum = 0. What do we do first?

The comparison direction trap

Here's where most LC 15 bugs hide, and it's subtle enough that you can stare at the code for minutes and not see it.

After finding a match, you need to skip L past duplicate values. But the skip condition uses === — and which direction you compare determines whether the code is correct or silently broken.

Option A — forward comparison:

1
while (left < right && nums[left] === nums[left + 1]) left++;
2
left++; // one more to land on the new value

Option B — backward comparison:

1
left++;  // advance first
2
while (left < right && nums[left] === nums[left - 1]) left++;

Both look reasonable. Both compile. Both pass simple test cases. But one of them silently drops valid triplets on certain inputs, and the other silently produces duplicate triplets on others.

The difference comes down to when the main advance happens relative to the skip loop. In Option A, the skip loop runs before the main advance — it skips past copies while L still points at the matched value, then does one final left++ to land on something new. In Option B, the main advance happens first, then the skip loop cleans up remaining copies by looking backward.

The trap: if you mix the advance order with the wrong comparison direction — say, advance first but compare forward — you can skip over a value that isn't a duplicate of the match. That value was a legitimate candidate, and you just silently dropped it.

Before you trace the full scenario, toggle between the two directions on a frozen state to see what each comparison touches:

Try both directions below. Watch which one breaks.

Just found (-2, 0, 2). Advanced both pointers. L is now at index 3.
i
-2
0
-1
1
0
2
L
0
3
1
4
R
2
5
Which comparison correctly skips duplicate values for L?

Full algorithm trace

Time to put it all together. Walk through the complete Three Sum on [-2, -1, 0, 0, 1, 2]. At every decision point — outer skip, pointer move, inner skip — you make the call.

Step 1/7i=0 (-2), L=1, R=5
i
-2
0
L
-1
1
0
2
0
3
1
4
R
2
5
-2 + -1 + 2 = -1
1 too low
-2 + -1 + 2 = -1. Sum is too small. Which pointer moves?
threeSum
1
function threeSum(nums) {
2
  nums.sort((a, b) => a - b);
3
  const result = [];
4
  for (let i = 0; i < nums.length - 2; i++) {
5
    if (i > 0 && nums[i] === nums[i-1]) continue;
6
    let left = i + 1, right = nums.length - 1;
7
    while (left < right) {
8
      const sum = nums[i] + nums[left] + nums[right];
9
      if (sum === 0) {
10
        result.push([...]);
11
        left++; right--;
12
        while (left < right && nums[left] === nums[left-1]) left++;
13
        while (left < right && nums[right] === nums[right+1]) right--;
14
      } else if (sum < 0) left++;
15
      else right--;
└─ sum = -1 < 0 left++
16
    }
17
  }
18
  return result;
19
}
i = 0
left = 1
right = 5
sum = -1

Find the bug

Time to put your understanding to the test. Someone wrote a Three Sum solution and it's producing duplicate triplets. The code looks almost right — the outer skip is there, the inner skip is there, the pointer advances are there. But one detail is wrong, and it's enough to break correctness.

This is exactly what happens in interviews: you write the algorithm, it passes the first example, and then the interviewer feeds in an array with dense duplicates and your output has repeats. If you can't spot the bug by reading the code, you need to trace through it.

Run it on a test input. Watch the duplicate appear. Then tap the line that caused it.

Running on [-2, -1, 0, 0, 1, 2], i=0 (value -2)
i
-2
L
-1
0
0
1
R
2
Sum=-1 < 0. left++ → 2.
Find the bug

Build the skip logic

Four blanks in the Three Sum template. All four are in the duplicate-handling sections. Fill them in.

function threeSum(nums) {
nums.sort((a, b) => a - b);
for (let i = 0; i < nums.length - 2; i++) {
if (i > 0 && ) continue;
// ... set up left = i+1, right = nums.length-1
// ... while (left < right) { check sum ... }
if (sum === 0) {
result.push([nums[i], nums[left], nums[right]]);
;
while (left < right && ) left++;
while (left < right && ) right--;
}
}
}

Harder input

[-2, 0, 0, 0, 2, 2]. Three 0s and two 2s. More duplicates, more chances for the skip logic to matter.

Same algorithm. Trace through and predict each step.

Step 1/6
i
-2
0
L
0
1
0
2
0
3
2
4
R
2
5
-2 + 0 + 2 = 0
-2+0+2=0. Found it! After advancing both pointers, does L or R need to skip duplicates?

Why both skips matter

You've now seen three separate mechanisms that work together to eliminate duplicates in LC 15:

  1. Outer skipif (i > 0 && nums[i] === nums[i-1]) continue — prevents the same fixed value from generating the same set of triplets twice
  2. Inner skip (L) — after a match, advance left past all copies of its current value — prevents the same left-side value from pairing with different right-side copies
  3. Inner skip (R) — same treatment for right, advancing it past its copies

Remove any one of these and the algorithm silently produces duplicates on certain inputs. Remove the outer skip and you get duplicates whenever the input has repeated values in the outer loop range. Remove the inner skip and you get duplicates whenever a match is found and the pointer's next value is identical.

The tricky part: each skip independently seems optional on simple test cases. [-1, 0, 1, 2] has no duplicates, so all three skips are no-ops. You need arrays like [-2, -1, 0, 0, 1, 2] or [0, 0, 0, 0] to surface the bugs.

Three scenarios follow. Each one removes a different piece of the skip logic. Predict what breaks before the answer is revealed.

Question 1/3
You skip duplicates in the outer loop but NOT after finding a triplet in the inner loop.
What kind of bug does this produce?