news 2026/10/6 16:32:21

LeetCode 80题详解:C语言双指针原地删除有序数组重复项II

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode 80题详解:C语言双指针原地删除有序数组重复项II

刷 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]。

fastnums[fast]slownums[slow-2]比较结果动作
初始-2---
212nums[0]=0不等写 nums[2]=1, slow=3
313nums[1]=0不等写 nums[3]=1, slow=4
414nums[2]=1相等跳过
514nums[2]=1相等跳过
624nums[2]=1不等写 nums[4]=2, slow=5
735nums[3]=1不等写 nums[5]=3, slow=6
836nums[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 26LeetCode 80
最多保留数量1 个2 个
比较对象nums[fast] != nums[slow - 1]nums[fast] != nums[slow - 2]
slow 初始值12
fast 初始值12
空数组特判numsSize == 0numsSize <= 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,甚至面试官现场改的变体题,你都能在几分钟内写出不越界、不翻车的解法。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 16:30:56

JS栈实现括号匹配:从LIFO原理到边界测试的完整指南

简介&#xff1a;这份JavaScript代码面向算法初学者、前端开发者及面试备战人群&#xff0c;解决的是经典的“括号匹配”问题&#xff1a;给定仅含 (、)、{、}、[、] 的字符串&#xff0c;判断其是否满足同类型闭合和正确顺序两个条件。实现思路围绕栈这一“后进先出”数据结构…

作者头像 李华
网站建设 2026/10/6 16:30:49

油气井管柱力学全解析:从受力分析到现场应用

钻井的人心里基本都有数&#xff1a;井越深&#xff0c;看不见的东西越要命。钻头在井底到底吃上了多重的钻压&#xff0c;钻柱是稳稳当当还是已经弯成了螺旋&#xff0c;地面上看到的只有钩载、扭矩、泵压、转速这么几个参数&#xff0c;剩下的全靠模型去反推。把地下几千米的…

作者头像 李华
网站建设 2026/10/6 16:30:31

交通大数据智能调度优化:从数据接入到可视化落地的完整实践

做交通大数据项目这几年&#xff0c;我越来越觉得&#xff0c;“智能调度优化”这几个字听起来像算法论文里的高冷术语&#xff0c;落到现实里其实特别烟火气&#xff1a;早晚高峰你刷了三分钟还没车&#xff0c;公交线路明明沿途一堆人却在空驶&#xff0c;网约车司机手机屏上…

作者头像 李华
网站建设 2026/10/6 16:30:25

SolidWorks浮动许可监控看板:开源方案让许可证从黑盒变白盒

去年接手公司CAD设计团队的软件运维时&#xff0c;最头疼的一件事就是SolidWorks的许可证。公司买了40个浮动授权&#xff0c;但几乎每天都有设计师在群里喊“连不上许可”“明明还有空位为什么我登不上去”“谁把我的许可挤掉了”。这些争执背后其实是同一个问题&#xff1a;大…

作者头像 李华
网站建设 2026/10/6 16:29:43

DASSIDirect3.0是什么?老显卡驱动组件安装排错与恢复指南

简介&#xff1a;DASSIDirect 3.0驱动程序是西门子PLC与Intouch组态软件建立通讯的核心组件&#xff0c;主要面向工业自动化领域的编程与维护人员&#xff0c;用于解决S7-200/300/400/1200/1500/400H等系列设备的数据交互与驱动配置问题。安装包共159个文件&#xff0c;压缩后约…

作者头像 李华
网站建设 2026/10/6 16:28:55

古诗自动生成与情感分析:从词向量到RNN的NLP实战全流程

简介&#xff1a;这份资源是一套围绕机器学习与自然语言处理的古诗自动生成与情感分析系统完整项目&#xff0c;适合具备一定编程基础的自然语言处理学习者、课程设计或毕业设计人员参考。资源按语料爬取、数据处理、数据分析、规则作诗、机器学习写诗等模块组织&#xff0c;既…

作者头像 李华