☰
LeetCode 27移除元素:双指针原地修改与复用模板全解析
2026/10/3 14:11:13 网站建设 项目流程

刷题龄超过三个月的人,多半会有一种错觉:难度标着 easy 的数组题,基本就是用来凑每日打卡数的。“移除元素”这道编号 27 的题,看起来更不像有坑——给你一个nums和一个val,原地删掉所有等于val的元素,返回剩余长度。可我不止一次在讨论区看到有人问:为什么我返回的k是对的,提交却 WA?为什么我把数组删空了,它还报错?所以这篇文章不止贴代码,我打算把题意、两种双指针写法、真实做题时踩过的坑,以及怎么把这题模板复用到 26、80、283 上,完整过一遍。

不管你是刚刷到第 20 题的新手,还是想把这套双指针话术整理成个人模板的人,都可以按自己的需求跳过一些段落;不过建议别跳过验证那块,那才是很多失分事故的源头。

1. 一道标注“easy”的题,为什么还能让不少人翻车

1.1 先把我理解的题意说清楚

第 27 题的原文很短,核心约束只有三个:原地、返回新长度、元素的顺序可以改变。前两个约束是题面直接写出来的,第三个常常被当成废话,但恰恰是它决定了最优解的形态。

所谓“原地”,在 LeetCode 这类在线评测系统里的意思是:你只能使用常数级别的额外空间。你不能新建一个数组,把不等于val的元素塞进去,再把这个新数组返回;检查器最终读的还是你传进去的那个nums。很多新手在这里就踩了第一条线,写出的代码本地运行结果全对,一提交就提示数组状态不对,因为检查器拿不到你的“新数组”。

所谓“返回新长度”,也不是只返回一个数字就完事。平台的做法是:调用你的removeElement(nums, val),然后检查nums前k个元素是不是都不等于val。至于k后面的位置,你留的是原值也好、垃圾值也好,全套不管。换句话说,这道题真正的交付物有两份:第一份是返回值k,第二份是数组前k个位置的实际内容。只算对长度、不把数组改对,等于白做。

1.2 两个示例演算定下的“交付物”

先看示例一:

输入nums = [3,2,2,3], val = 3,输出k = 2。

val是3,数组里有两个3,删掉之后剩下两个2,所以新长度是2。此时数组前两个元素应该都是2。至于下标2和3的位置,你写成[2,2,2,3]也好,写成[2,2,3,3]也好,都不会影响判题,因为检查器只看前2个元素。

再看示例二:

输入nums = [0,1,2,2,3,0,4,2], val = 2,输出k = 5。

数组长度是8,里面有3个2,删除后剩下[0,1,3,0,4],长度5。注意题目特别强调“元素的顺序可以改变”,所以你提交成[0,1,4,0,3]也没问题。这个顺序自由度非常重要,它意味着我们在某些情况下不需要把后面的所有元素都往前挪,只需要把最后面“有用”的元素搬过来填空位。

做题时我习惯先给这题列几个自测边界:空数组返回0;数组长度为1且唯一元素就是val,返回0;数组长度为1且元素不等于val,返回1;数组全部等于val,返回0。这四个边界能过滤掉一大半错误写法。

1.3 题目设计里藏着的两个提示

我第一眼看到这道题时,觉得它只是“删数组里的某个值”,但细想之后发现,出题人其实塞了两个提示在里面。

第一个提示是空间复杂度。题目说原地修改,等于明确告诉你:别想着用filter、列表推导式复制一份再赋值回来。你需要在一个数组里完成“查找—覆盖”的动作,而“查找—覆盖”最自然的实现方式就是双指针。

第二个提示是“元素的顺序可以改变”。假如题目要求你必须保持原有相对顺序,那么能用的方案就会少很多,基本只有快慢指针覆盖法;但既然允许乱序,我们就可以采用首尾指针的搬运法,让赋值次数从“保留多少个就写多少次”降到“删掉多少个就搬多少次”。

这两个提示叠加在一起,就把这道题的解法范围圈得很清楚了:O(n) 时间、O(1) 空间,双指针。接下来两种主流写法我分别展开。

2. 快慢指针收着写:保留相对顺序的最短实现

2.1 两个指针的职责要分清楚

