这道题在刷题圈里几乎无人不知:删除有序数组中的重复项。它是经典中的经典,不少人第一次在题库里刷它,是在刚接触数据结构的阶段,好多人以为两分钟就写完了,结果一提交就被边界用例教做人。也有不少人背下了答案,但面试官随口问一句“你这个慢指针为什么从 1 开始”,就愣住了。这篇想把这题从头到尾拆开讲清楚,包括题目到底在考什么、双指针怎么想出来、代码为什么要那样写、以及它背后真正通用的套路。适合刷题新人看,也适合准备面试、想把这题讲明白的人参考。
1. 先别急着写代码:这题到底在问什么
很多同学拿到题目,第一反应就是“我会去重”,然后开始想怎么把重复的删掉。但这件事在数组上实现,并没有想象中那么直接。
1.1 “有序”两个字是整个解法的命脉
题目明确说了“有序数组”。这四个字不是白给的,它意味着所有重复元素都连续排在一起。比如[1, 2, 2, 3, 4, 4, 4, 5],重复的 2 都连在一起,重复的 4 也都连在一起。
这个性质有多重要呢?它让你判断“当前这个元素是不是重复的”这件事变成了只需要和前一个元素比较。如果数组是无序的,一个元素可能在任意位置再次出现,你只能靠哈希表去记录“见过谁”,空间复杂度立刻就上去了。但有序数组不需要,你永远只需要回头看一个位置。这个看似简单的条件,是整个双指针解法成立的大前提。
有些人写代码的时候没意识到这一点,拿着双指针去硬套无序数组去重题,结果发现漏删或者错删。本质问题就出在这里:你没有用上“有序”这个信息。
1.2 “原地”和 O(1) 的真正含义
再看题目的两个硬性要求:原地修改数组,额外空间使用 O(1)。
原地修改(in-place)的意思是:你不能新建一个数组,把去重后的结果放进去再返回。你只能在传入的这个nums数组上动手。这其实就是说,结果数据必须覆盖回原来的数组里,而不是另起炉灶。
O(1) 额外空间的含义更严格:你可以用几个变量(整数、指针、循环变量),但不能用和数组长度相关的容器,比如哈希表、新数组、栈、列表。为什么很多人的第一版解法会挂在这?因为他们很容易写着写着就ans = []、seen = set(),这些操作单独看都挺自然,但在这个题目约束下全部不允许。
还有一个容易忽略的细节:返回值和最终数组的关系。题目要求返回去重后的新长度,比如去重后数组应该是[1, 2, 3],你就返回 3。至于原数组中索引 3 以及之后的位置上还留着什么旧值,没人关心,也不需要你去“清理干净”——这在后面第 4 章我会再展开说。
2. 从暴力法到双指针:完整推导过程
别小看这道题,它的解法不是一拍脑袋就有的。从最直观的暴力做法,到最优的双指针方案,中间其实有一条清晰的推导路径。
2.1 暴力解法的问题出在哪
最容易想到的做法是:从左到右扫描,发现某个元素和前一个重复了,就把它之后的所有元素整体往前挪一格。比如[1, 2, 2, 3],扫描到第二个 2 时,把后面的 3 往前覆盖,变成[1, 2, 3, 3],然后继续往后扫。
这种做法逻辑上完全正确,但有一个致命问题:每一次删除都可能引发一次大面积的元素移动。极端情况比如数组里有一万个元素,后面九千个都是重复的,那你每到一个位置就可能要移动一次剩余元素,整体时间复杂度会退化到 O(n^2)。虽然这个题在 LeetCode 上数据量不一定大到卡死你,但从算法设计的角度来说,这是不可接受的。
暴力思路还有一个更隐蔽的坑:你在扫描过程中不断修改数组,这会导致下标管理变得非常麻烦。用 Python 的del或者pop更不行,那会触发列表内部的内存移动,当时看着没问题,但你需要处理的边界条件会让你怀疑人生。
2.2 双指针的精髓:把逻辑上的“删除”改成“覆盖”
真正的突破口在于视角转换。数组的“删除”在底层并不是真的把这块内存抹掉,而是用后面的元素把前面的元素覆盖掉。既然最终我们只关心去重后的元素按顺序排在数组最前面,那我们完全不需要一个一个“删”,而是派一个指针去“收集”不重复的元素,把它们往前面写。
这就是双指针的雏形:
- 一个指针负责向前遍历整个数组,检查每一个元素是不是“新遇到的、不重复的元素”。这个指针我们叫它快指针
fast。 - 另一个指针负责标记“下一个不重复元素应该写入的位置”。这个指针我们叫它慢指针
slow。
快指针走过的地方,只要是第一次遇到的元素(因为有序,也就是跟前一个保留的元素不同),就把它丢给慢指针当前位置,慢指针再往前进一格。如果快指针发现当前元素和上一个保留的元素相同,就不管它,直接跳过。
你仔细品一下这个过程:快指针像是一个筛选员,负责判断“这个值是不是重复的”;慢指针像一个收件员,只负责站在下一个空位置上,等待筛选员把合格元素递过来。
2.3 慢指针到底指向哪里,代码才不会乱
双指针的写法在网上能看到很多版本,但最流行的有两类,区别就在慢指针的含义上。
一种写法让slow指向“已经归位的不重复序列的最后一个元素”,初始化在 0,快指针从 1 开始。当发现nums[fast] != nums[slow]时,先把slow加 1,再把nums[fast]写进nums[slow]。这种写法判断条件写起来很自然,但新手容易绕不明白“为什么要先加 1 再赋值”。
另一种写法让slow指向“下一个可以写入的位置”,初始化在 1,快指针也从 1 开始。此时slow - 1就是“已经归位序列的最后一个元素”,比较nums[fast]和nums[slow - 1]即可。如果不同,直接把nums[fast]赋值给nums[slow],然后slow自增。这种写法我个人更推荐,因为slow的角色极其明确,后续遇到变体题时也更容易推广。
两种写法最后返回的数值也不一样,第一种返回slow + 1,第二种返回slow。记住这个,不然很容易在边界测试上翻车。
3. 代码实现与逐行注释
思路清楚了,代码就变得非常机械。我直接给出推荐版本,再附上注释和不同语言的注意点。
3.1 Python 参考实现
先看我日常最常用、也最容易讲明白的版本:
def removeDuplicates(nums): if not nums: return 0 slow = 1 # 下一个不重复元素应该写入的位置,同时表示当前不重复序列的长度 for fast in range(1, len(nums)): if nums[fast] != nums[slow - 1]: nums[slow] = nums[fast] slow += 1 return slow逐个解释关键点:
- 空数组直接返回 0,这是第一个边界条件。
slow = 1的初始含义是:数组第一个元素一定是不重复的,所以它天然是去重结果的第一位,下一个写入位置从索引 1 开始。fast从 1 开始遍历,没必要从 0 看,因为第一个元素不可能靠自己判断重复。- 比较的是
nums[fast]和nums[slow - 1]。slow - 1是当前最后一个已经保留的元素。在有序数组里,新元素如果和它相同,就说明是重复的;如果不同,就是新值。 - 一旦发现新值,就写入
nums[slow],并让slow加 1。这个slow自增本身就是在“记录新长度”。
举个例子走一遍:nums = [0, 0, 1, 1, 1, 2, 2, 3]。
- 初始
slow = 1。 fast = 1,nums[1] = 0,和nums[0] = 0相同,跳过。fast = 2,nums[2] = 1,和nums[0] = 0不同,执行nums[1] = 1,数组变成[0, 1, 1, 1, 1, 2, 2, 3],slow = 2。fast = 3,nums[3] = 1,和nums[1] = 1相同,跳过。fast = 4,nums[4] = 1,和nums[1] = 1相同,跳过。fast = 5,nums[5] = 2,和nums[1] = 1不同,执行nums[2] = 2,slow = 3。fast = 6,nums[6] = 2,和nums[2] = 2相同,跳过。fast = 7,nums[7] = 3,和nums[2] = 2不同,执行nums[3] = 3,slow = 4。
最终返回 4,数组前四个元素是[0, 1, 2, 3],完全正确。而且整个过程只遍历了一遍数组,额外空间只有两个变量,真正的 O(n) 时间和 O(1) 空间。
3.2 不同语言下的细节差异
同一个逻辑,不同语言写起来需要注意的细节不太一样。
JavaScript 版本:
var removeDuplicates = function(nums) { if (nums.length === 0) return 0; let slow = 1; for (let fast = 1; fast < nums.length; fast++) { if (nums[fast] !== nums[slow - 1]) { nums[slow] = nums[fast]; slow++; } } return slow; };JavaScript 里数组的长度是可变的,但这里你不要去修改nums.length,也不需要splice删除多余元素。力扣判定时会只看返回长度对应的前几个位置,手动缩小数组反而浪费时间。
Java 版本:
class Solution { public int removeDuplicates(int[] nums) { if (nums.length == 0) return 0; int slow = 1; for (int fast = 1; fast < nums.length; fast++) { if (nums[fast] != nums[slow - 1]) { nums[slow] = nums[fast]; slow++; } } return slow; } }Java 的数组长度固定,你根本没法“删除”元素,所以更能体现“覆盖”的思路。这里返回slow后,调用方只需要遍历前slow个元素。
C/C++ 因为在系统层面更贴近数组本质,理解起来其实更顺——nums[slow] = nums[fast]就是一次内存拷贝,快指针遍历,慢指针标记写入位置,和底层内存操作完全对应。
3.3 必测的边界用例
写任何算法题,提交前都建议自己先跑几个用例。这个题至少测这些:
| 输入 | 期望返回 | 说明 |
|---|---|---|
[] | 0 | 空数组直接返回 0 |
[1] | 1 | 单元素数组不用处理 |
[1, 1, 1, 1] | 1 | 所有元素都相同 |
[1, 2, 3, 4] | 4 | 全部不重复,原样保持 |
[1, 1, 2, 2, 3, 3] | 3 | 每个值重复两次 |
[0, 0, 1, 1, 1, 2, 2, 3] | 4 | 常规混合情况 |
我习惯在本地写一个简单断言,直接打印返回值再打印修改后的数组前几位,确认是否符合预期。尤其是nums = [1, 1, 1, 1]这种用例,很多人第一次会卡在slow初始值上,输出 0 或者抛异常。
4. 实战中容易踩的坑与排查心得
这题代码短,但面试里能展开的东西并不少。不少人看完答案以为自己会了,一追问就露馅,问题大多集中在下面这几个地方。
4.1 返回值是长度,不是数组
这是新手最容易误会的地方。题目明确要求返回新长度,但很多本地调试的人会直接print(nums),发现数组后面还残留着旧值,比如[0, 1, 2, 3, 1, 2, 2, 3],立刻觉得自己写错了。
实际上,算法运行完后,数组的前slow个位置才是有效结果,后面的元素全部属于“历史残留”,题目不检查,你也不需要处理。这就好比你把桌面上的文件整理到前几个抽屉里,后面抽屉里堆着什么旧文件,根本不影响你整理完成这件事。
所以排查时,你应该打印的是nums[:slow],看这个切片是否符合预期。如果这个对,代码基本就没有逻辑问题。
4.2 快慢指针初始化的两种写法,选哪种
慢指针初始化成 0 还是 1,网上两种答案都有。比如这种:
def removeDuplicates(nums): if not nums: return 0 slow = 0 for fast in range(1, len(nums)): if nums[fast] != nums[slow]: slow += 1 nums[slow] = nums[fast] return slow + 1这版也能跑,判断逻辑是“当前元素是否和最后一个保留元素不同”。但问题是,这种写法里slow的语义是“最后一个保留元素的位置”,当你发现新值时,必须先自增再写入,代码的每一步都需要你在脑子里多绕一层。
我更推荐慢指针从 1 开始,把slow定义为“下一个写入位置”。这样比较nums[fast]与nums[slow - 1]时,slow - 1让你一眼就知道是在和“当前结果序列最后一个元素”比较,写读操作也更自然。面试里讲解时,这种定义方式听上去也更清晰,不容易被考官追问“这里为什么先加 1”。
4.3 面试官喜欢追问的变体:如果允许重复两次?
这题有一道特别经典的延续题:给定有序数组,允许每个元素最多出现两次,原地去重,返回新长度。比如[1, 1, 1, 2, 2, 3]需要变成[1, 1, 2, 2, 3]。
很多背诵解法的人在这里会突然卡住,因为他们只记住了“比较当前元素和上一个”这个操作,却没理解背后的本质。
一旦你理解了“慢指针是下一个写入位置”这个概念,这道变体就非常顺:
def removeDuplicates_allowTwo(nums): if len(nums) <= 2: return len(nums) slow = 2 for fast in range(2, len(nums)): if nums[fast] != nums[slow - 2]: nums[slow] = nums[fast] slow += 1 return slow为什么比较nums[fast]和nums[slow - 2]?因为slow - 2是当前“已保留序列中,从后往前数第二个位置”。如果新元素和前第二个位置都相同,说明它至少和前面两个数都相等,那就已经出现三次了,必须跳过。如果不同,说明它在当前结果序列中最多只会带来两个相同的值,可以写入。
这个思路推广一下,甚至可以直接解“最多保留 K 个重复项”的通用模板,把slow初始化为k,比较nums[fast]与nums[slow - k]。这就是搞懂了原理之后举一反三的效果。
5. 一套思路吃透同类题:双指针的通用模板
最后这一章,我想把视野拉开。如果你只把这题当成一道“要背的答案”,那就亏了。它其实代表了数组类题目里非常重要的一类模式:用快慢双指针做原地状态更新。
5.1 变体一:移除指定元素
这算是“删除有序数组中的重复项”的亲兄弟,题目要求原地移除所有数值等于val的元素,返回新长度。你可以完全套用慢指针的写法:
def removeElement(nums, val): slow = 0 for fast in range(len(nums)): if nums[fast] != val: nums[slow] = nums[fast] slow += 1 return slow这里甚至不需要比较相邻元素,只需要把不等于val的值全部“收集”到前面。你看,slow还是“下一个写入位置”,fast负责筛选,逻辑一模一样,只是筛选条件变了。
5.2 变体二:移动零
再看一道高频题:把数组里所有 0 移动到末尾,同时保持非零元素的相对顺序。你能用同样的思路解:
def moveZeroes(nums): slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1慢指针从左往右维护“非零序列的下一位置”,快指针扫到非零元素就交换过来。这样所有 0 会被自动“挤”到后面。如果你熟悉这个套路,刷题时一眼就能看出这类题本质上是一模一样的。
5.3 用“写入指针”的视角重新看待这类题
做了这些题之后,我最大的体会是:不要把它们当成独立题目去背。双指针里真正有用的思维模型是“快指针负责遍历,慢指针负责记录下一个写入位置”。
凡是遇到“需要在原地按某种规则筛选元素”的问题,你都可以往这个模型上靠:
- 写指针维护“结果序列”的长度和写入点。
- 读指针在原数组上逐个扫描。
- 判定条件决定读指针扫描到的元素该不该被写入。
一旦你习惯了这种思维,像“删除排序链表中的重复元素”、“把排序数组整理成每个元素都不重复”等变体,其实都是同一套内功心法的不同招式。你不需要把一个一个题都背住,只需要知道怎么把题目翻译成“筛选条件”。
我自己的经验是,第一次接触这题时,光看答案看了半天也没懂,后来动手在纸上把数组的每一步变化画出来,才恍然大悟。如果你也觉得代码抽象,建议也试一试:拿一组短数组,手动模拟慢指针和快指针的每一次移动,写完几个用例基本就能理解为什么慢指针是从 1 开始、为什么要比较slow - 1位置的元素。这个习惯帮我后面解决了不少更复杂的数组题,也推荐给你。