TL;DR
Slow/fast (write/read) two pointers over the sorted array β O(n) time, O(1) space.
Approach 1 β Brute force: dedupe through an auxiliary structure
The naive move: collect the distinct values into a separate container, then write them back.
class Solution:
def removeDuplicates(self, nums: list[int]) -> int:
seen: list[int] = []
for x in nums:
if not seen or seen[-1] != x:
seen.append(x)
for i, x in enumerate(seen):
nums[i] = x
return len(seen)
(Using set + sorted also works but costs O(n log n) and forgets that the input is already sorted.)
- Time:
O(n).
- Space:
O(n) for seen.
The time is fine β itβs the explicit O(1) extra memory constraint that kills it: seen is exactly the second array the problem forbids.
Approach 2 β Slow/fast two pointers, in place
The insight: in a sorted array, duplicates are adjacent, so βis this a new value?β is a single comparison against the last value kept, nums[write - 1]. The output is a prefix of the input, and the write pointer can never overtake the read pointer, so overwriting is always safe.
class Solution:
def removeDuplicates(self, nums: list[int]) -> int:
write = 1
for read in range(1, len(nums)):
if nums[read] != nums[write - 1]:
nums[write] = nums[read]
write += 1
return write
nums[0] is always kept, so both pointers start at 1. Invariant: nums[0:write] holds the distinct values among nums[0:read], in order.
Walkthrough on nums = [0,0,1,1,1,2,2,3,3,4]:
| read | nums[read] | vs nums[write-1] | action | write | prefix so far |
|---|
| 1 | 0 | 0 β equal | skip | 1 | [0] |
| 2 | 1 | 0 β new | write | 2 | [0,1] |
| 3 | 1 | 1 β equal | skip | 2 | [0,1] |
| 4 | 1 | 1 β equal | skip | 2 | [0,1] |
| 5 | 2 | 1 β new | write | 3 | [0,1,2] |
| 6 | 2 | 2 β equal | skip | 3 | [0,1,2] |
| 7 | 3 | 2 β new | write | 4 | [0,1,2,3] |
| 8 | 3 | 3 β equal | skip | 4 | [0,1,2,3] |
| 9 | 4 | 3 β new | write | 5 | [0,1,2,3,4] |
Return write = 5; nums[0:5] = [0,1,2,3,4].
- Time:
O(n) β one comparison per element.
- Space:
O(1).
Approach 3 β Compare against the previous read element
The insight: the same algorithm can key off nums[read] != nums[read - 1] instead of the kept prefix β adjacent inequality in a sorted array also marks the first copy of each value. Worth knowing because this variant generalizes cleanly to βkeep at most K duplicatesβ (compare with nums[write - K]).
class Solution:
def removeDuplicates(self, nums: list[int]) -> int:
write = 1
for read in range(1, len(nums)):
if nums[read] != nums[read - 1]:
nums[write] = nums[read]
write += 1
return write
Walkthrough on nums = [1,1,2]: read=1 β 1 == 1, skip; read=2 β 2 != 1, write nums[1] = 2, write = 2. Return 2, prefix [1,2].
For the follow-up βat most two of eachβ (LeetCode 80), swap the condition for nums[read] != nums[write - 2] β the prefix-based comparison of Approach 2 is the one that generalizes.
Common pitfalls
- Calling
nums.remove(x) or del nums[i] inside a loop β each deletion shifts the tail (O(n^2)) and skips elements as indices move under you.
- Starting
write at 0 and comparing nums[read] != nums[write - 1] β write - 1 = -1 wraps to the arrayβs last element in Python, silently corrupting the first decision.
- Returning
len(nums) or the deduped list instead of k β the judge needs the count, and the tail past k - 1 is deliberately garbage.
- Assuming this works on unsorted input β adjacency of duplicates is what makes the single comparison sufficient; unsorted input needs a set (and different problem constraints).
Pattern takeaway
Slow/fast pointers turn βfilter an array in placeβ into: write marks the end of the accepted prefix, read scans, and an element is copied down only when it passes the keep-test. When the input is sorted, membership tests collapse to a comparison with the last kept element β no hash set needed. Reach for this whenever the output is a subsequence of the input and order must be preserved: Remove Element, Move Zeroes, and the at-most-K-duplicates family are the same loop with a different if.