ARTICLE DETAIL

资讯详情

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

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

C语言双指针详解:LeetCode 80题删除有序数组重复项II 最近刷题群里有同事在聊 LeetCode 80 题“删除有序数组中的重复项 II”说这题是 26 题的“加强版”但好多人直接用 26 题的思路改结果一跑就错。其实这题非常适合用来理解 C 语言里“双指针 原地覆盖”的数组操作套路而且面试中出现的频率不低。我这次就用 C 语言把 80 题完整拆一遍同时和 26 题做对比把两题的思路差异、代码边界、常见失分点都讲透争取让你看完能直接写出通用版的解法。我一直觉得C 语言写这种数组题是最直观的没有容器封装、没有迭代器全靠下标和指针操作反而能逼你把“为什么这个指针要停在这个位置”想清楚。这篇文章适合正在刷 LeetCode 的初学者、准备面试的候选人以及想把双指针通用套路内化的朋友。下面我们直接进入正题。1. 题目定位与核心思路1.1 题目到底在说什么先明确一下题意。给定一个按非递减顺序排列的数组nums你需要在原地删除重复元素让每个元素最多出现两次然后返回删除后数组的新长度。举个例子如果输入是[1,1,1,2,2,3]处理完应该是[1,1,2,2,3]新长度是 5。注意“最多出现两次”这个条件不是“只能出现一次”也不是“去重到只剩一个”而是允许有两份相同的值。这里有两个关键约束很多人会忽略。第一必须原地修改不能用额外的数组空间复杂度要求 O(1)。第二返回值是新数组长度但函数内部要真的修改nums让数组的前len个元素是被保留的最终结果。LeetCode 的判题机制是调用你的函数后它会检查nums数组的前len个元素是否和预期一致。所以你的代码不能只返回一个数字还必须把数组的前面部分改写掉。这道题为什么值得认真做因为它不是简单的“去重”。它考察的是“如何在一个有序序列中以某种规则筛选元素”这是很多实际场景的抽象。比如说日志数据按时间排序后要去掉过度重复的异常记录、统计数据中保留“最多 N 条相同记录”的抽样逻辑都可以套用这个模式。1.2 为什么选择双指针方案看到“原地修改数组” “有序”这两个条件第一反应就应该是双指针。一个指针fast负责遍历整个数组寻找要保留的元素另一个指针slow负责记录“下一个可写入的位置”。fast永远走在slow前面或者说至少和slow同步所以时间复杂度是 O(n)、空间复杂度是 O(1)这就是标准的“快慢指针”模式。有人会问能不能用“从后往前删”的思路比如用memmove把后面的元素往前搬每次遇到重复就整体移动。这种做法虽然最后结果可能对但最坏情况是 O(n²) 的时间复杂度而且代码容易在处理下标时出错。面试中如果写出这种版本面试官大概率会追问“能不能优化”最后还是要回到双指针。所以我的建议是遇到这类题直接养成双指针的肌肉记忆。双指针里还有一个细节值得说slow指针指向的位置既是“当前可覆盖的位置”也是“已保留区间的末尾边界”。这个概念会在 80 题里放大因为判断条件从“和前一个元素比较”变成了“和更前面的元素比较”这恰好是 26 题和 80 题最核心的区别。2. 26 题与 80 题的对比从“保留一次”到“保留两次”2.1 26 题的标准解法回顾先看 26 题“删除有序数组中的重复项”要求是每个元素只保留一次。标准 C 语言解法是这样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从 1 开始因为第一个元素无论如何都会被保留。fast从 1 开始遍历每次比较nums[fast]和nums[slow - 1]。这里nums[slow - 1]代表“已保留区间中最后一个被写入的元素”。如果当前遍历的元素和它不同说明这个元素是第一次出现或者出现的新值就把它写到slow位置然后slow前进一格。如果相同说明它在已保留区间里已经存在了直接跳过。为什么比较的是nums[slow - 1]而不是nums[fast - 1]这是一个关键问题。如果用nums[fast - 1]只能判断“当前元素和前一个元素是否重复”但无法判断“当前元素在保留区间里是否已经存在”。因为fast前面的元素可能已经被跳过、被覆盖了。用nums[slow - 1]才能保证“保留区间内最后一个元素”是准确的比较基准。这也是双指针写法里最容易理解错的一个点。2.2 80 题的变化允许两个重复80 题把条件从“保留一次”改成了“最多保留两次”。如果你直接照搬 26 题的代码只把slow的初始值从 1 改成 2会得到一个显而易见的错误当一个新值连续出现很多次时第二个和后续元素都会被跳过。为什么因为if (nums[fast] ! nums[slow - 1])这个条件只能判断“和保留区间的最后一个元素是否相同”而保留区间里已经有新值了所以从第二个重复元素开始判断结果都是“相同”然后被跳过。80 题的正确手势是比较nums[fast]和nums[slow - 2]。slow - 2是保留区间里倒数第二个位置。为什么是倒数第二个因为题目允许两个相同的元素所以当你要写入一个新的元素时只要它不等于保留区间里“倒数第二个元素”就说明它不会导致某个值出现超过两次。换句话说当前元素nums[fast]可以和保留区间的最后一个元素相同但前提是它不能和倒数第二个相同因为如果它等于倒数第二个又等于最后一个那这个值就出现三次了。写出来就是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; }这段代码比 26 题只改了两处slow初始化为 2比较下标从slow - 1改成slow - 2。但这两处改动直接把“保留一个”升级成“保留两个”背后是同一个判断逻辑我们要检查的是“当前元素如果加入保留区间会不会让某个值超过允许的数量上限”。这个上限是 2所以回头看去掉最近的 2 个位置里的最靠前那个也就是slow - 2。2.3 两题思路对比表对比维度LeetCode 26 题LeetCode 80 题保留规则每个元素最多 1 次每个元素最多 2 次判断基准nums[fast]vsnums[slow - 1]nums[fast]vsnums[slow - 2]slow初始值12特殊情况处理numsSize 0返回 0numsSize 2直接返回原长度核心思维是否“见过”这个元素是否“已经保留了两次”这个元素从这个表能看出一个规律如果把“最多保留 k 次”写成通用解法判断基准就是nums[fast]和nums[slow - k]slow初始化为 k。这是面试中非常加分的推广点。后面第 4 节我会给出一份通用的 C 语言模板先不急。3. C 语言实现细节与边界条件处理3.1 完整代码与逐行解析先把完整的 C 语言代码贴出来然后逐行解释。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 时无论元素是什么都不可能让某个值出现超过两次。比如数组是[5]或[7, 7]直接返回原长度即可。这里的判断不能写成 0因为你还要兼容长度为 1 和长度为 2 的情况。int slow 2;为什么从 2 开始因为数组的前两个元素一定可以保留。不管前两个是否相同题目允许两个重复所以它们必然合法。如果你写过 26 题会发现slow从 1 开始的理由也类似第一个元素无条件保留。循环从fast 2开始因为下标 0 和 1 已经被视为保留区间的初始内容。接下来比较nums[fast]和nums[slow - 2]。注意这里访问nums[slow - 2]是安全的因为slow最小也是 2不会出现负数下标。当条件成立时把nums[fast]复制到nums[slow]然后slow。当条件不成立时fast继续往前走slow原地等待下一个可保留元素。3.2 为什么是nums[slow - 2]而不是nums[fast - 2]这个问题值得单独讲。很多人第一个思路是“既然是允许两个重复那我看看当前元素和前面第二个元素是否相同也就是nums[fast - 2]”。这个想法听起来有道理但实际是错的。假设数组是[1, 1, 1, 1]如果比较nums[fast]和nums[fast - 2]初始fast 2时比较nums[2]和nums[0]两者都是 1所以跳过。fast 3时比较nums[3]和nums[1]也都是 1跳过。最后数组还是[1, 1, 1, 1]返回长度 4答案错误。问题出在哪里因为你比较的不是保留区间的元素而是原始数组中的元素。当你跳过某些元素后nums[fast - 2]可能是一个已经被判定为“无效”的重复值用它做基准没有任何意义。只有nums[slow - 2]才是“已经确定保留的元素”中的倒数第二个。这个点用一句话总结就是判断基准必须来自“保留区间”不能来自“原始数组的相邻位置”。3.3 边界条件测试用例光看代码还不够我拿几组典型用例验证一下。nums [1, 1, 1, 2, 2, 3]slow 2fast 2时nums[2] 1nums[0] 1相等跳过。fast 3时nums[3] 2nums[1] 1不相等写入nums[2] 2slow 3。fast 4时nums[4] 2nums[1] 1不相等写入nums[3] 2slow 4。fast 5时nums[5] 3nums[2] 2不相等写入nums[4] 3slow 5。结果数组前 5 个是[1, 1, 2, 2, 3]返回 5正确。nums [0, 0, 1, 1, 1, 1, 2, 3, 3]这个用例比较有迷惑性中间连续四个 1。按代码逻辑前面的0, 0, 1, 1都会被保留第一个多余的 1 被跳过第二个多余的 1 也被跳过2和后面的3, 3都会保留。最终结果是[0, 0, 1, 1, 2, 3, 3]长度 7正确。nums [1, 1, 1, 1]numsSize 4slow 2fast 2时nums[2] 1nums[0] 1跳过fast 3时nums[3] 1nums[1] 1跳过。返回 2数组前两个是[1, 1]正确。nums []numsSize 0进入if (numsSize 2)分支返回 0正确。nums [1, 2]长度 2直接返回 2正确。这些用例覆盖了“无重复”“有重复但未超两次”“超多次重复”“空数组”等典型情况。刷 LeetCode 时强烈建议把这些用例在本地跑一遍尤其是 C 语言环境下肉眼观察数组内容比单纯看提交结果更有感觉。4. 常见失分点、调试技巧与面试扩展4.1 三个最容易踩的坑第一个坑是忘记处理numsSize 2的情况。有些写法只写了if (numsSize 0) return 0;导致数组长度为 1 或 2 时slow 2可能越界。比如nums [1]时slow初始为 2循环不执行直接返回 2但数组长度只有 1返回 2 就是错误的。这是初学者最容易犯的错误。第二个坑是把比较条件写成nums[fast] ! nums[slow - 1]这等价于“最多保留一个”直接退回 26 题的效果。我见过有人连题目都没看清就复制 26 题代码最后结果自然不对。刷题时如果发现测试用例[1, 1, 1, 2, 2, 3]返回的是 3 而不是 5多半就是这个问题。第三个坑是在循环里误改slow或fast的步长。比如有人想跳过重复元素就在循环体里加了一个while循环结果漏掉了外层for的fast导致死循环或者越界。记住这个双指针模式里fast每次都固定走一步slow只在写入时走一步两个指针都不要在循环体内做额外的自增。4.2 本地调试方法打印数组状态C 语言刷题最常见的调试方式就是打印中间状态。我在本地跑这段代码时会在循环里加一行临时输出printf(fast%d, slow%d, nums[fast]%d, nums[slow-2]%d\n, fast, slow, nums[fast], nums[slow - 2]);这样能看到每次比较的基准是什么。尤其在“为什么跳过这个元素”这类疑惑出现时打印能帮你确认逻辑是否和预期一致。不过提交到 LeetCode 前记得把多余的打印删掉否则输出会影响判题。如果是在 VSCode 里配好了 C 语言调试环境可以加断点观察slow和fast这两个变量的值变化。比如在if那一行打断点逐步执行看nums数组在被覆盖之前和之后的内容。我实际调试时发现很多人对整个数组被原地覆盖的过程没有直观感受以为nums[slow] nums[fast]会把后面没处理到的数据破坏掉。其实不会因为slow永远小于等于fast写入的位置不会越过fast当前扫描的位置。4.3 面试加分点从“保留两个”推广到“保留 k 个”如果在面试中只写出上面这段代码可能只是及格。想加分可以主动告诉面试官这个解法可以推广到“每个元素最多保留 k 次”的通用版本。C 语言实现如下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; }调用removeDuplicatesK(nums, numsSize, 1)就是 26 题调用removeDuplicatesK(nums, numsSize, 2)就是 80 题。面试官听到这个推广大概率会点头。这个通用模板的成立条件是有序数组因为无序数组无法通过“保留区间倒数第 k 个元素是否等于当前元素”来判断是否存在超过 k 次的重复。这个推广不是 80 题专属很多“删除有序数组重复项”的变体都可以用。比如某次周赛出现过“最多保留 k 次”的题目直接套模板就能过。刷题经验多了会发现很多题的区分度不在“会不会做”而在“能不能把单个解法抽象成通用模式”。4.4 复杂度分析时间复杂度是 O(n)fast遍历整个数组一遍slow最多移动到 n总操作次数是常数倍 n所以是线性时间。空间复杂度是 O(1)除了几个整型变量没有分配额外内存所有修改都在nums上完成。这两个指标是面试必问的内容要能自己推导出来无论slow还是fast每次循环都可能移动一步整体不会超过 2n 次操作所以常数级别。到这里LeetCode 80 题的核心解法、边界处理、调试方法和通用化思路都覆盖了。在实际操作中我的经验是先理解“比较基准是保留区间的倒数第 k 个元素”再记代码模板最后用几组边界用例做校验。这样无论题目变成多少个重复上限你都能快速写出正确的 C 语言实现。
返回列表