☰
力扣27移除元素:双指针原地修改数组的核心套路
2026/10/1 3:30:53 网站建设 项目流程

力扣第27题“移除元素”大概是所有双指针入门选手绕不开的一道题。我第一次在题解区看到这题时,还以为要把数组里的元素一个一个真正删掉,后来才明白,它考的是“覆盖”而不是“删除”。题目本身不难,却把原地修改数组、双指针、边界条件这几个算法基础概念串在了一起。这篇文章就围绕这道题,从暴力解法讲到双指针优化,再把相似题目串起来,适合刚刷力扣的朋友,也适合准备面试但想快速过一遍数组双指针套路的人。

先说结论:这题的核心不是“删”,而是把不等于val的元素全部挪到数组前面,最后返回一个新的长度。数组后半部分残留什么值都不用管,测试用例也只检查新长度范围内的元素。理解这一点,后面所有代码都好写了。

1. 先弄明白题目到底要干什么

1.1 原题描述与示例

题目是 LeetCode 27,英文名叫 Remove Element。给你一个数组nums和一个值val,需要原地移除所有数值等于val的元素,并返回移除后数组的新长度。限制条件是:不能额外开辟数组,只能使用 O(1) 的额外空间,元素顺序可以改变,并且不需要考虑数组中超出新长度后面的元素。

官方的两个示例很重要:

  • 示例 1:nums = [3,2,2,3],val = 3。函数返回新长度 2,且nums的前两个元素都是 2。
  • 示例 2:nums = [0,1,2,2,3,0,4,2],val = 2。函数返回新长度 5,前五个元素可以是0,1,3,0,4,顺序随意。

这里最容易忽略的是“顺序可以改变”。如果你做过 LeetCode 26 题(删除有序数组中的重复项),那道题要求保留相对顺序,所以只能用快慢指针。但 27 题明确告诉你顺序无所谓,这就多了一条路:从数组两头往中间走,遇到等于val的元素就用右边的元素来覆盖,能够减少很多不必要的搬移。

1.2 移除的本质是覆盖

“移除元素”这个词容易让人想到List.remove或者数组删除操作。但数组在内存里是连续空间,删除一个元素本身就要把它后面的元素整体前移。这题真正的意思是:让所有不等于val的值都排列在前面,并把它们的数量作为新长度返回。数组末尾多出来的是什么?是原来等于val的元素,也可能是什么都没清理的旧值,题目明确说了不用管。

打个比方,一个文件柜里有几个文件夹不需要了,我要做的是把需要的文件夹按顺序往前排,然后告诉别人“以后只看到第 N 个柜子之前就行”。后面不需要的文件夹不用真的丢掉,也腾不出空间,只是逻辑上没人再去看它们了。

所以,代码里你可能会看到nums[slow] = nums[fast],这种覆盖操作就是核心。只要写代码时理解“新长度后面的元素不影响结果”,边界条件就好处理了。

2. 最直接的思路:暴力前移,但别急着写

2.1 暴力解法怎么实现

如果第一次接触数组操作,最直觉的办法是:从头遍历数组,每遇到一个等于val的元素,就把后面的所有元素往前移动一位。这样每删除一个元素,后面元素都要集体搬家,代价很大。代码大概长这样:

def removeElement(nums, val): i = 0 n = len(nums) while i < n: if nums[i] == val: for j in range(i, n - 1): nums[j] = nums[j + 1] n -= 1 else: i += 1 return n

注意i不能每次都加一,因为当后面前移之后,原来的位置可能又来了一个新元素,需要重新判断。比如说nums = [1, 1, 1],val = 1,如果删一次就让i++,就会跳过很多没检查的元素。这个细节看起来小,却很容易写错。

嵌套循环的写法能通过示例,但在力扣上是能过的,因为 27 题数据范围不算大。但面试的时候如果只写出这个版本,通常还需要一句“时间复杂度是 O(n^2),还可以优化”。

2.2 为什么暴力解法不够好

最坏情况是什么?数组全是val,比如[2,2,2,2],每删除一个元素,后面所有剩余元素都要前移,整体操作次数接近 n^2/2。虽然 O(n^2) 在 n 比较小时没感觉,但这道题在 LeetCode 上绝不是为了让你用双重循环解决的。

