
刷 LeetCode 的人应该都有体会有些题单看一道觉得不难但隔几天遇到它的变体又觉得哪里不太对劲。力扣26题“删除有序数组中的重复项”和紧接着的80题“删除有序数组中的重复项 II”就是典型的“一道题带出一道题”的组合。26题要求原地去重让每个元素只出现一次80题则把条件放宽允许每个元素最多出现两次。这两道题放在一起刷收益不是112而是能打通“有序数组 原地修改 双指针覆盖”这一类题型的解题框架。本文就围绕这两道题把思路拆开揉碎从暴力做法讲到双指针最优解再聊到面试现场的实际考察点和通用模板。如果只是在编辑器里把代码跑通那这篇博文对你没太大价值如果你想搞清楚“为什么用快慢指针”“为什么返回长度而不是数组本身”“为什么80题不是改一个数字那么简单”那这篇文章就是按这个思路写的。我会把两道题的关联、推导过程和实测中的边界问题都展开顺带给出一个“允许保留K位”的统一写法以后遇到同类题可以直接套。1. 为什么把两道题放在一起刷最划算先说结论26题和80题共享同一个底层操作模型。它们都是“有序数组 原地去重 双指针覆盖”的组合区别只在于允许保留的重复次数。放在一起做你能明显感受到“一个约束条件变化代码逻辑会往哪个方向走”这种对比记忆比单独背题牢固得多。我第一次刷题时其实是反着来的先做了80题感觉“每个元素最多出现两次”有点绕回头又刷26题才发现原来80只是26的扩展。打个比方26题是在数组里找“不同的元素”并依次排好80题是在数组里找“不同的元素但每个元素允许站两个位置”。两者的底层扫描逻辑完全一致只是在写入条件上多了一个计数器判断。从面试角度来看这两道题也经常交替出现。你如果只背了26题的答案面试官把条件换成“最多出现两次”你就得现场推导反过来你把80题的通用写法掌握好26题只是一个“limit1”的特例不光能解两道题还能顺带应对“最多保留三个”“最多保留K个”这类扩展题。再算一笔实际收益。LeetCode的题解区里这两道题的题解加起来超过两千条评论区高赞答案几乎都是双指针。但很多帖子只贴代码不解释“为什么覆盖”“为什么从下标1开始比较”导致新手看完代码觉得自己懂了换个约束条件就卡住。这篇文章给你的东西就是把这些“为什么”补齐。2. 第26题慢指针做锚点快指针去找不同2.1 先想清楚为什么必须原地修改题目要求“原地删除”重复项这句话翻译成人话就是不能借助另一个数组来存结果只能在原数组上做覆盖操作。空间复杂度必须控制在O(1)这也是这道题的核心考点。如果你不假思索地用HashSet遍历一遍确实能得到去重后的元素集合但那是用一个额外数据结构换来的正确性面试官会追问“如果数组特别大内存不够怎么办”。原地修改的意义在于利用“数组本身可以按索引赋值”的特性把不重复的元素往前挪后面的元素即使残留旧值也没关系因为题目只要求返回前len个有效元素。这里有个初学者容易困惑的点既然返回值是去重后的长度那数组后面的元素是什么无所谓对吧对但需要注意LeetCode的判题逻辑会检查数组前len个位置后面的位置不在检查范围内。理解了这一点你就能接受“后面残留旧值也不要紧”的写法了。2.2 快慢指针的推导过程假设数组是[0, 0, 1, 1, 1, 2, 2, 3, 3, 4]目标是把它变成[0, 1, 2, 3, 4, ...]返回长度为5。我推导的过程是这样既然数组有序重复元素一定相邻。那我们只需要“把第一次出现的新元素保留下来”同时跳过连续的重复值。用一个慢指针slow记录“当前有效去重数组的最后一个位置”用一个快指针fast从头扫描整个数组。当nums[fast]和nums[slow]不同时说明遇到了新元素此时让slow前进一步并把nums[fast]的值写入nums[slow]。这个逻辑的关键在于slow永远指向“当前已经排好的、去重后的数组的最后一个元素”。fast负责发现“下一个不同的元素”。两者配合每个元素被扫描一次写入一次时间复杂度O(n)空间复杂度O(1)。2.3 代码实现与两个细节class 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有几个细节值得多说一句。第一个细节slow从0开始fast从1开始。因为数组的第一个元素无论如何都要保留不需要跟谁比较。从下标0开始比较避免了“空数组”和“单元素数组”的边界特判空数组单独处理是因为没有nums[0]可以访问。第二个细节为什么用slow 1作为返回值。因为slow是索引索引从0开始所以去重后数组长度等于最后一个有效元素的索引加1。比如上面例子中slow最后停在4返回值就是5。我见过有人在这个地方犯迷糊其实只要记住“索引转个数要1”就行。2.4 实测中的注意点残留数据会不会影响判题我自己在本地跑这段代码时打印结果会看到类似[0, 1, 2, 3, 4, 2, 2, 3, 3, 4]的数组。第一次看到这个结果我还愣了一下担心是不是代码写错了。后来确认这是正常现象因为我们在原数组上做了覆盖覆盖范围之外的数据没有被清理但题目判题只看前n个位置所以不影响结果。这个“残留数据”的小细节其实也能解释一个常见疑问为什么不从数组末尾开始处理原因是覆盖方向只有从前往后才行。如果从后往前覆盖后面的有效数据还没被读取就可能被新数据覆盖掉逻辑会变得非常混乱。这也是双指针要求“快指针先读、慢指针后写”的根本原因。3. 第80题不是“把1改成2”那么简单3.1 允许出现两次带来的判断条件变化很多刷题帖子给人造成的错觉是80题只要把26题的条件从1改成2就行。实际写一下就会发现如果只改成允许slow和fast相同两次需要额外记录每个元素的出现次数而这个记录本身又不能破坏O(1)空间要求所以思路要调整。核心变化在于我们不再比较“当前值和慢指针指向的值是否不同”而是比较“当前值和慢指针前第二个位置的值是否不同”。这句话是80题真正的题眼。为什么是“前第二个位置”设想一个有序数组每个元素最多保留两个。如果fast指向的值等于nums[slow - 1]说明当前值在结果数组里已经出现过一次了因为slow - 1是上次写入的位置现在再写入就等于出现了第二次这是允许的。但如果nums[fast]等于nums[slow - 1]且slow位置也是同一个值那再写就会造成第三次出现必须跳过。换个角度理解slow指向的是“当前有效结果数组中最后一个已经确定要被保留的值”。slow - 1指向的是“结果数组中倒数第二个值”。当nums[fast] ! nums[slow - 1]时说明这个新值是“一个新的数字段”的开头可以放心写入当nums[fast] nums[slow - 1]时还要判断当前值是否已经把“两次名额”占满了。3.2 正确代码与“slow - 1”的直觉理解class Solution: def removeDuplicates(self, nums: List[int]) - int: if len(nums) 2: return len(nums) slow 1 for fast in range(2, len(nums)): if nums[fast] ! nums[slow - 1]: slow 1 nums[slow] nums[fast] return slow 1这段代码最核心的一行就是if nums[fast] ! nums[slow - 1]。我建议你在本地多打印几轮来感受它。以[1, 1, 1, 2, 2, 3]为例初始slow 1指向第二个1fast 2指向第三个1。nums[2] nums[0]都是1条件不成立跳过。fast 3指向2。nums[3] ! nums[0]2 ! 1条件成立slow变为2写入2。此时数组变成[1, 1, 2, 2, 2, 3]。fast 4指向2。nums[4] ! nums[1]2 ! 1条件成立slow变为3写入2。数组变成[1, 1, 2, 2, 2, 3]。fast 5指向3。nums[5] ! nums[2]3 ! 2条件成立slow变为4写入3。最终返回5。注意这个过程中第三个1被正确跳过了两个2被正确保留3被写入。这就是slow - 1作为“倒数第二个有效元素”的直觉含义。3.3 边界条件和初值的选择80题的边界情况比26题多一些因为“最多出现两次”本身蕴含了一个信息数组长度如果小于等于2根本不需要处理直接返回原长度即可。slow的初始值为什么是1而不是0因为数组的前两个元素无论如何都会被保留第一个元素必然保留第二个元素如果和第一个相同属于允许的两次如果不同更是必须保留的新元素。所以从下标1开始作为“已经确定的最后一个有效位置”是安全的fast从2开始扫描即可。我见过一种错误改法把26题的slow初始值改成1fast从2开始但比较条件还是nums[fast] ! nums[slow]结果发现[1,1,1,2,2,3]会返回6也就是什么都没删掉。原因在于slow只记录了一个位置无法判断“当前值是否已经是第三次出现”。这就是为什么80题必须用“倒数第二个有效位置”作为比较锚点而不是“最后一个有效位置”。4. 面试官真正在意的三个边界问题刷过这两道题后我复盘面试时被问过的问题发现面试官基本不关心你会不会背双指针模板他们更在意下面三个边界场景。4.1 空数组和单元素数组空数组直接返回0。单元素数组直接返回1。这两个特判放在函数开头能避免索引越界。26题和80题的代码都需要这个特判但注意80题如果写len(nums) 2: return len(nums)已经把空数组和单元素数组一起覆盖了所以不需要再单独写if not nums。还有一个小陷阱26题的空数组特判要写在调用nums[0]之前。如果写成if len(nums) 1: return 1那么空数组会在nums[0]处崩溃。更稳妥的写法是先判断not nums再往下走。4.2 为什么返回值是长度而不是去重后的数组本身这是LeetCode这类题目的通用设计。我们讨论的是“原地修改”如果返回一个新数组那就违背了O(1)空间的要求。但判题系统又需要知道“哪些位置是有效的”所以用一个整数告诉你有效长度然后检查原数组的前n个元素。理解这一点很重要因为很多接口设计都遵循“通过返回值告知有效范围”的思路。比如有些网络协议的缓冲区处理也是修改原buffer返回实际写入长度。面试时如果能答出“返回长度是为了配合原地修改的约定同时让调用方知道有效区间”面试官会觉得你真正理解了这个设计的本质。4.3 会不会出现“写入位置和读取位置重叠”导致丢数据这是个非常实际的细节。当nums[fast]和nums[slow]或nums[slow - 1]不相等时我们执行slow 1; nums[slow] nums[fast]。如果slow 1 fast这次写入就是自身覆盖自身不丢数据如果slow 1 fast说明slow和fast之间有间隔中间夹着重复值这些重复值已经被扫描过了覆盖它们完全没问题。关键点在于快指针永远走在慢指针前面或平齐慢指针写入的位置一定是“已经扫描过的、不再需要的旧数据区”。只要fast比slow大写入就是安全的。这个安全性是双指针模型的数学保证不用额外判断。5. 一个模板通吃“允许保留K位”的进阶解法5.1 通用模板的推导既然26题是“最多保留1个”80题是“最多保留2个”那“最多保留K个”怎么写答案是把比较锚点从slow - 1换成slow - (k - 1)也就是slow - k 1。为什么因为要判断当前元素还能不能写就要看“当前元素是否已经占满了K个名额”。判断方式是检查结果数组的倒数第K个位置也就是索引slow - k 1。如果nums[fast]不等于这个位置的值说明当前元素在结果数组里出现的次数还不到K次可以写入如果相等说明包括当前元素在内一共有K1次了必须跳过。以K2为例锚点就是slow - 2 1 slow - 1和80题完全一致。以K1为例锚点就是slow - 1 1 slow和26题完全一致。这个统一性就是“一道题带出一道题”的最大价值。代码模板如下class Solution: def removeDuplicates(self, nums: List[int]) - int: def solve(k: int) - int: if len(nums) k: return len(nums) slow k - 1 for fast in range(k, len(nums)): if nums[fast] ! nums[slow - k 1]: slow 1 nums[slow] nums[fast] return slow 1 return solve(2) # 或 solve(1)这个模板可以同时解26题和80题但实际面试中我不建议一上来就写通用模板。因为部分面试官会认为你“没有针对题目思考只是套模板”。更好的策略是先用K2代入把思路讲清楚然后主动提一句“这个逻辑其实可以推广到保留K位”再写通用版本。这样既展示了代码能力又体现了抽象思维能力。5.2 面试现场的一个加分回答如果你想让回答更有区分度可以补充一句这类“原地覆盖”题的本质是“用快指针扩大视野用慢指针定义有效区间的边界”而K值只影响“边界比较的深度”。这个表述比单纯背代码高级很多因为它是从问题本质上归纳出来的结论。5.3 我实测过的三条扩展题“最多出现一次”的26题K1模板秒解。“最多出现两次”的80题K2模板秒解。如果面试官现场出“最多出现m次”你直接把solve(m)扔给他他会觉得你不是在背题而是真的掌握了一类模型。还有一个常见的变体把“有序数组”换成“无序数组”。这种情况下双指针就不适用了因为无序数组的重复元素不一定相邻必须先排序。排序会破坏原数组顺序所以很多面试题会加一句“能不能不用排序”。答案是如果要保持O(1)空间同时要求不改变相对顺序那就只能通过“更复杂的数据结构或多次遍历”来处理这就超出本文讨论范围了。但如果你能意识到“有序”是双指针解法成立的前提面试官对你会多一层认可。6. 从刷题到面试几个受益匪浅的细节习惯最后聊几个我实际刷题和面试中总结出的细节习惯不一定写在LeetCode题解里但确实能帮你少走一些弯路。第一务必在本地调试时打印每次迭代后的数组。LeetCode的在线判题不会展示中间过程但本地打印能让你直观看到“覆盖”到底是怎么发生的。我第一次跑80题的代码时打印出的数组变化让我终于理解了slow - 1这个锚点的意义。强烈建议你也这么做写个循环打印slow、fast、nums三个变量的变化坚持三轮比看十遍题解都管用。第二不要忽略函数开头对数组长度的处理。26题的空数组特判80题的len(nums) 2都属于“防御性编程”。这些边界情况在面试时是最容易出bug的你在白纸上写代码时与其花时间纠结主逻辑不如先把边界条件写清楚给自己留出更充裕的思考空间。第三想一想为什么题目要求“有序数组”。有序是双指针能高效工作的基础因为有序保证重复元素连续排列快指针才能通过“和锚点比较”来判断当前元素是否是新元素。如果你把数组换成乱序同样的代码会错得离谱。每次刷完题后追问自己一句“如果去掉某个条件这个解法还成立吗”是提升刷题效率的一个好习惯。第四理解“覆盖”和“删除”的区别。在很多人的直观认知里“删除”意味着“把后面的元素往前挪并清空末尾”。但在这两道题里“删除”被重新定义为“把有效元素覆盖到前面并返回有效长度”。这种思维转变不只在LeetCode有用在底层C/C编程、内存管理、日志裁剪等场景中也很常见。能用“覆盖长度”的思维去处理数据会让你看很多问题时的视角不同。第五同一道题隔一两周复盘一次。26和80题属于“刷完容易忘但一忘就忘得特别彻底”的类型因为解法看起来太短了短到让人觉得“这么简单我肯定记住了”。结果过两周再写很多人会卡在slow的初始值上。我的经验是每次复盘时不要看题解直接在白纸上推[0,0,1,1,1,2,2,3,3,4]这个用例能一步步推完才算真正过关。这两道题的魅力不在于代码多难而在于它们用最小的复杂度展示了“双指针覆盖法”的骨架。26题帮你理解“快慢指针比较”80题帮你理解“比较锚点可以偏移”两者叠加你就掌握了一个可以应对“保留K位”类题目的通用思路。刷题不追求数量能把一组关联题吃透比囫囵吞枣刷三五十道更有效。