ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

LeetCode 27移除元素:双指针原地修改与复用模板全解析

LeetCode 27移除元素:双指针原地修改与复用模板全解析 刷题龄超过三个月的人多半会有一种错觉难度标着 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 0nums[0] 3等于val跳过slow 0fast 1nums[1] 2不等于val执行nums[0] 2slow 1fast 2nums[2] 2不等于val执行nums[1] 2slow 2fast 3nums[3] 3等于val跳过最终返回slow 2。此时数组实际状态是[2, 2, 2, 3]前两个元素正好是[2, 2]完全合法。再看示例二nums [0,1,2,2,3,0,4,2], val 2处理过程如下fastnums[fast]动作slow00写入nums[0] 0111写入nums[1] 1222跳过232跳过243写入nums[2] 3350写入nums[3] 0464写入nums[4] 4572跳过5最终返回5数组前五个元素变成[0, 1, 3, 0, 4]答案正确。这个走查表格基本就是我做题时脑子里过的流程强烈建议新手也学着在纸上画一遍画过一次之后就不会再问“为什么slow不重置”这种问题。2.3 几个常见“差一行就死循环”的写法快慢指针虽然简单但我在讨论区见过不少改错版本的代码集中在这几种第一种是把slow 1写在if外面。这样一旦遇到valslow也会前进最后返回的长度会大于真实值检查器读前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 7nums[0] 0不等于2left 1left 1nums[1] 1不等于2left 2left 2nums[2] 2等于2执行nums[2] nums[7] 2right 6left 2nums[2]还是2执行nums[2] nums[6] 4right 5left 2nums[2] 4不等于2left 3left 3nums[3] 2等于2执行nums[3] nums[5] 0right 4left 3nums[3] 0不等于2left 4left 4nums[4] 3不等于2left 5此时left 5, right 4left 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。如果数组只有一个元素且等于valleft 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 题恰恰是最适合建立这个认知的起点。你现在能把这道题想清楚后面遇到任何条件过滤类的数组题都会比别人少走一条弯路。
返回列表