刷题刷到一定阶段你会发现,LeetCode 27题“移除元素”几乎是所有人绕不开的一道题。它被标记为“简单”,但恰恰是这种简单题,最能看出一个人写代码的基本功。面试的时候我也经常拿这道题当开场题,有人一分钟写完,有人写完自己都解释不清指针为什么要这样动。今天就把这道题彻底拆开,从双指针的原理推导,到各种变体题的迁移思路,再到实际面试里怎么说、怎么写、怎么避坑,一次讲透。
先明确一下我们要解决的是什么问题:给定一个数组nums和一个目标值val,原地移除所有数值等于val的元素,返回移除后数组的新长度。要求不能使用额外的数组空间,只能使用 O(1) 的额外空间,元素的顺序可以改变,而且不需要考虑数组中超出新长度后面的元素。
如果你已经刷过题,这三个要求你应该有感觉:原地修改、O(1)空间、不用管新长度之后的内容,这基本上就是在给你指路,让你用覆盖的思路去解,而不是新建数组拷贝。下面我把这道题的前因后果、代码实现、变体扩展和面试话术完整过一遍。
1. 先想清楚:移除元素到底在考什么
1.1 拆题:这道“简单题”的四个隐藏条件
很多人做这道题,上来就写了一个for循环 +if判断,遇到val就调用splice或者erase,然后把数组长度减一。这种写法在力扣上也能过,但如果你去面试,面试官大概率会追问一句:“你能保证它真的满足所有题目约束吗?”
第一个隐藏条件,也是最容易忽略的:必须原地修改。意思是不能新建一个数组,把不等于val的元素收集进去再整体赋值回来。虽然很多判题系统对这一点检查不严格,但题目本身明确说了,只能使用 O(1) 的额外空间。新建数组是 O(n) 空间,直接犯规。
第二个隐藏条件,返回值是数组的新长度,而不是删除后的数组。题目让你返回一个整数,后面的内容不用管了。这意味着你只需要把有效的元素全部挪到数组前面,然后返回一个长度值就行,后半段残留什么元素都无所谓。这个约束非常关键,它是所有“覆盖型”解法的理论基础。
第三个隐藏条件,元素的顺序可以改变。这句话不是白写的。如果你用双指针,从两端向中间夹逼,交换元素,会改变原有顺序,但题目允许,所以这也是一条完全合法的思路。很多人刷题时没注意到这句话,白白放弃了一种更省操作次数的解法。
第四个隐藏条件,注意边界情况。数组为空、数组所有元素都等于val、数组所有元素都不等于val,这三种情况代码必须要正确处理。空数组要返回 0;全等于val要返回 0;全不等于val要返回原数组长度,且不能改坏数据。这些边界条件看着简单,写错一个就是整个逻辑崩盘。
四个条件叠在一起,这道题其实就在考一个核心能力:你能否在有限空间内,通过元素之间的互相覆盖来完成一次“逻辑删除”。这是一切后续变体题的底层模型。
1.2 为什么说它是“原地算法”的启蒙题
如果你刚开始刷 LeetCode,我建议你把这道题当成“原地算法”的必修课。所谓原地算法,就是除了输入数据本身之外,几乎不再额外消耗存储空间,只能靠交换、覆盖、搬移来解决问题。这类算法在很多分布式系统、嵌入式环境、大数据场景里有实际意义,因为在这些场景里,额外开一块和原始数据一样大的内存,可能意味着整任务失败。
移除元素这道题,恰好能把“覆盖”这个概念讲明白。你不需要真正“删掉”某个元素,你只需要把需要保留的元素移动到数组前部,然后告诉调用方有效长度是多少。后面残留什么,谁也不会去看。这就像整理一张书桌,老板只关心你交出的文件是否整齐码在最上面,桌角还堆着什么废纸,无所谓。
一旦你掌握了这种“覆盖思想”,后面遇到删除有序数组中的重复项、移动零、移除链表元素,会发现它们全是同一个套路的小变种。所以这道题值得花时间去抠细节,而不只是背一个答案。接下来我把最主流的解法从原理到代码完整过一遍。
2. 双指针解法:一种思路吃透一类题
2.1 快慢指针是怎么推出来的
移除元素最经典的解法是快慢指针,也叫双指针中的“同向双指针”或“覆盖指针”。我们在没有任何额外空间的前提下,想要把所有不等于val的元素挪到数组前端,最自然能想到的方法就是一个指针负责“扫描”,另一个指针负责“记录摆放位置”。
我习惯把两个指针取名fast和slow。fast从头到尾遍历数组,负责检查每个位置的元素是否等于val。slow指向“下一个应该放置保留元素的位置”,初始时从 0 开始。当fast发现当前元素不等于val,就把这个值复制到slow指向的位置,然后slow往后移动一位。如果当前元素等于val,就跳过它,fast继续前进。
这样做下来,所有不等于val的元素都会被依次搬到数组前部,slow的值正好就是保留元素的数量。最后返回slow就是新数组长度。
举个直观的例子。假设数组是[3, 2, 2, 3],val = 3。一开始slow = 0,fast = 0,看到nums[0] == 3,跳过,fast变 1。接着nums[1] == 2不等于 3,执行nums[0] = nums[1],数组变成[2, 2, 2, 3],slow变成 1,fast变成 2。继续扫描,nums[2] == 2,赋值给nums[1],数组变成[2, 2, 2, 3],slow变成 2,fast变 3。最后一个nums[3] == 3,跳过。最终返回slow = 2,前两位是[2, 2],恰好是移除 3 之后的结果。
这个过程中有个小细节很多人没意识到:fast每次循环都会前进,但slow只有在发生覆盖时才会前进。也就是说,slow始终指向“保留区”的末尾。两个指针对同一段空间做操作,一个负责探索,一个负责落位,整个过程就是典型的“读写分离”。
2.2 三种主流语言的参考实现
理解了原理,写代码其实非常快。我平时刷题主要用 Python,面试手写有时候用 Java,偶尔前端同事会问 JavaScript 版本,三种实现我都放在这里。
Python 版本:
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 slowJava 版本:
class Solution { public int removeElement(int[] nums, int val) { int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != val) { nums[slow] = nums[fast]; slow++; } } return slow; } }JavaScript 版本:
var removeElement = function(nums, val) { let slow = 0; for (let fast = 0; fast < nums.length; fast++) { if (nums[fast] !== val) { nums[slow] = nums[fast]; slow++; } } return slow; };三个版本的逻辑完全一致,只是语法层面的差异。时间复杂度是 O(n),因为fast指针完整扫描了一遍数组;空间复杂度是 O(1),除了几个指针变量之外没有额外空间。这道题对时间复杂度其实没有更优的可能,因为至少要遍历一遍数组才知道哪些元素要移除。
2.3 最容易写错的地方:覆盖时机
代码本身不长,但我在面试中见过无数人栽在一个看似不起眼的细节上:到底什么时候该赋值、什么时候该移动slow?有些人的写法是这样的:
for fast in range(len(nums)): if nums[fast] != val: nums[slow] = nums[fast] slow += 1把slow += 1写到了if外面。这样写的话,即使fast指向的是等于val的元素,slow也会继续增长。最后返回的slow不是有效元素个数,而是一个被放大的错误值。数组前部也被错误覆盖,结果全乱。
记住一个判断标准:slow的每次前进,必须对应一个“被成功保留的元素”。如果判断条件不成立,说明当前fast位置的元素要被丢弃,不需要为它腾出位置,slow自然也不应该动。这个逻辑理清了,代码就不会写错。
另外还有一个很容易被忽略的点:当fast的元素赋给slow时,如果fast和slow指向同一个位置,赋值是自我赋值,没任何问题。只有当fast领先于slow时,覆盖才会真正发生。所以在[1, 2, 3, 4]这种没有任何val出现的数组里,整个数组不会被搬动一次,只是白白扫描一遍,效率上没有任何额外开销。
3. 进阶方向:当“不允许改变顺序”时怎么办
3.1 对撞指针的另一个经典套路
移除元素还有第二种主流解法,利用的是题目中“元素的顺序可以改变”这句话。思路是把不等于val的元素往前放,等于val的元素直接和数组末尾的元素交换,然后缩小尾部范围。
初始化两个指针,left指向数组开头,right指向数组末尾。让left从头开始扫描,如果nums[left] == val,就把nums[right]的值复制到nums[left],同时right左移一位。为什么可以直接覆盖?因为被覆盖的元素已经被判定为“不需要保留”,而right位置的元素还没被检查过,把它搬过来继续处理即可。如果nums[left] != val,说明当前元素保留,left右移。
当left和right相遇时,数组前半部分就是所有不等于val的元素,left的值就是新数组长度。
class Solution: def removeElement(self, nums: List[int], val: int) -> int: left, right = 0, len(nums) - 1 while left <= right: if nums[left] == val: nums[left] = nums[right] right -= 1 else: left += 1 return left这种解法有意思的地方在于,它不会复制每一个保留元素,而是直接把不要的元素“顶”到后面去。当数组里要删除的元素很少时,对撞指针明显更高效。举个例子,数组是[4, 1, 2, 3, 5],val = 4,快慢指针需要扫描完整数组,而对撞指针第一轮就把nums[0]和nums[4]做了交换,直接结束,只操作了一次。
3.2 到底该选哪种解法
你在 LeetCode 上提交两种解法都能通过,但在真实面试场景里,选哪种取决于你对“稳定性”的需求,以及后续题目会不会追加限制。
快慢指针最大的优势是保持元素原有顺序。如果题目要求删除元素之后,剩余元素的相对顺序不能改变,那只能用快慢指针。对撞指针会改变顺序,在某些题目里不允许,但在移除元素这道题里是允许的。
从操作次数上看,如果要删除的元素很少,对撞指针的交换次数更少;如果要删除的元素很多,快慢指针的覆盖次数也更少。代码风格上,我个人的经验是快慢指针更通用,因为它在“有序数组去重”那类题里有直接的迁移可能性,而对撞指针更像“一次性技巧”。如果你两种都掌握了,面试时先问清楚“顺序是否可以改变”,然后决定用哪种,会给面试官留下“考虑周全”的印象。
4. 变体题:从移除元素到一大批同源题
4.1 283. 移动零:把特殊值“清零”
移动零这道题,本质上就是把val = 0的移除元素题,加上一个“末尾补零”的动作。题目要求把数组里所有 0 移动到末尾,同时保持非零元素的相对顺序。
解法可以复用快慢指针的框架:先遍历数组,把所有非零元素按顺序覆盖到数组前部,这一步和移除元素一模一样,只是把val换成了0。然后在slow指向的位置之后,把剩下的位置全部填成 0。
class Solution: def moveZeroes(self, nums: List[int]) -> None: slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow] = nums[fast] slow += 1 for i in range(slow, len(nums)): nums[i] = 0这道题能在移除元素的基础上秒解,是因为你理解了“覆盖思想”。如果你只会死记硬背移除元素的代码,遇到移动零很容易卡住。这也是为什么我一直强调,刷简单题的时候一定要把原理吃透,而不是背答案。
4.2 26. 删除有序数组中的重复项
LeetCode 26题“删除有序数组中的重复项”和移除元素几乎是一个模子刻出来的。区别在于,移除元素给定了一个明确的val,而重复项问题要求“相邻且相等”的元素只能保留一个,这个值不是提前给定的,而是在扫描过程中动态确定的。
解法还是快慢指针。用slow指向下一个不重复元素应该放置的位置,用fast扫描整个数组。因为数组是有序的,所以只需要判断nums[fast]是否等于nums[slow - 1]。如果等于,说明是重复项,跳过;如果不等于,就覆盖到slow位置,然后slow前进。
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 slow这里有个细节,第一次见到的时候估计会困惑:为什么要判断slow == 0?因为当slow为 0 时,nums[slow - 1]是nums[-1],在 Python 里是最后一个元素,会出错。所以要么单独处理第一个元素,要么像上面这样加一个短路条件。这个坑很经典,属于刷题开荒必须要经历的一关。
4.3 80. 删除有序数组中的重复项 II:允许保留两个
这道题是 26 题的强化版,要求有序数组中的每个元素最多出现两次。只要把判断条件改一下,立刻就能做出来。
现在用slow指向下一个要放置元素的位置,fast扫描时,只需要判断nums[fast]是否等于nums[slow - 2]。如果等于,说明当前元素至少会重复三次,跳过;如果不等于,就覆盖到slow位置。
为什么这样判断是对的?因为数组是有序的,如果nums[fast] == nums[slow - 2],说明在slow - 2和fast之间至少已经有两个相同的元素了,当前的nums[fast]是第三个相同的值,必须跳过。如果nums[fast] != nums[slow - 2],说明它和这个位置上的元素不一样,一定不会导致同一元素出现三次,可以放心保留。
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 slow这道题你看出规律了吗?26题判断的是nums[fast] != nums[slow - 1],80题判断的是nums[fast] != nums[slow - 2]。允许保留 k 个重复项,就把下标偏移改成slow - k。这类问题的通用解法就是这么总结出来的,而不是一道题一个套路。
4.4 203. 移除链表元素:从数组到链表
刷过链表相关的题就会知道,leetcode热词里经常能看到“链表leetcode”,而移除链表元素正是移除元素思路在链表结构上的延展。LeetCode 203题要求删除链表中所有等于val的节点。
链表和数组不同,它没法直接按下标访问,也没有“新长度”的概念。但删除节点的核心思想是一样的:跳过不需要的节点,保留需要的节点。实现上需要用到虚拟头结点,也就是哨兵节点,来处理“头节点也可能被删除”的边界情况。
class Solution: def removeElements(self, head: Optional[ListNode], val: int) -> Optional[ListNode]: dummy = ListNode(-1) dummy.next = head prev = dummy curr = head while curr: if curr.val == val: prev.next = curr.next else: prev = curr curr = curr.next return dummy.next这里的dummy节点不是业务数据,纯粹是为了统一删除头结点和非头结点的操作逻辑。如果你不用dummy,就得单独写一个“如果头结点是val就移动头结点指针”的循环,代码会多出一截,还容易漏边界。这个技巧可以记下来,几乎所有的链表删除类题目都能用上。
5. 现场实战:面试中的正确表演方式
5.1 代码细节决定成败
刷题刷多了你会发现,真正的 diff 不在“会不会做”,而在“能不能写得让面试官满意”。移除元素这道题虽然简单,但代码细节上还是有几个点可以加分。
第一,变量命名别用i、j随手一写。刷题代码不需要过度讲究,但fast和slow、left和right这种表意清晰的命名,能让面试官一眼看出你的思路。我见过很多人在白板上写了一个i,写到一半自己都忘了i是扫描指针还是保留指针。
第二,循环条件要写对。快慢指针通常用for fast in range(len(nums)),这时候fast自然递增,不容易出错。如果换成while fast < len(nums),就一定要在循环体内手动让fast自增,漏掉就死循环。对撞指针则要注意left <= right和left < right的区别,这个会直接影响边界元素是否被正确处理。
第三,注释不是必须的,但关键判断最好顺口解释两句。比如你写if nums[fast] != val,可以说“fast 指向的元素需要保留,所以放到 slow 的位置,然后 slow 前进一步”。把思路说清楚,面试官才知道你不是背的模板。
5.2 常见错误与排查技巧实录
我把这道题最常见的错误整理成了一张表,按出现频率排序,你可以自查一下自己踩过几个。
| 错误类型 | 具体表现 | 原因分析 | 解决办法 |
|---|---|---|---|
| 慢指针自增位置错误 | slow += 1写在 if 外面,返回长度偏大 | 没有理解 slow 只在“保留元素”时才前进 | 把 slow 的自增缩进到 if 内部 |
| 没有原地修改 | 新建数组存放非 val 元素再复制回来 | 没注意 O(1) 空间限制 | 使用双指针覆盖,不开新数组 |
用remove/splice边删边遍历 | 删除后元素前移,跳过下一个待检查元素 | 不了解动态操作数组对索引的影响 | 用后端语言时直接覆盖,不要真删 |
| 对撞指针循环边界出错 | 数组中间某个 val 没被处理或越界 | left <= right写成<,漏掉最后一个元素 | 用具体小数组模拟一遍检查 |
| 空数组或全删数组返回错误 | 返回原数组长度而非 0 | 没有对边界条件单独思考 | 先想三个边界:空、全删、全保留 |
这里重点说一下“边删边遍历”这个坑。很多新手用 Python 会写类似这样的代码:
i = 0 while i < len(nums): if nums[i] == val: nums.pop(i) else: i += 1单看这段逻辑似乎没错,但它有一个致命问题:pop(i)之后,后面的元素会整体前移一位,此时如果不自增i,下一次循环就会处理原先i+1位置的元素,这样刚好不会跳过。可是如果你用的是for循环,那麻烦就大了——for i in range(len(nums))里的i每次都会自动加 1,删除一个元素后,紧跟其后的元素就被跳过了。这种情况在力扣上不是每次都能测出来,但一旦测试用例里有连续两个等于val的元素,结果必然出错。所以刷题的时候尽量养成“覆盖代替真删”的习惯,这不仅是为了满足空间要求,更是为了避免索引震荡的坑。
5.3 从一道简单题建立刷题节奏
最后聊点刷题方法论。很多人觉得简单题没价值,一上来就啃难题,结果越刷越挫败。我的经验是,简单题反而是建立“解题原型”的最好材料。移除元素这道题,值得你做到三件事:第一,不看题解,手写出来;第二,把两种双指针解法都写一遍;第三,把 26、80、283、203 这四道变体题都用同一套思维过一遍。
一旦完成这三步,你的收获绝对不是一个题解,而是一整套可以复用的“骨架”。以后再遇到“在数组中筛选保留某些元素”的需求,你会第一时间想到快慢指针;遇到“数据可以从两端向中间压缩”的需求,会想到左右对撞;遇到“链表删除”需求,会想到哨兵节点。刷题就是这样,比的不是谁题目刷得多,而是谁能从有限题目里提炼出更多的模式。
我个人实际面试中的一个体会是,移除元素几乎不会作为独立题目出现,它更多时候是作为后续题目的前置步骤。比如让你在有序数组中查找某个目标值,你先通过双指针去掉无效数据,再进入二分查找;或者让你判断一个数组是否可以通过移除一个元素变成递增数组,你依然需要先理解“什么情况下应该跳过某个元素”。把基础问题的本质搞明白,后面的一切都是在它之上叠砖加瓦。