ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言删除有序数组重复项:双指针原地修改与边界处理详解

C语言删除有序数组重复项:双指针原地修改与边界处理详解 刷 LeetCode 的时候最怕遇到那种看题面一脸懵、看了答案恍然大悟的题但第 26 题“删除有序数组中的重复项”绝对不在此列。它属于那种题目看起来极简、解法也极简、但想清楚原理后能让你悟到不少东西的基础题。尤其用 C 语言来做既没有容器帮你擦屁股也没有库函数替你封装逻辑每一步都得靠指针和下标白手起家反而是理解数组操作和算法思想的绝佳素材。原地删除重复元素O(1)额外空间返回新长度——这几个关键词一出来基本就锁定了双指针方案。这篇内容我会把题目的核心思路拆开揉碎讲清楚为什么是快慢指针、边界条件怎么处理、C 语言实现的几个细节坑再附上我实际提交和本地验证时踩过的一些坑。不管你是刚开始刷题的新手还是准备面试前过一遍基础题的老手这篇都可以直接拿来当参考。1. 题目核心拆解这题到底在考什么这题表面上只是“删重复项”但实际考察的是两个基本功一是对数组连续内存特性的理解二是双指针或者叫快慢指针的应用。题目给的是一个非严格递增的有序数组也就是说重复元素一定紧挨在一起这决定了我们不需要额外开一个哈希表来记录出现过的元素只需要比较相邻元素就能判断是否重复。1.1 题意精读别被“删除”两个字带偏很多人第一反应是“删除元素嘛数组里把后面的元素往前挪覆盖掉要删的位置最后再把数组长度减掉”。这个思路在逻辑上没错但实际操作起来会有一个问题数组的存储空间一旦分配就是固定的C 语言里根本没有“真正删除一个元素并缩短数组”这种操作。所谓删除本质上都是“用后面的有效元素覆盖前面的位置最后通过返回的新长度来圈定有效区域”。题目要求返回新的长度还要求必须原地修改输入数组这意味着我们其实是在“压缩”这个数组——把不重复的元素重新排到前面去而后面那些残留的旧值根本不需要管因为只要返回了正确的长度调用方就只会在前len个位置里读取数据。提示这一点看起来不起眼但恰恰是很多新手卡壳的地方——他们总想着把数组末尾的多余元素清理掉或置 0实际上完全没必要白白浪费了时间。1.2 为什么双指针是正解连续内存下的最优操作既然目标是“把不重复元素依次放到数组前面”那么就需要一个指针负责遍历整个数组找不重复的值另一个指针负责记录“下一个不重复值该放的位置”。这正是快慢指针的经典应用场景。快指针一般叫i用来扫一遍数组它的任务只有一个发现“不同的值”。由于数组有序只要nums[i] ! nums[i - 1]就说明nums[i]是一个新的元素需要被保留。慢指针一般叫index或slow则指向下一个要覆盖的位置。每找到一个新元素就把它放到nums[index]然后index继续等下一个。这样做的好处是一趟遍历完成所有操作时间复杂度 O(n)额外空间 O(1)。没有辅助数组没有动态内存分配甚至连临时数组副本都不需要。在 C 语言环境下这种朴素高效的写法就是最贴合语言特性的做法。2. 手写 C 语言解法每一行都讲清楚代码本身不长甚至可以压缩到十几行但每一行背后都有讲究。我先把完整代码给出来然后逐段拆解顺便说几个容易写错的地方。int removeDuplicates(int* nums, int numsSize) { if (numsSize 0) { return 0; } int index 1; // 慢指针下一个不重复元素存放的位置 for (int i 1; i numsSize; i) { if (nums[i] ! nums[i - 1]) { nums[index] nums[i]; index; } } return index; }2.1 边界处理为什么要单独处理空数组第一眼看上去if (numsSize 0) return 0;这行好像有点多余——如果数组为空循环本来就不会进去直接返回 0 不就行了吗确实如果numsSize 0那么for循环条件i numsSize一开始就不满足函数会直接走到return index;而index初始值是 1这时候就出问题了。返回 1 显然是错误的因为空数组里面一个元素都没有应该返回 0。所以要么你单独写一个if (numsSize 0) return 0;做保护要么把index改成从 0 开始计数。但后者会让后面的逻辑变啰嗦因为第一个元素永远是保留的有序数组中第一个元素必无重复前驱直接让index 1更符合直觉。两种写法都可以但显式处理空数组更清晰。2.2 慢指针为什么从 1 开始快指针为什么也从 1 开始因为第一个元素无论如何都会被保留所以慢指针直接从 1 开始意味着nums[0]已经稳坐钓鱼台不需要参与比较和覆盖。这个设计让循环从i 1开始每次比较nums[i]和nums[i - 1]。有同学可能会问“我用nums[i] ! nums[i 1]比较行不行”也可以但那样 i 的终止条件就得变成i numsSize - 1而且最后一个元素要额外处理边界判断容易漏。相比之下当前这种写法边界最干净快指针既不会越界也不需要末尾补充逻辑。2.3 核心循环逻辑覆盖与移动的顺序问题nums[index] nums[i]; index;这两句话的顺序不能反过来。必须先赋值再自增因为index指向的是“当前要覆盖的位置”赋完值后这个位置已经填好才能把它推进到下一个空位。这两行代码完成三个隐含步骤发现nums[i]是一个新元素与前一个不同。把这个新元素搬运到前方有效区的末尾。有效区的边界向前扩展一位。整个过程中数组前面的元素始终是有序且不重复的这就是双指针法的不可变性invariant。理解了这个不变性你就掌握了这类题目的通解框架。实操心得如果你用文字描述这段逻辑——让慢指针前面始终维护一段“不重复区”快指针不断探索并扩展这段区域——这个视角可以迁移到很多“原地压缩类”问题后面我在第四节还会展开。3. 复杂度分析与正确性验证一道题做完只写出能跑的代码还不够得能用复杂度分析和正确性论证把解法“讲圆”。这一步在面试中特别加分也是区分“背答案”和“真懂”的分水岭。3.1 时间与空间复杂度为什么是 O(n) 和 O(1)快指针i从 1 遍历到numsSize - 1每轮做一次比较遇到新元素时多做一次赋值全程总共处理n个元素。所以时间复杂度严格正比于数组长度写作 O(n)。整个算法只用了一个额外的整型变量index没有申请动态内存没有开辅助数组额外空间消耗是常数级别 O(1)。这完美符合题目的硬性要求——原地修改、常数空间。3.2 正确性论证用循环不变量说服自己我在前面提到了“不变性”这个词这里展开说清楚。在每轮循环开始时状态是这样的nums[0 ... index - 1]区间内保存了所有已扫描元素中的不重复值且顺序与原始相对顺序一致。index永远指向这个有效区间的末尾空缺位置。快指针i之前的所有元素都已经被“决策”完毕。每次执行if判断时如果nums[i] ! nums[i - 1]意味着“新值出现”将它搬进有效区否则跳过。这保证了循环结束时nums[0 ... index - 1]恰好是原数组去重后的完整结果。这个论证方式比“看着很对”要扎实得多也方便你自己在边界条件上找漏洞。4. 常见错误与调试实录这些坑我都踩过讲完了原理和代码下面这部分可能是对你最有用的——实际动手时最容易踩的坑以及排查问题时的思路。我在本地 VS Code 环境里用 C 语言反复跑过这题的多个变体把典型的错误场景整理成了下面这张速查表。错误类型错误写法/思路结果正确做法空数组未处理初始化index 1后直接进循环返回 1判定错误单独处理numsSize 0快指针起始位置错从i 0开始遍历且比较nums[i]和nums[i1]边界检查复杂化容易漏最后一个元素从i 1开始比较相邻前驱覆盖时指针顺序颠倒先index再nums[index] nums[i]第一个新元素放错位置覆盖到下一个空位结果错乱先赋值再自增比较时误用后驱比较nums[i]和nums[i1]且循环写成i numsSize最后一次访问nums[numsSize]越界比较前驱循环写成i numsSize返回值搞错返回index 1或index - 1多一位或少一位模拟一遍验证边界4.1 最隐蔽的坑返回值到底是 index 还是 index 1这个坑我见过太多人掉进去。分析一下index的初始值是 1指向第二个位置。那么循环结束后index本身就代表已经存放的元素个数。举例来说数组[0,0,1,1,2]初始index 1遇到第一个 1 时写入位置 1 然后自增为 2遇到第一个 2 时写入位置 2 然后自增为 3。最终index 3而新数组确实是 3 个元素[0,1,2]。完全吻合。如果你写成返回index 1就会把长度多算一位返回index - 1就会少一位。判断办法很简单拿一个三元素数组手动模拟一遍看index最终停在哪。4.2 内存越界经典场景比较后驱元素时踩线假设数组长度为 5索引范围是 0 到 4。如果你写的是nums[i] ! nums[i 1]并且for (int i 0; i numsSize; i)那么当i 4时nums[i 1]就是nums[5]——严格越界。C 语言不会帮你检查数组边界本地跑可能“碰巧没问题”但 LeetCode 的评测环境往往就会因为这个细节直接报错。不要赌未定义行为老老实实用比较前驱元素的写法让快指针从 1 起步就永远不会碰到这个问题。5. 从这题延伸出去的思考快慢指针的通用性第 26 题虽然简单但吃透它之后能给你带来一个特别有用的武器——双指针处理有序序列的思路。这个思路在后续好几个题目里都能直接套用属于常青树级别的算法模板。5.1 延伸变体一LeetCode 27 移除元素题目要求把数组中所有等于某个给定值的元素移除也是原地修改、返回新长度。思路跟 26 题几乎一模一样唯一区别在于判断条件不再是“与前一个元素比较”而是“与目标值比较”。慢指针仍然维护有效区快指针负责扫描遇到不等于目标值的就搬运。熟练了第 26 题27 题就是一分钟的事。5.2 延伸变体二LeetCode 80 删除有序数组中的重复项 II这一题的难度稍微上了一个台阶允许每个元素最多保留两个。难点在于判断条件从“与前一个比较”变成了“是否已经连续出现两次”。不过核心框架还是没变依旧是快慢指针只是慢指针不再简单地 1而是要根据计数情况进行条件判断。做这道题的时候你会更强烈地感受到第 26 题那座“地基”有多重要。个人经验我刷题的时候习惯每做一道双指针题就回去对比之前的代码看哪些判断条件变了、哪些结构没动。这个习惯能帮你把零散题目串成知识网络比单刷十道题效果更好。5.3 做这几道题时建议的自测用例分享一个我在本地验证用的测试清单覆盖了大多数边界情况你可以直接抄过去用空数组[]应该返回 0单元素数组[1]应该返回 1全重复数组[1,1,1,1]应该返回 1无重复数组[1,2,3,4]应该返回 4重复段在中间[1,2,2,3,3,3,4]应该返回 4重复段在开头和结尾[1,1,2,2]应该返回 2把这几组用例跑过一遍基本就能确定实现是稳的。6. 手把手实操本地 C 语言环境验证全流程光看代码和思路还不够建议你亲手把代码跑一遍。这一节我以 VS Code 配置好了 C 语言环境为前提带你走一遍本地验证的完整流程顺便指出容易出问题的小细节。6.1 本地测试代码怎么组织LeetCode 只需要你提交removeDuplicates这个函数但本地跑的时候得自己补一个main函数来调用和验证。更好的做法是写一个打印中间过程的版本方便观察每一步覆盖动作。#include stdio.h int removeDuplicates(int* nums, int numsSize) { if (numsSize 0) { return 0; } int index 1; for (int i 1; i numsSize; i) { if (nums[i] ! nums[i - 1]) { nums[index] nums[i]; index; } } return index; } int main() { int nums[] {0, 0, 1, 1, 1, 2, 2, 3, 3, 4}; int len sizeof(nums) / sizeof(nums[0]); int newLen removeDuplicates(nums, len); printf(new length: %d\n, newLen); for (int i 0; i newLen; i) { printf(%d , nums[i]); } printf(\n); return 0; }这里特别说一下sizeof(nums) / sizeof(nums[0])这是 C 语言里计算数组长度的标准写法。它利用的是sizeof在编译期返回字节数的特性——整个数组的字节数除以单个元素的字节数就是元素个数。但要注意这个写法只在数组本身生效。如果你把nums作为参数传进函数在函数内部再用sizeof(nums)得到的是指针的大小通常是 8 字节或 4 字节结果就完全不对了。这也是为什么 LeetCode 的题目接口会直接给你numsSize参数不用你自己算。6.2 编译运行与结果观察在 VS Code 里如果你装好了 C/C 扩展可以直接用 Code Runner 插件一键运行。如果不想装插件也可以手动在终端里执行 gcc 命令gcc -o remove_duplicates remove_duplicates.c ./remove_duplicates上面那份测试数组{0, 0, 1, 1, 1, 2, 2, 3, 3, 4}运行后应该输出new length: 5 0 1 2 3 4如果你看到的输出跟这个不一致就可以根据前面的排查表逐项检查自己的实现。6.3 调试小技巧打印 index 和 i 的变化过程当你觉得代码逻辑不太对、又看不出问题在哪的时候最有效的办法是打印关键变量。比如在循环里临时加上printf(i %d, index %d, nums[i] %d, nums[i-1] %d\n, i, index, nums[i], nums[i - 1]);观察快慢指针的移动轨迹和每次赋值动作能让你很快定位到是边界条件错了还是比较逻辑写反了。实际调试几次之后你会发现双指针题目的 bug 绝大多数出在“谁先走、谁判断、谁赋值”的顺序问题上这种打印法基本能一眼看穿。7. 总结一点刷题之外的心得如果只看代码本身这道题确实谈不上复杂但它的价值在于让你理解“原地修改”的真正含义以及双指针如何用最小代价完成数组压缩。尤其对 C 语言学习者来说这题所涉及的指针操作、边界判断、sizeof 使用、内存不越界等知识点都是平时写代码最容易出问题的地方。把这些细节抠扎实了后续接触更复杂的数据结构和算法时你的基础也会明显比别人更稳。我刚刷完这题的时候也没觉得有什么特别直到后来反复遇到它的变体题才慢慢体会到这十几行代码里蕴含的设计思想——很多问题看似复杂只要你找到了那个“快慢指针”的视角复杂就会被瞬间削平。这就是刷题真正有意思的地方。
返回列表