如果我说,力扣第27题“移除元素”比你想象的更能暴露编程功底,你可能觉得我在夸大其词。但这个看似平平无奇的题目,确实是《代码随想录》数组系列里特别值得反复琢磨的一道。它坑过我的面试候选人,也坑过我早年刷题时的提交记录。今天就把它彻底讲清楚。
写算法题这件事,很多人的误区是“会做题就行”。但真正拉开差距的,是你能不能把每一步选择背后的道理讲明白:为什么双指针能省时间?为什么不能直接删除?边界条件为什么这么处理?把这些问题想透,你刷的不是一道题,而是一类题的底层逻辑。
这篇文章按《代码随想录》的口径,把“移除元素”从暴力解法到双指针优化、从边界条件到面试追问、从同源扩展题到复杂度证明,完整地拆一遍。适合正在刷力扣进入瓶颈期的读者,也适合准备面试想让自己表达更清楚的人。我还会把我实际踩过的坑、面试别人时看到的典型失误,一并写进来。
1. 题目拆解:移除元素到底在考什么
1.1 一道题背后的三个“考点”
先把题目完整地抄下来:
给你一个数组 nums 和一个值 val,你需要原地移除所有数值等于 val 的元素,并返回移除后数组的新长度。不要使用额外的数组空间。元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。
这道题表面是“移除”两个字的语文题,实际上考的是三个点。
第一个,数组的存储结构。数组这一章为什么放在《代码随想录》的最前面?因为数组是所有线性结构的地基。数组在内存里是连续空间,这意味着你“删”一个元素,并不像删链表节点那样改个指针就算完,数组的删除本质上是一个“覆盖”操作。这是这道题首先要意识到的。
第二个,空间复杂度的约束。题目明确说“不要使用额外的数组空间”,这直接封死了你开一个新数组、把不等于 val 的元素装进去再拷回来的幼稚思路。面试官要的就是你在原地解决,这背后考的是对原地算法(in-place algorithm)的理解。
第三个,边界条件的敏感度。返回值到底是长度还是数组?空数组怎么办?val 出现在数组头部怎么办?这些细节是提交代码时反复吃“Wrong Answer”的地方。
所以,这道题做对不算本事,做对还知道为什么才是本事。
1.2 为什么“直接删除”这事根本不存在
很多刚刷题的人看到“移除”,第一反应是:调用语言自带的删除方法不行吗?在 Python 里,list.remove(val)或者写个循环nums.pop(i),多简单。我见过不少候选人真就这么答。
这个思路错在三个地方。
第一,pop和remove的时间复杂度不是 O(1)。Python 的list底层是动态数组,pop(i)一旦删掉中间某个位置,所有后续元素都要往前挪,单次操作就是 O(n)。最坏情况下,你每个元素都调一次pop,整体复杂度直接变成 O(n²)。
第二,循环里一边遍历一边删,还会引发经典的“索引错位”问题。你用for i in range(len(nums))去遍历,删掉一个元素后,后面所有元素下标集体前移,下一次循环访问的i已经指向了原来i+1的位置,结果就是漏掉元素。这就是我常说的“删着删着把自己绕进去了”。
第三,有些语言里,数组长度是固定的,根本没有“删除”这个动作。C 和 C++ 的静态数组,长度编译期就定死了,你能做的只有“覆盖”。
所以这道题的真正含义是:把不等于 val 的元素重新排到数组前面,并返回有效长度,让后面那些多余元素“没人管”。题目最后那句“你不需要考虑数组中超出新长度后面的元素”,就是在告诉你:别管尾巴,前面的东西才是关键。
想明白这一点,整道题的思路就顺了。
2. 双指针法:为什么它是最优解
2.1 暴力法:先看清它的上限在哪
在讲双指针之前,我先把暴力法说完,不是浪费时间,而是为了让你有一个对比的坐标系。
暴力法的思路很直白:从头扫描数组,碰到一个等于 val 的元素,就把后面所有元素整体往前挪一位,把当前这个位置盖掉。然后指针停留在原地,继续检查新挪过来的这个元素。
伪代码大概是这样的:
def remove_element_brute(nums, val): i = 0 while i < len(nums): if nums[i] == val: # 把 i 之后的元素整体前移 for j in range(i, len(nums) - 1): nums[j] = nums[j + 1] else: i += 1 return len(nums)看着好像挺合理,但你把脑子里的运行过程放慢,就会发现问题:每删除一个元素,都要把后面所有元素搬一次。如果数组里有 k 个等于 val 的元素,总搬运次数就是 k × (n - 平均删除位置)。最坏情况下数组全等于 val,你每删一个都要搬动剩余所有元素,总时间是 n + (n-1) + ... + 1 = O(n²)。
在 n 是 10⁵ 级别的测试数据面前,O(n²) 直接超时,惨不忍睹。我当年第一次提交这道题,就用这个方法在力扣上吃了一个 TLE,从此记住了这个教训。
暴力法的价值在于它给了你一个“下限”。你后续想出的所有优化,本质上都是在减少“搬运次数”。
2.2 快慢指针的思维跃迁:从“删掉”变成“保留”
暴力法的问题在于,它把注意力放在“怎么删”上。而双指针法的精髓,是把注意力放在“怎么留”上。
我给你一个生活化的比喻。你想在一堆苹果里挑出所有烂苹果扔掉,暴力法是“看见一个烂的,就把后面所有苹果往前挪”。快慢指针则是:你准备一个空篮子,左手(慢指针)指向篮子里要放的位置,右手(快指针)挨个检查每个苹果。好的放入篮子,烂的直接忽略。最后篮子里的数量就是答案。
在这个比喻里,快指针(fast)负责“探索”,把数组从头到尾扫一遍。慢指针(slow)负责“记账”,记录下一个可以用来覆盖的位置。每找到一个不等于 val 的元素,就把它写到nums[slow]上,然后 slow 前进一位。
核心代码如下:
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你对照动作拆一遍:
- fast 从 0 开始,逐个元素看。
- 如果
nums[fast] != val,说明它值得保留。把它覆盖到 slow 指向的位置。 - 如果
nums[fast] == val,跳过,fast 继续走,slow 不动。 - 循环结束,slow 正好等于保留元素的数量。
我把这个叫“一快一慢,快的负责看,慢的负责写”。快指针扫完整个数组只需要 O(n) 次操作,慢指针最多也只往前走 n 次,整体就是 O(n),空间复杂度 O(1)。
你可能会问:难道真的不用管数组后面的那些残留值吗?再读一遍题目最后那句话——不需要考虑超出新长度的元素。面试时你也可以主动提一句“我保留的元素都放在前 slow 个位置,后面是什么无所谓”,这本身就是一种对题目理解的体现。
2.3 代码落地:Python 和 C++ 的两版参考
既然说到了实现,我就把两个最常用来面试的语言版本都放出来。
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 slowC++ 版:
class Solution { public: 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]; } } return slow; } };Java 版逻辑完全一致,只是类型声明不同,我就不多贴了。
这里有一个细节值得注意:nums[slow++] = nums[fast]这一行,很多人会加一个判断,问“要不要先判断 fast 和 slow 是否相同,相同就不赋值”。加不加这个判断在结果上没有差别,因为数组原地覆盖,nums[fast]在 fast 大于 slow 时就是快指针自己读出来的值,即使 fast 还没超过 slow,也只是自己给自己赋值,浪费一次读写而已。
性能优化角度,可以在赋值前加一个if (fast > slow)来省掉无意义的自我赋值。但说实话,这个优化对时间影响微乎其微,反而会让代码可读性变差。我个人的建议是:在面试中不要做这种微优化,把逻辑写清楚比什么都重要。
2.4 为什么快慢指针不会漏元素
关于双指针,我见过太多人学会写法之后依然心里打鼓:fast 跳过了等于 val 的元素,这些“被跳过的”值会不会还留在数组前面,导致结果不对?
答案是:不会。
原因在于,slow 永远只覆盖那些已经“处理完毕”的位置。快指针沿途遇到不等于 val 的元素时,会把它们搬到 slow 的位置;而等于 val 的元素从来没有被搬到 slow 区域里。所以从下标 0 到 slow-1 这个区间内,装的全都是不等于 val 的元素。
你可以把 slow 当成一条“分界线”:分界线左侧是已经筛选完成的区域,右侧是尚未处理的区域。快指针一路向右,不断把右侧有价值的元素“搬运”到分界线处,分界线也随之右移。这个“分界线”思想,是双指针类题目里反复出现的核心意象,理解透了,后面刷“删除排序数组中的重复项”“移动零”都会很轻松。
3. 边界条件:最容易翻车的四种场景
3.1 空数组和单元素数组
很多人写完代码直接提交,连边界都不测。结果空数组时直接访问nums[0],报错。
空数组的情况很简单:len(nums) == 0,for 循环根本不执行,slow 直接返回 0,完全没问题。也就是说,双指针写法天然免疫空数组。
单元素数组要注意的是两种情况:唯一元素等于 val 时,fast 扫到它被跳过,slow 返回 0。唯一元素不等于 val 时,slow 返回 1。都正确。
我面试时经常追加一个问题:“如果数组长度是 1 并且唯一元素就是要删的 val,你的循环会不会越界?”很多候选人一紧张就开始怀疑自己的代码。其实只要理解 fast 是range(len(nums)),最大也就是访问nums[len-1],根本不会越界。
3.2 数组里全是 val
这种情况最考验你对返回值语义的理解。
假设nums = [3, 3, 3, 3],val = 3。快指针从头扫到尾,每个元素都等于 val,全部跳过,slow 从头到尾一直等于 0。函数返回 0。
从题目角度,你的“有效数组”是空的,长度 0,完全合理。可如果测试代码里写assert len(nums) == 0,这就错了。力扣的判题方式是:它只检查nums的前 0 个元素,也就是什么都不检查,自然通过。
这里也是很多候选人在面试时跟面试官扯不清的地方:数组本身长度没变,还是 4,为什么函数返回 0?你只要解释清楚“返回值是有效长度,不是物理长度”,问题就不存在了。
3.3 val 在数组头部连成串
如果nums = [2, 2, 2, 3, 4],val = 2。前三个元素全要删。很多人担心“前三个位置被覆盖会不会把后面的值弄丢”。
我们按代码模拟一遍:
- fast = 0,值为 2,跳过。
- fast = 1,值为 2,跳过。
- fast = 2,值为 2,跳过。
- fast = 3,值为 3,不等于 val,写入
nums[0]。数组变为[3, 2, 2, 3, 4],slow = 1。 - fast = 4,值为 4,写入
nums[1]。数组变为[3, 4, 2, 3, 4],slow = 2。
你会发现,真正要保留的 3 和 4 根本没丢,因为它们在 fast 扫描时已经被读出来了,写到了前面的位置。那些残留的 2 和 3 都在 slow 之后,属于“无人关心的区域”。这就是双指针法的自洽性。
3.4 val 根本不存在
nums = [1, 2, 3, 4],val = 5。快指针扫完所有元素,全部不等于 val,一个个写入 slow 位置。因为 fast 和 slow 永远同步增长,数组完全没有变化,slow 返回值等于原数组长度。
这个场景的潜在陷阱是,有初学者会担心“自己给自己赋值有没有问题”。再次强调,没问题,顶多是无意义的读写开销。真正的正确性,只看最终 slow 位置前的元素是否都是“幸存者”。
4. 左右指针法:另一种“删除”的哲学
4.1 思路区别:从“覆盖”到“交换”
快慢指针是面试中最稳妥、最通用的答案。但有时候,面试官会追问一句:“如果数组顺序不重要,有没有更省事的办法?”
这就引出了第二种双指针——左右指针法。
快慢指针的思路是“保留所有要留下的东西,把要删的挤到后面”。左右指针的思路则是“看到等于 val 的元素,直接从右边拉一个元素过来填上”。
具体做法:左指针从数组头部出发,右指针从尾部出发。
- 如果左指针指向的元素等于 val,就把右指针指向的元素复制到左指针位置,右指针左移一位。
- 如果左指针指向的元素不等于 val,左指针右移一位。
- 循环直到左指针和右指针相遇。
这个做法的巧妙之处在于,它不会反复复制整个数组,每删除一个元素,只需要复制一次。而且因为题目说“顺序可以改变”,所以你完全不需要维护原来的相对次序。
4.2 代码实现和它的坑
def remove_element_two_pointer(nums, val): left = 0 right = len(nums) - 1 while left <= right: if nums[left] == val: nums[left] = nums[right] right -= 1 else: left += 1 return left注意一个关键细节:把右边的值搬过来之后,左边这个位置的新值还没检查,所以不能立刻 left += 1,只能 right -= 1,然后在下一次循环里重新检查这个位置。
这是左右指针法最容易出 bug 的地方。我见过很多候选人,把nums[left] = nums[right]和left += 1写在一起,结果搬过来的新值又恰好等于 val,直接漏删。
举个例子,nums = [2, 2, 3, 4],val = 2。
- left = 0,value = 2,把
nums[3] = 4复制到nums[0],right = 2。数组变为[4, 2, 3, 4]。 - 下一轮,left = 0,value = 4,不等于 val,left = 1。
- left = 1,value = 2,把
nums[2] = 3复制到nums[1],right = 1。数组变为[4, 3, 3, 4]。 - 下一轮,left = 1,right = 1,此时
left <= right依然成立,检查nums[1] = 3,不等于 val,left = 2。 - 循环结束,返回 left = 2。
看起来一切正常。但如果你刚才错误地把 left += 1 写进了 if 分支,第二次删的时候,left 会直接从 0 变成 1,又因为数组前面已经被替换成 4,这个误操作不会立刻暴露,直到数组里出现“搬过来的元素依然等于 val”时才会翻车。这种 bug 藏在逻辑深处,调试起来非常难受。
4.3 左右指针法 vs 快慢指针法怎么选
两种方法都能过,但适用场景不完全一样:
- 如果题目要求“保持元素原有顺序”,必须用快慢指针。
- 如果不能额外使用空间,但允许改变顺序,左右指针法可以省去一些不必要的复制。
- 如果数组里等于 val 的元素特别多,左右指针法的平均赋值次数可能更少,因为它是“从尾部拿一个来顶替”,赋值次数等于被删元素个数。快慢指针的赋值次数等于保留元素个数。当要删的多、留的少时,快慢指针其实更吃亏,因为每留一个都要写一次,而左右指针法每删一个才写一次。
我在面试中看到很多候选人只会一种写法,面试官一旦追问“换种思路能做到 O(1) 吗”,当场卡壳。所以两种解法最好都能手写,并且能说清楚各自适合什么情况。
5. 常见错误和避坑实战
5.1 高频 Bug 速查表
我把这些年看到的典型错误整理成一个速查表,你可以直接截图放到刷题笔记里:
| 错误类型 | 错误表现 | 原因 | 修正方法 |
|---|---|---|---|
| 索引越界 | 空数组访问 nums[0] | 没考虑 len=0 | 用 for+range 或先判空 |
| 返回值错误 | 返回原数组长度 | 没改有效长度 | 返回 slow / left |
| 漏删元素 | 等于 val 的元素残留 | 用 list.remove 边删边遍历时索引错位 | 改用双指针覆盖 |
| 左右指针死循环 | left 一直卡住 | 复制右边值后没 right-=1 | 检查 right 的更新 |
| 过度优化 | 代码里到处都是 if | 太在意微优化 | 保证正确性优先 |
| 顺序遍历+pop | 性能超时 | 每次 pop 都是 O(n) | 不要用 pop 实现 |
这条表里每一行都是真实案例,尤其是第一行,我面试的候选人里至少有三次出现“空数组 Access 越界”这种低级错误。你可以在本地写一个测试函数,专门测空数组、满数组、全相等数组、穿插数组这四种情况,基本能覆盖 90% 的隐性 bug。
5.2 本地如何做自查和调试
我带实习生的习惯是,要求他们提交前必须跑一组自测用例。你甚至不需要写测试框架,一个函数搞定:
def test(nums, val): new_len = remove_element(nums, val) print(new_len, nums[:new_len]) assert all(x != val for x in nums[:new_len]) test([], 3) test([1], 1) test([1], 2) test([3, 2, 2, 3], 3) test([0, 1, 2, 2, 3, 0, 4, 2], 2)这个简单的打印加断言,能在三秒内告诉你算法对不对。不要嫌它简陋,实际调试中它比断点调试高效得多。
5.3 常见误区:把“双指针”当成银弹
我也见过有人学会这题之后,看到任何数组题都往双指针上套。这是另一个极端。
双指针适用于“有序性、对比性、原地重排”这类场景。如果是需要计数、需要哈希去重、需要动态规划的问题,硬套双指针只会把简单问题复杂化。移除元素这题之所以用双指针,是因为它本质上是过滤,过滤天然适合用一快一慢两个指针去完成。
你在后续刷题时,要有意识地放下模板,先想清楚:这个问题是在问“保留某些元素”还是“查找某些元素”还是“统计某些元素”。目的不同,工具就不同。
6. 同源扩展:移除元素家族还有这两道必刷
6.1 删除有序数组中的重复项
力扣 26 题和 27 题可以说是双胞胎,原题要求:给定一个有序数组,原地删除重复出现的元素,使每个元素只出现一次,返回新长度。
核心思路和移除元素一模一样,快慢指针,唯一区别是判断条件:
def remove_duplicates(nums): 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注意这里 slow 从 1 开始,因为第一个元素天然保留。fast 从 1 开始扫描,只有遇到和前一个保留元素不同的值,才写入。
一个细节是,这个版本的判断条件是nums[fast] != nums[slow - 1],而不是nums[fast] != nums[fast - 1]。两者差别在哪?nums[slow-1]是“已经决定保留的最后一个元素”,而nums[fast-1]可能是一个已经被跳过的重复值。如果用后者,结果大概率不对。
连刷 27 题和 26 题是个好策略,因为它们互相强化“快慢指针 + 覆盖”的心智模型,一石二鸟。
6.2 移动零
力扣 283 题“移动零”本质上是移除元素的一个变体:把数组中所有 0 移到末尾,同时保持非零元素的相对顺序。
思路是一样的,只是这次你不仅要“移除 0”,还要“把 0 补到后面”。
一种直观做法:先按移除元素的逻辑,把非零元素全部排到前面,得到有效长度 slow,然后在数组末尾原地补零。
def move_zeroes(nums): 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这个写法背后其实蕴含了一个很实用的工程思路:先压缩,后填充。你把一个“移动问题”拆成了“保留”和“清零”两个动作,比原地交换更易读,也更不容易错。
如果你面试时能主动关联“这道题和我刚才做的移除元素思路是一样的,只是多加一步”,这种横向迁移能力是面试官很看重的信号。
6.3 力扣 844:比较含退格的字符串
这道题稍微进阶一点,但核心还是双指针。
给定s和t两个字符串,#代表退格键,判断两个字符串经过退格处理后是否相等。最简单的思路是用栈模拟退格,但空间复杂度 O(n)。双指针解法可以从后往前扫描,遇到#跳过,同时统计需要忽略的字符数,空间 O(1)。
如果 27、26、283 这三道题你都吃透了,再看 844,会发现双指针的思想是一个连续谱系,只是应用场景从数组换到了字符串。这个关联梳理一遍,双指针的底子就算真正建立起来了。
我的几点真实体会
最后说点刷题之外的事。
我见过太多人,LeetCode 刷了几百道,面试时依然答不好“你是怎么想的”这个问题。原因就在于,他们刷题像背答案,没有建立“从问题到解法”的推导链条。移除元素这个题,恰好是建立链条的绝佳样本:你先想清楚数组删除的本质是覆盖,发现暴力法有大量重复搬运,然后想到用快慢指针一次搬运完成,再根据题目“顺序可改变”的提示,想到左右指针法。每一步都是问题驱动,不是套路驱动。
我自己在刷这题时,最获益的瞬间不是 AC 的那一刻,而是当我尝试去解释“为什么 slow 一定指向待覆盖的位置”时,我突然发现自己对数组的理解变深了。刷题不是为了那个绿色的 accepted,是为了在“解释”这个动作中真正掌握它。
如果你现在正卡在双指针或者数组相关的题上,不妨停下来,别急着写下一道,先把“移除元素”这题用两种方法各写三遍,再试着用大白话讲给身边人听。讲明白了,你就真的会了。
愿你顺利通过后续的每一道算法题。稳扎稳打,比什么都重要。