80. 删除有序数组中的重复项 II

复制代码
# Remove Duplicates from Sorted Array II:C 代码分析

## 1. 题目概述

题目:

[Remove Duplicates from Sorted Array II](https://leetcode.cn/problems/remove-duplicates-from-sorted-array-ii/)

给定一个按非递减顺序排列的数组 `nums`,要求原地删除重复元素,使每个数字最多出现两次,并返回处理后的有效长度 `k`。

要求:

- 必须在原数组上修改;
- 额外空间复杂度为 `O(1)`;
- 返回 `k`;
- `nums[0]` 到 `nums[k - 1]` 保存处理后的结果;
- `k` 之后的元素不需要关注。

例如:

```text
输入: [1, 1, 1, 2, 2, 3]
输出:5
结果: [1, 1, 2, 2, 3]

2. 第一版代码

复制代码
int removeDuplicates(int* nums, int numsSize) {
    if (numsSize == 0) return 0;

    int p, f = 0;

    for (int i = 1; i < numsSize; i++) {
        if (*(nums + p) == *(nums + i)) {
            f++;
            p++;
        }

        if (f > 1) {
            if (*(nums + p) >= *(nums + i)) {
                i++;
            } else {
                if (*(nums + p) < *(nums + i)) {
                    *(nums + p) == *(nums + i);
                    p++;
                }
            }
            f = 0;
        }
    }

    return p + 1;
}

3. 第一版存在的问题

3.1 p 未初始化

复制代码
int p, f = 0;

这里只初始化了 f,p 的值是不确定的。

随后立即使用:

复制代码
*(nums + p)

这会访问不可预测的位置,可能导致:

  • 读取错误数据;
  • 越界访问;
  • 未定义行为;
  • 返回错误长度。

如果 p 是写入指针,至少应初始化为:

复制代码
int p = 0;

但仅初始化仍不能修复整体逻辑。

3.2 指针表达式没有带来额外收益

复制代码
*(nums + p)

等价于:

复制代码
nums[p]

本题使用下标形式更清晰:

复制代码
if (nums[p] == nums[i])

3.3 p 的含义不稳定

代码中:

复制代码
if (*(nums + p) == *(nums + i)) {
    f++;
    p++;
}

发现重复元素后却让 p 增加,这不符合常见的双指针设计。

通常:

  • i 是读取指针;
  • p 是写入位置;
  • 只有确认需要保留元素时,p 才应该增加。

重复元素是否保留,需要根据已经写入的结果判断,而不是简单地在发现相等时移动写指针。

3.4 在循环体中修改 i

复制代码
if (*(nums + p) >= *(nums + i)) {
    i++;
}

外层循环结束后还会执行:

复制代码
i++

因此这里会连续增加两次,跳过一个元素。

这会导致:

  • 漏检查元素;
  • 重复次数统计错误;
  • 读写边界难以推导。

除非非常明确地证明跳过是正确的,否则不应该同时在循环体和 for 更新表达式中修改同一个循环变量。

3.5 == 是比较,不是赋值

复制代码
*(nums + p) == *(nums + i);

这条语句只是比较两个值,计算结果被丢弃,并没有修改数组。

如果本意是赋值,应写成:

复制代码
nums[p] = nums[i];

不过,这里即使改成赋值,也无法解决当前算法的整体问题。

3.6 可能越界访问

代码会先增加:

复制代码
p++;

随后又使用:

复制代码
nums[p]

但没有检查 p < numsSize。

当 p 到达数组末尾时,可能访问数组边界之外。

3.7 返回值没有可靠含义

复制代码
return p + 1;

由于 p 没有正确表示"有效结果长度",所以 p + 1 不一定是合法答案。

4. 第二版代码

复制代码
int removeDuplicates(int* nums, int numsSize) {
    if (numsSize == 0) return 0;

    int best = nums[0] - 1;
    int f = 0;
    int p = 0;

    for (int i = 0; i < numsSize; i++) {
        if (best < nums[i]) {
            best = nums[i];
            f = 0;
        }

        if (best == nums[i]) {
            f++;
        }

        if ((f > 2) && (best == nums[i])) {
            nums[i] = nums[0] - 1;
        }
    }

    for (int i = 0; i < numsSize; i++) {
        if (nums[i] != nums[0] - 1) {
            nums[p] = nums[i];
            p++;
        }
    }

    return p;
}

5. 第二版的思路

第二版分为两个阶段:

  1. 扫描数组,保留每个数字的前两次出现,将第三次及以后出现的元素改成特殊标记;
  2. 再次扫描数组,把不是特殊标记的元素移动到数组前部。

由于题目保证数组已经排序,因此相同数字会连续出现。变量 f 可以作为当前数字出现次数的计数器。

例如:

复制代码
输入:[1, 1, 1, 2, 2, 3]

第一轮处理后,可能变成:

复制代码
[1, 1, 0, 2, 2, 3]

其中 0 是通过:

复制代码
nums[0] - 1

构造出的标记值。

第二轮压缩后得到:

复制代码
[1, 1, 2, 2, 3, ...]

返回:

复制代码
5

6. 第二版是否正确

在 LeetCode 题目的输入约束下,第二版的核心思路通常可以得到正确结果:

  • 数组已排序;
  • 每个相同数字连续出现;
  • 当前数字变大时重置计数;
  • 同一个数字只保留前两次;
  • 第二次遍历负责压缩数组。

但是,这个实现仍有可改进之处。

6.1 best = nums[0] - 1 没有必要

复制代码
int best = nums[0] - 1;

因为数组已经排序,完全可以直接记录当前元素和出现次数,不需要构造一个"比第一个元素小一"的初始值。

而且当:

复制代码
nums[0] == INT_MIN

时:

复制代码
nums[0] - 1

会发生有符号整数溢出,属于未定义行为。

6.2 使用特殊标记修改数组,耦合较强

复制代码
nums[i] = nums[0] - 1;

这种方式依赖于标记值不会与有效输入冲突。

对于题目给定范围,nums[0] - 1 通常不会与合法数组值冲突,因为 nums[0] 是最小元素。但是从通用 C 代码角度看,这种做法不如直接使用写指针安全、清晰。

6.3 第二次遍历不需要

当前代码的时间复杂度仍然是:

复制代码
O(numsSize)

因为两次线性遍历仍然是 O(n)。

但可以在一次遍历中直接把保留元素写入前部,使代码更短,逻辑也更容易证明。

6.4 best 实际表示当前最大值

由于数组已经排序,代码中的:

复制代码
best

实际上相当于"当前正在处理的数字"。

这个命名不够准确。更清晰的名称可以是:

复制代码
current

或者直接不保存当前值,而是通过:

复制代码
nums[write - 2]

判断是否已经保留了两个相同元素。

6.5 第一轮中的比较可以简化

当前代码:

复制代码
if (best < nums[i]) {
    best = nums[i];
    f = 0;
}

if (best == nums[i]) {
    f++;
}

由于数组已经排序,nums[i] 只有两种主要情况:

  • 等于当前数字;
  • 大于当前数字。

可以直接使用当前写入结果判断,不必维护 best 和 f 两个变量。

7. 推荐解法:双指针

复制代码
int removeDuplicates(int* nums, int numsSize) {
    int write = 0;

    for (int read = 0; read < numsSize; read++) {
        if (write < 2 || nums[read] != nums[write - 2]) {
            nums[write] = nums[read];
            write++;
        }
    }

    return write;
}

8. 双指针算法说明

变量含义:

  • read:读取原数组中的元素;
  • write:下一个应该写入的位置。

判断条件:

复制代码
write < 2

表示前两个元素无论如何都可以保留。

当已经写入至少两个元素后:

复制代码
nums[read] != nums[write - 2]

如果当前元素不等于结果数组中倒数第二个元素,就说明当前数字出现次数还没有超过两次,可以保留。

如果:

复制代码
nums[read] == nums[write - 2]

说明结果数组中已经有两个相同数字,当前元素必须跳过。

9. 正确性示例

输入:

复制代码
[1, 1, 1, 2, 2, 3]

处理过程:

read 当前值 write 操作
0 1 0 保留
1 1 1 保留
2 1 2 等于 nums[0],跳过
3 2 2 保留
4 2 3 保留
5 3 4 保留

最终数组前部:

复制代码
[1, 1, 2, 2, 3]

返回:

复制代码
5

10. 正确性证明

循环开始时,nums[0] 到 nums[write - 1] 始终是已经处理过的元素,并且每个数字最多出现两次。

对于新读取的 nums[read]:

  • 如果 write < 2,结果中还没有两个元素,因此可以保留;
  • 如果 nums[read] != nums[write - 2],当前数字在结果中最多只出现一次,可以继续保留;
  • 如果 nums[read] == nums[write - 2],由于数组有序,当前数字已经在结果中出现至少两次,必须跳过。

因此,所有写入的元素都满足"每个数字最多出现两次",并且没有合法元素被错误删除。

11. 两种思路比较

项目 第一版 第二版 推荐双指针
基本思路 比较并移动指针 标记多余元素后再压缩 读取和写入同时完成
是否正确 不正确 在题目约束下通常可行 正确
遍历次数 预期一次,但逻辑不完整 两次 一次
额外空间 O(1) O(1) O(1)
主要问题 指针未初始化、赋值写成比较、越界风险 特殊标记、整数溢出风险 代码简洁
时间复杂度 无法可靠判断 O(n) O(n)

第二版比第一版进步明显:

  • 初始化了 p;
  • 利用了数组有序这一条件;
  • 可以统计相同元素的出现次数;
  • 使用第二轮压缩数组。

但它仍然采用了"先标记、后清理"的间接方式。推荐的双指针解法直接在目标位置写入合法元素,减少了状态变量,也避免了特殊标记。

12. 测试用例

复制代码
#include <assert.h>

int removeDuplicates(int* nums, int numsSize);

int main(void) {
    int a[] = {1, 1, 1, 2, 2, 3};
    assert(removeDuplicates(a, 6) == 5);
    assert(a[0] == 1);
    assert(a[1] == 1);
    assert(a[2] == 2);
    assert(a[3] == 2);
    assert(a[4] == 3);

    int b[] = {0, 0, 1, 1, 1, 1, 2, 3, 3};
    assert(removeDuplicates(b, 9) == 7);

    int c[] = {1, 1};
    assert(removeDuplicates(c, 2) == 2);

    int d[] = {1, 1, 1, 1};
    assert(removeDuplicates(d, 4) == 2);

    int e[] = {1, 2, 3};
    assert(removeDuplicates(e, 3) == 3);

    int f[] = {};
    assert(removeDuplicates(f, 0) == 0);

    return 0;
}

13. 结论

第一版主要问题是:

复制代码
int p;

未初始化,并且还存在:

复制代码
*(nums + p) == *(nums + i);

把比较误写成赋值、循环变量被额外修改、指针可能越界等问题。第一版不能正确完成题目要求。

第二版利用了排序数组中相同元素连续出现的特点。在题目约束范围内,它的"标记后压缩"思路基本可行,但存在:

  • 使用 nums[0] - 1 作为标记;
  • INT_MIN 时可能发生溢出;
  • 需要两次遍历;
  • 逻辑比直接写入更复杂。

推荐最终使用:

复制代码
int removeDuplicates(int* nums, int numsSize) {
    int write = 0;

    for (int read = 0; read < numsSize; read++) {
        if (write < 2 || nums[read] != nums[write - 2]) {
            nums[write] = nums[read];
            write++;
        }
    }

    return write;
}

时间复杂度为 O(n),额外空间复杂度为 O(1),并且直接满足原地修改和每个元素最多保留两次的要求。

相关推荐
jyOverQ1 小时前
从零开始学 RocketMQ(一):基础概念、环境搭建与简单消息收发【保姆级教程】
java·rocketmq·java-rocketmq
用户7771298509121 小时前
封装、继承、多态
java
Ysx1 小时前
比"本地全绿"更可怕的坑:"交付了"不等于"生效了"
java·后端
2301_786756601 小时前
2026 JCR 秋季数据重载解读:影响因子为什么会变?附期刊影响力数据批量追踪方案
java·服务器·前端
用户7771298509121 小时前
代码的命名规范有哪些?
java
臻选云栗子2 小时前
PCDN跑不起来?先别甩锅给平台,问题可能出在这
算法·程序员
旋律4142 小时前
为什么需要命名规范?有哪些命名规范?
java
小画家~2 小时前
vue3 里面的 itemList.reduce
前端·数据结构·算法
2301_786756602 小时前
中科院分区表停发后,期刊评价的技术逻辑:从期刊超越指数到多指标选刊方案
java·数据库·windows