资讯动态

LeetCode 27移除元素:双指针原地修改与系列题模板

发布时间:2026/10/17 3:17:21 来源:尧图企业网站定制
很多人刷力扣前几道题里第一个让人有点“感觉”的往往是第27题。题目看起来很简单给你一个数组原地移除某个值返回新长度。但真到了面试现场这道题能筛掉不少人。有人上来就写暴力移位有人用额外的数组还有人写完了双指针却解释不清为什么慢指针指向的位置一定是对的。我印象很深的是某次模拟面试里一个基础不错的A同学在这题上栽了跟头——代码是对的但被追问“你为什么敢直接覆盖数组前面的元素”的时候卡住了。这题不是背个模板就完了它考察的是对数组“原地修改”这件事的理解深度也是后续一大串双指针题目的起点。这篇文章就把第27题从题干到边界、从暴力解到双指针、再到同模板的系列变形完整拆一遍希望能帮你在面试里不仅写得出还能讲得清。1. 题干拆解题目里每一句话都在设置约束1.1 题干到底说了什么先看原题描述给定一个数组nums和一个值val你需要原地移除所有数值等于val的元素并返回移除后数组的新长度。不要使用额外的数组空间必须仅使用 O(1) 额外空间并原地修改输入数组。元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。这段描述很短但每一句话都有分量。“原地”意味着你不能 new 一个新数组、把不等于 val 的元素塞进去再返回这是空间限制“返回新长度”意味着函数返回值是一个 int外部只通过这个长度来理解你的数组状态而“元素的顺序可以改变”这句话很多人第一次读的时候直接忽略但它实际上是后面左右指针写法的合法性来源——如果题目要求保持相对顺序那左右指针法就不成立了。还有一句“不需要考虑数组中超出新长度后面的元素”这句话给了很大的宽容度。它意味着数组后段残留什么值是脏数据都无所谓你只需要保证前 k 个位置是“存活”的元素。这也解释了为什么可以用覆盖的方式“删除”一个元素——删除在数组语境下不是物理删掉而是把后面的值挪到前面来让有效区间缩短。1.2 从输入边界推导预期复杂度这题给出的数据范围是0 nums.length 1000 nums[i] 500 val 100。数据量非常小哪怕你用最笨的暴力移位在判题机上也能跑过。但力扣考察的是算法能力不是让题目过就完事。一个合格的双指针解应该是 O(n) 时间、O(1) 空间。从数据范围可以看到nums可以为空数组val甚至可能根本不在数组里这些都属于需要小心处理的边界。很多人提交出错不是主体逻辑写错而是空数组和“val 不存在于数组”这两种情况没单独考虑。记住这两点空数组直接返回 0val 不存在时数组一个元素都不该动返回原数组长度。1.3 常见的误读在带新人的过程中我见过几种典型误读这里统一说一下误读一以为“移除元素”意味着真的把数组变短。JS 里有人想用splicePython 里有人想用removeC 里有人觉得可以用erase。但函数签名是int removeElement(int* nums, int numsSize, int val)它返回的是长度底层数组空间没有变化。误读二以为“新长度”只是返回值和数组内容没关系。实际上判题逻辑是拿到你返回的 k 之后逐个检查nums[0]到nums[k-1]要求这些位置都不等于 val。所以数组前 k 位的内容必须和长度保持一致光改返回值是不够的。误读三忽略“顺序可以改变”。如果你不知道这个条件就只会想到保留原顺序的快慢指针而不会想到可以拿右侧元素直接填坑的左右指针。两种都能解但左右指针在某些情形下效率更高。2. 为什么暴力解法过得了判题机却过不了面试2.1 暴力解法的思路与实现最直观的思路是从左到右扫描数组一旦发现nums[i] val就把i之后的所有元素整体往前挪一位然后让i减一继续检查。用 Python 写大概是这样的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功能上是实现了内层循环把nums[i1:]全部前移同时用n - 1缩短有效长度。这个解法在数组长度只有 100 的题目里当然没问题但有一个很尴尬的点最坏情况下比如数组全是要删除的元素你每次删除都要移动后面剩下的所有元素整体复杂度是 O(n²)。2.2 暴力解法在面试中的致命伤面试官看到这个解法大概率会问一句“你能不能优化”这句话背后的潜台词是你的代码确实做对了但你没有抓住这个问题最本质的结构。为什么因为暴力解法把注意力放在了“要删除的元素”上而数组在内存中本来就是连续存储的所谓删除本质上是“用后面的元素覆盖前面的位置”。与其每次都把后面一大片元素拖过来我们其实可以换一个思路把注意力放在“要保留的元素”上。这里打个比方整理一个满是旧杂志的书架。暴力法是这样看到一本不要的杂志就把书架上后面所有书往前挪一格挪完继续找下一本不要的。双指针是这样你准备两个手指一个手指从书架最左往右滑动另一个手指指向当前“下一个该放书的位置”遇到好书就往前一放两个手指都前移遇到旧杂志只有扫描的手指继续走。整个过程中杂志不用丢堆在右边就行了。你看这两种思路的效率差异立刻就体现出来了。2.3 从暴力到双指针的思维路径实际上双指针并不是一个“凭空掉下来”的技巧它就是从“覆盖”这个语义里推出来的。既然删除等于覆盖那我们可以用一个慢指针指向下一个可覆盖的位置一个快指针遍历数组寻找不需要删除的元素。只要快指针找到不等于 val 的元素就把它写到慢指针指向的位置然后慢指针前移。这样一来每个元素最多被处理一次整体时间就是 O(n)。这就是第27题的标准解法也是后续所有“原地删除类”题目共通的出发点。3. 双指针的两种路线快慢指针与左右指针3.1 路线一快慢指针保持相对顺序快慢指针是这道题最通用的写法。它的核心思想是维护两个指针slow下一个可以写入的位置初始为 0fast当前扫描到的位置初始为 0。对于每个fast若nums[fast] ! val则把nums[fast]赋值给nums[slow]然后slow 1fast 1若nums[fast] val则slow不动fast 1。最后返回slow即可。def removeElement(nums, val): slow 0 for fast in range(len(nums)): if nums[fast] ! val: nums[slow] nums[fast] slow 1 return slow这段代码非常短但它是有一点“不变量”可以讲清楚的。循环过程中始终有nums[0..slow-1]是已经处理好的、不含 val 的元素nums[slow..fast-1]是已经扫描过但被“丢弃”的区域里面可能有些等于 val 的脏数据也可能是还没来得及覆盖的元素但无所谓因为我们最终不会去读它们nums[fast..n-1]是尚未扫描的区域。这个不变量一旦立住了返回slow的正确性就显而易见了。举个例子手动推演一下。假设nums [3, 2, 2, 3]val 3。初始slow 0fast 0。nums[0] 3跳过slow 0fast 1。nums[1] 2不等于 val赋值nums[0] 2slow 1fast 2。nums[2] 2赋值nums[1] 2slow 2fast 3。nums[3] 3跳过。循环结束返回slow 2数组前两位是[2, 2]正确。3.2 路线二左右指针最小化赋值次数左右指针法很多人不熟悉其实它利用的正是题干里那句“元素的顺序可以改变”。思路是用left从左往右找等于 val 的位置用right从右往左找一个不等于 val 的元素把右边的元素复制到左边被删的位置然后right左移。重复直到left right。def removeElement(nums, val): left 0 right len(nums) - 1 while left right: if nums[left] val: nums[left] nums[right] right - 1 else: left 1 return left这个写法赋值次数更少因为每个等于 val 的位置只做一次赋值而且是从右侧“捡”一个元素来填坑。同样推演nums [3, 2, 2, 3]val 3开始left 0right 3。nums[0] 3于是nums[0] nums[3]此时数组变成[3, 2, 2, 3]right变成 2left 保持不变。注意关键点nums[0]被赋成了 3而nums[3]原来就是 3所以这一步其实没有产生变化但right前移了有效区间缩短。继续nums[0] 3仍然成立于是再次nums[0] nums[2]数组变成[2, 2, 2, 3]right 1。nums[0] 2不等于 valleft 1。nums[1] 2不等于 valleft 2。此时left 2right 1循环结束返回 2。数组前两位是[2, 2]正确。这里有一个容易被忽略的细节当nums[left] val时我们把nums[right]赋给nums[left]但没有让 left 立即前进而是让 while 循环重新检查nums[left]。为什么因为从右侧拿过来的那个元素也有可能等于 val。如果不去检查就直接left 1就会漏掉一个需要删除的值。这是左右指针法最常见的 bug。3.3 两种路线怎么选两种解法的时间复杂度都是 O(n)空间复杂度都是 O(1)但适用场景有区别。快慢指针保留了元素的相对顺序代码更“正统”也更容易迁移到其他题目左右指针不保留顺序但赋值次数少在某些要求最小化元素移动次数的场景更有优势。对比维度快慢指针左右指针时间复杂度O(n)O(n)空间复杂度O(1)O(1)是否保持相对顺序是否最多赋值次数n 次等于 val 的元素个数代码直观程度高容易解释略绕但很巧妙通用性可扩展到 26、80、283 等题仅适用于此题这类允许乱序的场景面试时我更推荐先说快慢指针。因为它的循环不变量清晰解释起来不容易卡壳而且当你后续刷到第26题、第283题时会发现它们都是同一套模板只是“保留条件”变了。4. 提交记录里的坑边界条件与循环细节4.1 左右指针的循环条件为什么必须是left right左右指针法里while (left right)和while (left right)看起来差一个等号实际就差出一种错误。看一个具体例子nums [2]val 1。数组里没有任何元素等于 1正确答案是返回 1。如果用while (left right)初始left 0right 0循环体根本不会执行直接返回left 0结果就错了。换成while (left right)循环会执行一次nums[0] 2不等于 1于是left 1返回 1正确。所以这个等号不是细节问题它负责的是“最后一个待检查元素”的归属。快慢指针用for fast in range(n)天然覆盖了所有位置一般不会出这个错。但如果有人用while fast n也要注意边界确保 fast 走到最后一个元素时循环仍然会执行。4.2 快慢指针里slow和slow的位置写 C 的同学经常在快速编码时把nums[slow] nums[fast]误写成nums[slow] nums[fast]。这两者差别非常大前者先把当前 slow 位置覆盖然后 slow 前进后者先把 slow 前进一位再覆盖结果就是数组的第一个位置永远没被写入而最后返回的 slow 又多了 1整个结果错位。这种错误在压力面试中特别容易犯因为编译器和线上判题不一定能立刻暴露问题——如果数组恰好第一个元素就不需要删结果侥幸正确如果第一个元素正好需要删除数组第一个位置上残留脏数据判题直接失败。保险的做法是写nums[slow] nums[fast]; slow;两行逻辑清晰不抢跑。等习惯了再压缩成一行。4.3 左右指针里“被覆盖位置的脏数据”其实没问题有些初学者在看到左右指针法后会问nums[left]被nums[right]覆盖了但nums[right]原来的位置还有一份旧数据数组里岂不是出现了两个重复元素没错但这是被允许的。因为我们只保证nums[0..return-1]是干净的后面残留什么值不重要。这也是题干“不需要考虑数组中超出新长度后面的元素”这句要求的真正意义——它给了我们在数组内部腾挪的余地。4.4 一组值得收藏的边界测试用例我每次写完这种数组原地修改题都会先跑一遍下面这张表里的用例确认没有低级错误。你自己刷题的时候也可以把这张表存下来当作通用测试集输入数组val期望返回说明[]00空数组[1]10唯一元素且等于 val[1]21唯一元素但不等于 val[3,3,3,3]30全部等于 val[1,2,3,4]54val 根本不存在[0,1,2,2,3,0,4,2]25标准混合场景答案前五位应为[0,1,3,0,4]快慢指针其中最后一行是力扣官方示例里的经典用例快慢指针跑完后数组前五位是[0, 1, 3, 0, 4]顺序保持严格一致。5. 一套双指针模板打穿系列题26、80、2835.1 第26题有序数组去重做完第27题后立刻去做第26题会有一种强烈的“真题复现”感。26题要求删除有序数组中的重复项每个元素只保留一次。套用第27题的快慢指针模板只需要把“不等于 val”的保留条件换成“和上一个保留元素不同”def removeDuplicates(nums): slow 0 for fast in range(len(nums)): if slow 0 or nums[fast] ! nums[slow - 1]: nums[slow] nums[fast] slow 1 return slow这个写法的关键是利用数组本身有序所以只要判断nums[fast]是否等于“已经写入的最后一个元素”nums[slow-1]就可以了。如果相等说明是重复项跳过如果不相等写入并移动 slow。注意slow 0的特判这是因为当 slow 为 0 时nums[slow - 1]访问的是数组尾部必须避开。5.2 第80题每个元素最多保留两次第80题是在26题基础上放宽一点点每个元素最多可以保留两次。模板几乎不变只是把比较对象从nums[slow-1]改成nums[slow-2]def removeDuplicates(nums): slow 0 for fast in range(len(nums)): if slow 2 or nums[fast] ! nums[slow - 2]: nums[slow] nums[fast] slow 1 return slow为什么用slow - 2因为如果当前nums[fast]和之前保留下来的倒数第二个元素相同就说明在[fast]之前已经至少保留下两次该值了当前这个必是第三个需要被跳过。比如数组[1,1,1,2,2,2,3]slow 和 fast 走到第三个 1 时nums[fast]是 1nums[slow-2]也是 1相等跳过但走到第一个 2 时nums[fast]是 2nums[slow-2]是 1不相等保留。这个“保留前 k 个就与 slow-k 比较”的规律是可以推广到任意 k 的。5.3 第283题移动零第283题要求把数组中所有 0 移到末尾同时保持非零元素的相对顺序。表面上看和删除元素不太一样但本质上就是“删除 0”之后再往数组末尾补 0。用第27题的模板做非常顺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第一步用快慢指针把所有非零元素集中到数组前段这一步和 removeElement 一模一样只是把val换成 0第二步从slow开始到数组末尾全部填 0。如果面试官追问能否只用一个循环完成也可以直接交换法快指针碰到非零就和慢指针交换但那个写法我觉得反而不如先平移后补零好解释。面试时能用一两个清晰的循环讲明白比强行压缩成一个循环加一堆边界判断要好。5.4 把这类题抽象成一个模板做完上面这一个系列你会得到一个非常顺手的解题框架。凡是“原地修改数组并返回新长度”的题都可以套这个骨架slow 0 for fast in range(len(nums)): if 保留条件(nums, fast, slow): nums[slow] nums[fast] slow 1 return slow第27题保留条件是nums[fast] ! val第26题保留条件是fast 0 或 nums[fast] ! nums[slow-1]第80题保留条件是slow 2 或 nums[fast] ! nums[slow-2]如果换成“最多保留 k 个”保留条件就是slow k 或 nums[fast] ! nums[slow-k]。这套模板在面试里的价值在于你不需要每次重新推导双指针的正确性而是迅速定位到“这道题到底要保留哪些元素”然后一句话说出保留条件。面试官考察的往往就是你能不能提炼出这个条件。6. 面试时的表达顺序与加分操作6.1 不要一上来就写双指针面试时如果你拿过题就甩出最终版双指针其实不是最优策略。更好的表达顺序是先用一句话说清暴力思路和它的复杂度再顺势引出双指针。比如你可以说“如果每次发现 val 就把后面的元素往前挪最坏是 O(n²)。但我们可以换个角度关注要保留的元素用两个指针原地覆盖这样是 O(n)、O(1)。”这段话会让面试官明确感受到你的思维过程。6.2 写完代码后主动说明“为什么不变量成立”写完后不要干等面试官提问。你可以主动指着代码说“这里 slow 指向的永远是下一个待写入位置nums[0..slow-1]始终不含 valfast 没扫过的区域还没有被处理。循环结束时 slow 就是新长度。”这就把一个简单的代码题升华到了算法思维展示的层次尤其在二面三面里非常加分。6.3 主动提一下“顺序可改变”这个条件如果你写的是左右指针法一定要明确指出“我用到了题目里‘元素的顺序可以改变’这个条件所以左右指针交换是合法的。”如果你写的是快慢指针也可以顺带提一句“这个写法不依赖那个条件所以在顺序敏感的场景也适用。”你会惊讶地发现很多候选人写对了代码但完全没有意识到条件与解法之间的关联。6.4 这道题在后续面试中的“连锁考点”第27题往往不是终点。面试官可能在你写完这题之后紧接着追加第26题、第283题甚至让你现场推演“最多保留 k 个元素”的通用解。所以准备这道题时我建议你把整个系列题的解法都过一遍尤其是 26、80、283 三题。它们共享同一套代码骨架记住了骨架剩下的只是不同保留条件的替换。回想我最初刷这道题的时候也是记代码、背模板但讲不出为什么。后来刷完 26、80、283再回头看 27才真正理解了数组“覆盖”操作的含义所谓的删除不过是重新划定有效区间的边界。如果你现在也在为双指针发愁不妨这四题一起刷连在一起对比着看会比单刷一遍印象深得多。最后一个小建议是这四道题的代码量都很小适合每天用口述的方式过一遍思路不用看代码就能讲清楚“谁是快指针、谁是慢指针、保留条件是什么”这三个问题面试就稳了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