另外,暴力解法虽然也在原地操作,但元素的移动次数太多了。比如快慢指针版本遇到不等于val的元素时只需要复制一次,而暴力版本可能同一个元素反复被往后挪,造成大量无意义赋值。

我在实际刷题的时候,很少直接跳过暴力解法。先写暴力能帮助确认题意有没有理解错,尤其是正确计算返回长度和索引变化。但写完之后一定要想一想:能不能在一次遍历里完成?这样就会自然引出双指针。

3. 快慢指针:移除元素的标准解法

3.1 指针的含义

快慢指针是数组双指针里最经典的套路之一。在这道题里,定义两个索引:

  • slow表示“新数组的写入位置”,或者说“当前已经确认不等于 val 的元素个数”。
  • fast用来遍历整个数组,寻找不等于val的元素。

每次fast指向的元素不是val,就把它复制到slow位置,然后slow加一。如果fast指向的是val,直接跳过,什么都不做。

这样做的结果是:slow之前的元素永远是合法元素,slow最后就是新长度。因为是fast往前跑,它扫过的区域已经包含了所有原数组元素,所以信息不会丢失。

3.2 代码实现

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 slow

Java 版本也差不多:

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; } }

C++ 更是如出一辙,这里就不重复了。核心就是那一个if,简单得让人怀疑自己是不是写对了。

3.3 为什么这样写是对的

用示例nums = [3,2,2,3],val = 3来手动走一遍:

  • slow = 0,fast = 0:nums[0] = 3,等于val,跳过,slow仍然是 0。
  • fast = 1:nums[1] = 2,不等于val,把 2 写到nums[0],数组变成[2,2,2,3],slow = 1。
  • fast = 2:nums[2] = 2,不等于val,把 2 写到nums[1],数组变成[2,2,2,3],slow = 2。
  • fast = 3:nums[3] = 3,等于val,跳过。

最后返回slow = 2。此时nums的前两个位置是[2,2],虽然第三个位置还是 2,第四是 3,但没关系,题目只看前两个。

快慢指针的时间复杂度是 O(n),每个元素只访问一次;空间复杂度是 O(1)。它的另一个优点是保持非val元素的相对顺序不变。虽然这题没要求,但如果你以后做 283 题“移动零”,会发现这个特性非常重要。

4. 左右指针:利用“顺序可以改变”优化赋值次数

4.1 思路来源

快慢指针已经能达到 O(n) 了,但还有优化空间。看一个极端例子:nums = [1, 2, 3, 4, 5],val = 10。也就是说数组里一个等于val的元素都没有。快慢指针会怎么操作?它会遍历所有元素,然后每个元素都复制一遍到原位置,也就是做了 n 次nums[slow] = nums[fast],虽然数组根本没变。

能不能减少这种无谓赋值?题目允许改变顺序,那么可以从左边找一个等于val的位置,从右边找一个不等于val的位置,用右边的值覆盖左边。如果右边也是val,就把右指针继续往左移。这样,大多数情况下赋值次数只等于需要被覆盖的位置数。

这种思路也叫左右指针、首尾指针、相向双指针。在很多数组处理题里都有类似套路。

4.2 代码实现

一个标准写法:

class Solution: def removeElement(self, nums: List[int], val: int) -> int: 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从前往后找等于val的元素,right从后往前提供可以用来覆盖的值。一旦发现nums[left] == val,就用nums[right]覆盖它,同时right左移。覆盖之后left不急着加一,因为nums[right]原来可能也是val,需要再判断一次当前的nums[left]。如果nums[left] != val,说明这个位置已经合法,left加一。

循环条件是left <= right,为什么呢?因为当left == right时,还剩最后一个元素需要检查。如果它是val,就用它自己覆盖自己(其实没必要,但代码统一处理),然后right变成left - 1,循环结束,返回left。如果它不是val,left加一后变成right + 1,循环结束。两种情况都能正确处理边界。

4.3 快慢指针和左右指针怎么选

