
1. 题目拆解与双指针思路1.1 题目到底在考什么力扣第26题“删除有序数组中的重复项”是很多人的入门题也是面试里出现频率相当高的一道。题目本身看着很简单给你一个非严格递增排列的数组让你原地删除重复出现的元素使每个元素只出现一次返回删除后数组的新长度。这里有几个关键词值得划重点——“有序”“原地”“返回新长度”。“有序”这两个字决定了这道题可以用很巧妙的方式解决。如果没有“有序”这个前提你要去重还得先排序或者用哈希表复杂度就完全不一样了。“原地”意味着你不能新建一个数组再拷贝回去空间复杂度得控制在O(1)。“返回新长度”则暗示了一个约定只要数组的前k个位置是去重后的结果后面剩下多少元素都不管。这点很多新手没看懂以为要真的把数组缩短结果去纠结怎么缩容。我最初刷这道题的时候第一反应是“这不就是unique加erase吗”。确实C的std::unique干的就是这事Python里甚至可以直接dict.fromkeys。但如果只是调用现成函数这道题就白刷了。面试官想看到的是你能不能手写出这个过程并且说清楚每一步的语义。1.2 双指针的直觉快慢指针的“圈地”思想双指针解法是这道题的标准答案也是必须掌握的思路。核心操作就一句话用一个慢指针维护“已经处理好的区域”的末尾用一个快指针去扫描整个数组。什么叫“圈地”你可以想象成整理房间慢指针代表你已经整理好的那一块区域快指针代表你正在检查的下一件物品。如果检查到的物品和整理区最后一件不同就把它搬进整理区让整理区扩大一格如果相同说明是重复的直接跳过。慢指针从0开始快指针从1开始。每次比较nums[fast]和nums[slow]是否相等如果不等就把slow往前移一位然后把nums[fast]的值赋给nums[slow]。如果相等说明fast指向的值和slow指向的值相同是一个重复项不做任何操作fast继续往后走。这样一轮扫完之后slow指向的位置就是去重后数组的最后一个元素slow1就是新数组的长度。因为fast每次至少走一步slow最多也走n步整体就是一趟线性扫描。1.3 为什么不能用额外数组有些朋友会想“我开一个set把所有元素丢进去再倒回原数组不是更省事吗”省事是省事但打破了“原地”的要求。题目明确要求原地修改额外开一个长度为n的数组或者set空间复杂度就是O(n)不符合题意。更深一层说这道题考察的其实是“如何在不借助额外存储的情况下利用数据本身的规律”。有序数组的重复项一定是相邻的这个性质给了我们一个线索去重的过程本质上就是把不相邻的、不同的元素往前提。双指针正是利用了这一点让空间复杂度降到了O(1)。我在实际面试中遇到过不少候选人上来就说用哈希表被问到“能不能O(1)空间”就卡住了。其实只要抓住“有序数组重复项相邻”这个关键性质双指针的思路就顺理成章了。2. 代码实现与细节打磨2.1 最经典的C写法双指针的代码非常短短到很多人背下来就完事了但真正理解每一行语义的人不多。先看C实现class Solution { public: int removeDuplicates(vectorint nums) { if (nums.empty()) { return 0; } int slow 0; for (int fast 1; fast nums.size(); fast) { if (nums[fast] ! nums[slow]) { slow; nums[slow] nums[fast]; } } return slow 1; } };这段代码就是我上面说的“圈地”思路。slow指向当前已经去重区域的最后一个元素fast负责巡逻。注意比较的是nums[fast] ! nums[slow]而不是nums[fast] ! nums[fast - 1]这两种写法在这个场景下结果是等价的但语义上我还是推荐比较slow。为什么因为slow指向的是“已确认去重区域的末尾”而fast - 1指向的只是一个相邻位置。在去重逻辑里我们要判断的是“这个新元素是否和已整理区的最后一个元素相同”不是“它和前一个元素是否相同”。虽然在这个特定的双指针写法下两者结果一样但用slow语义更清晰也更容易迁移到后面要讲的变体题。2.2 其他语言版本Java写法和C几乎一样class Solution { public int removeDuplicates(int[] nums) { if (nums.length 0) { return 0; } int slow 0; for (int fast 1; fast nums.length; fast) { if (nums[fast] ! nums[slow]) { slow; nums[slow] nums[fast]; } } return slow 1; } }Python版本要稍微注意一下因为Python没有真正的数组用的是listclass Solution: def removeDuplicates(self, nums: List[int]) - int: if not nums: return 0 slow 0 for fast in range(1, len(nums)): if nums[fast] ! nums[slow]: slow 1 nums[slow] nums[fast] return slow 1这三种语言的核心逻辑完全一致只是语法层面的区别。如果你要准备面试建议选一门自己最熟的语言把这段代码练到闭着眼都能写出来同时能讲清楚每一步在干什么。2.3 代码里的三处细节踩过坑才懂第一处细节是空数组的判断。慢指针初始为0如果数组为空直接访问nums[0]就会越界。所以必须在一开始就判断。有些老手会写成if (nums.size() 1) return nums.size()因为长度为0或1的数组都不可能有重复项这样写更省事本质上是一回事。第二处细节是为什么返回slow 1而不是slow。因为slow是下标从0开始计数。去重后数组有slow 1个元素。举个例子数组[1, 2, 3]slow最终停在2长度是3也就是2 1。这个1是初学者最容易错的地方我见过太多人在这里返回slow结果长度少了1。第三处细节是赋值覆盖会不会丢失数据。有人担心nums[slow] nums[fast]这一步会覆盖掉还没扫描到的元素。其实不会因为fast始终大于slow被覆盖的位置最多是slow的下一个位置而这个位置里的元素要么已经被处理过要么就是和当前重复的不会影响后续比较。3. 边界条件与测试用例设计3.1 这些用例比代码更重要我在给这道题写单元测试的时候一般会准备下面几组用例。别嫌多这些用例能帮你确认算法在各种极端情况下都不会翻车。第一组是空数组和单元素数组。空数组返回0单元素数组直接返回1。这两个用例专门用来验证边界判断。第二组是完全没有重复的数组比如[1, 2, 3, 4, 5]。这种情况下slow和fast几乎同步前进每次比较都不相等每个元素都被保留。第三组是全部相同的数组比如[1, 1, 1, 1]。这种情况下fast一路往后走slow一直停在0最后的长度是1剩下的三个位置是残留的旧值但题目不关心。第四组是重复项在中间的情况比如[0, 0, 1, 1, 1, 2, 2, 3, 3, 4]。这种用例最能检验赋值覆盖的逻辑是否正常。3.2 手动模拟一遍全过程我们用手写的方式走一遍[0, 0, 1, 1, 1, 2, 2, 3, 3, 4]这个用例。初始状态下slow 0指向第一个0fast 1指向第二个0。比较nums[1]和nums[0]都是0fast直接跳到2。nums[2]是1不等于nums[0]的0于是slow变成1nums[1] 1。此时数组前两个位置变成[0, 1]slow 1指向1。fast继续走nums[3]是1和nums[1]的1相等跳过。nums[4]是1相等跳过。nums[5]是2不等于1slow变成2nums[2] 2。之后的过程类似数组前5个位置最终变成[0, 1, 2, 3, 4]slow停在4返回5。这个模拟过程看起来很机械但每一步都印证了双指针的核心逻辑slow永远指向最后一个已保留的元素fast负责在前面探路。3.3 题目对“新长度之后”的约定很多刚开始刷题的朋友会困惑“数组明明还有残留数据为什么长度可以只返回前k个”因为题目明确规定函数返回k之后评测器只检查数组前k个元素是否满足去重且有序的要求后面的元素爱是啥是啥。这个约定其实非常有实际意义。在真实工程里你删除数组中的一个元素后面元素的前移并不等于要销毁旧数据很多时候只是把逻辑长度缩小而已。C的std::vector调用erase之后size会变但capacity不变超出size的那部分内存数据也还是在那里的。理解这一点你对“逻辑长度”这个概念会有一个具体的感知。4. 进阶思考与相似题目串联4.1 留一个重复项留两个呢力扣有一道姊妹题叫“删除有序数组中的重复项 II”编号80规则是每个元素最多出现两次。那题只是把“比较slow”改成“比较slow - 1”思路就变了。为什么会有这种变化因为当允许重复两次时slow指向的位置不能作为参照了——它可能是第一次出现的元素也可能已经是第二次出现的元素直接比较无法判断。解决办法是让slow永远指向“已处理区域的倒数第二个位置”或者换个写法用一个count变量记录当前元素出现了几次超过限制就只移动fast。这类变体题的核心还是双指针只是“保留条件”从“不相等”扩展成了“次数不超过2”。如果你能理解原题80题其实就是半小时的事。4.2 双指针题型的识别套路刷了这类原地数组题之后你会慢慢总结出一个规律凡是要求“原地修改数组/字符串”且结果只依赖相对顺序的八成可以用双指针解决。典型场景包括移除元素、删除重复项、移动零、颜色分类荷兰旗问题、合并两个有序数组。这些题看似不同本质都是“用一个指针维护结果区用一个指针遍历原数据”。唯一的差异是“判断是否加入结果区”的条件。移动零是“非零就加入”移除元素是“不等于val就加入”这题是“不等于前一个就加入”。把这一类题放在一起对比着刷效率比一道一道地孤立刷要高很多。4.3 这道题在面试中的价值如果面试官只让你写出这题那八成是面试的前15分钟热场题。但热场题也有讲究考察的是你写代码的熟练度、对边界条件的敏感性、以及对复杂度的表述是否清晰。我见过不少候选人代码写得对但被问“为什么空间复杂度是O(1)”时支支吾吾说不出来。还有一点值得注意面试官常常会在你答完之后追加一个条件比如“如果我允许你重复的元素最多出现k次呢”。这时候如果你只是背了答案很容易懵。但如果你理解的是“保留条件”这个抽象概念就能立刻应对把比较条件改成计数逻辑即可。这也是为什么我在前文反复强调要理解每一步的语义而不是背模板。5. 常见错误与调试技巧5.1 我见过的几种典型错误写法第一种错误是返回值写错。有人写成return slow也有人写成return slow 1但初始化slow 1。前一种会让长度少1后一种在数组为空的时候会返回1直接崩掉或者判错。解决办法是先确定slow的定义再根据定义推返回值。第二种错误是循环边界写错。有人写for (int fast 0; ...)把fast从0开始结果第一个元素就和自己比较虽然不影响结果但会平白多做一次无意义的比较。更危险的是写成fast nums.size() - 1这样会漏掉最后一个元素的处理。第三种错误是没有处理空数组。这在C和Java里是典型的越界崩溃在Python里则是IndexError。写代码之前先看一眼边界永远是性价比最高的一件事。5.2 如何用调试手段验证逻辑遇到不对的用例时我建议在关键位置打印中间状态。比如循环里打印每一次比较的slow、fast和当前数组内容。因为数组很短打印出来的状态一目了然。特别是当你的数组里有负数、有超过两位数的数字时人脑跟踪很容易出错打印调试比盯着代码想高效得多。另一个技巧是准备一个“暴力对照”函数。用一个简单粗暴的方法比如新建一个数组去重和你写的双指针方法同时跑随机生成的数据对比结果是否一致。随机测试跑上几百组大部分隐藏的边界问题都会暴露出来。5.3 一道题背后的刷题方法论老实说26题本身没有太多“含金量”但它是理解双指针这一类题的最佳入门题。我的建议是不要满足于AC。AC之后想一想——如果数组不是有序的还能用双指针吗如果要求空间O(1)还能怎么解如果数据量特别大这道题的时间复杂度会不会成为瓶颈我之前带过一些朋友刷题大家共同的误区是刷题数量优先、复盘质量不足。遇到一个题目AC了就不再回头看结果过了两周在面试里遇到相似的题还是没思路。其实像26题这种基础题你把它的变体、它的边界、它的优化思路都滚一遍远比刷5道同类型但不深挖的题有用。这个内容后续还可以继续扩展把80题做一遍把“移动零”和“移除元素”做一遍再总结一份自己专属的双指针模板。模板不需要多花哨只要能让自己在面试的紧张状态下快速写出正确代码就是好模板。我在实际准备面试时就是这样把一类题型做成一套组合拳效果比零散刷题要扎实得多。