在前两篇博客中,我们用快慢指针顺利解决了“移除元素”和“删除有序数组中的重复项I”(元素最多出现一次)的问题。而这道“删除有序数组中的重复项II”,核心要求从“元素最多出现一次”升级为“元素最多出现两次”,但解题框架依然可以复用快慢指针——只需要在判断条件上做针对性调整。如果你已经掌握了前两题的思路,这道题会让你进一步体会到“框架复用、细节微调”的算法解题思维。今天就带大家拆解这道进阶题,看看快慢指针如何适配新的需求。
一、题目回顾:删除有序数组中的重复项II
先明确题目核心要求,避免理解偏差:
给你一个有序数组 nums,请你原地删除重复出现的元素,使得出现次数超过两次的元素只出现两次,返回删除后数组的新长度。 不要使用额外的数组空间,必须在原地修改输入数组并使用 O(1) 额外空间完成。
举个例子:
输入:nums = [1,1,1,2,2,3] → 输出:5,且 nums 前 5 个元素为 [1,1,2,2,3]
输入:nums = [0,0,1,1,1,1,2,3,3] → 输出:7,且 nums 前 7 个元素为 [0,0,1,1,2,3,3]
关键提示:数组依然是有序的,重复元素必相邻——这是我们复用快慢指针思路的核心前提!
二、解题思路:复用快慢指针,升级判断条件
回顾上一题“删除有序数组中的重复项I”的思路:慢指针维护有效区域,快指针遍历找“新的唯一元素”(即和慢指针指向元素不同的元素)。这道题的核心框架完全一致,唯一的区别是“有效元素”的定义变了:
上一题的有效元素是“和慢指针指向元素不同”(最多出现一次);这道题的有效元素是“要么和慢指针指向元素不同,要么和慢指针指向元素相同但慢指针前一个元素不同”(最多出现两次)。
1. 快慢指针的角色定义(延续前两题)
-
slow(慢指针):维护“符合要求的有效区域”的边界,指向当前有效区域的最后一个元素(和上一题定义完全一致)。
-
fast(快指针):遍历整个数组,负责寻找“符合要求的有效元素”(即可以纳入有效区域的元素,确保该元素在有效区域中出现次数不超过两次)。
2. 核心判断逻辑推导
因为数组有序,重复元素必相邻,我们可以通过“慢指针位置的元素”和“慢指针前一个位置的元素”来判断快指针的元素是否符合要求:
-
初始状态:slow 从 0 开始(第一个元素必符合要求,纳入有效区域),fast 从 1 开始(从第二个元素开始遍历判断)。
-
快指针 fast 遍历数组,分两种情况判断:
-
情况1:nums[fast] !== nums[slow] → 说明是新的不同元素,必然符合要求(出现次数1次),直接将 slow 右移一位,把 nums[fast] 放到 slow 位置。
-
情况2:nums[fast] === nums[slow] → 说明是重复元素,需要进一步判断当前有效区域中该元素已出现的次数:
-
如果 nums[slow] !== nums[slow - 1] → 说明当前有效区域中该元素只出现了1次,再加入一次(共2次)符合要求,将 slow 右移一位,把 nums[fast] 放到 slow 位置。
-
如果 nums[slow] === nums[slow - 1] → 说明当前有效区域中该元素已出现2次,再加入就超过限制,直接跳过,fast 继续右移。
-
-
-
遍历结束后,slow 指向的是最后一个有效元素的索引,所以有效元素的个数是 slow + 1(索引从0开始)。
这里有个关键细节:当 slow = 0 时(有效区域只有第一个元素),slow - 1 = -1,此时 nums[slow - 1] 是 undefined,不会等于 nums[slow],所以“nums[slow] !== nums[slow - 1]”会成立,刚好能处理“第二个元素和第一个元素相同”的情况(允许加入,变成2次)。
三、代码实现:基于上一题微调判断条件
结合上面的思路,我们可以在“删除有序数组中的重复项I”的代码基础上,只微调重复元素的判断逻辑,就能得到本题的解法:
function removeDuplicates(nums: number[]): number {
// 思路:和 删除有序数组中的重复项 I 一样 判断条件改变一下
let slow = 0;
for (let fast = 1; fast < nums.length; fast++) {
if (nums[fast] === nums[slow]) {
// 重复元素,判断是否已出现2次
if (nums[slow] !== nums[slow - 1]) {
nums[++slow] = nums[fast];
}
} else {
// 不同元素,直接加入有效区域
nums[++slow] = nums[fast];
}
}
// slow 是最后一个有效元素的索引,个数=索引+1
return slow + 1;
};
3. 代码执行过程演示
我们以「nums = [1,1,1,2,2,3]」为例,一步步看代码如何工作:
-
初始状态:slow=0,fast=1,nums=[1,1,1,2,2,3]
-
fast=1:nums[1]=1 === nums[0]=1 → 重复元素;nums[0] !== nums[-1](undefined)→ 允许加入,slow++变成1,nums[1] = nums[1](值不变),fast++变成2
-
fast=2:nums[2]=1 === nums[1]=1 → 重复元素;nums[1] === nums[0]=1 → 已出现2次,跳过,fast++变成3
-
fast=3:nums[3]=2 !== nums[1]=1 → 不同元素,slow++变成2,nums[2] = nums[3] → nums变成[1,1,2,2,2,3],fast++变成4
-
fast=4:nums[4]=2 === nums[2]=2 → 重复元素;nums[2] !== nums[1]=1 → 已出现1次,允许加入,slow++变成3,nums[3] = nums[4] → nums变成[1,1,2,2,2,3],fast++变成5
-
fast=5:nums[5]=3 !== nums[3]=2 → 不同元素,slow++变成4,nums[4] = nums[5] → nums变成[1,1,2,2,3,3],fast++变成6(循环结束)
-
返回 slow + 1 = 5,正好是有效元素的个数,nums 前 5 位为 [1,1,2,2,3](符合预期)
四、关键注意事项(避坑指南)
这道题的代码看似简单,但有几个细节很容易踩坑,尤其是重复元素的判断逻辑和边界处理:
1. 快慢指针的初始位置
❌ 错误做法:fast 从 0 开始,或者 slow 从 1 开始。 ✅ 正确做法:slow=0,fast=1。
原因:slow=0 是第一个有效元素,fast 从1开始才能依次判断后续元素是否符合要求;如果 fast 从0开始,会重复判断第一个元素,导致逻辑冗余;如果 slow 从1开始,会遗漏第一个元素的处理。
2. 重复元素判断的顺序
❌ 错误做法:先判断 nums[slow] !== nums[slow - 1],再判断 nums[fast] === nums[slow]。
✅ 正确做法:先判断 nums[fast] === nums[slow],再判断 nums[slow] !== nums[slow - 1]。
原因:只有当 fast 和 slow 元素相同时,才需要判断重复次数;如果元素不同,直接纳入有效区域即可,无需判断重复次数,这样能减少无效判断,也避免逻辑混乱。
3. 空数组和单元素数组的边界处理
如果输入 nums = [](空数组)或 nums = [5](单元素数组),上面的代码会怎么样?
- 空数组:for 循环中 fast=1 开始,而 nums.length=0,循环不执行,返回 slow + 1 = 0 + 1 = 1?这就错了!
- 单元素数组:for 循环中 fast=1 开始,nums.length=1,循环不执行,返回 slow + 1 = 0 + 1 = 1(正确)。
✅ 修复方案:在代码开头加一句边界判断,处理空数组和长度≤2的数组(长度≤2的数组必然符合要求,直接返回长度):
function removeDuplicates(nums: number[]): number {
// 边界处理:数组长度≤2时,直接返回长度(无需删除)
if (nums.length <= 2) return nums.length;
let slow = 0;
for (let fast = 1; fast < nums.length; fast++) {
if (nums[fast] === nums[slow]) {
if (nums[slow] !== nums[slow - 1]) {
nums[++slow] = nums[fast];
}
} else {
nums[++slow] = nums[fast];
}
}
return slow + 1;
};
为什么要处理长度≤2的数组?因为当数组长度为1或2时,无论元素是否重复,都符合“最多出现两次”的要求,直接返回长度即可,无需后续遍历判断,既简化逻辑又提升效率。
4. 不要混淆“先移指针再赋值”的逻辑
这是延续上一题的核心逻辑:无论是不同元素还是符合要求的重复元素,都必须先将 slow 右移一位,再赋值。因为 slow 原本指向的是有效区域的最后一个元素,新元素要放到它后面的位置;如果先赋值再移指针,会覆盖当前的有效元素。
五、与前两题的快慢指针对比(举一反三)
为了帮大家更好地梳理快慢指针的复用逻辑,这里整理了三道题的核心差异,方便对比记忆:
| 题目 | 核心要求 | 有效元素判断条件 | 初始位置 |
|---|---|---|---|
| 移除元素 | 移除指定值,保留其他元素 | nums[fast] !== val | slow=0,fast=0 |
| 删除有序数组中的重复项I | 元素最多出现1次 | nums[fast] !== nums[slow] | slow=0,fast=0 |
| 删除有序数组中的重复项II | 元素最多出现2次 | nums[fast] !== nums[slow] 或(nums[fast] === nums[slow] 且 nums[slow] !== nums[slow-1]) | slow=0,fast=1(或加边界处理后无需关注) |
| 从对比中能清晰看出:三道题的核心框架都是“快慢指针+原地修改”,差异仅在于“有效元素的判断条件”——这就是算法解题的“框架思维”:掌握核心框架后,根据题目要求微调细节即可解决一类问题。 |
六、总结
这道“删除有序数组中的重复项II”是快慢指针框架的进阶应用,核心要点可以总结为 3 句话:
-
复用快慢指针核心框架,有效区域由 slow 维护,fast 负责遍历找有效元素;
-
判断条件围绕“元素最多出现两次”设计:不同元素直接纳入,重复元素需判断已出现次数;
-
注意边界处理(数组长度≤2)和“先移指针再赋值”的逻辑,避免踩坑。
通过这三道题的练习,相信大家对快慢指针解决“原地修改有序数组”的问题已经形成了清晰的思路。其实这道题还可以进一步拓展:如果要求“元素最多出现k次”,该如何修改代码?核心思路依然不变,只是判断条件需要调整为“统计有效区域中该元素的出现次数是否小于k”——比如可以通过 slow - k + 1 的位置来判断(有兴趣的同学可以尝试实现)。
最后,祝大家刷题顺利,继续深耕框架思维,轻松应对更多同类题目!