很多题解把左右指针称为“优化版”,但这不意味着它永远更好。它还额外依赖“顺序可以改变”这个条件。如果把 27 题改成“保持相对顺序”,左右指针就不能用了。所以严格来说:

  • 快慢指针:稳定、通用、保持相对顺序,但每个非val元素至少赋值一次。
  • 左右指针:当val出现次数较少时,赋值次数更少;但当val出现次数很多时,赋值次数可能不比快慢指针少。比如数组全是val,左右指针每次都在原地覆盖,赋值了 n 次,而快慢指针直接跳过,一次都不用赋值。

两种写法的时间复杂度都是 O(n),都是标准答案。面试时你可以先说快慢指针,然后补充说“这道题允许改变顺序,所以还可以用相向双指针来减少赋值次数”,这会是加分项。

我用一个表格简单对比:

解法时间复杂度空间复杂度保持相对顺序赋值次数特点
暴力前移O(n^2)O(1)是元素可能被反复移动
快慢指针O(n)O(1)是非 val 元素最多复制一次
左右指针O(n)O(1)否只覆盖等于 val 的位置

5. 边界条件与常见的坑

5.1 边界条件测试

这道题的边界条件看着简单,但每次换一种写法都可能翻车。我至少会在本地跑下面几组用例:

  • nums = [],val = 0:返回 0。
  • nums = [1],val = 1:返回 0。
  • nums = [1],val = 2:返回 1。
  • nums = [1, 1, 1],val = 1:返回 0。
  • nums = [1, 2, 3],val = 4:返回 3,数组原样。

快慢指针在这些用例下都很稳健。左右指针需要特别注意right初始值是len(nums) - 1,如果数组为空,right = -1,循环不会进入,返回 0;如果只有一个元素,前面已经分析过,结果也是对的。真正容易出问题的是while left < right而不是<=,会漏掉最后一个元素,这个我在初学时就踩过坑。

5.2 力扣测试到底检查什么

你应该已经注意到,这个方法返回的是一个整数,而不是新的数组。力扣的评测逻辑实际上是:先调用你的函数拿到返回值newLength,然后只检查nums的前newLength个元素是否全部不等于val。至于nums[newLength:]里发生了什么,完全无视。

所以你在写测试代码的时候,不要写assert nums == something,而应该写:

new_len = solution.removeElement(nums, val) assert all(nums[i] != val for i in range(new_len))

还有一点,题目里写着“不需要考虑数组中超出新长度后面的元素”,这给了我们很大的自由度。快慢指针会把后面的旧值留在原地,左右指针也可能把相同的val值复制来复制去,都不会影响结果。

5.3 刷题过程中的几个典型错误

第一个典型错误是在用暴力解法时忘记在删除元素后回退索引。如果你用类似 C 语言的for循环,删除当前元素后直接i++,会跳过一个元素。Python 里如果写成for i in range(len(nums))再在循环里删元素,问题更隐蔽,因为range是固定长度的,删除后索引会错位。所以我后来写数组删除类题目时,都会优先考虑双指针而不是依赖“真正删除”。

第二个典型错误是快慢指针里搞混nums[slow] = nums[fast]的方向。有时候脑子一热写成nums[fast] = nums[slow],整个数组就会被一个值覆盖。我建议在草稿纸上画一下:slow是“接收者”,fast是“提供者”。每次是让前面的坑接收后面的值。

第三个典型错误是左右指针覆盖后没有再次检查left位置。记住nums[left] = nums[right]只是从右边拿来一个值覆盖,但这个值本身可能还是val,所以要继续循环。不要在赋值后立刻left += 1,否则会把等于val的元素漏到前面。

6. 双指针模板与相似题目

6.1 26题:删除有序数组中的重复项

LeetCode 26 题和这道题长得非常像:给你一个有序数组,原地删除重复出现的元素,使每个元素只出现一次,返回新长度。它同样要求原地、O(1) 空间,而且因为是有序数组,不能用无序数组那种“顺序可以改变”的解法,还是得用快慢指针。

模板是:

