☰
LeetCode 26删除有序数组重复项:双指针C语言实现与细节剖析
2026/9/30 22:06:13 网站建设 项目流程

LeetCode 26题,删除有序数组中的重复项,在LeetCode上难度标的是"简单",但真去刷过一遍的人心里都清楚:这题第一遍能一次写对的人并不多。原因很简单,它考的不是什么高深算法,而是一个特别容易被想岔的细节——C语言里数组元素是"删不掉"的。你所谓的删除,本质上是把不需要的元素用其他值盖掉,然后告诉调用者:有效数据就在前k个,后面的不用看了。这个"盖掉"的时机、位置、顺序,就是整道题的全部考点。

我第一次做这道题的时候,也犯过"拿变量存重复值再移位"这种笨办法,虽然能跑通,但代码又长又容易错。后来才意识到,双指针解法不是竞赛技巧,而是对"有序数组"这个条件最自然的回应。这篇文章我就把自己从暴力到双指针的完整思路、C语言实现细节、还有实际调试中踩过的坑都写出来,每个点都讲清楚为什么这么做,而不是只贴一段能通过的代码。无论你是刚学C语言没多久的初学者,还是准备面试想快速过一遍高频题,这篇都能给你一点实在的东西。

1. 这题到底在考什么:先把题目里的三句话嚼碎了

1.1 "有序数组"三个字,决定了整个解法的走向

题目里最关键的信息不是"删除重复项",而是"有序数组"。这两个词放在一起,意味着所有重复元素一定是紧挨着的,不可能出现"1, 2, 1, 2"这种交错情况。别小看这个前提,它直接把问题的复杂度从"需要记住哪些数字出现过"降到了"只需要比较相邻两个数是否相等"。

打个比方,整理一个书架。如果书是按编号排好序的,你只需要从左边走到右边,看到有两本相邻的书编号一样,就抽掉一本;如果书架是乱的,你就得拿个本子记下每本书的编号,才能知道后面出场的书是不是重复的。后者显然费事得多。LeetCode 26就是前者,因为数组已经有序,我们根本不需要哈希表、不需要额外记录,一趟遍历就能解决。

这也是为什么很多经验丰富的面试官喜欢拿这道题开场:它考察的就是你能不能抓住"有序"这个条件,并且顺着条件设计出最省的解法。如果你一上来就想"用个哈希表存出现过的数字",虽然思路没错,但明显没读懂题目真正的暗示,面试官会追问一句:那有序这个条件有什么用?

1.2 "原地修改"意味着什么:C语言里没有数组长度这回事

题目要求原地修改,不能用额外的数组空间。放到C语言的语境下,还有一层更隐蔽的约束:C语言的数组一旦定义,长度就固定了,你不可能真正把数组变短。所以最终函数的返回值是"新的长度",而不是真的把数组截断。

这道题的函数签名是:

int removeDuplicates(int* nums, int numsSize);

注意这里传的是指针int* nums,不是数组。C语言里数组作为参数传递时会"退化"成指针,函数内部并不知道数组到底有多长,必须靠numsSize这个参数把长度传进来。这也是为什么函数的返回值是int——调用者在拿到数组之后,只能通过你返回的长度来判断哪些位置的数据是有效的。

举一个实际场景。假设数组是[0, 0, 1, 1, 1, 2, 2, 3, 3, 4],经过函数处理后,内存里这块空间可能变成了[0, 1, 2, 3, 4, ...],后面几个位置是什么值已经不重要了,函数返回5,调用者就知道只看前5个位置。这就是"删除"在C语言里的真实含义:不是抹除数据,而是划定新的有效范围。

2. 思路推演:从暴力搬移到双指针

2.1 最直接的暴力做法:遇到重复就把后面整体往前搬

初学者最容易想到的做法是这样:遍历数组,发现某个元素和它前面一个相同,就把后面所有元素往前挪一个位置。这个思路完全正确,但效率很差。

