
刷 LeetCode 绕不开第 27 题《移除元素》这道用 Python 写的数组题是双指针思想最经典的入门场景给定数组 nums 和一个值 val要求原地移除所有等于 val 的元素并返回新长度额外空间只能 O(1)。我第一次做的时候先想到用列表推导过滤交上去才发现空间不符合要求后来认真对比了题解里两种主流策略——双指针原地覆盖和首尾交换优化才算真正吃透了这题的考点。这篇文章不打算只贴代码我会把两种思路各自怎么写、为什么能这么做、边界在哪、什么时候用哪个更划算全部拆开讲清楚。适合刚开始刷双指针的新手也适合想系统整理数组操作套路的老手回味一下。1. 题目拆解第 27 题到底在考什么1.1 原题要求与三个容易被忽略的约束第 27 题的原题描述其实很短但越短的题越容易读丢条件。完整意思是给你一个数组 nums 和一个值 val在不能申请额外数组、只能 O(1) 空间的前提下原地修改数组把所有值等于 val 的元素“移除”然后返回移除后数组的新长度。这里面有三个细节新手很容易忽略却直接决定了解法走向。第一返回的是长度不是数组。LeetCode 判定的时候会用你返回的整数 k 去读取 nums 前 k 个元素做断言后面的元素无论是什么都不会被检查。也就是说我们根本不需要物理上“删掉”那些等于 val 的元素只要保证“保留元素全部落在数组前 k 个位置”就够了。第二题目明确写了“元素的顺序可以改变”。这句话在常规题解里经常被一笔带过但它恰恰是第二种策略首尾交换优化的合法性依据。如果题目要求保持相对顺序相向双指针那套“从尾部拿元素补位”的写法就不能用了。第三“不需要考虑数组中超出新长度后面的元素”等于给了我们覆盖的许可。数组是一段连续内存物理删除中间某个元素需要把后续元素全部往前搬。如果每次遇到 val 都触发一次这样的大搬移最坏情况下复杂度会退化成 O(n²)所以题目期望我们用“覆盖整理”的思路一次遍历把保留的元素归拢到前段而不是真的做删除操作。1.2 两种策略的解题方向差异同样满足这些约束社区里主流写法分两派。第一种是用快慢指针在原地做“筛选覆盖”slow 记录下一个可用槽位fast 负责扫描非 val 元素往里写第二种是用相向双指针做“首尾交换”left 从前往后找 valright 从后往前提供替补元素遇到一个补一个。两者的时间复杂度都是 O(n)空间都是 O(1)差别主要体现在三个方面赋值的次数、对元素顺序的影响、以及边界条件的复杂度。快慢指针像运动会入场时重新整队每个人都按顺序往前补位整齐但累首尾交换像二手物品清理留下的大部分东西原地不动只有需要扔掉的位置才从后面拿东西来补省力但队形乱了。在展开具体代码之前建议你先在心里记住这个类比。后面所有细节包括复杂度分析、选型建议本质上都是在解释这两条路线各自付出了什么代价、换来了什么好处。2. 策略一双指针原地覆盖快慢指针2.1 核心代码与变量语义先看完整实现这是最推荐新手优先掌握的版本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代码只有几行但每个变量都有明确职责。fast 每轮指向当前正在检查的元素slow 永远指向“下一个要被覆盖的空位”。当 nums[fast] ! val 时说明 fast 指向的元素需要保留那就把它写入 slow 指向的位置然后 slow 前移一位当 nums[fast] val 时这个元素被跳过slow 原地不动。为什么可以直接覆盖因为数组里位于 slow 和 fast 之间的位置都已经是检查过的、等于 val 的“垃圾值”或者已经被复制走的旧值我们不再需要它们。把保留元素往前写相当于把这些垃圾值当作废纸用掉完全符合题目“只关心新数组前 k 个元素”的约定。为什么返回值是 slow因为 slow 不是某个坐标它本身就是一个计数器每当一个非 val 元素被成功写入slow 就加一。循环结束时数组前 slow 个位置恰好按原顺序存放了所有不等于 val 的元素所以 slow 的数值就是新数组长度。2.2 手推执行过程用 LeetCode 自带的例子跑一遍nums [3, 2, 2, 3]val 3。fastnums[fast]是否等于 val操作slow03是跳过012否nums[0]2slow1122否nums[1]2slow1233是跳过2最终返回 2数组前两个元素确实是 [2, 2]正确。注意 fast 比 slow 跑得快因为中间有两个 3 被跳过了slow 自然落后。再看一个 val 出现频率更低的例子nums [0, 1, 2, 2, 3, 0, 4, 2]val 2。fast 扫描到 0 和 1 时分别写入 nums[0] 和 nums[1]遇到两个 2 跳过fast4 的 3 写入 nums[2]fast5 的 0 写入 nums[3]fast6 的 4 写入 nums[4]最后一个 2 跳过返回 5。这个例子能清楚看到 slow 和 fast 的差距被拉大因为等于 val 的元素越多slow 落后越多。2.3 复杂度与适用边界时间复杂度 O(n)fast 严格递增遍历一遍空间复杂度 O(1)只用了两个整型变量。赋值次数值得单独说一下在 val 完全不存在的极端情况下每个元素都会被执行一次nums[slow] nums[fast]哪怕是 slow fast 这种“自己赋给自己”的情况也会产生一次 Python 层面的绑定操作。如果数组中所有元素都等于 val赋值次数是 0。总体而言快慢指针的赋值次数约等于 n 减去 k其中 k 是 val 出现的次数。这套写法的优势是代码短、边界少、语义清晰。只要题目要求保留元素相对顺序基本上只能走这条路线所以第 26 题、第 80 题、第 283 题这些经典数组题都用同一个模板。缺点是当 k 很小的时候大量保留元素明明已经留在原地还是会做一次自我赋值存在理论上的常数浪费。3. 策略二首尾交换优化相向双指针3.1 核心代码与边界处理第二种写法的完整实现如下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这里 left 负责从前往后找“应当被移除的元素”right 负责从后往前提供替补。一旦发现 nums[left] val就把 right 位置的元素复制到 left然后 right 减一。注意此时 left 不要急着加一因为从右侧搬过来的元素也有可能是 val它需要在下一次循环里重新接受检查。反过来如果 nums[left] ! val说明当前位置可以保留left 加一继续前进。有个细节很容易写错循环条件必须是left right不能是left right。当左右指针相遇时说明数组里还剩最后一个元素没有处理这个元素可能是 val也可能不是。用left right会让它被漏掉导致返回长度少一或者多一具体后果见后面第 5 部分的踩坑分析。另外要澄清一个概念这个写法严格说是“覆盖”不是“交换”。因为 nums[right] 被复制走后right 位置的值会残留我们并不需要管它它已经落到了新数组范围之外。真正的交换版本也可以跑通但会引入多余的中间变量或者 Python 元组解包开销代码也更容易绕晕没必要。为什么返回值是 left因为算法终止时left 恰好越过最后一个需要保留的元素停在新数组的边界上。数组索引从 0 开始left 的数值正好等于保留元素的个数。3.2 手推执行过程与边界覆盖用同一个例子 nums [3, 2, 2, 3]val 3 走一遍leftrightnums[left]操作更新后 left/right033nums[0]nums[3]3right--left0right2023nums[0]nums[2]2right--left0right1012leftleft1right1112leftleft2right1结束返回 2。数组前两个位置此时是 [2, 2]正确。注意与快慢指针的区别这里的保留元素是 [2, 2]但它们的原始位置发生了改变相对顺序没有保证。边界情况也值得逐个验证我把常见场景整理成一张表场景输入val返回原因空数组[]10left0right-1循环不进去val 不存在[1, 2, 3]43left 一路右移越过 right全部是 val[2, 2, 2]20不断用右侧值覆盖最终 left 停在 0单元素且等于 val[5]50leftright 时执行覆盖后 right--单元素且不等于 val[5]31left 后大于 right其中“单元素且等于 val”是最容易写崩的用例。left right时nums[left] nums[right]其实是一次自己赋自己的操作随后 right 减一变成 -1循环结束返回 0逻辑依然正确。这就是为什么循环条件必须带等号。3.3 复杂度与适用边界时间复杂度 O(n)right 单调递减整体遍历线性完成空间复杂度 O(1)。赋值次数与快慢指针形成鲜明对比快慢指针的赋值量约等于保留元素个数首尾交换的赋值量约等于被移除元素个数 k。当 k 很小时比如 val 在数组里只出现一次全程可能只做一次覆盖但当 k 接近 n 时几乎每轮都要覆盖一次反而比快慢指针更辛苦。代价是元素顺序会被打乱。比如原数组 [1, 2, 3, 1]val 1用首尾交换最终前两个元素可能是 [3, 2]而不是 [2, 3]。所以这套写法只能在题目明确允许顺序改变时使用。第 27 题给了许可直接用它没有风险但如果遇到类似“保持相对顺序”的要求就必须回到快慢指针。4. 深度对比什么时候该用哪一种4.1 从赋值次数看常数差异把两种策略的赋值次数放在一起对比结论就非常清晰了。假设数组长度为 nval 出现次数为 k快慢指针赋值次数约等于 n - k首尾交换赋值次数约等于 k。这个规律不用死记理解其来源就不会忘快慢指针在每个非 val 元素上都会执行一次写入所以是 n - k首尾交换的每次覆盖都对应一个 val 被处理掉右侧搬来的值如果是 val下一轮还会再次触发覆盖归根结底每个 val 都会引发一次写入所以是 k。场景快慢指针赋值次数首尾交换赋值次数更优选择val 出现 1 次k1n-11首尾交换val 出现一半kn/2n/2n/2差不多几乎所有元素都是 val接近 0接近 n快慢指针这个表格描述的是理论赋值量。在 LeetCode 的判题环境里数组规模通常不大两种解法跑出来的耗时差距往往只有几毫秒真正的意义在于帮你看清双指针各自在“省”什么一个省空间一个省操作。如果你对这种常数差异感兴趣可以在本地做一个 benchmark生成十万个元素的数组分别用稀疏 val、中间比例、密集 val 三种分布计时。代码模板给到import random from time import perf_counter def fast_slow(nums, val): slow 0 for fast in range(len(nums)): if nums[fast] ! val: nums[slow] nums[fast] slow 1 return slow def left_right(nums, val): left, right 0, len(nums) - 1 while left right: if nums[left] val: nums[left] nums[right] right - 1 else: left 1 return left n 100000 for k in [100, 50000, 99900]: arr [3] * k [1] * (n - k) random.shuffle(arr) for func in [fast_slow, left_right]: a arr.copy() t0 perf_counter() func(a, 3) t1 perf_counter() print(fk{k:6d}, {func.__name__}: {t1 - t0:.4f}s)实测下来你在 val 稀疏时能看到首尾交换的明显优势在 val 密集时看到快慢指针的优势。中间比例差距通常会缩小到噪声级别。这个结论比单纯背“快慢指针好还是首尾交换好”可靠得多。4.2 可读性、稳定性和面试表达就代码维护来说快慢指针突出一个“稳”语义简单边界少基本不会写错。首尾交换更“巧”但代码里藏着left right和返回值这两个容易翻车的点。我见过不止一个人在白板上自信地写下while left right最后返回值搞错反而给面试官留下“基础不牢”的印象。面试建议是先讲快慢指针写完代码后补一句“题目允许改变顺序如果 val 出现次数较少还可以从两端向中间优化减少赋值次数”。这句话能展示你读过题目约束也知道常数优化空间。如果面试官接话再写第二版不迟。千万别一上来就努力炫技结果边界写崩了。生活化的总结是快慢指针像排队时每个人都往前挪一步整齐但累首尾交换像只把空位附近的元素从队尾调过来省力但队形乱了。选择策略前先问清楚“题目允不允许乱队形”。4.3 变体扩展一套双指针打天下第 27 题之所以值得精做是因为它是双指针模板的“教学样板”。吃透之后很多数组题可以快速迁移题目关键点双指针用法26. 删除有序数组中的重复项排序数组 去重快慢指针比较相邻元素27. 移除元素移除指定值快慢指针或首尾交换80. 删除有序数组中的重复项 II最多保留两个相同元素快慢指针检查 nums[fast] 与 nums[slow-2]283. 移动零把 0 移到末尾快慢指针把非零前移后面补零977. 有序数组的平方平方后排好序两端夹逼按绝对值从大到小填入结果844. 比较含退格的字符串退格符 #两个指针各自从尾部走跳过被退格抵消的字符第 26 题和第 27 题经常被拿来对比一个要求删除重复项一个要求删除指定值本质都是“筛选 压缩”。第 80 题是第 26 题的加强版第 283 题是第 27 题的变体把“删除 0”换成“把非零元素往前挪”。当你做完这一串题会发现快慢指针就是一个通用的“原地过滤模板”而首尾交换则是“允许乱序时省操作的优化手段”。5. 实测踩坑与本地验证指南5.1 四个高频边界错误第一个坑出现在首尾交换的循环条件上。写成while left right同时又直接return left当数组最后一个元素如果不是 valleft 会停在 right 之前少加一次。我举个最简单的例子nums [1, 2]val 3。这个 val 根本不存在正确答案是 2但错误写法返回 1。因为 left 从 0 走到 1 后1 1为假循环退出left 还没来得及检查最后一个元素。改成while left right就能避免这个问题。第二个坑是快慢指针里用 pop 或 del 删除元素。有些新手觉得“原地删除”就是真的把元素从列表里删掉于是写出类似if nums[i] val: nums.pop(i)的循环。pop 会改变数组长度导致后续索引错位而且每次删除都是 O(n) 的搬移整体可能退化成 O(n²)。第 27 题的正解是“覆盖”而不是“删除”这个观念转变很重要。第三个坑是返回值写错。有人循环写对了最后却return len(nums)被原数组长度误导完全没有领会 slow 和 left 这两种指针的计数器含义。记住本题返回的永远是“新数组长度”不是原长度也不是某个固定值。第四个坑是首尾交换里漏掉right - 1。不收缩右边界while 循环会无限重复覆盖同一个位置导致死循环或者最终数组前段混入 val。每次从右边取走替补元素后右侧区域都要立刻收缩一格这是整个算法的“推进机制”。额外补一个小坑从 LeetCode 复制代码到本地运行时不要忘记from typing import List。在线环境默认导入了类型标注需要的工具本地如果没导入直接跑会报 NameError。别问我是怎么知道的这种低级错误恰恰最容易发生在刷题初期。5.2 本地运行与 LeetCode 提交细节给你一个可以直接复制的本地测试模板同时包含两种实现from typing import List class Solution: def removeElement_fast_slow(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 def removeElement_left_right(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 def test(): cases [ ([3, 2, 2, 3], 3), ([0, 1, 2, 2, 3, 0, 4, 2], 2), ([], 1), ([1, 2, 3], 4), ([2, 2, 2], 2), ([5], 5), ] for nums, val in cases: a nums.copy() b nums.copy() k1 Solution().removeElement_fast_slow(a, val) k2 Solution().removeElement_left_right(b, val) print(fnums{nums}, val{val}, fast_slow{k1}, left_right{k2}, 结果{a[:k1]}, {b[:k2]}) if __name__ __main__: test()这里有两个细节值得留意。第一测试时用nums.copy()分别复制数组因为两种方法都会原地修改数组如果共用同一个对象第二个方法的输入已经被第一个改过了。第二验证时只看nums[:k]这正是 LeetCode 判题系统的做法后面的残留元素不用管。提交到 LeetCode 时方法名和参数名保持题目给定的removeElement(self, nums: List[int], val: int)选择其中一个实现作为最终答案即可。代码里可以加 print 调试判题系统会忽略标准输出不过提交前最好还是删掉保持整洁。5.3 个人心得与后续扩展我自己最开始只背快慢指针的模板觉得代码短、永远正确直到某次在讨论区看到有人用首尾交换才意识到自己没读懂题目里“元素的顺序可以改变”这句话的潜台词。从那以后我做数组题会先圈出三个条件顺序是否敏感、空间是否限制、返回值是什么再决定用哪套模板。如果你决定在实战中使用首尾交换我建议写完代码后在草稿上把while left right的“最后一个元素”场景画一遍。左右指针相遇时无论是需要覆盖还是直接跳过都必须确保这个位置被处理过。这一条规则能帮你避开大部分边界错误。再送你一个刷题习惯把做过的双指针题目按“快慢指针”和“相向指针”两个类别记录整理各自的适用条件。第 27 题是这套分类里最基础的样板把它彻底吃透后面第 26 题、第 80 题、第 283 题甚至第 844 题都能顺畅迁移。毕竟双指针题目千变万化核心永远只有两件事指针各自负责什么循环结束的边界条件在哪里。