# 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. 第二版的思路
第二版分为两个阶段:
- 扫描数组,保留每个数字的前两次出现,将第三次及以后出现的元素改成特殊标记;
- 再次扫描数组,把不是特殊标记的元素移动到数组前部。
由于题目保证数组已经排序,因此相同数字会连续出现。变量 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),并且直接满足原地修改和每个元素最多保留两次的要求。