设想数组[1, 1, 2, 2, 3],遍历到下标1的时候发现nums[1] == nums[0],于是把下标2到4的元素都往前移一位,数组变成[1, 2, 2, 3, 3];继续遍历的时候还要小心下标别越界,因为数组变短了。如果重复元素很多,比如一万个元素里有一大半是重复的,每发现一个重复就要移动剩余所有元素一次,总的时间复杂度是O(n²)。

这种解法也不是不能用,LeetCode 26的数据规模不大,暴力方法也能通过。但从学习的角度,它没有利用"有序数组"这个条件到极致,而且代码写起来很容易出错——移动完元素之后遍历下标怎么处理?要不要因为数组变短而提前结束循环?这些细节想想都头疼。

2.2 双指针的思路:一个负责找新元素,一个负责记位置

双指针解法把"遍历"和"写入"两件事分开了。我们准备两个下标:

  • slow:指向"已经处理好的、没有重复的那一段"的末尾位置,也就是下一个不重复元素该放的位置;
  • fast:负责往前探查,一个元素一个元素地看过去。

核心逻辑一句话就能说清:每当fast发现一个新元素,就把这个新元素放到slow的位置,slow前进一格;如果fast看到的还是旧元素,就继续往前走,什么都不做。

用数组[0, 0, 1, 1, 1, 2, 3, 3]来走一遍:

  • 初始slow = 0,指向第一个元素0(第一个元素肯定保留);
  • fast = 1,发现nums[1] == nums[slow],也就是0 == 0,重复了,slow不动;
  • fast = 2,发现nums[2] = 1不等于nums[slow] = 0,说明遇到了新元素。先让slow变成1,再把nums[2]的值赋给nums[1]。数组变成[0, 1, 1, 1, 1, 2, 3, 3];
  • fast = 3,发现nums[3] = 1等于nums[slow] = 1,继续;
  • fast = 4,nums[4] = 1等于nums[slow] = 1,继续;
  • fast = 5,nums[5] = 2不等于nums[slow] = 1,slow变成2,把2赋给nums[2];
  • fast = 6,nums[6] = 3不等于nums[slow] = 2,slow变成3,把3赋给nums[3];
  • fast = 7,nums[7] = 3等于nums[slow] = 3,继续。

最终slow = 3,数组前4个位置是[0, 1, 2, 3],函数返回slow + 1 = 4。注意slow是指向下标,而下标从0开始,所以长度要比下标多1。

2.3 为什么用slow指针判断而不是fast-1

网上有些代码写法是nums[fast] != nums[fast - 1],这也能通过,但有细微差别。用fast - 1做比较,隐含逻辑是"因为数组有序,只要当前元素和前一个不同就是新元素"。这个判断本身没错,问题在于如果前一个位置的数据已经被覆盖过了,写起来心里总归不够踏实。

用nums[fast] != nums[slow]做判断,逻辑上更严谨:slow指向的永远是"已保留的最后一个元素",你拿当前探查的元素和它比较,如果不同,说明确实遇到了新的值。而且这个写法对数组有序性的依赖最小——哪怕数组只是部分有序,只要重复元素聚在一起,这个逻辑也能工作。

我个人的习惯是推荐用slow做判断。因为它把"已处理的区间"和"待探查的区间"划分得很明确,代码读起来也更清楚。面试的时候,如果面试官让你解释slow的含义和为什么fast从1开始,用slow版本解释起来顺畅得多。

3. C语言实现与代码逐行拆解

3.1 完整代码先放出来