def removeDuplicates(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 开始,是因为第一个元素一定保留,比较的对象是nums[slow - 1],表示上一个保留位置的元素。如果fast的值不等于它,就说明出现了一个新的不同元素,把它放到slow位置。

和 27 题对比一下:27 题是“不等于 val 就保留”,26 题是“不等于前一个不同的值就保留”。本质上都是从数组里筛出符合条件的元素,然后往前放。区别只是筛的条件不同。

6.2 283题、80题:双指针的各种变形

LeetCode 283 题“移动零”也可以看成 27 题的变体:把数组中所有 0 移到末尾,同时保持非零元素的相对顺序。如果你把val设为 0,27 题会返回非零元素的数量,但不会把 0 放到数组末尾。283 题要求最后面补零,所以可以在 27 题代码之后,把slow之后的位置全部赋值为 0:

def moveZeroes(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

80 题“删除有序数组中的重复项 II”是在 26 题基础上允许最多重复两次。快慢指针仍然好用,但比较对象从slow-1变成slow-2:因为如果当前位置的元素和前两个保留位置的元素相同,说明已经重复两次了,不能再保留。代码也很接近:

def removeDuplicates(nums): if len(nums) <= 2: return len(nums) slow = 2 for fast in range(2, len(nums)): if nums[fast] != nums[slow - 2]: nums[slow] = nums[fast] slow += 1 return slow

看到规律没有?这类题就是一个“筛选 + 前移”的模板,难点只在筛选条件。把 27 题吃透,后面 26、80、283 基本都能顺手写出来。这也是为什么我一直建议新手不要把 27 题当作一个孤立的题目刷,而是把它当作数组双指针的入口。

7. 一些刷题经验

7.1 动手之前先画图

只盯着代码看,很容易绕晕。我第一次学快慢指针时,总觉得slow和fast像两只乱跑的手,不好理解。后来在纸上写数组,用两个小三角标出位置,一步步走下来才彻底明白。

画图时不需要画得太复杂。比如nums = [0,1,2,2,3,0,4,2],val = 2,把fast从 0 移到 7,把不等于 2 的元素依次写到slow位置。你会发现slow始终落后或等于fast,因为只有当fast遇到合法元素时slow才会前进。遇到val时slow停住,等fast跑到下一个合法元素来填坑。这个过程用一个例子跑一遍,比背十遍代码都有用。

7.2 怎么读题解区

力扣题解区每天都有很多大佬分享,比如你搜“灵茶山艾府”的题解,会发现他经常把题目归类到某个套路下,讲得也很细。但我建议你在自己 AC 之后再去看题解,哪怕你 AC 的代码很笨。

原因很简单:如果先看题解,你很容易把别人的思路背下来,但遇到变体题还是不会。先写一版能过的代码,再对比题解区的高频思路,你会发现自己卡在哪一步:是没想到快慢指针,还是边界没处理好。这样看题解才有收获。

7.3 把这一题放进你的刷题路线

如果你刚开始刷力扣,不要一上来就按题号顺序“每日一题式”猛刷。更高效的做法是按专题刷。数组双指针是一个非常适合入门的专题,通常顺序可以是:

  • 27 移除元素
  • 26 删除有序数组中的重复项
  • 283 移动零
  • 80 删除有序数组中的重复项 II
  • 88 合并两个有序数组

这五道题做完,数组原地操作的很多思维习惯就养成了。之后再遇到链表的双指针、字符串的双指针,也会有迁移的感觉。27 题本身不难,但它能帮你建立“用索引覆盖值来模拟删除”的直觉,这个直觉在后续很多题目里都会反复用到。

还有一个小技巧:每做完一道题,把代码里最核心的几行抄在笔记里。比如快慢指针的核心就是if判断和nums[slow] = nums[fast],左右指针的核心就是“右边提供覆盖值,左边被覆盖后不急着前进”。记录下来之后,隔几天再翻一眼,比刷十道新题更有效。

我个人做这道题时,最大的收获是记住了“数组删除不等于真正删除,而是逻辑上缩短”。后来做很多需要原地修改数组的问题,我都会下意识想:能不能用一个慢指针保留合法序列?这个习惯就是从 27 题养成的。如果你也能把这个思维带走,这道题就完全没有白刷。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询