Skip to content
not500
Go back

LeetCode 80 删除有序数组中的重复项 II:按段压缩的双指针题解

On this page

这题我一开始也很容易往“遇到第三个重复元素就删掉”那个方向想,但真写成原地算法以后,会发现更顺手的视角不是逐个删除,而是按段处理

原题链接:

题目要求可以简化成一句话:

这几个条件一合起来,最重要的性质其实就是:相同元素一定连在一起。所以这题完全可以把数组看成若干个“重复块”,每次处理一块,而不是一个个元素去抠。

一、这份代码在做什么

先看代码:

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;
    }
}

这不是最常见的模板写法,但核心思路很清楚:

换句话说,这份代码并不是在“删除第三个重复元素”,而是在做一件更整体的事:

扫描当前重复块,只把这个块里允许保留的那部分写回前缀。

二、最关键的一行:nums[j] == nums[j + 2]

这题最值得盯住的地方,不是后面的分支,而是这句:

while (j <= n - 2 && nums[j] == nums[j + 2]) j++;

因为数组有序,所以一旦 nums[j] == nums[j + 2],就说明当前这个数至少连续出现了 3 次。

这时候继续右移 j,本质上是在做一件事:

比如 nums = [1, 1, 1, 2, 2, 3]

跳出来以后,可以把它理解成:

这就是这份写法和常见“比较 nums[fast]nums[slow - 2]”模板最大的不同:它先按块定位,再按块回写。

三、图解:把数组看成一段一段的块

LeetCode 80 删除有序数组中的重复项 II 图解:j 跳过多余重复项,i 把每段中最多两个值写回答案前缀。

图里可以把整个过程拆成三步:

  1. j 先在当前重复块内部右移,跳过多出来的重复项。
  2. i 负责把当前块里允许保留的值写回前缀。
  3. j 已经没法再看 j + 2 时,说明主循环只能停下,最后一两个候选值交给收尾逻辑处理。

这个视角比“边扫边删”更稳,因为题目本来就是有序数组,重复项天然就是按块出现的。

四、结合样例走一遍

用题目里的经典样例:

nums = [1, 1, 1, 2, 2, 3]

目标结果应该是:

[1, 1, 2, 2, 3]

1. 先处理 1 这一段

开始时:

因为 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];
}

这里可以不用死记分支,只记住它在处理什么:

对于这个样例,最后会补上一个 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];
}

它的作用可以理解成:

nums[i - 1] 表示答案前缀的最后一个值。

如果 nums[j + 1] 和它相同,说明当前块和已经写好的前缀在值上连起来了,再写两个就可能超限,所以这里只写一个,避免出现第三次。

这也是这份代码里最容易绕的地方:它不是单纯根据“当前块长度”决定写几个,而是把“当前块”和“已写前缀”一起考虑。

六、这份写法的优点和难点

我觉得这份代码的优点是:

但它也确实有一个难点:

所以理解这份实现的关键,不是去硬抠每一次自增,而是先抓住两个不变式:

  1. nums[0 .. i - 1] 始终是当前已经整理好的答案前缀。
  2. j 的任务不是逐个删除元素,而是尽快走完当前重复块。

只要这两个点抓住,整份代码就会顺很多。

七、复杂度

虽然 j 在循环里会跳来跳去,但每个位置整体上只会被访问有限次,所以总时间仍然是线性的。

八、总结

这题最常见的写法,通常是维护一个慢指针,然后判断当前值能不能写到 slow 位置。

而你这份代码的特点在于,它不是按“单个元素是否保留”来思考,而是按“当前重复块里最多保留两个”来思考。

一句话概括这份实现:

j 跳过重复块中多余的元素,再用 i 把每一段里允许保留的部分原地压缩到数组前缀。

如果后面还想把这篇题解继续打磨,我会优先改两个方向:

但就题意来说,这份代码本身已经是正确的,而且随机样例也能稳定通过。


Share this post:

Previous Post
Java 21 虚拟线程
Next Post
拆解 Lyra:一个开源提示词优化器提示词