快慢指针的写法是这道题最通用、最容易记忆的版本。它的核心思想是维护两个指针:一个慢指针slow,它指向“下一个可以写入的位置”;一个快指针fast,它负责往前扫描整个数组,寻找不等于val的元素。

这里有一个非常重要的不变量:当fast向前移动时,nums[0:slow]这个前缀区间,始终是一个已经处理好的、完全不含val的合法前缀。fast每遇到一个不等于val的元素,就把它写到slow指向的位置,然后slow前进一位;如果fast遇到了等于val的元素,就直接跳过,slow原地不动。

为什么不担心覆盖掉还没处理的元素?因为slow <= fast永远是成立的。当你用nums[slow] = nums[fast]覆盖时,覆盖的位置要么已经被fast扫描过了,要么就是当前fast所在的位置,它不是一个“还没被检查的未来位置”。这也是快慢指针能在原地安全工作的底层逻辑。

2.2 标准实现和状态推演

完整代码如下:

from typing import List 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

代码只有六行核心逻辑,但每一步都值得推敲。我用示例一nums = [3,2,2,3], val = 3走一遍:

  • fast = 0,nums[0] = 3,等于val,跳过,slow = 0
  • fast = 1,nums[1] = 2,不等于val,执行nums[0] = 2,slow = 1
  • fast = 2,nums[2] = 2,不等于val,执行nums[1] = 2,slow = 2
  • fast = 3,nums[3] = 3,等于val,跳过

最终返回slow = 2。此时数组实际状态是[2, 2, 2, 3],前两个元素正好是[2, 2],完全合法。

再看示例二nums = [0,1,2,2,3,0,4,2], val = 2,处理过程如下:

fastnums[fast]动作slow
00写入nums[0] = 01
11写入nums[1] = 12
22跳过2
32跳过2
43写入nums[2] = 33
50写入nums[3] = 04
64写入nums[4] = 45
72跳过5

最终返回5,数组前五个元素变成[0, 1, 3, 0, 4],答案正确。这个走查表格基本就是我做题时脑子里过的流程,强烈建议新手也学着在纸上画一遍,画过一次之后就不会再问“为什么slow不重置”这种问题。

2.3 几个常见“差一行就死循环”的写法

快慢指针虽然简单,但我在讨论区见过不少改错版本的代码,集中在这几种:

第一种是把slow += 1写在if外面。这样一旦遇到val,slow也会前进,最后返回的长度会大于真实值,检查器读前k个元素时,很容易把漏掉的val也算进来。

第二种是使用while循环,但更新逻辑写反。比如:

while fast < len(nums): if nums[fast] == val: nums[slow] = nums[fast] # 这里写反了,等于 val 的元素反而被保留 slow += 1 fast += 1

写反之后,等于val的元素全被保留,不等于val的反而被跳过,结果完全错乱。

第三种是把“覆盖”写成“交换”。覆盖和交换在最终结果上可能都能通过,因为多余的val只要不出现在前k个位置就没问题;但交换会多做一次临时变量的赋值操作,并没有带来任何额外收益。在这种直接替换场景下,覆盖永远优于交换。

3. 首尾双向指针:不在乎顺序时要会抄近路

3.1 思路本质是“用尾部的幸存者填空洞”

既然题目允许改变元素顺序,那我们就可以用另一套更“省力”的思路:左指针从数组头部向右走,右指针从数组尾部向左走。当左指针遇到一个等于val的元素时,它不是继续往前覆盖,而是把右指针指向的元素搬过来填掉这个坑,同时右指针向左移动一位;如果左指针指向的元素不等于val,它本来就该留在原地,左指针直接右移。

这个思路最反直觉的地方是:右指针搬过来的元素可能也等于val。但这完全没关系。因为搬过来之后,右指针已经向左移动了,如果左指针原地再检查一次,发现搬过来的还是val,它会再次从新的右指针位置搬运元素。等于val的元素在这个过程中会被反复“丢弃”,直到整个数组里所有等于val的值都被推挤到右端,或者被自己覆盖掉。

3.2 代码和一次完整走查

from typing import List class Solution: def removeElement(self, nums: List[int], val: int) -> int: left, right = 0, len(nums) - 1 while left <= right: if nums[left] == val: nums[left] = nums[right] right -= 1 else: left += 1 return left

