LeetCode80. 删除有序数组中的重复项 II
题目描述
给你一个有序数组 nums,请你 原地 删除重复出现的元素,使得出现次数超过两次的元素只出现两次,返回删除后数组的新长度。
不要使用额外的数组空间,你必须在 原地 修改输入数组并在使用 O(1) 额外空间的条件下完成。
说明:
为什么返回数值是整数,但输出的答案是数组呢?
请注意,输入数组是以「引用」方式传递的,这意味着在函数里修改输入数组对于调用者是可见的。
你可以想象内部操作如下:
java
// nums 是以"引用"方式传递的。也就是说,不对实参做任何拷贝
int len = removeDuplicates(nums);
// 在函数里修改输入数组对于调用者是可见的。
// 根据你的函数返回的长度, 它会打印出数组中 该长度范围内 的所有元素。
for (int i = 0; i < len; i++) {
print(nums[i]);
}
示例
示例 1:
输入: nums = [1,1,1,2,2,3]
输出: 5, nums = [1,1,2,2,3]
解释: 函数应返回新长度 length = 5,并且原数组的前五个元素被修改为 1, 1, 2, 2, 3。不需要考虑数组中超出新长度后面的元素。
示例 2:
输入: nums = [0,0,1,1,1,1,2,3,3]
输出: 7, nums = [0,0,1,1,2,3,3]
解释: 函数应返回新长度 length = 7,并且原数组的前七个元素被修改为 0, 0, 1, 1, 2, 3, 3。不需要考虑数组中超出新长度后面的元素。
提示
1 <= nums.length <= 3 * 10^4-10^4 <= nums[i] <= 10^4nums已按升序排列
题解
方法一:计数辅助法(空间 O(1))
思路
由于数组已经排序,相同的元素必然连续出现。我们可以使用快慢指针配合计数器来解决问题:
- 快指针
fast:从索引 1 开始遍历数组,寻找不同的元素。 - 慢指针
slow:从索引 1 开始,指向下一个有效元素应该存放的位置。 - 计数器
count:记录当前元素已经出现的次数。
遍历过程中:
- 如果快指针找到的元素
nums[fast]与前一个有效元素nums[slow-1]不同,说明遇到了新元素,将其放到慢指针nums[slow]位置,重置计数器count为 1; - 如果元素相同
nums[slow-1] == nums[fast],且计数器count小于 2,说明当前元素还可以再出现一次,将其放到慢指针nums[slow]位置,计数器count加 1; - 如果计数器
count已经达到 2,则跳过该元素。
这种方法逻辑清晰,但需要额外维护一个计数器变量。
复杂度分析
- 时间复杂度: O(n),快指针遍历整个数组一次。
- 空间复杂度: O(1),原地操作,仅需常数个额外变量。
代码
java
class Solution {
public int removeDuplicates(int[] nums) {
// 数组长度不超过 2 时,直接返回原长度
if (nums.length <= 2) {
return nums.length;
}
// 慢指针:指向下一个有效元素应存放的位置
int slow = 1;
// 记录当前元素已出现的次数
int count = 1;
// 快指针:从索引 1 开始遍历
for (int fast = 1; fast < nums.length; fast++) {
// 如果遇到不同的元素,说明遇到了新元素
if (nums[slow - 1] != nums[fast]) {
// 将新元素放到慢指针位置
nums[slow] = nums[fast];
// 重置计数器
count = 1;
// 慢指针右移
slow++;
} else {
// 元素相同,但出现次数少于 2 次,可以保留
if (count < 2) {
nums[slow] = nums[fast];
// 计数器加 1
count++;
// 慢指针右移
slow++;
}
// 如果 count >= 2,则跳过该元素
}
}
// 遍历结束后,slow 的值即为有效元素的个数
return slow;
}
}
图解示例
以 nums = [1,1,1,2,2,3] 为例:
text
初始状态:[1, 1, 1, 2, 2, 3] slow=1, fast=1, count=1
↑
slow/fast
第1步:fast=1, nums[fast] == nums[slow-1]=1,相同,count=1<2 → 保留
操作:nums[1]=1(不变), count=2, slow=2, fast=2
[1, 1, 1, 2, 2, 3]
↑
slow/fast
count=2
第2步:fast=2, nums[fast] == nums[slow-1]=1,相同,count=2 → 跳过
操作:仅 fast++ → fast=3, slow 保持 2
[1, 1, 1, 2, 2, 3]
↑ ↑
slow fast
count=2
第3步:fast=3, nums[fast]=2 ≠ nums[slow-1]=1,新元素 → 保留
操作:nums[2]=2 ~覆盖, count=1, slow=3, fast=4
[1, 1, ~2, 2, 2, 3] ← ~后为本次修改位置
↑ ↑
slow fast
count=1
第4步:fast=4, nums[fast]=2 == nums[slow-1]=2,相同,count=1<2 → 保留
操作:nums[3]=2(不变), count=2, slow=4, fast=5
[1, 1, 2, 2, 2, 3]
↑ ↑
slow fast
count=2
第5步:fast=5, nums[fast]=3 ≠ nums[slow-1]=2,新元素 → 保留
操作:nums[4]=3 ~覆盖, count=1, slow=5, fast=6(结束)
[1, 1, 2, 2, ~3, 3] ← 前5个为有效结果
↑
slow(返回5)
count=1
方法二:通用覆盖法(最优解,空间 O(1))
思路
利用数组已排序的特性,可以设计更简洁的覆盖逻辑:
由于每个元素最多允许出现两次,慢指针 slow 从索引 2 开始,快指针 fast 也从索引 2 开始遍历。对于快指针遇到的每个元素,只需判断它是否等于 nums[slow - 2]:
- 如果
nums[fast] != nums[slow - 2],说明当前元素可以安全保留(最多是第二次出现),将其赋值给nums[slow],慢指针右移。 - 如果
nums[fast] == nums[slow - 2],说明该元素已经出现了两次,跳过即可。
这种方法的核心洞察是:慢指针前方两位的元素,一定代表了当前"允许出现两次"的元素的边界。代码极其精简,是最优解法。
核心原理:为什么比较 slow-2 就能保证最多出现两次?
不变量 :nums[0...slow-1] 中每个元素最多出现 2 次。
证明思路(数学归纳法):
-
基础情况 :
slow=2时,前 2 个元素最多出现 2 次 -
归纳步骤 :假设
nums[0...slow-1]已满足条件,判断nums[fast]能否写入:- 如果
nums[fast] == nums[slow-2]:- 由于数组有序,
nums[slow-2] == nums[slow-1](相同元素连续) - 说明该元素已出现 2 次,再写入会变成 3 次 → 跳过
- 由于数组有序,
- 如果
nums[fast] != nums[slow-2]:- 要么是新元素(出现 0 次),要么只出现了 1 次(在
slow-1位置) - 写入后最多出现 2 次 → 保留
- 要么是新元素(出现 0 次),要么只出现了 1 次(在
- 如果
-
结论 :通过比较
slow-2,可以精确控制每个元素最多出现 2 次
直观理解 :slow-2 是「已保留序列」的倒数第 2 个元素,如果当前元素等于它,说明已经出现了 2 次(slow-2 和 slow-1),不能再写入。
复杂度分析
- 时间复杂度: O(n),快指针遍历整个数组一次。
- 空间复杂度: O(1),原地操作,无需额外空间。
代码
java
class Solution {
public int removeDuplicates(int[] nums) {
// 数组长度不超过 2 时,直接返回原长度
if (nums.length <= 2) {
return nums.length;
}
// 慢指针:从索引 2 开始,指向下一个有效元素应存放的位置
int slow = 2;
// 快指针:从索引 2 开始遍历
for (int fast = 2; fast < nums.length; fast++) {
// 如果当前元素与 slow 前方两位的元素不同,说明可以保留
// nums[slow-2] 代表已保留的元素边界,保证每个元素最多出现两次
if (nums[slow - 2] != nums[fast]) {
// 将有效元素放到慢指针位置
// 然后慢指针右移
nums[slow++] = nums[fast];
}
}
// 遍历结束后,slow 的值即为有效元素的个数
return slow;
}
}
图解示例
以 nums = [0,0,1,1,1,1,2,3,3] 为例:
text
初始状态:[0, 0, 1, 1, 1, 1, 2, 3, 3] slow=2, fast=2
↑
slow/fast
第1步:fast=2, nums[fast]=1 != nums[slow-2]=0,不同 → 保留
操作:nums[2]=1(不变), slow=3, fast=3
[0, 0, 1, 1, 1, 1, 2, 3, 3]
↑
slow/fast
第2步:fast=3, nums[fast]=1 != nums[slow-2]=0,不同 → 保留
操作:nums[3]=1(不变), slow=4, fast=4
[0, 0, 1, 1, 1, 1, 2, 3, 3]
↑
slow/fast
第3步:fast=4, nums[fast]=1 == nums[slow-2]=1,相同 → 跳过
操作:仅 fast++ → fast=5, slow 保持 4
[0, 0, 1, 1, 1, 1, 2, 3, 3]
↑ ↑
slow fast
第4步:fast=5, nums[fast]=1 == nums[slow-2]=1,相同 → 跳过
操作:仅 fast++ → fast=6, slow 保持 4
[0, 0, 1, 1, 1, 1, 2, 3, 3]
↑ ↑
slow fast
第5步:fast=6, nums[fast]=2 != nums[slow-2]=1,不同 → 保留
操作:nums[4]=2 ~覆盖, slow=5, fast=7
[0, 0, 1, 1, ~2, 1, 2, 3, 3] ← ~后为本次修改位置
↑ ↑
slow fast
第6步:fast=7, nums[fast]=3 != nums[slow-2]=1,不同 → 保留
操作:nums[5]=3 ~覆盖, slow=6, fast=8
[0, 0, 1, 1, 2, ~3, 2, 3, 3] ← ~后为本次修改位置
↑ ↑
slow fast
第7步:fast=8, nums[fast]=3 != nums[slow-2]=2,不同 → 保留
操作:nums[6]=3 ~覆盖, slow=7, fast=9(结束)
[0, 0, 1, 1, 2, 3, ~3, 3, 3] ← 前7个为有效结果
↑
slow(返回7)
最终结果:返回 slow=7,nums 前 7 个元素为 [0, 0, 1, 1, 2, 3, 3]
两种方法对比
| 方法 | 时间复杂度 | 空间复杂度 | 代码简洁度 | 适用场景 |
|---|---|---|---|---|
| 计数辅助法 | O(n) | O(1) | 中 | 逻辑直观,适合初学者理解 |
| 通用覆盖法 | O(n) | O(1) | 高 | 最优解,代码精简,可扩展至 k 次 |