资讯动态

LeetCode移除元素题解:双指针与原地修改的两种高效解法

发布时间:2026/9/26 5:49:19 来源:尧图企业网站定制
1. 题目理解与核心考点1.1 题目原文与要点拆解移除元素是LeetCode上的第27题。原题描述很简单给你一个数组 nums 和一个值 val你需要原地移除所有数值等于 val 的元素并返回移除后数组的新长度。不要使用额外的数组空间必须仅使用 O(1) 额外空间并原地修改输入数组。元素的顺序可以改变。不需要考虑数组中超出新长度后面的元素。我第一次刷这道题的时候第一反应是这有什么难的遍历一遍把等于val的删掉不就完事了吗但仔细一读题就发现问题了它要求原地修改也就是说你不能新开一个数组把不等于val的元素装进去再复制回来。同时数组在大部分编程语言里一旦创建长度就固定了所谓移除并不是真的把元素从内存里删掉而是把不等于val的元素挪到数组前面然后返回一个长度值让调用方从这个长度之前的位置去读取有效数据。这道题的核心考点有三个一是对数组这种连续内存结构的理解二是双指针思想的最基础应用三是对原地修改边界的把握。它不涉及任何复杂的数据结构也没有高深的算法技巧就是单纯考察你有没有操作指针/下标来避免额外空间浪费的思维习惯。在LeetCode的推荐刷题顺序中这道题通常和删除排序数组中的重复项移动零放在一起作为双指针的入门套餐。我个人的看法是这三道题连刷比上来就做三数之和要友好得多。1.2 为什么这道题值得反复练我知道很多老手会觉得这道题太简单了甚至有些新手也会不屑一顾这不就是一次遍历的事情吗但我的实际体验是这道题作为热身和找回手感的价值被严重低估了。首先它是双指针思想的最简化模型。在这道题里没有滑动窗口、没有左右边界收缩的复杂逻辑就一个快指针负责探路一个慢指针负责记录合法数据的位置。把这个逻辑吃透了后面做压缩字符串移除重复元素链表去重都会顺畅很多。我见过不少刷到中等难度题就卡住的人回头看根因往往就是对这种最基础的指针移动逻辑没有形成肌肉记忆。其次这道题对原地修改的边界理解非常考验细节。比如你返回的新长度之后的位置要不要处理如果val在数组末尾快指针先走还是慢指针先走这些细节错一处提交结果就是红叉。而这些细节恰恰是面试手撕代码时最容易暴露的问题。反复练习这道题本质是在练写代码前先推演指针的每一步走向这个习惯。1.3 暴力解法——先确保能跑通我见过很多学习建议直接告诉你要用双指针但我个人认为对于第一次接触这道题的人不妨先写一个暴力解法跑通了再去优化。为什么因为只有先实现一个能跑通的版本你才能对新长度这个返回值有直观感知也才能体会到暴力解法在空间上的浪费从而真正理解双指针优化的意义。暴力解法的思路很直接遍历数组每遇到一个等于 val 的元素就用它后面的所有元素往前覆盖一位。这个操作的时间复杂度是 O(n²)因为数组元素会被多次移动。代码写出来也很短但提交到LeetCode上在数据量小的时候也能通过数据量大了就会超时。写这个版本不是为了过题而是为了对照。def removeElement(nums, val): i 0 while i len(nums): if nums[i] val: for j in range(i 1, len(nums)): nums[j - 1] nums[j] # 删除了一个元素长度减1但i位置被后一个元素填充了需要重新检查 # 用一个变量维护有效长度更清晰 i - 1 # 这种写法容易出bug只是演示思路 i 1 return len(nums)上面这个实现我故意写得比较粗糙它其实有一个经典的边界bug当 i 指向的位置是最后一个元素且等于 val 时内层循环直接不执行len(nums) 没有变化实际上元素并没有被移除。所以暴力解法不是不能写而是很容易在边界细节上翻车。我建议真正想练暴力版的用一个新数组来收集不等于 val 的元素最后拷贝回去——虽然空间不符合题目要求但逻辑清晰适合用来理解题目意图。2. 双指针解法——最优解的核心思路2.1 快慢指针思路拆解双指针解法是这道题的标准答案也是几乎所有语言题解里最高频的写法。我先说思路一个慢指针 slow 指向下一个合法元素应该存放的位置一个快指针 fast 从头到尾遍历数组当 fast 指向的元素不等于 val 时就把这个元素复制到 slow 指向的位置然后 slow 加一当 fast 指向的元素等于 val 时什么都不做继续向前走。这个逻辑用一句话概括就是快指针负责找好人慢指针负责留位置。等 fast 走完整个数组时slow 的值恰好就是移除后数组的新长度因为 slow 之前的所有位置都已经填上了不等于 val 的元素。说实话我第一次看到这个解法时觉得有点绕为啥要复制元素而不是直接删除呢后来我想明白了一个类比这就像在火车上查票你从第一节车厢走到最后一节快指针遇到没有票的人等于val的元素就直接跳过遇到有票的人就把他领到前面已经清空的座位上慢指针指向的位置。等到查完全部车厢前面已经坐满了有票的人后面被领走的座位空出来了新长度就是前面坐人的车厢数。这个类比我每次讲给朋友听都说一下子就通了。2.2 代码实现与细节处理下面给一个我自己用的Java版本这也是面试时最常写的语言版本之一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; }就这么短八行代码一个循环一个判断一个赋值一个自增。但你别小看这几行我第一次写的时候差点把 nums[slow] nums[fast] 写反了变成 nums[fast] nums[slow]结果整个数组被错误覆盖。后来我总结了一个记忆技巧慢指针是接收方快指针是提供方所以永远是把快指针的值给慢指针的位置。写完之后再自问一句slow 是下一个位置还是当前位置我是先赋值再自增还是先自增再赋值这两个问题想清楚了代码就不会错。如果是Python版本写法几乎一样def removeElement(nums, val): slow 0 for fast in range(len(nums)): if nums[fast] ! val: nums[slow] nums[fast] slow 1 return slowPython的列表和Java的数组在原地修改上的行为一致代码逻辑也是一一对应的。如果你是用C写vector也可以这样操作。这道题的一个优点是它在任何主流语言里的解法都长一个样不用担心语言特性差异导致的思路变形。2.3 复杂度分析时间复杂度快指针遍历数组一次慢指针在最坏情况下所有元素都不等于 val也遍历数组一次整体是 O(n) 级别。空间复杂度除了两个指针变量和循环变量没有使用任何额外的数据结构是严格意义的 O(1)。这个复杂度分析本身就是面试考点。你在解释复杂度的时候可以顺便说一句元素被移动的次数在最坏情况下等于数组长度因为每个不等于 val 的元素都可能被往前搬一次。这个搬动次数的细节很多题的题解不会提但面试官问起来你如果能答到这一层印象分会明显不一样。还有一点容易被忽略这个方法在val 出现的频率很低时有点浪费。举个例子数组是 [1, 2, 3, 4, 5]val 6。快指针每走一步发现元素不等于6就会把它复制到自己身上比如 nums[0] nums[0] 这种自己赋值给自己的操作。虽然没有逻辑错误但在某些特殊场景下会产生无意义的写入。这就是第三种解法的优化动机。3. 更优解左右交换法3.1 思路推导既然题目说了元素的顺序可以改变这就等于给我们开了一道后门如果 val 很少或者我们不在乎元素的相对顺序完全可以用首尾交换的方式来减少元素移动次数。思路是这样的维护一个左指针 left 从数组头部开始一个右指针 right 从数组尾部开始或者维护一个 end 表示当前有效数组的尾部。当 nums[left] 等于 val 时把数组尾部的元素拿过来填到这个位置上同时让 right 减一。如果不等于left 加一继续检查下一个。循环一直到 left 超过 right 为止此时 left 的值就是移除后数组的新长度。这个思路的关键点在于从尾部拿过来的那个元素它本身也可能等于 val所以不能直接让 left 跳过必须在下一个循环中再次检查 left 位置。很多第一次写这个解法的人包括我都会在这里踩坑拿过来的元素没检查就直接 left导致结果出错。3.2 代码实现def removeElement(nums, val): n len(nums) left 0 right n - 1 while left right: if nums[left] val: nums[left] nums[right] right - 1 else: left 1 return left这个版本比快慢指针多了一个交换赋值的步骤但好处是当 val 的出现次数很少时需要移动的元素个数大幅减少。比如 [1, 2, 3, 4, 5] 里移除 6快慢指针要做五次自己给自己赋值的无意义操作而左右交换法一次都不做直接 left 一路加到 5返回 5。极端情况下如果数组里几乎没有等于 val 的元素这种优势会非常明显。同样用Java写一遍public int removeElement(int[] nums, int val) { int left 0; int right nums.length - 1; while (left right) { if (nums[left] val) { nums[left] nums[right]; right--; } else { left; } } return left; }3.3 两种解法对比我自己在做题和实际面试模拟中对这两种解法的选择有一个判断标准如果面试官没有要求保持元素相对顺序我倾向于写左右交换法因为它更能体现你对题目约束的敏感度——你注意到了元素的顺序可以改变这句话并据此做出了更优的方案。如果面试官强调要保持原有顺序那就老老实实写快慢指针。维度快慢指针左右交换法元素顺序保持原有相对顺序不保证尾元素会被挪到前面最坏时间复杂度O(n)O(n)元素移动次数等于不等于 val 的元素个数等于等于 val 的元素个数代码复杂度更易理解适合初学者稍微绕一点需要处理尾部元素再检查适合场景需要稳定顺序的删除只关心最终长度或 val 出现极少这个对比表我建议你收藏起来。不是说这道题本身有多重要而是这个同样要求在 O(1) 空间完成但两种解法在不同场景下各有优势的思路在后续很多数组题里都会反复出现。比如移动零那道题就可以看作快慢指针的一个变体。4. 边界条件、测试用例与踩坑实录4.1 边界条件分析刷题的人都知道代码跑通不算本事边界条件全覆盖才算稳。这道题的边界条件有几个典型的第一个是空数组。nums 为空时fast 循环根本不进入slow 直接返回 0这没问题。但如果你写的是左右交换法left 0right -1while left right 条件直接不成立返回 left 也就是 0也没问题。怕的是你在代码里写 nums[right] 之前没有检查 right 是否合法一旦右指针越界就崩了。第二个是数组中所有元素都等于 val。这时候快慢指针版本里 fast 扫描一遍slow 一步都没动返回 0数组前 0 个元素是有效数据正确。左右交换法里left 一直等于 val不断从 right 拿元素right 一路减到 -1循环结束left 依然是 0正确。第三个是所有元素都不等于 val。慢指针会把整个数组原封不动地复制一遍自己复制自己返回原长度。这个场景最容易让人自我怀疑我这么操作了一遍数组看起来没变我的代码是不是白跑了其实没白跑这是正确行为。第四个是 val 出现在数组末尾。比如 [3, 2, 2, 3]val 3。快慢指针处理到最后一个元素时fast 检查发现等于 val跳过slow 停留在 2最后返回 2前面两个位置是 [2, 2]符合预期。4.2 测试用例设计我推荐你不管用什么语言写完之后至少拿下面这组用例过一遍assert removeElement([3, 2, 2, 3], 3) 2 assert removeElement([0, 1, 2, 2, 3, 0, 4, 2], 2) 5 assert removeElement([], 0) 0 assert removeElement([1], 1) 0 assert removeElement([1], 2) 1 assert removeElement([4, 4, 4, 4], 4) 0其中 [0, 1, 2, 2, 3, 0, 4, 2] 是LeetCode上的官方示例它的返回长度是 5而且前5个元素可以是 [0, 1, 3, 0, 4] 这五个值的任意排列。注意它说可以是意味着你的具体排列顺序可以不同只要值对就行。如果你用快慢指针结果是 [0, 1, 3, 0, 4, 4, 4, 2]前面5位是0,1,3,0,4如果用左右交换法结果可能是 [0, 1, 4, 0, 3, ...]因为顺序被改变了。两个答案都被判对。这个答案不唯一的特性也侧面提醒我们LeetCode的判题逻辑主要看返回值和有效前缀的元素集合而不是看整个数组的最终样子。4.3 常见错误与排查方法我在评论区见过最多、自己也犯过的错误有三类拦截最值得说。第一类是慢指针赋值方向写反。前面提过了不赘述。想排查这个问题很简单打印每一轮循环后的数组状态肉眼观察前几个位置是否符合预期。这个方法虽然笨但比纯看代码有效得多。第二类是返回值搞错。有人用 fast 变量当返回值有人用 slow 1 当返回值。记住一句话slow 指向的是下一个合法位置同时也是合法元素的数量。因为数组下标从 0 开始第 0 到第 slow-1 就是 slow 个元素所以直接返回 slow 就行。第三类是左右交换法里从尾部搬来的元素没有重新检查。我还是建议你把这个用例跑一遍[1, 1, 2]val 1。第一次循环left0 发现 nums[0]1把 nums[2]2 搬过来数组变 [2, 1, 2]right 变成 1。第二次循环left0 检查 nums[0] 等于 2不是 valleft 变成 1。第三次循环left1right1检查 nums[1] 等于 1把 nums[1]也就是自己搬到 nums[1]right 变成 0。循环结束返回 left 1。数组是 [2, 1, 2]前 1 位是 2正确。但如果你在第一次循环后贸然 left就会漏掉从尾部搬过来的那个值是否等于 val 的检查可能返回错误结果。5. 举一反三相似题目与工程延伸5.1 相似题型与刷题顺序如果你是在按力扣刷题攻略规划自己的刷题路线我建议你按下面这个顺序来先做第 26 题删除有序数组中的重复项。这道题和移除元素的区别在于它要求数组是有序的而且判断条件是相邻元素是否重复而不是是否等于某个给定值。解法同样是快慢指针但慢指针的比较对象不是 val而是已保留的最后一个元素。做完这两题你会对快指针探路、慢指针接收这个模式非常熟悉。然后做第 283 题移动零。这道题可以看作移除元素的变体先把所有非零元素用快慢指针搬到前面然后把后面的位置全部补 0。有了移除元素的基础这道题的改造成本几乎是零。再往后可以做第 844 题比较含退格的字符串它用到了双指针从后往前比较的思路已经带一点技巧性了。这样由浅入深你会明显感觉到自己的能力在慢慢搭建而不是东一榔头西一棒子。洛谷那边也有不少数组相关的入门题但风格和LeetCode不太一样更侧重简洁的算法实现和输入输出处理。如果打算备战机考或者ACM初级场可以把力扣这题做完之后去洛谷刷几道普及组数组题感受一下比赛题和面试题在表达方式上的差异。关于leecode必刷基础算法题这个说法我的看法是与其追求刷完多少题不如先把每道题吃透。删除元素这道题就是吃透的最佳起点——它足够简单简单到你可以花大量时间去琢磨不同写法、不同边界、不同优化而不至于被算法本身难倒。5.2 实际工程场景中的应用你可能觉得这种数组题在实际开发中没什么用毕竟谁会闲着没事在代码里移除数组元素呢但我在真实的业务代码里确实遇到过类似的场景。一次是在做埋点数据清洗的时候从上报的原始日志列表里过滤掉某些无效渠道的数据。当时我们用的语言是Python数据存在 list 里最自然的写法是[x for x in data if x[channel] ! invalid]。但有几个核心链路对内存敏感要求在不复制大列表的情况下原地过滤这时候快慢指针的思想就能直接落地用两个下标在原列表上操作把有效数据往前挪最后用del lst[slow:]把尾部无效数据清掉。这个方案在最坏情况下也能保持 O(1) 额外空间。另一次是在做在线表格组件的时候需要批量删除选中的多行数据。如果一行一行地用splice删除每次删除都会导致后面的行索引整体前移而多个选中行的索引是基于原始表格计算的删着删着就乱了。用快慢指针的思路一次性把保留行集中到前面再统一截断就可以避免逐行删除带来的索引错乱。说白了双指针不只是刷题术语它本身就是一种批量操作中如何安全地原地搬运数据的思路总结。所以我会建议刷题的时候不要总想着这个题我在工作中用不到而是去思考这个思路在什么业务场景下会等价出现。你带着这个视角去刷题每道题都能刷出额外价值。5.3 我的实操体会刷了这么多年的题这道题我已经写过不知道多少遍了。但每次带新人或者自己复盘的时候我还是会从头到尾手写一遍。为什么因为它能帮我快速检验自己的编码习惯是否有退化变量命名是否清晰、循环边界是否在下笔前就已经明了、写完代码是否立刻能说出复杂度、会不会在写完之后下意识去跑一下测试用例而不是直接点提交。我个人建议你也养成一个习惯写完任何算法题后先自己在脑子里过三层检查。第一层代码有没有语法错误第二层边界条件有没有覆盖空数组、全匹配、零匹配三种场景第三层复杂度分析能不能随口说出来。这三件事都完成了再点提交按钮。长期坚持下来你的编码准确率和调试速度都会有一个非常明显的变化。另外还有一个实用技巧在本地调试时用random生成随机数组和随机 val然后用暴力解法结果和你的双指针解法结果做对拍。对拍这个习惯如果你现在还没有真的建议培养起来。它能在你刷到中等、困难题时快速发现那些肉眼看不出来的逻辑错误。这道移除元素对了拍之后你基本可以把这份信心带去刷整个数组双指针专题了。

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

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

免费获取报价 →
↑