力扣27移除元素:双指针原地修改数组的核心套路

📅 发布时间:2026/10/1 3:30:55
力扣27移除元素:双指针原地修改数组的核心套路
力扣第27题“移除元素”大概是所有双指针入门选手绕不开的一道题。我第一次在题解区看到这题时还以为要把数组里的元素一个一个真正删掉后来才明白它考的是“覆盖”而不是“删除”。题目本身不难却把原地修改数组、双指针、边界条件这几个算法基础概念串在了一起。这篇文章就围绕这道题从暴力解法讲到双指针优化再把相似题目串起来适合刚刷力扣的朋友也适合准备面试但想快速过一遍数组双指针套路的人。先说结论这题的核心不是“删”而是把不等于val的元素全部挪到数组前面最后返回一个新的长度。数组后半部分残留什么值都不用管测试用例也只检查新长度范围内的元素。理解这一点后面所有代码都好写了。1. 先弄明白题目到底要干什么1.1 原题描述与示例题目是 LeetCode 27英文名叫 Remove Element。给你一个数组nums和一个值val需要原地移除所有数值等于val的元素并返回移除后数组的新长度。限制条件是不能额外开辟数组只能使用 O(1) 的额外空间元素顺序可以改变并且不需要考虑数组中超出新长度后面的元素。官方的两个示例很重要示例 1nums [3,2,2,3]val 3。函数返回新长度 2且nums的前两个元素都是 2。示例 2nums [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 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; } }C 更是如出一辙这里就不重复了。核心就是那一个if简单得让人怀疑自己是不是写对了。3.3 为什么这样写是对的用示例nums [3,2,2,3]val 3来手动走一遍slow 0fast 0nums[0] 3等于val跳过slow仍然是 0。fast 1nums[1] 2不等于val把 2 写到nums[0]数组变成[2,2,2,3]slow 1。fast 2nums[2] 2不等于val把 2 写到nums[1]数组变成[2,2,2,3]slow 2。fast 3nums[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。如果它不是valleft加一后变成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设为 027 题会返回非零元素的数量但不会把 0 放到数组末尾。283 题要求最后面补零所以可以在 27 题代码之后把slow之后的位置全部赋值为 0def 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] 080 题“删除有序数组中的重复项 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 删除有序数组中的重复项 II88 合并两个有序数组这五道题做完数组原地操作的很多思维习惯就养成了。之后再遇到链表的双指针、字符串的双指针也会有迁移的感觉。27 题本身不难但它能帮你建立“用索引覆盖值来模拟删除”的直觉这个直觉在后续很多题目里都会反复用到。还有一个小技巧每做完一道题把代码里最核心的几行抄在笔记里。比如快慢指针的核心就是if判断和nums[slow] nums[fast]左右指针的核心就是“右边提供覆盖值左边被覆盖后不急着前进”。记录下来之后隔几天再翻一眼比刷十道新题更有效。我个人做这道题时最大的收获是记住了“数组删除不等于真正删除而是逻辑上缩短”。后来做很多需要原地修改数组的问题我都会下意识想能不能用一个慢指针保留合法序列这个习惯就是从 27 题养成的。如果你也能把这个思维带走这道题就完全没有白刷。