我用示例二nums = [0,1,2,2,3,0,4,2], val = 2完整走一遍:

  • left = 0, right = 7,nums[0] = 0不等于2,left = 1
  • left = 1,nums[1] = 1不等于2,left = 2
  • left = 2,nums[2] = 2等于2,执行nums[2] = nums[7] = 2,right = 6
  • left = 2,nums[2]还是2,执行nums[2] = nums[6] = 4,right = 5
  • left = 2,nums[2] = 4不等于2,left = 3
  • left = 3,nums[3] = 2等于2,执行nums[3] = nums[5] = 0,right = 4
  • left = 3,nums[3] = 0不等于2,left = 4
  • left = 4,nums[4] = 3不等于2,left = 5
  • 此时left = 5, right = 4,left > right,结束循环

返回5。最终数组状态可能是[0, 1, 4, 0, 3, 0, 4, 2],前五个元素[0, 1, 4, 0, 3]确实都不等于2,答案正确。注意我把4和0的顺序打乱了,但题目允许。

这个写法的边界条件也值得单独列一下:

  • 如果val根本不在数组里,左指针会一路走到数组末尾,返回长度n。
  • 如果数组所有元素都等于val,每次都用右指针自己覆盖左指针位置,right不断左移,最后right变成-1,返回0。
  • 如果数组只有一个元素且等于val,left = right = 0时进入循环,nums[0] = nums[0],right = -1,返回0。

很多人在写这个版本时会把while left <= right写成while left < right。两种写法大多数情况下都能通过,但while left < right会在数组只有一个元素时直接跳过判断,返回1。如果这个唯一元素恰好等于val,返回结果就错了。所以我个人更推荐<=这个版本,逻辑上更完整。

3.3 两种方案怎么选

两种解法的时间复杂度都是 O(n),额外空间都是 O(1),差别体现在几件小事上:

对比维度快慢指针首尾指针
相对顺序保持原有顺序不保证顺序
赋值次数每保留一个元素就赋值一次每删除一个元素才赋值一次
代码直观度更符合直觉,容易讲清楚需要多绕一层“填坑”逻辑
适用场景任何情况都能用仅当题目允许改变顺序时可用

如果数组里要删除的元素很少,快慢指针的赋值次数接近n,首尾指针的赋值次数接近删除次数;如果删除的元素很多,首尾指针的优势更明显。但在普通做题场景里,这个常数差异基本不影响过题。真正影响选择的是“要不要保持原数组的相对顺序”,这道题明确说了可以改变顺序,所以两种都能过;如果哪天一觉醒来题目改成“必须保持相对顺序”,毫不犹豫用快慢指针。

4. 在真实做题现场最容易踩进去的坑

4.1 用list.remove做循环删除的复杂度陷阱

很多人第一次看到这题会写出这种代码:

while val in nums: nums.remove(val)

逻辑上它完全正确,运行结果也对,但一提交就超时。原因是remove不是 O(1) 操作。在 CPython 的实现里,remove需要先线性扫描找到第一个等于val的元素,然后把该位置后面的所有元素整体向左移动一位。如果数组里要被删除的元素有m个,每次删除都要移动剩余元素,最坏情况下的总移动次数是 O(n * m),近似 O(n²)。

当n到十万级别时,O(n²) 基本等于不可接受。这个效率问题不是“刷题才需要注意”,而是真实项目里删除大量元素时最容易遇到的性能雷区:看起来是单行代码,背后的搬运量却惊人。

4.2 一边遍历一边改数组的崩溃轨迹

还有一类错误是遍历时用pop删除:

for i in range(len(nums)): if nums[i] == val: nums.pop(i)

用nums = [1, 2, 2, 3], val = 2模拟一遍就知道为什么不对。range(len(nums))在循环开始时就固定成4,但pop会让数组变短。当i = 1时删掉一个2,数组变成[1, 2, 3];接下来i = 2,指向的是3,第二个2被跳过了。如果再往后走,i可能超过数组当前长度,直接IndexError。

这类问题的根源是“迭代时的索引和数组实际长度解耦了”。真实项目的经验是:如果必须在遍历时删除元素,优先考虑从后往前遍历,因为删除后面的元素不会影响前面元素的索引;而如果只是像这道题一样“把某些值剔除”,用指针覆盖法永远是最稳的选择。

4.3 返回值与数组状态不一致的失分点

还有一种失误隐蔽性很强:返回值没错,但数组没改对。比如:

