刷题打卡到第24天,遇到了这道非常经典的“27. 移除元素”。说实话,第一眼看到题目的时候,我觉得这就是个送分题:遍历一遍,把不等于目标值的元素留下来不就行了。可真当我准备动手写代码的时候,才发现“原地修改数组”这五个字的分量比我想象的重得多。这道题在线判题平台上的编号是27,题目名称叫“移除元素”,核心就一句话:给你一个数组 nums 和一个值 val,你需要原地移除所有数值等于 val 的元素,并返回移除后数组的新长度。
这篇东西不是单纯把标准答案贴一遍,而是把我从读题、踩坑、写第一版暴力解法、再理解双指针优化,到最后真正把它吃透的完整过程记录下来。适合刚接触数组双指针技巧的读者,也适合那些觉得“明明看懂了题解,自己一写就废”的朋友。如果你正准备面试,想搞明白这类题背后通用的思考方式,那这篇内容应该能给你一些参考答案之外的思路。
1. 题目到底在问什么:给数组做定点清除
1.1 先看一个最简单的例子
题目通常会给出这样的示例:nums = [3,2,2,3],val = 3。函数的返回值应该是 2,同时数组的前两个元素需要变成 2 和 2。至于数组后面的位置是什么,判题程序不关心。
第一次看到这个要求,很多人会下意识觉得:既然只看前两个位置,那我直接把不等于 val 的元素往前挪不就行了?对,思路就是这样,但这恰恰是双指针解法的雏形。只是当时我还没意识到,这个“往前挪”的动作,背后藏着很多细节。
我当时的第一个念头是更暴力的:新建一个临时数组,把不等于 val 的元素存进去,然后复制回来。这在普通编程练习里完全可行,但放到这道题里,会被明确判为不符合要求,因为题目写了“原地”。
1.2 判题平台的评判逻辑是理解本题的关键
在线判题平台并不会真的去检查整个数组每一个位置上的值。它的验证逻辑大致是:先看你的返回值 k 是否等于“剩余有效元素的数量”,再看数组下标 0 到 k-1 这些位置上的元素,是否按顺序等于所有不等于 val 的原数组元素。
换句话说,数组下标 k 之后残留了多少个 val,甚至残留了哪些值,都不影响最终判定。这个设计不是宽松,而是无奈:在 C 语言这类静态数组的世界里,数组的长度一旦确定就无法改变,所谓“移除元素”能做的只是把有效元素堆到数组头部,然后告诉外部“有效长度到这里就是尽头”。
想明白这一点之后,我对这道题的理解有了质的变化:这道题本质上不是删除,而是覆盖。你要做的事情,是把数组里那些不等于 val 的元素,一个接一个地写到数组的前面位置。
1.3 为什么不直接调用语言自带的方法
如果用 Python 写,有人可能会说:直接循环判断 nums[i] == val,然后用 del nums[i] 删掉不就行了吗?听起来很直觉,但实际写出来会非常痛苦。删除一个元素会让后续所有元素往前移动,同时数组长度变短,索引跟着变化;如果用 for i in range(len(nums)) 边遍历边删,很容易出现两个相邻的 val 只删掉一个的情况,因为删掉第一个之后,第二个自动前移,而循环索引已经跳过了这个位置。
我试过一次用 while + remove 处理连续重复值,结果翻车翻得很彻底:remove 每次只删掉第一个匹配项,你需要反复调用,再加上遍历时的索引错位,代码又臭又长,效率还是 O(n²)。
这些原生方法带来的麻烦,恰好说明了为什么需要设计一种“只扫描一遍、在数组内部搬运元素”的思路。双指针就是为解决这个问题而生的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一个绕不开的前提:原地修改的存储约束
2.1 O(1) 额外空间意味着什么
题目里通常会有一句“不要使用额外的数组空间,你必须在原地修改输入数组”。这句话翻译成人话就是:除了几个临时变量之外,你不许申请和数组规模相关的额外存储。
很多人在这一步就倒下了。不是做不到,而是思维惯性太强。我们的第一反应永远是“开个新列表”,因为这样最不容易出错:遍历原数组,把符合条件的元素加入新列表,最后再搬运回去。逻辑毫无问题,唯一的毛病就是开了 O(n) 级别的额外空间,直接违反题目约束。
我当时也犯了这个错误,还安慰自己“反正最后结果对”。结果提交之后,有的平台会判错,有些平台虽然不报错但会给出内存警告。这才让我正视一个问题:刷题不能只看最终答案对不对,还要看解法是否符合题目的物理约束。
2.2 那些看起来正确但实际违规的方案
我整理过几个典型的“看似正确但违规”的写法,你们可以对照一下自己有没有踩过:
- 新建列表:
res = [x for x in nums if x != val],然后nums[:] = res。虽然结果正确,但申请了与原数组等长的额外空间。 - 反复 del:在遍历过程中删除元素,数组本身的存储结构被反复调整,虽然原地上操作,但时间和空间复杂度都很难看。
- 先打包原数组再赋值回来:本质上是把数据复制了一份,同样违规。
这里要特别说明一下,如果是在业务代码里写,我举双手赞成用列表推导式,简洁优雅,性能也好。但算法题有算法题的游戏规则,题目明说原地修改,你就得按规则来。
2.3 正确的姿势:用“写入指针”模拟数组压缩
真正符合约束的做法,是让数组自己“压缩”自己。想象一条传送带,上面放着货物,你需要把其中不合格的货物挑出去,剩下的货物要在传送带前端码齐。传送带不能停下来更换,只能在货物经过的时候,把合格的搬到前一个码放位置。
这就是双指针的物理模型。一个指针负责读取——它在数组里从头走到尾;另一个指针负责写入——它始终指向“下一个可以放置合格元素”的位置。读到的元素如果合格,就写到写入指针指向的位置,写入指针前进一格;如果读到的是 val,就跳过,写入指针纹丝不动。
整个过程不需要任何额外数组,只需要两个整型变量,空间复杂度 O(1)。
3. 双指针怎么一步步想出来:从暴力搬运到单次扫描
3.1 暴力的代价:每次删除都是全量拉扯
如果不考虑双指针,最朴素的“原地”思路长这样:遍历数组,每找到一个 val,就把它后面所有元素整体往前挪一位。这种方案在逻辑上完全正确,但时间效率惨不忍睹。
我拿一个具体数组演示一下:nums = [0,1,2,2,3,0,4,2],val = 2。第一次在 index=2 处遇到 2,把后面 5 个元素全部往前挪;第二次在新 index=3 处又遇到原来的第二个 2,又要移动 4 个元素;最后在数组尾部还会遇到一个 2。每一次遇到 2,都要把后续一堆元素搬一次家,总移动次数随数组变长而急剧膨胀,最坏情况下会退化成 O(n²)。
在本地跑小数组感觉不出来,一旦数组长度到十万级别,这种解法会慢到让人怀疑人生。也正是因为这一版写得让我自己都觉得丢人,才推动我去寻找更聪明的做法。
3.2 两个指针的分工逻辑
双指针解法的代码很短,但背后的分工设计值得仔细品味:
- slow 指针:写入指针,指向“下一个可以被覆盖的位置”。初始为 0,每写入一个合法元素就自增一次。它的最终值,就是移除后数组的新长度。
- fast 指针:读取指针,负责扫描整个数组。它从 0 走到 n-1,判断每个位置上的值是否等于 val。
fast 遇到的元素有两种情况:等于 val,则什么都不做,继续前进;不等于 val,则把 nums[fast] 写到 nums[slow],然后 slow 和 fast 同时前进一格。
这里有个容易被忽略的点:当没有 val 出现时,fast 和 slow 一直指向同一个位置,这个赋值就成了“自己写给自己”,虽然有些多余,但也正是这个多余的赋值,让逻辑变得无比统一,不需要加任何 if 分支去判断指针是否相同。
3.3 完整实现:一份可以直接跑的代码
python复制from typing import List
class Solution:
def removeElement(self, nums: List[int], val: int) -> int:
slow = 0
for fast in range(len(nums)):
if nums[fast] != val:
nums[slow] = nums[fast]
slow += 1
return slow
代码就这么几行。核心逻辑非常清晰:fast 负责发现值得留下的元素,slow 负责记录该写到哪里。
我最初写的时候犯过一个低级错误:在 if 里把 nums[slow] = nums[fast] 写反了,变成了往 fast 的位置写。这么一写,数组前半段倒是正常,后半段却被改得乱七八糟。这个错误告诉我一个经验:双指针题里,永远要记清楚哪个指针是读、哪个指针是写。建议给慢指针取名叫 write,给快指针取名叫 read,能有效减少混淆。
3.4 另一种思路:把 val 交换到数组末尾
除了覆盖法,还有一种交换法:从数组两端同时向中间移动,左指针遇到 val 时,从右指针位置取一个元素覆盖过来,右指针左移。这样做的好处是,等于 val 的元素会被集中到数组尾部,你甚至能精确知道“被移除的元素”最后去了哪里。
交换法不保证数组的相对顺序,因为当左指针指向的 val 被右边的覆盖后,右边的元素可能并不是原数组中紧随其后的那个合法元素。它在特定场景下很有用,比如 val 占比很低,或者题目要求保留被移除元素的痕迹。覆盖法则天然保持相对顺序,所以绝大多数场景下,覆盖法是首选。
| 维度 | 覆盖法 | 交换法 |
|---|---|---|
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(1) | O(1) |
| 元素相对顺序 | 保持 | 可能改变 |
| 数组末尾的残留 | 原样残留 | 集中堆放 val |
| 代码复杂度 | 极简 | 略复杂,需处理双指针相遇 |
| 适用场景 | 默认首选 | 需要保留被移除值位置时 |
4. 边界条件才是真正的考官:容易被判死的几个场景
4.1 空数组与全命中场景
写完代码之后,我第一件做的事不是提交,而是列出边界测试用例。空数组是最直接的:nums = [],val = 任意值。此时 fast 循环一次都不会执行,slow 保持 0,返回 0,正确。
全命中场景,也就是数组里所有元素都等于 val,比如 nums = [2,2,2,2],val = 2。此时每一个元素都被跳过,slow 一直停在 0,循环结束后返回 0。表面上看,这个结果是对的,但你打开数组一看,四个 2 全在原位,一个没动。有人会担心:这样算不算没移除干净?
这就回到了第一节说的判题逻辑:返回值 k 是 0,判题程序只检查下标 0 到 -1,也就是什么都不检查,所以前 0 个元素天然满足条件。数组后面的残留不影响判定。这也解释了为什么覆盖法可以“偷懒”——它不需要真的把 val 从数组里清掉,只需要保证有效元素按顺序排列在头部。
4.2 全部不命中与 val 在开头结尾
再看全部不命中的场景,nums = [1,2,3,4],val = 5。此时每个元素都不等于 val,fast 每走一步都会把值写回 slow 的位置。由于 slow 和 fast 同步增长,这个赋值是“自己写给自己”,数组内容完全不变,最终返回 4。这个场景逻辑最简单,却是最容易让人困惑的地方:既然没有 val,为什么还要赋值?因为统一逻辑比特判分支更可靠,程序员的时间比那几次多余的 CPU 周期值钱得多。
val 出现在数组开头的情况也值得单独测试,比如 nums = [3,2,2,3],val = 3。第一步 fast 在 index=0 位置发现值等于 3,跳过;第二步 fast 读到 2,写入 nums[0];第三步 fast 读到另一个 2,写入 nums[1];第四步 fast 读到最后一个 3,跳过。最终 slow=2,数组前两位是 [2,2],完美。
val 在数组结尾的情况更轻松,前面的元素全部不动,最后一个元素被跳过,数组内容几乎原样保留,只是长度减一。
4.3 我最常用来自测的用例表
如果你也想检验自己的解法,可以直接把这组用例跑一遍:
| nums | val | 预期返回值 | 数组前 k 位 |
|---|---|---|---|
| [] | 3 | 0 | [] |
| [3] | 3 | 0 | [] |
| [1] | 3 | 1 | [1] |
| [3,2,2,3] | 3 | 2 | [2,2] |
| [0,1,2,2,3,0,4,2] | 2 | 5 | [0,1,3,0,4] |
| [2,2,2,2] | 2 | 0 | [] |
| [1,2,3,4] | 5 | 4 | [1,2,3,4] |
把最后一个用例特别拿出来说,就是因为它容易让人产生“数组没变,代码白跑了”的错觉。实际上,写操作确实发生了,只是写回的值和原来的值相同,肉眼看不出来而已。
4.4 我提交时真的踩过的坑
上面说的都是理论推演,我真正提交时遇到问题的是另一个边角情况:数组中连续出现多个 val,而且 val 正好在中间。比如 nums = [1,2,2,2,3,4],val = 2。如果使用 while + remove 的写法,第一次 remove 删掉第一个 2,数组变成 [1,2,2,3,4];第二次 remove 再删掉现在的第一个 2,变成 [1,2,3,4];第三次删掉,变成 [1,3,4]。这个思路勉强能用,但对数组长度造成的反复搬运会让性能非常差。
另一个坑是,写覆盖法时忘了处理返回长度和数组内容的对应关系。比如返回值写成了 slow + 1,结果数组元素全部合法时,返回值比实际元素数量多一位,判题程序一查数组末尾就发现对不上,直接报错。这类偏移错误,靠的就是多写测试用例来暴露。
5. 一道题带出一类题:快慢指针在相邻题型中的迁移
5.1 从“移除指定值”到“去除重复项”
如果只把这道题当一道题来背,过两天就忘了。真正有价值的是,把这道题和它的“亲戚”们放在一起对比,找出它们共用的骨架。
先说“删除有序数组中的重复项”这道题。数组是有序的,你要让每个元素只出现一次。解法同样是双指针:fast 扫描,slow 写入。唯一的区别在于判断条件不同——移除元素时,判断的是 nums[fast] 是否等于 val;去重时,判断的是 nums[fast] 是否等于 nums[slow - 1](也就是上一个已经写入的合法元素)。
你看,骨架一模一样,只是“该不该写入”的判定条件换了一下。这就是快慢指针的核心抽象:用一个读指针遍历全部数据,用另一个写指针标记合法数据应该存放的位置,至于数据是否合法,完全由你自己的条件决定。
5.2 与“移动零”的本质联系
再看“移动零”这道题。给定数组,把所有 0 移动到末尾,保持非零元素的相对顺序。如果套用移除元素的思路:把 0 当作 val,先移除所有 0,得到一个只含非零元素的前缀,再在这个前缀之后补上等量的 0,不就完成了?
事实上,更简洁的写法是:第一个循环用快慢指针把所有非零元素往头部搬,第二个循环从慢指针位置开始,把剩余位置全部填 0。跟移除元素相比,多了一步“补 0”的动作。也就是说,移动零等于移除元素加上补位,它们之间就是一层窗户纸的距离。
5.3 快慢指针的通用代码骨架
经过这几道题的对比,我自己总结出了一个通用的代码模板:
python复制slow = 0
for fast in range(n):
if 满足保留条件:
nums[slow] = nums[fast]
slow += 1
return slow
不需要每次重新想指针逻辑,只需要想清楚“保留条件”是什么。移除元素是 nums[fast] != val,去重是 nums[fast] != nums[slow - 1],移动零是 nums[fast] != 0。判定条件一变,题型就变,但框架始终稳定。
这种归纳能力,是我刷到第 24 天时才慢慢建立起来的。一开始我也是一道一道背代码,后来发现但凡数组类题目,十个里有五六个都能往这个模板上套。从被动记忆到主动归纳,这个转变才是刷题数量之外真正重要的进步。
6. 练习 24 天之后,我总结的几条小习惯
6.1 先手写模拟,再写代码
我做这道题时最受益的一步,是在纸上画了一个长度为 8 的数组,手动模拟了 slow 和 fast 的每一步移动。别看这个动作简单,它能逼你亲眼看到指针的移动轨迹,而不是只在脑子里想象。
遇到指针类题目,建议先用小数组走一遍完整流程,确定每一步的 slow 和 fast 分别指向哪里、数组内容发生了什么变化。这个过程最多花三分钟,却能省掉大量调试时间。我现在做题,写代码前多花三分钟画图,写代码后调试时间能少一半还多。
6.2 覆盖思维比删除思维好用得多
算法题里的“删除”,实际上绝大多数是“覆盖”。真正意义上的删除元素,意味着要释放内存、调整长度,那是数据结构课程里链表该操心的事情。数组的删除操作,在底层就是覆盖——把后面的元素往前挪,把逻辑长度减一。
所以当你看到一个数组题要求删除、移除、保留某类元素时,第一反应不应该是“怎么删”,而应该是“怎么把该留的往前排”。这些说法听起来像文字游戏,但用覆盖思维去解题,思路会通顺很多。
6.3 提交前必查的三个边界
现在我每写完一版数组题解法,提交前都会固定自查三件事:空数组能不能过?全部元素都命中 val 能不能过?全部元素都不命中能不能过?这三条如果都过了,大概率不会在测试用例上翻车。
更严格一点,可以把数组长度为 1 的三种情况也加进去:单个元素等于 val、单个元素不等于 val,以及 val 是任意值但数组为空。这些用例加起来不到十个,却能覆盖这道题绝大多数的分支逻辑漏洞。
6.4 面试时关于这道题的常见追问
最近帮 A 同学模拟面试时,发现面试官对这道题并不满足于标准解法,还会追加几个问题。我整理一下常见追问和自己的回答思路:
- “为什么返回值是长度而不是数组本身?” 答:数组无法在原有存储空间上缩短,只能通过返回逻辑长度告知调用方有效区间的边界。
- “如果 val 的数量特别多,能优化吗?” 答:可以考虑从两端向中间的双指针,减少覆盖次数。但要注意相对顺序不再保证。
- “如果要求保留被移除元素在数组中的位置,怎么做?” 答:交换法,把等于 val 的元素集中到数组尾部。
- “数组本身有序,能否用二分加速?” 答:如果有序,可以先二分找到 val 的连续区间,把区间右侧的合法元素整体左移,时间复杂度仍为 O(n),但常数更小;在特定场景下可以结合二分减少比较次数。
这些追问的本质,是考察你对自己解法边界条件的理解程度。把题想透了,这些问题自然不会成为障碍。
最后再分享一点我个人的体会。移除元素这道题的价值,不在于它有多难,而在于它是理解“空间受限时如何在数组内部做元素重排”的最佳入门素材。如果你也处在刷题前期,不用急着追求刷题数量,遇到这种基础题,多用三分钟想一想它背后的指针设计逻辑,再对比一下相邻题型的异同,收获会比多刷三道题更大。等到某天你看到数组题的第一反应,不是“能不能开个新数组”,而是“能不能用两个指针在内部把数据理顺”,那这道题就没有白做。