int removeDuplicates(int* nums, int numsSize) { // 空数组直接返回0,后面所有逻辑都建立在至少有一个元素的基础上 if (numsSize == 0) { return 0; } // slow指向最后一个已保留的元素 int slow = 0; // fast从1开始,第一个元素默认保留 for (int fast = 1; fast < numsSize; fast++) { if (nums[fast] != nums[slow]) { // 遇到新元素:slow先前进,再把新值写进去 slow++; nums[slow] = nums[fast]; } } // slow是最后一个元素的下标,数组长度是下标加1 return slow + 1; }

这段代码短小精悍,核心逻辑就一个if加两行赋值。但越是简洁的代码,越要确保每一行都理解透了。

3.2 逐行讲解:函数签名、指针操作、边界处理

空数组判断是第一个不能漏掉的边界。如果numsSize是0,函数直接返回0。如果不做这个判断,后面的nums[0]就成了越界访问,在LeetCode上可能表现为"运行时错误",在本地跑可能得到随机值,属于典型的未定义行为。

**int slow = 0**为什么初始化为0?因为第一个元素nums[0]无论如何都要保留。哪怕整个数组只有一个元素,它也不存在重复的问题。所以slow初始指向0,表示"已经保留的元素中最后一个就是第一个元素"。

**for (int fast = 1; fast < numsSize; fast++)**为什么从1开始?因为0号元素已经处理过了,如果fast也从0开始,第一轮比较就是nums[0] != nums[0],永远为假,浪费一次循环不说,还容易让人误解逻辑。

**if (nums[fast] != nums[slow])**是关键判断。slow指向最后一个已保留元素,fast指向正在检查的元素。如果不相等,说明fast找到了一个从未出现的新值,需要保留它。如果相等,说明还是同一个值重复出现,什么都不做,让fast继续往后找。

**slow++; nums[slow] = nums[fast];**这两行顺序不能反。先把slow向后移动一位,给新元素腾出位置,再赋值。有人会写成nums[++slow] = nums[fast],效果一样,纯属个人风格。要特别注意,这里slow永远不会超过fast,因为每处理一个新元素,slow才前进一步,而fast每轮循环都在前进。这个性质保证了赋值操作不会覆盖掉还没检查过的元素。

3.3 复杂度分析:为什么O(n)是这道题最优解

时间复杂度O(n),因为fast从头到尾走了一遍,slow只在遇到新元素时移动,整个过程每个元素最多被访问两次——一次被fast读,一次在重复时被忽略,或者在一次被slow写。空间复杂度O(1),只用了两个整型变量,没有申请任何额外数组。

能不能比O(n)更快?不可能。哪怕数组已经完全有序、完全没有重复,你也必须把每个元素都看一遍才能确定它是不是新值。所以O(n)是这道题的下界,这个双指针方案就是最优解。

顺便说一句,有人会问"能不能用二分查找",这是另一个维度的问题。二分查找可以用来快速统计某个值出现的次数,比如"统计_py_里有多少个1",但它不能解决"把不重复元素搬到前面"这个需要全量遍历的问题,两者不是一回事。

4. 写这道题最常见的四个坑

4.1 坑一:slow先赋值再自增,把还没检查的元素覆盖了

我见过不少朋友把判断逻辑反过来写,变成了这样:

if (nums[fast] != nums[slow]) { nums[slow] = nums[fast]; slow++; }

看起来好像只是把两行换了个顺序,但结果完全不同。如果先赋值再让slow自增,那么nums[0]会被nums[1]覆盖,然后slow变成1。下一次比较的时候,nums[slow]指向的不再是"最后一个已保留元素",而是"刚刚写入新值的位置后面那个还没处理过的原始元素"。整个比较基准就乱了。

举个例子,数组[0, 1, 2],正确逻辑走一遍返回3;如果先赋值后自增,第一次fast=1时把nums[0]改成1,数组变成[1, 1, 2],slow变成1,第二次比较nums[2]=2和nums[1]=1,能得出正确结论纯属侥幸。数据一复杂,错误就藏不住了。

这个坑的本质是:没有想清楚slow到底指向什么。slow必须始终指向"已经保留的最后一个元素",所以一定是先移动它,再把新值写进去。

4.2 坑二:返回值算错,return slow还是slow+1

这是最容易答错的地方之一。slow是下标,不是长度。返回slow意味着少算了一个元素。比如数组[1],slow始终是0,如果返回slow就返回了0,等于告诉调用者"这个数组没有有效元素",显然是错的。

正确返回值是slow + 1。从另一个角度理解:slow表示"除第一个元素外,还发现了多少个新元素",总长度就是1加这个数。

如果担心自己记混,就在本地写个测试,打印removeDuplicates的返回值,再手动数一下去重后数组应该有几个元素,对照一遍心里就有数了。

4.3 坑三:忘了处理空数组和单元素数组

空数组的问题前面说过了,必须单独判断。单元素数组其实不用特殊处理:slow = 0,for循环从fast = 1开始,此时fast < numsSize不成立,循环体一次都不执行,直接返回0 + 1 = 1,完全正确。

但新手容易在单元素数组上画蛇添足,比如加一个if (numsSize == 1) return 1;,这倒也不算错,但是完全没必要。理解了边界情况之后,你会发现空数组是唯一需要特殊处理的边界。

4.4 一个实用的本地调试技巧:打印每一步的数组状态

LeetCode的在线环境不方便调试,但你在本地IDE里完全可以自己写个测试程序,在每个关键步骤打印数组当前状态。比如在nums[slow] = nums[fast]之后加一行:

printf("fast=%d, slow=%d, nums[slow]=%d\n", fast, slow, nums[slow]);

或者每轮循环结束打印整个数组前几个元素,观察slow和fast的移动。我第一次彻底弄懂这道题,就是靠这种"可视化"的笨办法——打印几遍之后,双指针的行为模式就刻在脑子里了,之后再遇到同类型题目基本不会再错。

5. 从刷题到实战:这道题背后的通用能力

5.1 面试官为什么爱考这道题

LeetCode 26属于典型的"一看就会,一写就错"题目,非常适合考察候选人写代码的基本功。它不需要复杂的算法知识储备,但要求你能正确理解指针语义、边界条件、原地修改的概念,还要保证代码足够简洁。

面试官问这道题,通常还会追加几个变种问题:如果是无序数组怎么办?如果要求保留重复元素最多出现两次怎么办?如果返回的不是新长度而是修改后的数组本身怎么办?这些问题都是在考察你能否灵活调整解法。比如保留最多出现两次,只需要加一个计数器变量;无序数组则可以先排序再调用这套逻辑,或者用哈希表记录出现次数。

5.2 延伸场景一:不是"有序"数组怎么办

如果数组无序,双指针的直接比较逻辑就不成立了,因为你无法预判某个值后面还会不会再次出现。这时候有两个常见方案:

  • 先排序再使用双指针。排序的时间复杂度O(n log n),整体思路最简单。代价是改变了元素相对顺序,而且如果面试官要求保持原顺序,这条路就走不通。
  • 用哈希表记录已经出现过的值。遍历一遍,第一次出现的值放入结果区,同时记入哈希表,之后遇到重复就跳过。时间O(n),空间O(n)。代价是需要额外空间,但能保持相对顺序。

这两种方案分别对应"牺牲时间"和"牺牲空间",面试中讲出这个取舍,比直接背代码有用得多。

5.3 延伸场景二:字符串去重、数据清理等真实需求

LeetCode 26的思路在真实开发中很常见。比如处理一份日志文件,按时间排序的事件列表里有很多重复记录,需要去重;或者从传感器读取的数值序列中,丢弃相邻重复的采样点,只保留变化的点。这些场景都是"有序数据 + 原地去重"的典型应用,双指针写法可以直接平移过去。

区别在于,真实开发中你可能面对的是字符串指针数组、结构体数组,或者更复杂的数据类型。判断相等的逻辑从=变成strcmp或者自定义比较函数,但整体框架完全一样。掌握这道题之后,遇到"有序容器去重"这类需求,第一反应就应该是双指针,而不是启动for循环嵌套。

我个人在实际带新人的时候,也习惯先让他们做这道题。它逼着你养成几个好习惯:动手写代码之前先分析边界条件,思考变量每一步指向什么含义,写完再回头检查返回值。这些习惯养成了,后面做更复杂的链表、树、动态规划题目都会顺畅很多。LeetCode 26不算什么惊天动地的难题,但把它彻底吃透,比稀里糊涂刷十道简单题都值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询