刷题刷到第 12 天,我特意把 LeetCode 的 26 题《删除有序数组中的重复项》和 80 题《删除有序数组中的重复项 II》放在一起复盘。原因很直接:这两道题看起来只差一个“II”,解法骨架也几乎一模一样,但如果你只是把第一题的代码背下来再套第二题,大概率会卡在同一个误区里出不来。更准确地说,26 题教会你“用双指针做原地覆盖”,80 题则逼着你把“覆盖条件”真正理解透——否则你不会明白,为什么把比较对象从nums[slow-1]改成nums[slow-2],整个问题就迎刃而解了。
如果你是正在做刷题计划、或者准备面试手写算法的人,这两道题几乎是绕不过去的。它们被归为“简单/中等”难度,但恰恰是面试官喜欢拿来热场的题目:算法不复杂,却能快速检验你对数组操作、边界条件、原地修改和复杂度分析的熟悉程度。这篇复盘我不想只贴一份能通过的题解,而是想把真正容易忽略的东西讲清楚——slow 指针的语义、为什么比较下标是slow-k而不是fast-k、边界用例怎么设计,以及怎么把这两道题抽象成一个通用模板,以后遇到“最多保留 k 个重复项”的变体都能秒写。
1. 先看清题:26 和 80 的“删除”都不是真的删除
1.1 题目描述里的四个关键词,信息量比想象中大
LeetCode 26 题的描述很简短:给你一个升序排列的数组nums,请你原地删除重复出现的元素,使每个元素只出现一次,返回删除后数组的新长度。元素的相对顺序应该保持一致。不要使用额外的数组空间,必须在不使用额外空间的前提下原地修改输入数组。
这句话里藏着四个关键词:“升序排列”“原地”“新长度”“相对顺序”。四个词单独看都认识,合在一起就决定了你的解题方向。
“升序排列”是前提中的前提。数组已经有序,相同元素一定连续排列,所以我们不需要用哈希表去记录“哪些值出现过了”,只需要观察相邻位置或者结果区间的尾部。如果数组是无序的,这套双指针写法立刻失效。
“原地修改”意味着不能新建一个数组再复制回去。你只能在原数组上搬动元素,把合法元素往前挪。
“返回新长度”意味着题目只关心返回值的长度,以及数组前 length 个位置的内容。nums[length]到nums[n-1]是什么完全不重要,你在覆盖时留下的脏数据不会被判错。这一点能帮你放下很多心理包袱。
80 题几乎一模一样,唯一的差别是把“每个元素只出现一次”改成了“每个元素最多出现两次”。换句话说,26 题是严格去重,80 题是容错去重。
1.2 同一个输入,两种输出
为了直观感受两题的差别,我列了一张表,后面验证代码时会反复用到:
| 输入 nums | 26 题返回长度 | 26 题处理后有效前缀 | 80 题返回长度 | 80 题处理后有效前缀 |
|---|---|---|---|---|
[] | 0 | [] | 0 | [] |
[1] | 1 | [1] | 1 | [1] |
[1,1,1,2,2,3] | 3 | [1,2,3] | 5 | [1,1,2,2,3] |
[1,1,1,1] | 1 | [1] | 2 | [1,1] |
[1,2,3] | 3 | [1,2,3] | 3 | [1,2,3] |
注意最后一行“全不重复”的情况,它专门用来检验:当所有元素都不重复时,双指针方案必须做到原封不动。很多人在边界测试里只关注“全是重复”和“空数组”,却忘了“没有重复”同样容易触发问题。
2. 双指针解法的本质:从“删除”到“覆盖继承”
2.1 暴力解法为什么会被一票否决
新手最容易想到的方案有三个,但都有明显硬伤。
一是用list.remove()挨个删。每次删除都是 O(n) 的数组搬移,整体最坏能到 O(n²);而且 Python 里一边遍历一边删除会引发索引错乱,写出来基本是给自己埋雷。
二是新建一个 list 只收集不重复元素,再拷回原数组。这在逻辑上是完全正确的,但违反了“不要使用额外的数组空间”的硬性约定。LeetCode 的判题器通常不会严格检测你用了多少额外空间,但面试官一眼就能看出来。
三是用字典统计每个元素的出现次数,再重建数组。思路本身没问题,但空间复杂度是 O(m),m 是不同元素个数,比题目要求的 O(1) 多出一截。
问题的核心在于:题目要的不是“删除”这个动作本身,而是“把合法元素按顺序堆到数组头部”。删除的成本太高,覆盖的成本是 O(1)。这就是双指针能成为标准解法的根本原因。
2.2 slow 和 fast 的分工:传送带分拣模型
我把这个思路叫作“流水线分拣”。
把nums从头到尾看成两个区域。左半边是已经处理好的“合格区”,记为[0, slow);右半边是还没扫描的“待检区”。fast是当前正在检查的元素下标,它永远往前走;slow是指向合格区末尾的指针,也就是下一个可以写入元素的位置。
每当fast扫描到一个符合保留条件的元素,就把它搬运到nums[slow],然后slow自增,合格区扩大一格。如果不符合保留条件,slow原地不动,fast继续前进。
这套机制的关键在于:读指针和写指针完全解耦。fast负责读原始数据,slow负责写结果数据,读的永远比写的快(或者相同),所以覆盖永远不会破坏还没读到的元素。这个“用 fast 读,用 slow 写”的思路,是很多原地数组题目的通用底座。
2.3 26 题的两份标准代码,以及为什么推荐 slow=0 写法
26 题的保留条件一句话就能说清:当前元素和合格区最后一个元素不相同,就保留。
为什么比较的是nums[slow-1]而不是nums[fast-1]?因为nums[slow-1]是到目前为止最后一个被保留下来的值。如果fast指向的新值和它相同,说明fast是重复项,不应该进入合格区;如果不同,说明出现了新值,可以打包放行。
我推荐下面这种写法,它把边界条件融进循环,不需要额外处理空数组:
class Solution: def removeDuplicates(self, nums: List[int]) -> int: slow = 0 for fast in range(len(nums)): if slow == 0 or nums[fast] != nums[slow - 1]: nums[slow] = nums[fast] slow += 1 return slowslow从 0 开始,当合格区为空时slow == 0,当前元素直接进入;当合格区非空时,才比较nums[fast]和nums[slow-1]。这段代码不需要if not nums这类提前返回,输入为空数组时循环体一次都不执行,直接返回 0。
另一份同样常见的写法是:
class Solution: def removeDuplicates(self, nums: List[int]) -> int: if not nums: return 0 slow = 1 for fast in range(1, len(nums)): if nums[fast] != nums[slow - 1]: nums[slow] = nums[fast] slow += 1 return slow这份代码把nums[0]视为天然保留,所以slow初始化为 1,fast也从 1 开始遍历,减少了循环内的一次slow == 0判断。两者在面试中都能通过,但我更推荐slow = 0的版本,因为它的形态可以很自然地泛化成“最多保留 k 个”的通用模板,后面 80 题会用到。
2.4 有序性是这个解法的命门
为什么我一直强调“数组有序”?因为这套“只和前面一个保留值比较”的逻辑,本质上是把“元素值是否出现过”压缩成“元素值是否等于前一个保留值”。只有在有序数组里,相同值才一定连续,这个压缩才不会漏判。
如果输入是无序数组,比如[2,1,2,1],用同样的逻辑遍历:第一个 2 入队,第二个 1 因为不等于 2 而入队,第三个 2 又不等于 1 而入队,第四个 1 又不等于 2 而入队,最终返回 4,等于什么都没删,还会打乱相对顺序。所以在无序数组里,真正该用的工具是哈希表,而不是双指针。
有序这个条件,把“判断一个值是否出现过”的成本从 O(1) 的哈希查询,降到了 O(1) 的相邻比较。它不改变时间复杂度的量级,但让空间复杂度降到了 O(1),这就是这道题能成为“面试高频题”的底层原因。
3. 80 题:把比较下标从 slow-1 改成 slow-2,为什么就对了
3.1 允许出现两次,检查对象从“最后一个”变成“倒数第二个”
80 题要求每个元素最多出现两次。我沿着 26 题已经搭好的框架继续想:保留条件需要从“合格区最后一位不同”升级成“合格区倒数第二位不同”。
为什么?
先说结论:当fast指向的值是v,且数组有序时,如果nums[slow-2] == v,那么合格区倒数第二位和倒数第一位已经连续两个都是v,再加上当前元素就会变成三个v,非法;如果nums[slow-2] != v,那么即使nums[slow-1] == v,写入后也只会出现两个连续的v,合法。
只看倒数第一位不够,是因为当合格区末尾只有一位时,它可能是这个值的第一个出现,也可能是第二个出现。我们需要一个能判断“这个值已经出现过两次了吗”的锚点。由于数组有序,这个锚点就是往前数第二个保留元素。
还是一组具体例子:
- 合格区为
[1,1,2],当前遇到2。nums[slow-1] = 2,只看最后一位会认为重复,但它明明可以再加一个2,变成[1,1,2,2]。问题就出在“只看最后一位”把“2 第一次出现”和“2 第二次出现”混为一谈了。 - 把判断换成
nums[slow-2]:此时nums[slow-2] = 1,2 != 1,允许写入。写入后[1,1,2,2],一切合法。
3.2 标准代码与手动验证
所以 80 题的标准解法几乎是 26 题的直译:
class Solution: def removeDuplicates(self, nums: List[int]) -> int: slow = 0 for fast in range(len(nums)): if slow < 2 or nums[fast] != nums[slow - 2]: nums[slow] = nums[fast] slow += 1 return slowslow < 2处理的是合格区不足两个元素时的场景:前两个元素不管是什么值都可以直接放入,因为即使它们相等也还不超过“最多两个”的限制。
用经典输入[1,1,1,2,2,3]跑一遍全过程:
| fast | 当前值 | slow | nums[slow-2] | 条件 | 操作 | 操作后 nums 前几位 |
|---|---|---|---|---|---|---|
| 0 | 1 | 0 | - | slow<2 | 写入,slow=1 | [1] |
| 1 | 1 | 1 | - | slow<2 | 写入,slow=2 | [1,1] |
| 2 | 1 | 2 | nums[0]=1 | 相等,跳过 | slow=2 | [1,1] |
| 3 | 2 | 2 | nums[0]=1 | 不等,写入 | slow=3 | [1,1,2] |
| 4 | 2 | 3 | nums[1]=1 | 不等,写入 | slow=4 | [1,1,2,2] |
| 5 | 3 | 4 | nums[2]=2 | 不等,写入 | slow=5 | [1,1,2,2,3] |
最终返回 5,数组前五位是[1,1,2,2,3],和题目要求完全一致。
再看一个极端输入[1,1,1,1]:
| fast | 当前值 | slow | nums[slow-2] | 条件 | 操作 |
|---|---|---|---|---|---|
| 0 | 1 | 0 | - | slow<2 | 写入,slow=1 |
| 1 | 1 | 1 | - | slow<2 | 写入,slow=2 |
| 2 | 1 | 2 | nums[0]=1 | 相等,跳过 | |
| 3 | 1 | 2 | nums[0]=1 | 相等,跳过 |
返回 2,前两位[1,1],把“四个 1 压缩成两个 1”这件事表达得非常清楚。
3.3 为什么标准答案是 nums[slow-k],而不是 nums[fast-k]
这是 80 题最容易踩、我也经常在评论区看到的坑。
很多人会把条件写成nums[fast] != nums[fast-2],理由是“当前元素和它前面第二个元素比较”。在部分用例下,这种写法碰巧也能通过,但它依赖的是原数组有序这个特例下的一种巧合,逻辑上并不可靠。
原因在于:nums[fast-2]是原始输入中跳过中间元素后的位置,而nums[slow-2]是结果序列里的位置。fast和slow之间隔着若干已经被判定为“不需要保留”的元素,fast-2指向的位置完全可能是那些已经被跳过的值,而不是结果区间的真实倒数第二位。
更严谨一点的表述是:在写这类原地覆盖算法时,你需要保证“结果前缀[0, slow)在每一步都满足约束”。用fast相关的下标去判断,计数器就脱离了结果序列本身;用slow相关的下标去判断,才算真正维护了结果序列的不变式。
所以面试时如果被问到“为什么是slow-2”,你可以这样回答:我永远只关心“结果序列”里倒数第二个值是什么,而不是原数组里当前元素前面的第二个值。这个差别,就是对“结果前缀合法”这一不变式的坚持。
3.4 一题两吃:把 k 参数化,套出通用模板
把 26 题和 80 题放一起看,最大的收获是发现它们可以被统一成同一个模板:
def remove_duplicates_k(nums, k): slow = 0 for fast in range(len(nums)): if slow < k or nums[fast] != nums[slow - k]: nums[slow] = nums[fast] slow += 1 return slowk=1时这是 26 题,k=2时这是 80 题。如果面试官临时改口说“每个元素最多保留 3 次”,你只需要把调用的k改成 3。
很多题解会把 26 题和 80 题分成两篇文章讲,但我的实际体会是,放在一起刷才能触达规律层。26 题是骨架,80 题是让骨架长出肌肉,而 k 参数化是让肌肉学会发力。
4. 实测踩坑与调试:差一错误、负索引与残留数据
4.1 三个高频错误,每一个我都见过
我在评论区、公司面试辅导甚至自己重写代码时,都反复见过下面这三类错误。
第一个错误:返回数组而不是长度。LeetCode 判题器要求返回整数,也就是合法前缀的长度。有人写完return nums[slow]或者return nums,类型直接不对。还有人会写return nums[:slow],Python 切片会创建一个新列表,等于额外使用空间,同样不合题意。
第二个错误:80 题里比较下标写错。最典型的就是把nums[slow-2]写成nums[fast-2]。我在上一节讲过,这不是一个能保证正确的不变式写法。面试现场如果你解释不清,面试官很容易判定你只是在背答案。
第三个错误:漏掉slow < k这个初始条件。有人把代码简化成:
if nums[fast] != nums[slow - 2]: nums[slow] = nums[fast] slow += 1当slow < 2时,nums[slow-2]在 Python 里并不是报错,而是负索引——nums[-1]会访问数组最后一个元素,nums[-2]访问倒数第二个元素。这种“负索引糖”非常坑人,它让一个本应越界的错误悄悄变成逻辑错误。比如输入[1]时,slow=0,nums[-2]读取的是数组里不存在的“倒数第二个”,结果可能异常通过,也可能直接返回错误长度,全看原数组长度。遇到这种边界,写slow < 2 or ...才是正道。
4.2 调试方法:打印 slow 的移动轨迹
这类原地覆盖题目的调试,最直观的方式就是打印每一步的状态。
我自己的复现步骤很简单,在本地把代码临时加上打印:
nums = [1, 1, 1, 2, 2, 3] slow = 0 for fast in range(len(nums)): if slow < 2 or nums[fast] != nums[slow - 2]: nums[slow] = nums[fast] slow += 1 print(f"fast={fast}, v={nums[fast]}, slow={slow}, nums_prefix={nums[:slow]}")运行之后,你会看到slow的移动轨迹,也能立刻发现哪些元素被跳过、哪些元素被写入。等逻辑确认无误,再删掉打印语句提交。
如果你想更系统一点,可以写一个校验函数,随机生成有序数组,再用暴力法生成“标准答案”,拿自己的函数结果和标准答案对比。这样跑几十组随机数据,比手动测试强得多。
4.3 复杂度意识:O(n) 是最优解的下界
两个题目都是 O(n) 时间、O(1) 空间。很多人做完就结束了,但我建议在面试时主动补一句复杂度分析:
时间上,每个元素无论是否保留,都要被fast访问一次,最坏情况(所有元素互不相同)必须完整扫描一遍数组,所以 O(n) 是下界,不存在更低复杂度的算法。
空间上,全程只用了slow和fast两个整数变量,是严格的常数空间。
这个复杂度结论看似简单,但能体现你有没有考虑“为什么不能更优”的习惯。面试官很吃这一套。
5. 从这两道题延伸出去:快慢指针的一类题
5.1 约束决定解法:有序、原地、保序三个前提
把 26 和 80 抽象成模型,它们是同一类“数组压缩”问题,有三个关键约束:
- 有序:相同值连续排列,让“比较前 k 个保留值”成为可能。
- 原地:不能开新数组,必须覆盖写。
- 保序:相对顺序不能变,只能从前往后搬,不能随意交换。
三个约束同时出现时,双指针几乎是唯一自然的解法。反过来,如果题目放开“原地”限制,新建一个 list 收集合法元素会更清晰;如果放开“有序”限制,就需要哈希表记录出现次数;如果放开“相对顺序”限制,可能可以先排序再处理。
所以我一直觉得,刷题的第一步不是马上想算法,而是把题目里的约束条件圈出来。这些约束决定了你能用什么工具,不能用什么工具。
5.2 双指针家族:快慢、左右、前后怎么选
很多人一说到双指针,脑子里只有“左右逼近”(比如两数之和、反转数组)。但双指针至少有三个分工模式,面对不同问题要选不同模式:
- 左右指针:常用于有序数组、字符串反转、滑动窗口。一左一右往中间逼近。
- 快慢指针:常用于链表中环的检测、原地数组压缩。一个读快,一个写慢。
- 前后双指针:有时也叫同向双指针,两个指针都从同一端出发,只是速度不同。
26 题和 80 题属于典型的快慢指针。同门师兄弟还有:
- LeetCode 27:移除元素,条件是
nums[fast] != val时写入; - LeetCode 283:移动零,把非零元素往前放,末尾补零;
- LeetCode 83:删除排序链表中的重复元素,链表版的前后节点比较;
- LeetCode 88:合并两个有序数组,从后往前的双指针。
这些题共享同一套基因:用 fast 读,用 slow 写,写不写得看条件。你刷完 26 和 80 之后再去碰它们,会发现上手速度明显快很多。
5.3 给同在做刷题计划的人一点建议
如果你和我一样在跑长周期的刷题计划,比如 100 天刷完一批高频题,我的建议是别按题号顺序硬刷,而是按“套路单元”成组刷。比如数组压缩组,就把 27、26、80、283 放同一天;链表去重组,又把 83、82 放一天;二分查找组、BFS 组、DFS 组各自成块。
我前宇宙的刷题计划里,Day 12 正好卡在数组压缩这个节点上。那天的安排就是先做 26 和 80,再做 27 和 283 做巩固。换成别的专题也是一样,比如后面刷到 BFS 时,把 994 腐烂的橘子、岛屿数量、最短路径这些题放一块儿,比分散刷印象深得多。做二分专题时,爱吃香蕉的狒狒这类“搜索答案区间”的题又会让你从另一个角度理解二分。
一天一题听着很励志,但一天一组同类型题,才是能把套路内化的方式。26 和 80 这对兄弟题,就是检验你有没有真懂“快慢指针 + 原地覆盖”的最佳试金石。
我自己在打卡笔记里会额外写一句话:这道题教会我什么?26 题教会我双指针的骨架,80 题教会我改变慢指针的偏移量就能改变容错次数。如果你也能把每天刷题后的“一句话心得”攒下来,一个月后再回头看,会比单纯的通过记录有价值得多。