Remove Duplicates from Sorted Array: The Two-Pointer In-Place Pattern
Solve Remove Duplicates from Sorted Array in-place with two pointers — intuition, exact JavaScript code, a worked walkthrough, complexity, and common mistakes.
This article has an interactive companion
Don't just read it — step through it interactively, one state change at a time.
The array is already sorted, which means every duplicate is sitting right next to its twin. That single fact is the whole problem. You never have to search for repeats — you just walk forward and notice when a value changes.
The catch is the phrase in-place. You are not allowed to spin up a new array or a Set. You have to rearrange the unique values into the front of the array you were handed, using O(1) extra space, and return how many uniques there are. That constraint is what turns an easy dedupe into a clean two-pointer exercise.
The problem
Given a sorted integer array nums, remove the duplicates in-place so each value appears once, keeping the relative order. Return k, the number of unique elements. The first k slots of nums must hold those unique values; whatever sits past index k doesn't matter.
Input: nums = [0, 0, 1, 1, 1, 2, 2, 3, 3, 4]
Output: 5, nums = [0, 1, 2, 3, 4, _, _, _, _, _]The underscores are values LeetCode's judge ignores. You return 5, and positions 0 through 4 must read 0, 1, 2, 3, 4. Two constraints shape everything:
- Sorted input — equal values are always adjacent, so a duplicate is just a value equal to the one immediately before it.
- In-place, O(1) space — no second array, so you overwrite the front of
numsas you go.
The brute force baseline
If the in-place rule were relaxed, the obvious move is to dump everything into a Set and copy it back.
function removeDuplicates(nums) {
const unique = [...new Set(nums)]; // O(n) extra space
for (let i = 0; i < unique.length; i++) {
nums[i] = unique[i];
}
return unique.length;
}This is O(n) time, but it allocates a whole second structure to hold up to n values, so it's O(n) space — and it throws away the one thing you were given for free. The array is sorted. A Set treats the input as if the order were random, doing hash work to rediscover adjacency that was already handed to you. Interviewers ask this problem specifically to see whether you exploit the sorted precondition instead of ignoring it.
The key insight
In a sorted array, nums[read] is a new unique value exactly when it differs from nums[read - 1]. If they're equal, it's a repeat you've already recorded. That's the entire test — one comparison, no lookups, no memory.
So run two pointers across the same array:
readscans every element left to right, inspecting candidates.writemarks the next slot where a unique value belongs.
read always moves. write only moves when read lands on something new — and at that moment you copy nums[read] into nums[write]. Because write never outruns read, you always overwrite a slot you've already scanned, so nothing gets clobbered before it's read. This is the two-pointer in-place pattern: a slow write pointer trailing a fast read pointer, compacting good values toward the front.
The optimal solution
This is the exact algorithm the visualizer steps through, variable names and all.
var removeDuplicates = function (nums) {
if (nums.length === 0) return 0;
let write = 1;
for (let read = 1; read < nums.length; read += 1) {
if (nums[read] !== nums[read - 1]) {
nums[write] = nums[read];
write += 1;
}
}
return write;
};Three details carry the whole solution:
writestarts at 1, not 0. Index0in a sorted array is always the first unique value — there's nothing before it to duplicate — so it's correct by default and the first free slot is index1.readstarts at 1 too. The comparisonnums[read] !== nums[read - 1]needs a validread - 1, so scanning begins at index1and looks back at index0.- The return value is
writeitself, notwrite + 1. Becausewritewas seeded at1(already countingnums[0]), it ends as the exact count of uniques.
The comparison is against nums[read - 1], the previous read position, not nums[write - 1]. In a sorted array both give the same answer, and comparing to the untouched predecessor is the simplest way to reason about it.
Walkthrough
Trace nums = [1, 1, 2, 3, 3]. Watch write sit still on duplicates and step forward on new values. "nums after" shows the array once the step's write (if any) lands.
| read | nums[read] | nums[read-1] | New value? | write (before → after) | nums after |
|---|---|---|---|---|---|
| — | — | — | init | write = 1 | [1, 1, 2, 3, 3] |
| 1 | 1 | 1 | no | 1 → 1 | [1, 1, 2, 3, 3] |
| 2 | 2 | 1 | yes | 1 → 2 | [1, 2, 2, 3, 3] |
| 3 | 3 | 2 | yes | 2 → 3 | [1, 2, 3, 3, 3] |
| 4 | 3 | 3 | no | 3 → 3 | [1, 2, 3, 3, 3] |
The loop ends with write = 3, so the answer is 3, and the first three slots read [1, 2, 3] — exactly the uniques in order. The junk (3, 3) left in the tail is fine; the judge only inspects the first write elements.
Notice step read = 2: the value 2 gets copied into index 1, overwriting the second 1 that was already scanned and counted at read = 1. That's why compacting in-place is safe — write only ever lands on ground read has already covered.
Complexity
| Approach | Time | Space | Why |
|---|---|---|---|
| Set + copy back | O(n) | O(n) | allocates a second structure holding up to n values |
| Two-pointer in-place | O(n) | O(1) | one linear scan, only two integer pointers |
The optimal version touches each element once with a single comparison and at most one assignment, so time is linear in the array length. Space is constant — write and read are the only extra variables, no matter how large nums grows.
Common mistakes
- Returning
write + 1. Becausewriteis seeded at1, it already counts the first element. Adding one over-reports the length by one. (If you instead startwrite = 0and compare againstnums[write], then you do returnwrite + 1— mixing the two conventions is the classic off-by-one here.) - Comparing
nums[read]tonums[write]after mismatch handling gets tangled. Comparing tonums[read - 1]keeps the logic decoupled from where you're writing. Reach for that unless you have a reason not to. - Forgetting the empty-array guard. With
nums.length === 0, seedingwrite = 1and returning it would report1unique in an empty array. The earlyreturn 0handles it. - Trying to actually delete elements. You never
spliceor shrink the array. You overwrite the front and return a count; the tail is intentionally left as-is.
Where this pattern shows up next
The slow-write / fast-read compaction shows up any time you filter or rearrange an array in-place:
- Remove Element — the same write-pointer compaction, but keyed on "not equal to a target" instead of "not equal to the previous value".
- Move Zeroes is a cousin idea, but for a closer match see Reverse String — two pointers converging from both ends instead of chasing.
- Best Time to Buy and Sell Stock — one pass with a trailing "best so far" pointer, the same discipline of carrying only the state the next decision needs.
- Merge Sorted Array — two read pointers plus a write pointer, walking from the back to avoid overwriting unread data.
You can also step through the algorithm interactively and watch the write pointer freeze on duplicates and jump forward on new values.
FAQ
Why does the write pointer start at 1 instead of 0?
In a sorted array, the element at index 0 is always the first occurrence of its value — there's nothing before it that it could duplicate. So it's already in the correct place and already counted. The first slot that might need overwriting is index 1, which is why both write and read begin there and the final write is the unique count directly.
What is the time and space complexity?
O(n) time and O(1) space. The single for loop visits each of the n elements once, doing one comparison and at most one assignment per element, so runtime scales linearly. The only extra memory is the two integer pointers write and read, which is constant regardless of input size — that's what satisfies the in-place requirement.
Does this approach work if the array is not sorted?
No. The whole method relies on duplicates being adjacent, which is only guaranteed when the array is sorted. On unsorted input, nums[read] !== nums[read - 1] would let through repeats that aren't neighbors. For unsorted data you'd need a Set (O(n) space) or a sort first (O(n log n) time) before this scan applies.
Why compare against nums[read - 1] instead of the last written value?
Comparing to nums[read - 1] keeps the duplicate test tied to the input's natural order rather than to wherever you happen to be writing. Since the array is sorted and read only moves forward, the previous read position is always the correct reference for "is this a new value?" Comparing against nums[write - 1] also works, but coupling the check to the write cursor makes the reasoning harder without buying anything.
Make it stick: run this one yourself
Don't just read it — step through it interactively, one state change at a time.