删除有序数组中的重复项:读写双指针原地压缩
要求原地、O(1) 空间——不是新建数组,而是让有效前缀自己生长出来。
升序数组中的相同值聚成连续分组,每组只留第一个。维护一个「写指针」指向有效前缀的末尾、一个「读指针」扫描全部元素:读指针遇到新值就把它写到写指针处并前移写指针。返回写指针就是答案 k。
给你一个升序数组,比如 [0,0,1,1,1,2,2,3,3,4]。请原地删除重复出现的元素,使每个元素只出现一次,并返回删除后数组的新长度。
注意两个关键词:原地,意味着不能用额外数组;返回新长度,意味着不要求真的把数组缩短——多余的元素留在后面完全合法。
这篇文章带你走一遍:为什么「复制一份」不行,双指针是怎么出现的,以及那个容易让人困惑的契约——返回 k 就够了。
先明确问题
输入升序数组 nums,要求原地去重,返回去重后的长度 k。前 k 个元素必须是不重复的,且相对顺序不变。
示例:nums = [0,0,1,1,1,2,2,3,3,4],返回 5,前 5 个元素是 [0,1,2,3,4]。
数组后面的部分可以是任何值——题目只验证 nums[0..k) 。这是很多初学者卡住的地方:以为要物理删除。
先看最显然的做法:复制一份
不要求原地的话,太简单了:新建一个数组,遍历一遍,第一次见到的值就 append 进去。
但它用掉了 O(n) 的额外空间。题目明确要求 O(1) 空间,这条路被堵死。
那么问题变成:能不能在同一个数组上,把「该保留的值」搬到前面,同时不丢失「还没看的值」?
双指针:一个读,一个写
关键观察:数组升序,所以相同的值一定连在一起。这意味着「新值」只会在连续分组的开头出现。
我们用两个指针:write 指向「下一个该写入的位置」(有效前缀的末尾),read 指针从头扫到尾。
初始化:write = 1(下标 0 的值一定保留),read = 1。然后读指针每走一步,问一句:nums[read] 和 nums[write-1](有效前缀的最后一个值)相等吗?
不相等:说明遇到了新值,把它写到 nums[write],write 前移。相等:说明是重复副本,跳过。
关键write 指向「有效前缀的末尾」——它是写入点,也是比较的基准。
一路压缩:遇见新值就写入
手推一遍 [0,0,1,1,1,2,2,3,3,4]:
read=1:nums[1]=0 与 nums[write-1]=nums[0]=0 相等 → 跳过,read 前进。
read=2:nums[2]=1 与 nums[0]=0 不等 → 新值!nums[1]=1,write 变 2。
read=3、4:都是 1,与 nums[1]=1 相等 → 跳过。
read=5:nums[5]=2 ≠ nums[1]=1 → 新值!nums[2]=2,write 变 3。
…… 如此反复,读到结尾时 write=5,前 5 位恰好是 [0,1,2,3,4]。
注意 write 永远 ≤ read:写的位置要么是自己,要么是已经被读过的位置,所以覆盖是安全的。
理解契约:返回 k,而不是真的缩短
扫描结束时 nums 变成 [0,1,2,3,4,2,2,3,3,4],后面还有一堆旧值。
但题目只要求返回 k=5,并保证 nums[0..5) 是不重复的。后 5 位是什么不重要。
这正是「原地」题型的通用套路:不删除,只压缩有效前缀,用返回值告知有效长度。
契约「删除」在这里的意思不是物理移除,而是让 k 个有效元素占据数组头部。
七行 Go:双指针原地压缩
func removeDuplicates(nums []int) int {write := 1for read := 1; read < len(nums); read++ {if nums[read] != nums[write-1] {nums[write] = nums[read]write++}}return write}
1write 指向「下一个写入点」,同时它就是有效前缀的长度。
2读指针从 1 开始扫描(下标 0 一定保留)。
3遇到和有效前缀最后一个值不同的新值,就写入并推进 write。
4相等则是重复副本,什么都不做。
5返回 write 就是去重后的长度。
总结
读指针负责发现新值,写指针负责安放新值,返回写指针就是 k。
- 升序使相同值连续成组,「新值」只出现在分组开头。
- 双指针里 write ≤ read 恒成立,所以原地覆盖安全。
- 返回值 k 定义有效前缀,数组尾部残留无关紧要。