这题我一开始也很容易往“遇到第三个重复元素就删掉”那个方向想,但真写成原地算法以后,会发现更顺手的视角不是逐个删除,而是按段处理。
原题链接:
题目要求可以简化成一句话:
- 数组已经有序
- 每个数最多保留两次
- 必须原地修改
- 额外空间只能是
O(1)
这几个条件一合起来,最重要的性质其实就是:相同元素一定连在一起。所以这题完全可以把数组看成若干个“重复块”,每次处理一块,而不是一个个元素去抠。
一、这份代码在做什么
先看代码:
class Solution {
public int removeDuplicates(int[] nums) {
int n = nums.length - 1, i = 0, j = 0;
if (n <= 1) return n + 1;
for (; j <= n - 2; i++) {
while(j <= n - 2 && nums[j] == nums[j + 2]) j++;
if (i > 0 && nums[j + 1] == nums[i - 1]) {
nums[i] = nums[++j];
}else {
nums[i] = nums[j];
nums[++ i] = nums[++ j];
}
j ++;
}
int c = n - j;
if (c % 2 == 0) {
if (nums[i - 2] != nums[n]) nums[i ++] = nums[n];
} else {
if (nums[i - 2] != nums[n - 1]) nums[i ++] = nums[n - 1];
if (nums[i - 2] != nums[n]) nums[i ++] = nums[n];
}
return i;
}
}
这不是最常见的模板写法,但核心思路很清楚:
i指向答案区间的下一个可写位置j在原数组里向前扫while (nums[j] == nums[j + 2])用来跳过“超过两个”的重复项- 主循环尽量一次写入 1 到 2 个元素
- 最后再把数组尾部剩下的 1 到 2 个候选值单独收尾
换句话说,这份代码并不是在“删除第三个重复元素”,而是在做一件更整体的事:
扫描当前重复块,只把这个块里允许保留的那部分写回前缀。
二、最关键的一行:nums[j] == nums[j + 2]
这题最值得盯住的地方,不是后面的分支,而是这句:
while (j <= n - 2 && nums[j] == nums[j + 2]) j++;
因为数组有序,所以一旦 nums[j] == nums[j + 2],就说明当前这个数至少连续出现了 3 次。
这时候继续右移 j,本质上是在做一件事:
- 把
j推到当前重复块的靠后位置 - 让前面那些“多出来的重复值”被整体跳过
比如 nums = [1, 1, 1, 2, 2, 3]:
- 起点
j = 0 - 因为
nums[0] == nums[2],说明1至少有 3 个 - 所以
j右移到1 - 这时
nums[1] != nums[3],跳出循环
跳出来以后,可以把它理解成:
- 当前这段
1已经定位完了 - 真正应该保留的,只会是这段中的最后两个候选位置
这就是这份写法和常见“比较 nums[fast] 与 nums[slow - 2]”模板最大的不同:它先按块定位,再按块回写。
三、图解:把数组看成一段一段的块
图里可以把整个过程拆成三步:
j先在当前重复块内部右移,跳过多出来的重复项。i负责把当前块里允许保留的值写回前缀。- 当
j已经没法再看j + 2时,说明主循环只能停下,最后一两个候选值交给收尾逻辑处理。
这个视角比“边扫边删”更稳,因为题目本来就是有序数组,重复项天然就是按块出现的。
四、结合样例走一遍
用题目里的经典样例:
nums = [1, 1, 1, 2, 2, 3]
目标结果应该是:
[1, 1, 2, 2, 3]
1. 先处理 1 这一段
开始时:
i = 0j = 0
因为 nums[0] == nums[2],说明 1 至少出现了三次,所以 j 会先右移。
跳出 while 后,代码进入 else 分支:
nums[i] = nums[j];
nums[++i] = nums[++j];
这两句会把两个 1 写进答案前缀,于是前缀变成:
[1, 1]
2. 再处理 2 这一段
这时 j 已经来到 2 这一段的开头附近。
由于 2 只出现两次,while (nums[j] == nums[j + 2]) 不会继续跳。于是又进入 else 分支,把两个 2 写进去:
[1, 1, 2, 2]
3. 最后收尾 3
主循环的条件是 j <= n - 2,也就是必须保证还能访问 j + 2。
当数组只剩最后一两个候选值时,主循环就不再安全,所以代码用这一段来收尾:
int c = n - j;
if (c % 2 == 0) {
if (nums[i - 2] != nums[n]) nums[i++] = nums[n];
} else {
if (nums[i - 2] != nums[n - 1]) nums[i++] = nums[n - 1];
if (nums[i - 2] != nums[n]) nums[i++] = nums[n];
}
这里可以不用死记分支,只记住它在处理什么:
- 主循环结束后,尾部最多只剩 1 到 2 个候选值
- 如果它们和答案前缀倒数第二个值不同,就可以写进去
- 这样仍然能保证“每个值最多出现两次”
对于这个样例,最后会补上一个 3,得到:
[1, 1, 2, 2, 3]
五、为什么 if (i > 0 && nums[j + 1] == nums[i - 1]) 这样判断
这段判断第一次看会有点绕:
if (i > 0 && nums[j + 1] == nums[i - 1]) {
nums[i] = nums[++j];
}
它的作用可以理解成:
- 如果当前即将写入的值,会让某个数字在答案前缀里超过 2 次
- 那就只补一个,而不是补两个
nums[i - 1] 表示答案前缀的最后一个值。
如果 nums[j + 1] 和它相同,说明当前块和已经写好的前缀在值上连起来了,再写两个就可能超限,所以这里只写一个,避免出现第三次。
这也是这份代码里最容易绕的地方:它不是单纯根据“当前块长度”决定写几个,而是把“当前块”和“已写前缀”一起考虑。
六、这份写法的优点和难点
我觉得这份代码的优点是:
- 没有额外数组,满足
O(1)空间 - 把“多余重复项”通过
j的跳跃整体略过 - 思路上更接近“按块压缩”,不是机械套模板
但它也确实有一个难点:
- 变量移动比较密集
for头、while、if/else、末尾收尾都在改i和j- 如果没有“按块处理”的视角,代码会显得很绕
所以理解这份实现的关键,不是去硬抠每一次自增,而是先抓住两个不变式:
nums[0 .. i - 1]始终是当前已经整理好的答案前缀。j的任务不是逐个删除元素,而是尽快走完当前重复块。
只要这两个点抓住,整份代码就会顺很多。
七、复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
虽然 j 在循环里会跳来跳去,但每个位置整体上只会被访问有限次,所以总时间仍然是线性的。
八、总结
这题最常见的写法,通常是维护一个慢指针,然后判断当前值能不能写到 slow 位置。
而你这份代码的特点在于,它不是按“单个元素是否保留”来思考,而是按“当前重复块里最多保留两个”来思考。
一句话概括这份实现:
用
j跳过重复块中多余的元素,再用i把每一段里允许保留的部分原地压缩到数组前缀。
如果后面还想把这篇题解继续打磨,我会优先改两个方向:
- 给
i、j起更语义化的名字 - 把尾部收尾逻辑单独拆开,让主循环和收尾职责更清晰
但就题意来说,这份代码本身已经是正确的,而且随机样例也能稳定通过。