刷 LeetCode 的时候,很多人 26 题过了就顺手点开 80 题,觉得“无非是把最多出现一次改成两次”。我第一次做 80 题也是这么想的,把 26 题的代码里slow - 1改成slow - 2,然后提交,结果被[1,1,1,2,2,2,3]这种用例教做人了。这篇文章就用 C 语言把 LeetCode 80(删除有序数组中的重复项 II)讲透,从题目细节、双指针思路、完整代码到和 26 题的对比,以及我实际踩过的坑,一次说清楚。不管你是刚接触 C 语言的初学者,还是在准备笔试面试、想巩固双指针套路的同学,这篇都值得花十分钟看完。
1. 题目在说什么:先别急着写代码,把三个细节抠清楚
1.1 输入输出与函数签名
题目给的是一个“非严格递增排列”的数组,也就是升序数组,相同元素必然相邻。要求原地删除重复出现的元素,让每个元素最多出现两次,然后返回删除后数组的新长度。不能开额外数组,空间复杂度必须是 O(1)。
举个例子:
输入:nums = [1,1,1,2,2,3] 输出:5,nums 变为 [1,1,2,2,3]LeetCode 上 C 语言的函数签名是这样:
int removeDuplicates(int* nums, int numsSize);nums是数组首地址,numsSize是元素个数,返回值是处理后的新长度。
1.2 三个容易忽略的细节
第一,“原地删除”不是真的删除,而是覆盖。数组的内存长度在函数调用时已经固定了,你不可能真的把它变短,你只需要保证数组前 k 个位置是最终结果,返回 k。
第二,LeetCode 判题时只检查返回的 k 和nums[0]到nums[k-1]这些位置,后面残留什么数据它根本不管。我见过有人担心“nums 后面还留着旧值会不会被判错”,完全不会,这是题目设计好的。
第三,C 语言里函数参数nums是int*指针,不是数组名,不要在函数内部用sizeof(nums)/sizeof(nums[0])去算长度。在 64 位系统上sizeof(nums)是 8,不是数组总字节数,这样算出来一定错。老老实实用numsSize。
2. 解题思路的拐点:从“和前一个比”到“和倒数第二个比”
2.1 先回顾 26 题的双指针逻辑
26 题要求“每个元素最多出现一次”。经典双指针写法是这样:
int removeDuplicates(int* nums, int numsSize) { if (numsSize == 0) { return 0; } int slow = 1; for (int fast = 1; fast < numsSize; fast++) { if (nums[fast] != nums[slow - 1]) { nums[slow] = nums[fast]; slow++; } } return slow; }这里slow是“下一个要写入的位置”,同时它也是“当前已经处理好的区域长度”。fast负责往前扫描,如果fast指向的值和已经处理区域里最后一个值不同,说明这是一个需要保留的新值,就把它写到slow位置,然后slow++。
这个逻辑能成立,是因为数组有序,重复元素都挤在一起。slow - 1就是保留区最后那个数,只要当前值和它不一样,就一定是新的一段值的开头。
2.2 80 题为什么可以直接把 1 换成 2
80 题允许每个元素最多出现两次,那么前两个元素无论如何都应该保留,不需要任何比较。所以slow的初始值从 1 变成 2,比较对象也从nums[slow - 1]变成nums[slow - 2]。
if (nums[fast] != nums[slow - 2]) { nums[slow] = nums[fast]; slow++; }这里nums[slow - 2]是什么?它是已处理保留区里倒数第二个位置。举个例子,如果目前保留区是[1,1],slow = 4,那么nums[slow - 2]就是nums[2],也就是保留区倒数第二个元素。当fast指向的值和它相等,说明这个值在保留区里已经出现两次了,再放进去就是第三个,必须跳过。
一句话概括这个思路:我不关心前面到底有几个重复值,我只保证“保留区最后两个”和“当前扫描值”不同。如果一样,说明再放就超了;如果不一样,说明当前值最多也就刚出现一次,放心写进去。
2.3 手动走查一组用例,比背代码有用得多
拿nums = [0,0,1,1,1,1,2,3,3]走一遍,期望结果长度是 7,数组前 7 位是[0,0,1,1,2,3,3]。
| fast | nums[fast] | slow | nums[slow-2] | 比较结果 | 动作 |
|---|---|---|---|---|---|
| 初始 | - | 2 | - | - | - |
| 2 | 1 | 2 | nums[0]=0 | 不等 | 写 nums[2]=1, slow=3 |
| 3 | 1 | 3 | nums[1]=0 | 不等 | 写 nums[3]=1, slow=4 |
| 4 | 1 | 4 | nums[2]=1 | 相等 | 跳过 |
| 5 | 1 | 4 | nums[2]=1 | 相等 | 跳过 |
| 6 | 2 | 4 | nums[2]=1 | 不等 | 写 nums[4]=2, slow=5 |
| 7 | 3 | 5 | nums[3]=1 | 不等 | 写 nums[5]=3, slow=6 |
| 8 | 3 | 6 | nums[4]=2 | 不等 | 写 nums[6]=3, slow=7 |
注意 fast=4 和 fast=5 这两个位置,它们的值都是 1,此时保留区是[0,0,1,1],nums[slow-2]正好是保留区里倒数第二个 1,所以判定“1 已经保留满两个了,跳过”。fast=7 时,nums[slow-2]读的是nums[3],也就是保留区里的 1,虽然此时nums[3]还是 1,但数组里这个位置后面已经被改过,整个处理过程都只看保留区边界,不受原数组残留值的干扰。
3. C 语言实现:推荐写法、通用版本、反面写法
3.1 推荐实现:slow - 2 隔位比较
先上完整代码:
int removeDuplicates(int* nums, int numsSize) { if (numsSize <= 2) { return numsSize; } int slow = 2; for (int fast = 2; fast < numsSize; fast++) { if (nums[fast] != nums[slow - 2]) { nums[slow] = nums[fast]; slow++; } } return slow; }逐行拆一下:
if (numsSize <= 2)是必须的特判。数组长度不超过 2 时,就算所有元素都相同,也不可能出现“某个元素超过两次”的情况,直接返回原长度。如果没有这个特判,slow = 2,当numsSize = 1或 0 时,循环根本不会执行,但返回值会是 2,这就错了。slow = 2语义是“前两个位置直接保留,从第三个位置开始决定要不要写入”。- 循环里
fast也从 2 开始,因为前两个元素不需要检查。 - 判断条件用
nums[fast] != nums[slow - 2],不等就写,相等就跳过。 - 最终返回
slow,它恰好就是去重后的数组长度。
时间复杂度 O(n),空间复杂度 O(1)。整个算法只扫了一遍数组,没有任何额外分配,完全满足题目要求。
3.2 泛化版本:一个函数解决“最多保留 k 个”
既然 26 题是“最多保留 1 个”,80 题是“最多保留 2 个”,那很自然想到:如果题目改成“最多保留 k 个”,代码是不是也长一样?答案是肯定的:
int removeDuplicatesK(int* nums, int numsSize, int k) { if (numsSize <= k) { return numsSize; } int slow = k; for (int fast = k; fast < numsSize; fast++) { if (nums[fast] != nums[slow - k]) { nums[slow] = nums[fast]; slow++; } } return slow; }26 题就是removeDuplicatesK(nums, numsSize, 1),80 题就是removeDuplicatesK(nums, numsSize, 2)。面试或者笔试遇到这类题,直接套这个模板,比临时推边界条件快得多。我后来写这类题基本不再单独记 26 和 80 的代码,只记这个通用模板。
3.3 计数器写法:能跑,但我不推荐
网上也有用计数器实现的版本,大致长这样:
int removeDuplicates(int* nums, int numsSize) { int j = 0; int count = 0; for (int i = 0; i < numsSize; i++) { if (i > 0 && nums[i] == nums[i - 1]) { count++; } else { count = 1; } if (count <= 2) { nums[j] = nums[i]; j++; } } return j; }这套逻辑在有序数组上确实能跑出正确答案。但我不推荐在面试或笔试时用,原因有三个:
一是count的语义太重。你得时刻记住什么时候重置、什么时候累加、为什么count <= 2才写。一旦面试官追问“如果保留 k 个你怎么改”,计数器版本要改的地方远多于双指针版本。
二是它比较的是nums[i]和nums[i - 1],这个i - 1原数组位置可能在之前被覆盖过。在有序数组上结果碰巧是对的,但你要向别人解释清楚“为什么覆盖不影响判断”,解释成本很高。
三是双指针版更接近问题本质。slow就是保留区边界,nums[slow - k]就是“已经出现过的第 k 个位置”,语义清清楚楚,讲起来也方便。所以如果你还没有形成自己的写法,我建议直接用双指针版。
4. 与 26 题的对比:看起来只差一个数字,实际是两个维度
4.1 题目差异对照表
| 对比项 | LeetCode 26 | LeetCode 80 |
|---|---|---|
| 最多保留数量 | 1 个 | 2 个 |
| 比较对象 | nums[fast] != nums[slow - 1] | nums[fast] != nums[slow - 2] |
| slow 初始值 | 1 | 2 |
| fast 初始值 | 1 | 2 |
| 空数组特判 | numsSize == 0 | numsSize <= 2 |
| 难度 | 简单 | 中等 |
代码层面确实只差了几个数字,但这背后是两个层次的思维:
26 题只需要“和前一个比”,本质上是一维的:只要不是重复出现的第一个值,就保留。80 题需要“和倒数第二个比”,本质上是在处理“一个值最多占两个坑位”的容量限制。你可以把slow - k理解成“保留区对当前值开放的最后一个允许位置”,这个位置之前的 k 个位置已经被这个值占满,当前值就不能再进来了。
4.2 一个必翻车的改法:用nums[fast] != nums[fast - 1]
很多教程里 26 题用的是另一种写法:
if (nums[fast] != nums[fast - 1]) { nums[slow++] = nums[fast]; }26 题这样写是可以过的,因为数组有序,nums[fast - 1]是当前扫描位置前一个元素,只要当前值和前一个不同,就说明是新值开头。但千万不要把这个习惯带到 80 题,我就在这上面翻过车。
拿[1,1,1,2,2,2,3]试验,错误写法是这样:
int slow = 2; for (int fast = 2; fast < numsSize; fast++) { if (nums[fast] != nums[fast - 1]) { nums[slow++] = nums[fast]; } }走查一下:
- fast=2,
nums[2]=1,nums[1]=1,相等,跳过; - fast=3,
nums[3]=2,nums[2]=1,不等,写nums[2]=2,slow=3; - fast=4,
nums[4]=2,nums[3]=2,相等,跳过; - fast=5,
nums[5]=2,nums[4]=2,相等,跳过; - fast=6,
nums[6]=3,nums[5]=2,不等,写nums[3]=3,slow=4。
最后返回 4,正确应该是 5。问题出在哪?出在 fast=4 时,第二个 2 本来应该写入,但因为nums[4]和nums[3]相等,被错误跳过。nums[fast - 1]只能告诉你“原数组相邻两个值等不等”,它不能告诉你“这个值在保留区里已经出现了几次”。一旦slow落后于fast,nums[fast - 1]还可能已经被覆盖,比较结果就更不可信。
4.3 复杂度、判题细节与代码风格对比
两个题的时间复杂度都是 O(n),空间复杂度都是 O(1),差别在边界处理。
26 题的空数组特判必须写,否则slow = 1,返回 1,数组是空的也返回 1,直接错。80 题的numsSize <= 2特判更宽,因为长度不超过 2 的数组无论如何都满足“最多出现两次”的条件。
代码风格上,我建议 26 题也用通用模板写,也就是slow = k,比较nums[slow - k],这样两道题的写法完全统一,不容易记混。你要是非把 26 题和 80 题当成两个独立解法背,到考场上很容易把slow初始值、比较下标哪个是 1 哪个是 2 搞反。
5. 本地验证与调试:C 语言做题的正确姿势
5.1 写一个 main 函数,把样例全部跑一遍
LeetCode 上做题只需要提交函数,但本地调试时我习惯自己写一个main,把所有样例和边界用例一次性跑完。完整代码贴出来,可以直接复制编译:
#include <stdio.h> int removeDuplicates(int* nums, int numsSize) { if (numsSize <= 2) { return numsSize; } int slow = 2; for (int fast = 2; fast < numsSize; fast++) { if (nums[fast] != nums[slow - 2]) { nums[slow] = nums[fast]; slow++; } } return slow; } void test(int* nums, int numsSize) { int len = removeDuplicates(nums, numsSize); printf("len = %d: ", len); for (int i = 0; i < len; i++) { printf("%d ", nums[i]); } printf("\n"); } int main() { int a[] = {1, 1, 1, 2, 2, 3}; test(a, sizeof(a) / sizeof(a[0])); int b[] = {0, 0, 1, 1, 1, 1, 2, 3, 3}; test(b, sizeof(b) / sizeof(b[0])); int c[] = {1, 1, 1, 1, 1}; test(c, sizeof(c) / sizeof(c[0])); int d[] = {1, 2, 3, 4, 5}; test(d, sizeof(d) / sizeof(d[0])); int e[] = {1, 1}; test(e, sizeof(e) / sizeof(e[0])); return 0; }在你的 VSCode 或者命令行里编译运行:
gcc -g -o test test.c ./test输出应该是:
len = 5: 1 1 2 2 3 len = 7: 0 0 1 1 2 3 3 len = 2: 1 1 len = 5: 1 2 3 4 5 len = 2: 1 1这个测试函数把结果和原始数组一起打印,方便你肉眼确认前 k 位是否正确。注意我用了sizeof(a) / sizeof(a[0])计算数组长度,这个技巧只能用在 main 里真正定义数组的场景,函数内部的指针参数不能用,前面已经强调过了。
5.2 边界用例:空数组、长度 1、长度 2、全相同、全不同
刷数组题最容易漏边界,我整理了一份常用用例清单,建议每个题都跑一遍:
| 用例 | 输入 | 期望输出 |
|---|---|---|
| 空数组 | [] | 0 |
| 单元素 | [1] | 1 |
| 双元素相同 | [1,1] | 2 |
| 双元素不同 | [1,2] | 2 |
| 全相同 | [1,1,1,1,1] | 2 |
| 全不同 | [1,2,3,4,5] | 5 |
| 混合重复 | [1,1,1,2,2,2,3] | 5 |
这些用例覆盖了numsSize <= 2特判、循环不执行、连续重复、交错重复等主要分支。每次写完代码先跑这份清单,能省下大量提交被罚时的时间。
5.3 gdb 观察 slow 和 fast:一次“亲眼所见”的排查
有一次我把循环条件写成了fast <= numsSize,本地测试某些用例居然没崩,提交到 LeetCode 就报运行时错误。后来用 gdb 才定位到问题。
编译的时候加-g参数保留调试信息:
gcc -g -o test test.c gdb ./test在 gdb 里打断点、运行、单步观察:
break removeDuplicates run print slow print fast print nums[slow-2] continue-g编译后变量名都能直接看。那次我看到fast一路跑到了numsSize,也就是数组最后一个元素的后面,再去读nums[fast]就是越界访问。虽然当时数组后面恰好是合法内存,没立刻崩,但这种未定义行为迟早出事。
如果你用的是 VSCode 的 C/C++ 插件,可以直接在行号左边打断点,然后在“运行和调试”面板里看变量变化,效果比 gdb 命令行更直观。我在本地跑 LeetCode 题时,常用的组合就是 VSCode + GCC,配合断点调试,做数组、指针相关的题效率高不少。
C 语言里数组越界不一定会马上报错,因为越界访问的往往是相邻内存,可能“碰巧”还能读出值来。这种问题用眼睛看代码很难发现,最好的办法就是调试器里把fast、slow这些下标变量打出来,看它们是否超出了合理范围。
6. 这类题背后的通用思路与适用边界
6.1 从 k=1、k=2 到 k=任意值
再回头看 3.2 的通用代码,你会发现整套逻辑其实只需要两个信息:
slow:保留区边界,也是保留区当前长度;- 比较
nums[fast]和nums[slow - k]:判断当前值如果在保留区里再放一个,会不会让它的连续出现次数超过 k。
这个模板可以直接应对面试官的各种变体,比如“最多保留 3 个”“最多保留 n 个”。把 k 当作参数传进去,就再也不用担心改代码时把某个数字漏掉。这也是我推荐把 26 题当成 k=1 特例去记忆的原因:题目会变,套路不变。
6.2 为什么“有序”是这个算法的命门
这个算法的正确性建立在数组有序的前提上。因为有序,相同的值必然相邻,所以“保留区最后 k 个位置”才能代表“这个值在当前出现的所有情况”。
如果数组无序,比如[1,2,1,2,1],用slow = 2的算法跑:
- fast=2,
nums[2]=1,nums[0]=1,相等,直接跳过; - 后面的 1 也都会被跳过。
但原数组里 1 只出现在位置 0、2、4,如果题目要求“每个元素最多出现两次”,位置 2 的 1 本来应该保留,结果被错误丢弃。所以说,只要题目里少了“有序”两个字,这个算法就不成立。遇到无序数组要处理同类问题,你只能先排序,或者用哈希表统计频率,然后重新组织数组,那就不再是这道题的考点了。
6.3 双指针覆盖思想还能用在哪
“一个指针维护已处理区域,一个指针向前探索”这个模式,在 LeetCode 里远不止 26 和 80 两题。27 题“移除元素”是slow维护不含目标值的区域,fast遍历整个数组;283 题“移动零”本质上是把非零元素先挪到前面,再在后面补零;链表里的慢快指针找中间节点、检测环,也是同一个思路的变体。
把这些题放在一起看,你会发现双指针的核心就一句话:用两个下标(或指针)把一趟扫描分成“已处理区”和“待处理区”,每次迭代只决定一个元素的去留,把数组原地改写。时间 O(n)、空间 O(1) 的原因也在这里——每个元素最多被扫描一次、最多被写入一次。
最后分享一个我自己的习惯:刷这类“原地去重”的题,拿到题目先不要写代码,先问自己三个问题——返回值是什么?前几个位置必须正确?哪些位置可以无脑保留?这三个问题想清楚,slow的初始值、比较下标的k、特判条件就全出来了。把这套流程走熟了,以后遇到 26、80,甚至面试官现场改的变体题,你都能在几分钟内写出不越界、不翻车的解法。