news 2026/10/10 6:42:44

LeetCode 27 移除元素:从暴力到双指针的原地操作详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode 27 移除元素:从暴力到双指针的原地操作详解

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]
单个元素且等于valnums = [2], val = 2返回0
单个元素且不等于valnums = [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一测就挂”。把边界条件补齐,这道题才真正算刷透了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 6:42:28

AnyPS5:PS5存档管理工具设计与备份校验实践

1. “AnyPS5”这个名字背后&#xff1a;一次存档管理的重构实践大概半年前&#xff0c;我在整理手头几台PS5主机时&#xff0c;遇到了一个几乎所有多机党、多账号用户都会撞上的麻烦&#xff1a;存档东一个西一个&#xff0c;备份文件散落在不同硬盘里&#xff0c;命名全靠“日…

作者头像 李华
网站建设 2026/10/10 6:42:25

机房UPS选型全攻略:从负载计算到冗余架构

1. 不同规模机房的选型思路拆解1.1 先认清机房的“规模”本质做UPS选型这么多年&#xff0c;我最大的感受是&#xff1a;很多人都把注意力放在“买多大功率”上&#xff0c;却忽略了机房规模背后的本质差异。机房的规模不应该只看物理面积&#xff0c;更应该看IT负载总量和可用…

作者头像 李华
网站建设 2026/10/10 6:42:11

2024年VScode配置Python开发环境完整指南:从解释器到依赖锁定

简介&#xff1a;面向希望在VSCode中高效搭建Python开发环境的初学者与进阶用户&#xff0c;这份资源以2024年最新实践为基础&#xff0c;系统整理了从安装解释器、管理虚拟环境到配置调试器、集成Git与常用插件的完整流程。压缩包共152个文件&#xff0c;约3.54MB&#xff0c;…

作者头像 李华
网站建设 2026/10/10 6:42:06

Spring Boot + Vue 高校医务室预约系统全栈开发实践

做高校医务室预约系统这类项目的同学和同行&#xff0c;这几年是越来越多了。原因其实很现实&#xff1a;Spring Boot Vue 这套前后端分离组合&#xff0c;既能支撑起一个完整可运行的业务项目&#xff0c;也能在简历里讲成有真实使用场景的作品&#xff0c;而高校医务室的业务…

作者头像 李华
网站建设 2026/10/10 6:40:42

Python字符串不可变机制与高效处理实战

不知道你有没有过这种经历&#xff1a;从C/C或者Java转过来写Python&#xff0c;上手第一个字符串操作就懵了——想改某个位置的字符&#xff0c;直接给字符串下标赋值&#xff0c;结果TypeError: str object does not support item assignment&#xff0c;当场怀疑人生。这个报…

作者头像 李华
网站建设 2026/10/10 6:40:37

Cesium自定义材质实战:从Fabric语法到GLSL动态着色器全解析

前一阵做三维态势项目&#xff0c;客户要在卫星图上叠加一圈可调节的雷达扫描波纹&#xff0c;Cesium内置材质里翻了一圈——纯色、条纹、棋盘格、发光箭头都试过&#xff0c;要么太生硬&#xff0c;要么根本模拟不了“波峰从中心一圈圈往外推”的动态效果。后来去翻了Cesium的…

作者头像 李华