Day 24了,今天刷到LeetCode第27题“移除元素”。这道题很多初学者一看就觉得简单:不就是把数组里等于某个值的元素删掉吗?但实际动手写的时候,问题就来了——原地操作不允许开新数组、返回的是新长度而不是新数组、删除元素后下标要不要回退、会不会漏删……一系列细节全冒出来。我这次把这道题从暴力解法到两种双指针方案完整拆了一遍,边写代码边记录踩坑点,希望能帮同样在刷数组题的朋友少走一些弯路。
1. 题目拆解:先搞清楚“移除元素”到底在考什么
1.1 题面解读与关键约束
题目描述很简短:给定一个数组nums和一个目标值val,需要在原地移除所有数值等于val的元素,返回移除后数组的新长度。看起来就是“遍历 + 删除”两步,但有几个约束条件非常关键,值得逐条划重点。
第一是“原地”。原地意味着不能用额外数组来接收结果,不能先new一个新数组、把不等于val的元素放进去、再拷贝回来。这个约束直接淘汰了很多人第一反应想到的“筛选复制”方案。需要搞清楚的是,C++的vector、Python的list、Java的ArrayList内部都是连续内存存储,对“原地”的要求是一致的:你只能在原数组上做覆盖或交换操作。
第二是返回值语义。题目要求返回新长度k,并且只说“忽略超出新长度后面的元素”。也就是说调用方只关心nums[0]到nums[k-1]这些位置,后面元素是什么都无所谓。这一条容易被忽略,但它其实给优化制造了大量空间——你不需要真的把元素“删掉”或“清空”,只需要保证前k个位置是正确的就行。
第三是“元素的顺序可以改变”。这个条件看起来是松约束,实际上是开启相向双指针方案的前提。如果要求保持相对顺序,就只能用快慢指针做覆盖;如果允许乱序,那就可以用交换或“右边元素填充左边坑位”的策略大幅减少移动次数。
所以这道题真正考察的不是你会不会写删除语句,而是能不能在一个数组上同时完成“遍历、判断、覆盖”三个动作,并且对整个过程的时空复杂度有清晰认识。
1.2 隐藏的考察点:原地操作与复杂度意识
第27题在LeetCode里被标记为简单题,代码量确实不大,但信息密度不低。我刷下来感觉至少有三个隐藏考察点。
第一个是空间复杂度敏感度。原地操作直接要求O(1)额外空间,这在工程上是很常见的限制。比如业务中操作一个百万行的内存表,或者处理网络协议栈里的一块缓冲区,你再开辟一个等长的数组往往是不现实的。
第二个是时间复杂度的优化意识。暴力删除法的复杂度是O(n^2),在数据量小的时候无所谓,但到十万、百万级就会明显变慢。面试官经常会追问“还能不能优化”,本质上就是在看你有没有主动分析复杂度的习惯。
第三个是返回值语义的正确理解。这道题返回的并不是删除后的数组,而是删除后的长度。这个概念在C++的erase、Java的ArrayList removeIf、Python的列表推导式里都有不同的落地方式,理解清楚底层的“覆盖”逻辑,写出的代码才更贴近运行时的真实行为。
看到了这三个隐藏点,再看这道题就有意思多了:它本质上是一个“在有限空间内做数据筛选与压缩”的问题。
2. 暴力解法:先能做对,再谈优化
2.1 暴力删除的核心思路与代码实现
很多人的第一反应是:遍历数组,遇到等于val的元素就把它删掉。C++里可以调用vector的erase,Python里可以调用list.pop或remove,Java的ArrayList也有remove方法。把这套思路称为“边遍历边删除”。
先说C++版本,比较直接的做法长这样:
int removeElement(vector<int>& nums, int val) { for (int i = 0; i < nums.size(); i++) { if (nums[i] == val) { nums.erase(nums.begin() + i); i--; // 删除后下标回退 } } return nums.size(); }这里有一个极其关键的细节:删除后为什么要i--?因为erase会让后面的元素集体前移一位,如果不回退下标,原本在下一位的元素会被直接跳过,造成漏删。这是典型的“边遍历边修改容器结构”的坑,而且只在你删除了连续多个val时才会显形,实际排查起来还比较隐蔽。
Python版本有另一个更隐蔽的问题。很多人会下意识这样写:
def remove_element(nums, val): for i in range(len(nums)): if nums[i] == val: nums.pop(i) return len(nums)这个写法在遇到“val连续出现”时一定会出问题。原因是range(len(nums))是在循环开始前就确定好的,一旦pop改变了列表长度,序列的推进和列表真实下标就对不上了。比如nums = [2, 2, 3],val = 2,i=0时删掉下标0的元素,列表变成[2, 3],但下一次循环i=1时,检查的是nums[1]也就是3,第二个2就这样被跳过去了。
所以暴力方案看起来逻辑清晰,真写起来边角很毛糙。Java里直接使用ArrayList的remove也会遇到类似问题,因为每次remove都会移动后续元素。
2.2 为什么暴力解法实际写起来很别扭
暴力解法除了容易踩坑,还有一个被很多人忽视的底层代价:数组的删除操作本质上是一次“批量移动”。每删除一个等于val的元素,它后面的所有元素都要往前挪一位。极端情况下,比如数组十万元素全部等于val,第一次删除要移动99999个元素,第二次移动99998个……累计移动次数是O(n^2)的。
我自己在本地做过一个简单测试:用相同的数据规模去跑暴力删除和双指针覆盖,前者耗时明显要高出几个量级。这个差距不是代码风格造成的,而是算法复杂度决定的。只要数据量足够大,暴力方案在LeetCode上就会直接超时。
不过我的建议是:暴力解法不要跳过,但也不要背。写一遍,切身感受下标回退、长度变化、连续删除这些别扭点,你才能在面试中说清楚“暴力法能过但复杂度不好”这句话的分量。写完之后再去看双指针方案,那种“原来是这么绕过来的”感觉,比直接看答案要深刻得多。
3. 双指针(快慢指针)解法:标准答案
3.1 核心思想:用一个慢指针维护“有效区域”边界
快慢指针的解法,核心思维是把“删除”变成“覆盖”。
维护两个指针:fast用来遍历整个数组,负责“找”;slow指向当前位置,负责“装”。具体规则是:fast每向前一步,就检查nums[fast]是否等于val。如果不等于val,就把nums[fast]的值赋给nums[slow],然后slow加一;如果等于val,跳过,fast继续前进。
一轮遍历结束后,slow的值就是新数组的长度k,而且nums[0]到nums[k-1]全都是不等于val的元素,同时保持了元素间的相对顺序。
理解这个方案时,我更喜欢用“整理书架”的类比:书架上有一些书要清掉,但书架空间有限,你不能把要留的书先挪到另一个房间,只能在同一排书架上腾地方。最好的办法就是从左往右扫一遍,看到要留的书就拿起来放到左侧的“目标区域”,看到不要的书就扔在原地不管。题目最后只检查“目标区域”有多少本,所以废书留在原位完全没关系。
3.2 代码实现与细节验证
C++参考实现:
int removeElement(vector<int>& nums, int val) { int slow = 0; for (int fast = 0; fast < nums.size(); fast++) { if (nums[fast] != val) { nums[slow] = nums[fast]; slow++; } } return slow; }Python参考实现:
def remove_element(nums, val): slow = 0 for fast in range(len(nums)): if nums[fast] != val: nums[slow] = nums[fast] slow += 1 return slow有几个细节值得手动验证一下。
当数组里一个val都没有时,fast和slow同步前进,每个元素都执行一次“自我赋值”,nums内容不变,slow最终等于数组长度,返回原长度,正确。
当数组全部等于val时,slow一步都不走,fast从0走到末尾,循环结束返回0,也正确。
当val在数组中间出现时,会出现真正的覆盖效果。比如nums = [3, 2, 2, 3],val = 3。fast=0时遇到3跳过;fast=1时遇到2,写入nums[0] = 2,slow=1;fast=2时遇到2,写入nums[1] = 2,slow=2;fast=3时遇到3跳过。最终返回2,nums变成[2, 2, 2, 3],只看前2个元素,完全符合预期。
这里有一个初学者容易疑惑的点:nums[slow]被覆盖时,原来的值不需要管吗?确实不需要。因为slow位置只会被slow之前已经确认的“保留元素”填充,原本在slow位置的数据可能等于val,也可能是不等于val的旧数据,但它本质上是一个“已废弃”的坑位。只要slow最终指向0到k-1的每个位置都被写入正确值,任务就完成了。
3.3 为什么这个解法是面试的“安全牌”
快慢指针版本是我最推荐在面试中优先讲的方案,原因有几个层面。
逻辑简单,容易说明白。整个代码的核心就是一个条件判断加一次赋值,没有任何指针回退、下标变换、循环条件修正之类的额外操作。你在白板上写出来,面试官一眼就能看懂,沟通过程很顺畅。
复杂度优秀且无额外内存开销。时间复杂度O(n),每个元素访问一次;空间复杂度O(1),没有开任何辅助数组。
它保留相对顺序。虽然题目允许乱序,但保留相对顺序在很多扩展场景里会变成硬要求。你给出的解法天然兼容“保持顺序”这个更强的约束,是一个更通用的答案。
还能直接迁移到其他题目。第26题“删除有序数组中的重复项”、第283题“移动零”都可以用几乎同一套代码框架来解决。你在面试时提到“这个框架我在处理数组筛选类问题时经常用”,本身就体现出算法思维的抽象能力。
所以在面试时我通常建议的顺序是:先讲快慢指针,把正确性和复杂度说清楚,再根据面试官的反应补充相向双指针优化方案。不要一上来就甩一个“最优解”,这样反而容易让面试官觉得你只是在背答案。
4. 相向双指针优化:当顺序可以改变时,还能更快
4.1 思路来源:利用“允许乱序”这个条件
快慢指针有一个最坏情况:假设数组前十万个元素全部是val,后面十万个元素全部不是val。快慢指针会怎么做?它会从后往前把十万个保留元素一一搬移到前面,移动量是十万次。
但题目里写了“元素的顺序可以改变”。那我们就有了一个完全不同的操作方向:左指针从左往右找“要删除的元素”,右指针从右往左找“要保留的元素”,找到之后,把右边的保留元素直接填到左边的删除坑位上。这样每个坑位最多被填一次,每遇到一个val,只做一次赋值操作,移动次数显著减少。
这里可以做一个直观对比:快慢指针是“把所有保留元素依次往前搬”,相向双指针是“用后面的保留元素填充前面的删除坑位”。前者搬的是“人”,后者填的是“坑”。面对“大量val集中在前半段”的数据分布时,它们的操作量差距非常明显。
4.2 代码实现与边界条件
C++参考实现(右开区间写法):
int removeElement(vector<int>& nums, int val) { int left = 0, right = nums.size(); while (left < right) { if (nums[left] == val) { nums[left] = nums[right - 1]; right--; } else { left++; } } return left; }Python参考实现:
def remove_element(nums, val): left, right = 0, len(nums) while left < right: if nums[left] == val: nums[left] = nums[right - 1] right -= 1 else: left += 1 return left这里有几个特别值得推敲的点。
第一,为什么right初始化为nums.size()而不是nums.size() - 1?因为我刻意选择了“左闭右开区间”的表示方式。right指向的是有效范围之外的第一个位置,也就是右边界不包含在待检查范围内。这样的好处是:当数组为空时,left = 0,right = 0,循环条件不成立,直接返回0,不需要额外判空。如果right初始化为size() - 1,空数组就会变成-1,访问nums[right]时直接越界。
第二,当nums[left] == val时,把nums[right - 1]赋给nums[left]之后,为什么不能马上left++?因为从右边搬过来的元素有可能是val。搬过来之后必须留在原位置再检查一轮,如果这个新元素也等于val,就继续从右边取一个覆盖它,直到当前位置变成非val元素。这个“当前left位置不急于推进”的细节,是很多不看边界条件的人最容易写错的地方。
第三,当nums[left] != val时,left才能前进,因为当前位置已经确认是要保留的元素了。此时右侧那些“已检查但未处理”的元素继续留在[right, n)区间内,不需要处理。
我建议你把这段代码在实际数组上跑几个具体例子,特别是val出现在中间、val连续出现、右侧元素全部是val等场景,终点是彻底搞懂“为什么right--之后left不着急动”。
4.3 对比三种解法的复杂度与实际操作量
把三种解法放在一起对比,能看得更明白:
| 解法 | 时间复杂度 | 空间复杂度 | 元素移动特点 |
|---|---|---|---|
| 暴力删除 | O(n^2) | O(1) | 每删除一个元素都要移动后续全部元素 |
| 快慢指针 | O(n) | O(1) | 每个保留元素最多被移动一次,最坏会移动大量元素 |
| 相向双指针 | O(n) | O(1) | 每个val坑位最多被覆盖一次,移动量稳定可控 |
需要强调的是,两种线性解法的理论复杂度都是O(n),实际运行时的差异是“移动元素次数”的常数因子。在绝大多数工程场景下,这个差异不会造成质变,但在特定数据分布下确实会体现出差距。比如前面提到的“前十万全是val,后十万全不是val”,快慢指针要搬十万个元素,相向双指针只需要从右侧取一次、直接填到左侧坑位上,虽然具体操作次数同样是十万左右,但只对左侧的val位置做覆盖更高效。
面试时,能说出“快慢指针保持顺序,相向双指针减少搬移但不保证顺序,两者都是O(n) + O(1)”这样的对比,远比单纯写出最优代码更有含金量。
5. 边界条件与常见坑点实测
5.1 典型的边界场景
刷题最怕的不是主流程逻辑错,而是边界用例翻车。我整理了一张自测表,每次写完后都会跑一遍:
| 测试场景 | 输入 | 预期结果 |
|---|---|---|
| 空数组 | nums = [], val = 1 | 返回0 |
| 全删除 | nums = [1,1,1], val = 1 | 返回0 |
| 一个不删 | nums = [1,2,3], val = 4 | 返回3 |
| 删除头部连续值 | nums = [2,2,1,3], val = 2 | 返回2,前2个元素为[1,3] |
| 删除尾部连续值 | nums = [1,3,2,2], val = 2 | 返回2,前2个元素为[1,3] |
| 单个元素且等于val | nums = [2], val = 2 | 返回0 |
| 单个元素且不等于val | nums = [2], val = 3 | 返回1 |
实测最容易翻车的是两个场景:一个是空数组在的右指针初始化问题,另一个是连续val下的下标回退问题。前者是相向双指针特有的,后者是暴力解法特有的。快慢指针方案几乎不太会遇到严重的边界问题,这也是它“安全牌”的由来。
5.2 指针越界与覆盖顺序的细节问题
相向双指针最容易犯的错是右指针越界。很多人把循环条件写成left <= right,然后在循环体里访问nums[right],一旦right等于-1或者left越过right,程序直接崩溃。稳妥的办法是统一使用左闭右开区间[left, right)来思考:循环条件保持left < right,right始终指向一个尚未检查的位置,访问nums[right - 1]的合法性就非常好推理。
还有一个很容易被忽略的问题:从右边搬过来的元素可能是从未被检查过的。以nums = [2, 3, 2, 1],val = 2为例,left=0时找到val=2,right-1指向的位置是1,把1覆盖到nums[0],此时nums[0] = 1,然后right变成3,left不前进,下轮检查nums[0] = 1,确认非val,left++。一切正常。但如果右侧元素本身就是val,比如nums = [2, 2, 2, 1],val = 2,left=0时把右侧的2覆盖过来,nums[0]还是2,right变成3,left保持0,下一轮继续处理,直到right-1指向最后的1时才填进来。这个“反复覆盖直到找到非val元素”的过程是正常的,而不是bug。
快慢指针版本则基本没有这类问题,因为它对每个元素只检查一次,不存在“搬来的元素还需再验证”的过程。这也是快慢指针更好写、更好解释的原因之一。
6. 延伸思考:从第27题看懂一类“原地去重”问题
6.1 第26题与第27题的关系
刷过LeetCode的人都知道,第26题“删除有序数组中的重复项”和第27题是标准的学生题。26题的代码框架和27题几乎一模一样:
int removeDuplicates(vector<int>& nums) { if (nums.empty()) return 0; int slow = 1; for (int fast = 1; fast < nums.size(); fast++) { if (nums[fast] != nums[slow - 1]) { nums[slow] = nums[fast]; slow++; } } return slow; }对比一下就能发现:26题是“保留与上一个保留元素不同的元素”,27题是“保留与val不同的元素”。26题比较的对象是慢指针前一个位置的值,27题比较的对象是固定目标值。两者在代码上只差一个判断条件,但抽象思维是同一个:用快慢指针把“需要保留的元素”往前覆盖,慢指针始终指向新数组的边界。
所以第27题可以看作双指针思想的起点题。练熟之后,第26题、第80题“删除有序数组中的重复项II”、第283题“移动零”都能套同一个模板,只不过比较条件或后续操作略有变化。
6.2 双指针在不同场景下的变形
以第283题“移动零”为例,要求是把数组里所有0移动到末尾,同时保持非零元素的相对顺序。用快慢指针处理时,其实是在做“筛选 + 补位”两件事:fast遇到非0元素就往前覆盖,最后从slow位置开始到数组末尾统一补0。这和第27题的本质区别在于“删除后的空位需要填什么”——27题不需要管,移动零则必须补0。
还有一类题目,比如“原地移除所有重复元素”或“移除指定区间的元素”,本质上都是同一套“筛选 + 压缩”的思路。掌握了第27题,相当于拥有了一把钥匙,可以打开一排数组题的锁。
在真实的工程场景中,这种思路也很有用。比如清理日志数组里某个错误码的记录、过滤传感器数据流中的异常值、在内存受限设备上处理大数组等。双指针不只是一个面试技巧,它背后是“在有限空间内高效整理数据”的通用工程思维。
刷题到Day 24,一个越来越明显的感受是:数组题的精髓不在于会多少API,而在于能不能理解数据在底层是怎么存储、怎么移动的。删除在数组里是一种“奢侈”的操作,它伴随着整体移动。所以解题时与其执着于“怎么把元素删掉”,不如反过来思考“怎么把要保留的元素组织起来”。快慢指针里那把“慢指针”是你维护的新数组边界,相向双指针里那个“右指针”是你的备用元素池,想通了这两个视角,再看第27题乃至后续的同类题,都会通透很多。
如果你也在刷这道题,建议不要急着背代码,先把三个版本的写法都过一遍,再拿那张边界测试表逐行跑一遍。我个人踩过最大的坑就是“自认为逻辑正确,结果连续val一测就挂”。把边界条件补齐,这道题才真正算刷透了。