k = nums.count(val) nums = [x for x in nums if x != val] return k

本地单独跑这段代码时,你打印k确实是正确长度,但检查器读nums时,nums还是原来的数组——因为nums = ...只是让局部变量重新绑定到新列表,原数组的存储位置根本没有任何变化。于是你拿到了一个看似正确的k,却交了一份没有实际修改数组的答案。

我在本地做题时养成了一个习惯:写一个小的验证函数,把数组状态一并检查,而不是只看返回长度。可以参考下面这个简单的验证代码:

def verify(nums, val, expected_k): nums_copy = nums[:] k = Solution().removeElement(nums_copy, val) assert k == expected_k, f"长度不一致: {k} vs {expected_k}" assert nums_copy[:k].count(val) == 0, "前 k 个元素里仍有残留 val" print("验证通过")

每次写完解法,先把官方两个示例和自己的边界用例跑一遍,确认返回值正确、前k个元素无残留,再提交。这样能过滤掉九成以上的隐性错误。

5. 从这道题总结出的可复用模板

5.1 移除类问题的双指针书写模板

把快慢指针抽象一下,其实可以提炼出一个通用的移除模板:

def remove_subarray(nums, should_keep): k = 0 for i in range(len(nums)): if should_keep(nums[i]): nums[k] = nums[i] k += 1 return k

这个模板的核心思想是:从前往后扫描,用一个k表示“下一个合法元素应该放的位置”。should_keep是一个纯粹的条件判定,决定当前元素是否需要保留。需要保留时,写入并推进k;不需要保留时,什么都不做。

这个模板最大的好处是,你在解题时不需要考虑“删除”这个动作的具体细节,只需要想清楚“什么元素该留着”。我们程序员写业务代码时也经常遇到类似场景——从列表中过滤掉某类数据,与其反复remove,不如一次性把该留的拣到前面,然后截断尾部。

5.2 与第26、80、283题的联动

这道题的模板可以直接迁移到另外三道高频题上。

第 26 题“删除有序数组中的重复项”:条件变成“第一个元素或者与上一个保留元素不同的元素”。

def removeDuplicates(nums): k = 0 for i in range(len(nums)): if k == 0 or nums[i] != nums[k - 1]: nums[k] = nums[i] k += 1 return k

第 80 题“删除有序数组中的重复项 II”:条件变成“每个元素最多保留两次”,于是比较目标从nums[k-1]变成nums[k-2]。

def removeDuplicates2(nums): k = 0 for i in range(len(nums)): if k < 2 or nums[i] != nums[k - 2]: nums[k] = nums[i] k += 1 return k

第 283 题“移动零”:保留所有非零元素,然后把数组尾部补零。它用的还是同一个模板,只是在循环结束后多做一步补零操作。

def moveZeroes(nums): k = 0 for i in range(len(nums)): if nums[i] != 0: nums[k] = nums[i] k += 1 for i in range(k, len(nums)): nums[i] = 0

把这些题放到一起看,你会发现它们根本没有本质差异。真正变化的只有should_keep这个条件:不等于某个值、不等于前一个值、不等于前两个值、不等于零。同一个循环骨架,四道题通吃。这是“移除元素”这道 easy 题最值钱的地方——它不只是让你会做一道题,而是让你掌握一类题的写法。

5.3 我备考时的练习节奏

最后分享我自己练这类题的节奏。拿到题目后,我不会直接抄最优解,而是先写一个暴力版,哪怕是用while val in nums: nums.remove(val)也能写。跑通暴力版之后再问自己三个问题:瓶颈在哪里?能不能用指针原地解决?如果允许改变顺序,会不会有更短的写法?

想明白之后用双指针重写,再用表格手推一遍示例,最后用验证函数把边界用例跑全。整个过程我一般控制在 15 到 25 分钟。如果超时,我会看讨论区那个最简洁的答案,然后合上代码自己重写一遍,绝不直接复制。

这套流程坚持了大概二十道数组题之后,我就发现一个规律:数组类题目的最优解,往往不是“如何高效删除”,而是“如何把要保留的元素摆到最前面”。第 27 题恰恰是最适合建立这个认知的起点。你现在能把这道题想清楚,后面遇到任何条件过滤类的数组题,都会比别人少走一条弯路。

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

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

立即咨询