news 2026/9/26 5:49:18

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode移除元素题解:双指针与原地修改的两种高效解法

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 slow

Python的列表和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 = 0,right = -1,while 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。第一次循环,left=0 发现 nums[0]==1,把 nums[2]=2 搬过来,数组变 [2, 1, 2],right 变成 1。第二次循环,left=0 检查 nums[0] 等于 2,不是 val,left 变成 1。第三次循环,left=1,right=1,检查 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,然后用暴力解法结果和你的双指针解法结果做对拍。对拍这个习惯如果你现在还没有,真的建议培养起来。它能在你刷到中等、困难题时,快速发现那些肉眼看不出来的逻辑错误。这道"移除元素"对了拍之后,你基本可以把这份信心带去刷整个数组双指针专题了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 5:49:16

RAG知识库全链路实战:从文档解析到混合检索的工程指南

RAG 这个词这两年出现的频率太高了&#xff0c;高到很多人一上来就问“用哪个向量数据库”&#xff0c;却很少有人先把整条链路想清楚。我前后搭过七八套知识库系统&#xff0c;从最早的纯关键词检索&#xff0c;到后来的向量召回&#xff0c;再到现在的混合检索加重排&#xf…

作者头像 李华
网站建设 2026/9/26 5:49:01

BugKu——game1

一、题目2、方法访问服务器&#xff0c;是一个游戏。F12&#xff0c;发现里面有个js文件。这段代码是一个经过混淆的 JavaScript 脚本&#xff0c;核心功能是&#xff1a;实现 Base64 的编码&#xff08;encode&#xff09;和解码&#xff08;decode&#xff09;&#xff0c;并…

作者头像 李华
网站建设 2026/9/26 5:48:34

DeepSeek V4.1 Flash (Batch) 批量推理性能与质量深度评测

在处理大规模数据或需要自动化生成大量内容的场景中&#xff0c;单个请求逐个处理的方式往往显得力不从心。无论是电商平台的商品描述生成、金融领域的日报汇总&#xff0c;还是教育行业的试题批量制作&#xff0c;传统模式下的等待时间和资源消耗都成为了制约效率的瓶颈。许多…

作者头像 李华
网站建设 2026/9/26 5:48:31

扣子Coze工作流实现AI代码审查:从节点编排到API发布的完整实践

简介&#xff1a;面向扣子COZE平台的AI编程案例合集&#xff0c;适合希望快速上手智能机器人开发的产品经理、独立开发者和运维人员&#xff0c;尤其适用于需要将对话系统与企业现有工具链打通的场景。压缩包内仅含1个PDF文档&#xff0c;大小186KB&#xff0c;轻量便于阅读与传…

作者头像 李华
网站建设 2026/9/26 5:47:52

Cline中文本地化实践:OpenAI兼容协议下的VSCode编程代理配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 5:47:01

网络药理学与机器学习复现:从代码到实战的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华