LeetCode 26删除有序数组重复项:双指针C语言实现与细节剖析

发布时间:2026/9/30 22:06:23
LeetCode 26删除有序数组重复项:双指针C语言实现与细节剖析 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 4nums[4] 1等于nums[slow] 1继续fast 5nums[5] 2不等于nums[slow] 1slow变成2把2赋给nums[2]fast 6nums[6] 3不等于nums[slow] 2slow变成3把3赋给nums[3]fast 7nums[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如果先赋值后自增第一次fast1时把nums[0]改成1数组变成[1, 1, 2]slow变成1第二次比较nums[2]2和nums[1]1能得出正确结论纯属侥幸。数据一复杂错误就藏不住了。这个坑的本质是没有想清楚slow到底指向什么。slow必须始终指向已经保留的最后一个元素所以一定是先移动它再把新值写进去。4.2 坑二返回值算错return slow还是slow1这是最容易答错的地方之一。slow是下标不是长度。返回slow意味着少算了一个元素。比如数组[1]slow始终是0如果返回slow就返回了0等于告诉调用者这个数组没有有效元素显然是错的。正确返回值是slow 1。从另一个角度理解slow表示除第一个元素外还发现了多少个新元素总长度就是1加这个数。如果担心自己记混就在本地写个测试打印removeDuplicates的返回值再手动数一下去重后数组应该有几个元素对照一遍心里就有数了。4.3 坑三忘了处理空数组和单元素数组空数组的问题前面说过了必须单独判断。单元素数组其实不用特殊处理slow 0for循环从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不算什么惊天动地的难题但把它彻底吃透比稀里糊涂刷十道简单题都值。