这道题我建议每个刷题的人都认认真真做一遍,不是因为难,而是因为它足够经典、足够有代表性。LeetCode 26题“删除有序数组中的重复项”看起来只是个数组操作题,但它背后涉及的原地修改、双指针思想、循环不变量,是后续很多中等难度题目的共同地基。尤其是很多新手在这一题上栽跟头,往往是因为没想明白“为什么可以在遍历的同时修改数组”——我们今天就把它彻底讲透。
1. 题目概述与核心考点拆解
1.1 原题描述
给你一个升序排列的数组 nums,请你原地删除重复出现的元素,使每个元素只出现一次,返回删除后数组的新长度。元素的相对顺序应该保持一致。不需要考虑数组中超出新长度后面的元素。
示例 1: 输入:nums = [1,1,2] 输出:2, nums = [1,2] 解释:函数应该返回新的长度 2,并且原数组 nums 的前两个元素被修改为 1, 2。 示例 2: 输入:nums = [0,0,1,1,1,2,2,3,3,4] 输出:5, nums = [0,1,2,3,4]1.2 考点拆解
第一层考点是“原地操作”。绝大多数新手第一次做题时,第一反应是创建一个新数组,把不重复的元素放进去,然后复制回来。这当然能做对,但空间复杂度是O(n),这道题的要求是在O(1)额外空间内完成。这不是故意刁难,而是实际工程场景中非常常见的需求——当数组特别大、内存又有限时,我们不可能为了去重就开辟同等大小的新空间。
第二层考点是双指针。快慢指针是数组、链表类问题里出现频率最高的技巧之一。理解这道题的快慢指针,后面再做“移除元素”“移动零”这些题,你会发现套路完全一样。
第三层考点是对“循环不变量”的理解。这是一个有点学术气息的概念,但在本题中非常直观:我们维护一个变量len,它代表“当前已验证的不重复区间的长度”。每一步循环,我们只需要保证 nums[0] 到 nums[len-1] 之间没有重复元素即可。想清楚这一句话,代码几乎是水到渠成的事。
2. 暴力解法与优化思路的对比
2.1 新手最容易想到的“开新数组”写法
我们先来看一个很多新手都会写的版本:
var removeDuplicates = function(nums) { const arr = []; for (let i = 0; i < nums.length; i++) { if (nums[i] !== nums[i + 1]) { arr.push(nums[i]); } } for (let j = 0; j < arr.length; j++) { nums[j] = arr[j]; } return arr.length; };这个解法能通过测试用例,但它有两个问题:
第一,额外使用了一个数组,空间复杂度不达标。第二,两轮遍历加一轮复制,逻辑上绕了一圈。你其实可以一边找不重复元素,一边就直接写到前面去。
但请别觉得自己这么写很笨——能写出这个版本,说明你已经抓住了问题的核心:“只有和前一个元素不同的时候,这个元素才需要保留。”这个判断逻辑本身是对的,接下来的所有优化,都是围绕“如何在保留这个逻辑的同时,省掉额外空间”来进行的。
2.2 从暴力到双指针的思维飞跃
暴力写法的本质是:“先找到所有不重复的元素,再分批放回去”。双指针的做法是:“每找到一个不重复的元素,立刻放到正确的位置上”。
这里有一个关键的前提需要想明白:因为数组是有序的,所以重复的元素一定相邻。因此判断一个元素是否需要保留,只需要看它和“前一个被保留的元素”是否相同——如果相同,跳过;如果不同,就把它放到“下一个应该放置的位置”。
这个“下一个应该放置的位置”就是慢指针,负责遍历每一个元素的指针就是快指针。
3. 双指针解法详细推演
3.1 核心思路
我们定义两个指针:
- 快指针 i:负责遍历整个数组,它是“侦察兵”,挨个检查每个元素。
- 慢指针 len:代表“去重后数组的当前长度”,同时它的指向就是下一个不重复元素应该放置的下标。
核心规则就一句话:当 nums[i] 和 nums[len - 1] 不相等时,说明遇到了新的不重复元素,我们执行 nums[len] = nums[i],然后 len++。
你可能会问:为什么是和 nums[len - 1] 比,而不是和 nums[i - 1] 比?
这个问题特别关键。因为 nums[len - 1] 是“最后一个被保留的元素”,而 nums[i - 1] 可能已经被覆盖过了,代表不了“保留下来的序列的末尾”。举个例子就明白了。
3.2 手动推演一次完整过程
以数组[0, 0, 1, 1, 1, 2, 2, 3, 3, 4]为例:
初始状态:len = 0,i = 0
当 i = 0,len = 0 时,因为我们还没有保留过任何元素,所以第一个元素不管是什么,都直接保留。代码上就是因为当 len = 0 时,nums[i] 和 nums[len - 1] 的比较对象是 nums[-1],所以需要特判。不过在实际写法上有一种更优雅的方式,我们马上讲。
当 i = 1,len = 1 时,此时 nums[1] = 0,nums[len - 1] = nums[0] = 0,相等,说明 nums[1] 是重复元素,跳过。
当 i = 2,len = 1 时,此时 nums[2] = 1,nums[0] = 0,不相等,执行 nums[1] = 1,len 变为 2。此时数组变成
[0, 1, 1, 1, 1, 2, 2, 3, 3, 4]。注意,下标 2 的位置仍然是原来的1,但无所谓,因为我们已经把它复制到了下标1的位置。当 i = 3,len = 2 时,此时 nums[3] = 1,nums[1] = 1,相等,跳过。
当 i = 4,len = 2 时,nums[4] = 1,nums[1] = 1,相等,跳过。
当 i = 5,len = 2 时,nums[5] = 2,nums[1] = 1,不相等,执行 nums[2] = 2,len = 3。
后面的过程就完全一样了。最终 len = 5,数组前五位是
[0, 1, 2, 3, 4]。
3.3 你会在过程中发现的规律
刚学的时候,你可能会被数组中间那些“残留的脏数据”干扰——比如上面推演过程中,数组很长一段时间都是[0, 1, 1, 1, 1, 2, 2, 3, 3, 4],看起来好像原数组还没清理干净。这里需要建立一个认知:
我们从头到尾就不在乎原数组后半段的元素变成了什么,只要保证前 len 个元素满足要求,这道题的最终判定就是正确的。
LeetCode 的判题系统会去检查 nums 的前 len 个元素。
4. 代码实现与多语言对照
4.1 JavaScript 版本
var removeDuplicates = function(nums) { if (nums.length === 0) return 0; let len = 1; for (let i = 1; i < nums.length; i++) { if (nums[i] !== nums[len - 1]) { nums[len] = nums[i]; len++; } } return len; };这段代码特别简洁,但它的巧妙之处在于:我们让 len 从 1 开始,天然绕过了第一个元素没有前置元素可以比较的边界问题。
4.2 Python 版本
class Solution: def removeDuplicates(self, nums: List[int]) -> int: if not nums: return 0 length = 1 for i in range(1, len(nums)): if nums[i] != nums[length - 1]: nums[length] = nums[i] length += 1 return lengthPython 写法和 JavaScript 几乎一模一样,因为这道题的核心逻辑不依赖任何语言特性。
4.3 Java 版本
class Solution { public int removeDuplicates(int[] nums) { if (nums.length == 0) return 0; int len = 1; for (int i = 1; i < nums.length; i++) { if (nums[i] != nums[len - 1]) { nums[len] = nums[i]; len++; } } return len; } }4.4 一个更容易理解的变种写法
也许你更习惯把双指针写得更“对称”一点,也就是一个慢指针pre、一个快指针cur同步移动:
var removeDuplicates = function(nums) { if (nums.length <= 1) return nums.length; let pre = 0; for (let cur = 1; cur < nums.length; cur++) { if (nums[cur] !== nums[pre]) { pre++; nums[pre] = nums[cur]; } } return pre + 1; };这个版本中,pre 是最后一个不重复元素的下标,cur 是遍历指针。当发现不重复元素时,pre 先向前挪一格,再把新元素搬过来。最终 pre + 1 就是不重复数组的长度。
我个人觉得这个版本对初学者更友好,因为 pre 的语义非常明确——“已经处理好的不重复区间的最后一个元素位置”。
5. 时间复杂度与空间复杂度分析
5.1 时间复杂度:O(n)
整个算法只有一个循环,快指针从头走到尾,每个元素只被访问一次。所以时间复杂度是O(n)。这里的n是数组长度。
5.2 空间复杂度:O(1)
整个过程只用了几个额外的变量(len、i或pre、cur),不随输入规模增长而增长。严格来说,我们只使用常数级的辅助空间,所以空间复杂度是O(1)。
5.3 为什么这个复杂度很重要
我们做一个简单的估算。如果数组长度是1000万,开新数组意味着额外申请约40MB内存(按int类型计算)。而双指针解法只需要额外的几个字节。在实际生产环境中,这种差距是致命的。这就是为什么越来越多的面试官会明确要求说出你的空间复杂度。
6. 常见错误与避坑指南
6.1 错误一:比较对象选错
有些同学写的是:
if (nums[i] !== nums[i - 1]) { nums[len] = nums[i]; len++; }这个写法看起来没问题,但在某些情况下会出错。考虑数组[1, 1, 2, 2, 3]:
- i = 1,nums[1] = 1,nums[0] = 1,相等,跳过。
- i = 2,nums[2] = 2,nums[1] = 1,不相等,正确执行 nums[1] = 2,数组变为
[1, 2, 2, 2, 3]。 - i = 3,nums[3] = 2,nums[2] = 2,注意这时候 nums[2] 已经被污染成了2,相等,跳过——看起来没什么问题。
- i = 4,nums[4] = 3,nums[3] = 2,不相等,执行 nums[2] = 3,结果正确。
你再举几个例子,这个写法可能在某些组合下也能跑对。但它的逻辑是不严谨的——因为你拿来做比较的 nums[i - 1] 不一定是“保留序列的最后一个元素”,它可能是原数组的残留数据。如果测试用例足够刁钻,就会出问题。
结论:比较的对象一定要是 nums[len - 1] 或 nums[pre],这是整个算法的核心,不要改。
6.2 错误二:对空数组的处理
很多新手在写的时候容易漏掉:
if (nums.length === 0) return 0;如果不做这个判断,代码里使用 nums[len - 1] 时会访问 nums[-1],得到 undefined,比较逻辑就会彻底乱掉。虽然 LeetCode 的测试用例一般不会拿空数组来坑人,但面试官可能会追问。
6.3 错误三:忘了题目“原地修改”的要求
这个错误主要出现在用暴力解法时——有些同学把新数组的元素拷贝到 nums 之后,会被判通过,但面试官心里会给你打个折。请务必习惯这种原地操作写法,因为这是实际场景中最常见的需求。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 返回长度偏大 | 比较对象用了 nums[i-1] 而不是 nums[len-1] | 统一改成和保留序列末尾比较 |
| 空数组测试报错 | 缺少空数组判断 | 开头加上 length === 0 判断 |
| 结果数组顺序错乱 | 覆盖操作逻辑混乱 | 手动推演两轮,找出错误步骤 |
| 空间复杂度不过关 | 使用了额外数组 | 改用双指针原地覆盖 |
7. 基于实战的三点心得
7.1 第一点:这道题是双指针思想的“最小可行样例”
它没有链表指针操作那么抽象,也没有滑动窗口那么复杂,但它完整展示了“快指针负责探索,慢指针负责记录”这一经典分工。建议你把[0,0,1,1,1,2,2,3,3,4]手动推演几遍,直到不需要看代码也能口述流程,然后再去做 LeetCode 27(移除元素)、283(移动零),你会发现几乎是换汤不换药。
7.2 第二点:在面试中展示你的思维过程
我见过不少候选人——包括一些工作两三年的开发——做这道题的时候直接闷头写代码,写对了,面试官问一句“为什么这里用 nums[len - 1] 而不是 nums[i - 1]”就卡住了。其实面试官想听的并不是标准答案,而是你有没有建立“保留序列”这个抽象概念。你只要说清楚“len 维护的是去重后数组的长度,它同时也是下一个要填充的位置,nums[len - 1] 是最后一个保留元素”,这道题就已经拿到分了。
7.3 第三点:进阶思考——如果数组没有排好序怎么办
很多人做完这题会问:如果数组是无序的,但要求去重后保持首次出现顺序,怎么做?
这时候就不能用双指针了,因为重复元素未必相邻。通常做法是用哈希表记录已出现的元素:
var removeDuplicates = function(nums) { const seen = new Set(); let len = 0; for (let i = 0; i < nums.length; i++) { if (!seen.has(nums[i])) { seen.add(nums[i]); nums[len] = nums[i]; len++; } } return len; };你看,双指针的框架完全没变——快指针遍历,慢指针记录,只是判断条件从“和前一个元素比较”变成了“查哈希表”。这就是为什么我说双指针是一种“思想”而不是一种“模板”,理解了思想,你才能在不同场景下灵活变形。
我在带新人做这道题的时候,最常说的一句话是:不要急着把代码写出来,先从[1,1,2]这个最短的非平凡用例开始,在纸上把所有变量一次一次写下来,每一轮循环都画清楚,很快你就能在脑子里执行这段代码了。这道题做完之后,建议大家顺手把 LeetCode 27 和 283 一起刷了,它们的核心逻辑几乎是一致的,但场景略有变化——做完这三道题,“原地修改数组”这个类型你就彻底打通了。