Converging pointers squeeze inward from both ends. You've seen that pattern — start from opposite sides, march toward the middle, meet in the center. It's satisfying and symmetric.
But there's a whole family of problems where both pointers move the same direction — left to right, one chasing the other. No symmetry. No meeting in the middle. Just one pointer racing ahead while the other lags behind, waiting for permission to move.
Here are three problems that look completely unrelated at first glance:
[1, 1, 2, 2, 3, 4, 4], shrink it to unique values only — without making a new array.[0, 1, 0, 3, 12].Three different problems. Three different LeetCode pages. But underneath the surface, they share the exact same skeleton. The same loop, the same pointer relationship, the same invariant. The only thing that changes is a single if condition.
You just have to see what connects them. Let's start by feeling the problem before we reach for any tools.
Here's the array: [1, 1, 2, 2, 3, 4, 4]. Your job: strip it down to unique values only. No new arrays allowed — you have to work in-place, modifying the original.
Before we talk about algorithms, try doing it manually. Tap the duplicates to remove them. Keep one of each value, in order.
This should feel a little tedious. That's the point — you're about to feel exactly why we need a systematic approach.
Notice what your brain had to do. For every element, you had to scan backwards: “Have I already seen a 1? Have I already seen a 2?” That's a lot of mental bookkeeping. Now imagine the array has 10,000 elements. Or a million.
The brute force approach — checking every element against every previous element — is O(n^2). But because the array is sorted, there's a much simpler question you can ask: “Is this element the same as the one right before it?” That's O(1) per element, O(n) total.
The challenge isn't the comparison logic. It's the mechanics: how do you remove elements from an array without creating a new one? You can't literally delete a slot from a fixed-size array. You need a different strategy entirely.
You need to overwrite elements in-place — but you can't read and write to the same position without corrupting data you haven't processed yet. How do you keep the “already cleaned” output separate from the “not yet scanned” input when they share the same array?
Picture the array as a tape running through a machine. The machine has two heads that touch the tape:
The read head's job is to look at data. The write head's job is to place data. They move independently — sometimes in lockstep, sometimes with a growing gap between them.
This gap is the key. Everything behind the write head is the “clean” zone — the final result being assembled, one element at a time. Everything between the two heads is “garbage” — stale data that hasn't been overwritten yet but doesn't matter anymore. Everything ahead of the read head is unexplored territory.
Think of it like copying a document by hand. Your eyes (the read head) scan each word. Your pen (the write head) only moves when you find a word worth copying. If you hit a duplicate word, your eyes keep moving but your pen stays put.
Drag the W and R pointers around to see the three zones shift in real time:
The beautiful thing about this model: the write condition is the only thing that varies between problems. For Remove Duplicates, the write head asks “is this different from what I last wrote?” For Move Zeroes, it asks “is this non-zero?” For Remove Element, it asks “is this not the value I'm deleting?” Same machine, different filter.
Let's watch it in action.
Here's the tape machine running Remove Duplicates on [1, 1, 2, 2, 3, 4, 4].
Tap through each step. There are three micro-operations per element: compare (read head looks at the current value), act (write or skip based on the condition), and advance (read head moves to the next cell).
Watch the code panel on the right — the highlight jumps between the if, the write, and the loop advance. Pay attention to when the write head moves and when it stays put.
function removeDuplicates(nums) { let slow = 0; for (let fast = 1; fast < nums.length; fast++) { if (nums[fast] !== nums[slow]) {nums[1]=1 vs nums[0]=1 slow++; nums[slow] = nums[fast]; } } return slow + 1;}Did you notice the pattern? Every time the read head finds a value that matches what's already at the write position, it skips. Every time it finds something new, it copies it into the next write slot and advances the write head.
The processed zone (behind W) grows one element at a time. The garbage zone (between W and R) grows whenever a duplicate is skipped. The unprocessed zone (ahead of R) shrinks with every step.
Now you're the write head. The read head advances automatically — it always moves forward. Your job is to decide: write this value (copy it to the write position and advance), or skip it (leave the write head where it is).
If you write when you shouldn't, you'll corrupt the result — a duplicate sneaks into the clean zone. If you skip when you shouldn't, you'll lose a unique value.
Think before each tap: “Is the value at READ different from the value at WRITE?”
The discipline here is binary: write or skip, yes or no, at every single step. There's no backtracking, no second-guessing. Once the read head moves past an element, it's gone. That's what makes the algorithm O(n) — each element is visited exactly once.
At any point mid-algorithm, every cell in the array belongs to exactly one of three zones. The boundaries are defined by the two pointer positions.
Given the pointer positions shown, label each cell. Tap a cell to cycle through: P (processed — part of the final result), G (garbage — stale data between the pointers), U (unprocessed — not yet seen by the read head).
This three-zone structure is the loop invariant — it holds true at the start of every iteration. It's the same invariant in Remove Duplicates, Move Zeroes, and Remove Element. The only difference between these algorithms is the write condition that determines when the boundary between processed and garbage shifts.
Understanding the invariant is more important than memorizing the code. If you can picture the three zones and know which condition moves the write head, you can reconstruct any of these algorithms from scratch.
Here's the payoff. Move Zeroes — push all non-zero values to the front of [0, 1, 0, 3, 12].
It's the exact same tape machine. The write head still lags behind the read head. The three zones still hold. The only difference is the question the write head asks: not “is this different from what I last wrote?” but “is this non-zero?”
Toggle between the three variants and watch the condition morph. The structure is identical -- only the predicate changes:
if (arr[R] !== arr[W-1]) write(W, R)
Write: copy arr[R] to arr[W], advance W
Watch the code — one condition changed, same structure. You're the write head again. Think about what makes this problem subtly different: in Remove Duplicates, the comparison is between the read position and the write position. In Move Zeroes, the comparison is between the read value and a fixed constant (zero).
function moveZeroes(nums) { let slow = 0; for (let fast = 0; fast < nums.length; fast++) { if (nums[fast] !== 0) {nums[1]=1 nums[slow] = nums[fast]; slow++; } }}Same loop, same pointer relationship, same invariant. One if condition changed. That's the power of the tape machine model — once you internalize the structure, you can adapt it to any partitioning problem by swapping out the filter.
Something's wrong with this implementation. Run the trace and find the broken line.
Run it on [1, 1, 2, 2, 3]. Watch the output diverge from what you'd expect. Then tap the line that's broken.
The algorithm finished. The write pointer slow is sitting at some index. What should the function return?
This is where people fail in interviews — not because the algorithm is hard, but because the return value has an off-by-one that depends on the problem. In Remove Duplicates, slow points to the last valid element, so the length is slow + 1. In Move Zeroes, you don't return anything — the array is modified in-place and the caller sees the result directly.
The trap: slow doesn't always mean the same thing. In some implementations, slow points to the last written position. In others, it points to the next write position. You have to know which convention your code uses.
You've watched the machine run. You've operated the write head. You've found the bug. Now build the algorithm from the ground up.
Three blanks in the code below. Each one is a critical decision point: the write condition, the write action, and the return value. Fill them in.
If you get stuck, think back to the tape machine: what does the read head compare against? Where does the write head place the value? What does slow represent when the loop ends?
“Two pointers moving the same direction” sounds a lot like “fast and slow pointers on a linked list.” But they're fundamentally different algorithms that happen to share a superficial structure.
Array partitioning (what we just learned) uses a write head and a read head to separate elements by a predicate. The slow pointer is a boundary marker. The fast pointer is a scanner. They live on an array.
Cycle detection (Floyd's algorithm) uses two pointers moving at different speeds through a linked list. The slow pointer moves 1 step; the fast pointer moves 2 steps. If they ever meet, there's a cycle. The math is modular arithmetic, not partitioning.
Same shape — two pointers, same direction, one faster. Completely different mechanics and purpose. See if you can classify these problems correctly.
One more problem: [3, 2, 2, 3, 4, 2, 5] — remove every 2.
Same tape machine. Different condition. The write head asks “is this not equal to 2?” instead of checking for duplicates or zeros.
You have the controls. No hints this time — if you've internalized the model, the condition is obvious. If you haven't, go back and re-read the zones.