☰
移除元素与双指针:数组原地删除的核心思路与边界自测
2026/10/9 17:21:08 网站建设 项目流程

跟着代码随想录的数组章节往下刷,很多人是被第二道题“移除元素”绊了一下的。不是它难,而是它和第一题二分查找的画风完全不同:二分查找只需要你在一段静态的排序数组里找下标,“移除元素”却要求你原地删掉一个数组里的指定值,还不能用额外数组。我第一次做这题的时候,下意识写了remove、pop之类的现成接口,跑完样例还挺高兴,回头一想完全不对——这题根本不是考你会不会调API。

移除元素对应的是LeetCode 27题,题面很短:给你一个数组nums和一个值val,原地移除所有等于val的元素,返回移除后数组的新长度。它看起来是数组专题里最“小而美”的一道题,但地位很特别:它是数组双指针的入门题,是后续删除有序数组重复项、移动零、有序数组平方等一系列题目的共同出发点。这篇文章不打算只贴一个标准答案,我会把暴力解为什么不行、双指针怎么一步步推出来、边界条件怎么自测、以及面试官在这道题背后真正想问什么,完整拆一遍。无论你是校招刷题、社招突击还是考研机试,这套思维模型都值得吃透。

1. 为什么移除元素是数组专题的分水岭

1.1 数组删除的本质其实是“覆盖”

很多初学者第一次做这道题时,会陷入一个很自然的困惑:数组里有一个元素等于val,我把它“删掉”不就行了吗?

问题在于,数组在内存里是一段连续空间,长度在创建时就固定了。语言层面并没有“删除一个格子”这种操作,你能做的只有把后面的元素一个个往前挪,用后面的值盖住要删的位置。你说的“删除”,本质上是“覆盖”。这一点想通了,整道题的走向就清楚了:移除元素就是在数组上做一次筛选,让不等于val的元素按原顺序排列在数组头部,最后返回一个“逻辑长度”,数组本身在内存里的长度不需要变,超出逻辑长度的部分也不用管。

我见过有人用Python的del nums[i]或nums.remove(val)来做这题,跑几个样例确实能过。但remove的时间复杂度是O(n),它背后的动作就是把目标元素之后的所有元素整体前移,而且每删一个都要重新扫描,几个删除操作叠加起来就是O(n²)。更麻烦的是,力扣这道题要求你返回“新长度”,而remove会直接改变数组长度,你的返回值跟判定逻辑都对不上。面试官让你写这道题,想看的恰恰是你自己动手实现那套“覆盖前移”的逻辑,而不是把底层操作交给解释器。

1.2 暴力解法:能跑通,但离面试要求很远

顺着“覆盖”的思路,最容易想到的解法是两层循环:外层遍历数组,遇到等于val的元素,就用内层循环把它后面的所有元素整体往前挪一位,然后数组的逻辑长度减一。下面是Python版本的暴力解法:

def removeElement(nums, val): i = 0 n = len(nums) while i < n: if nums[i] == val: # 把 i 之后的元素全部往前挪一位 for j in range(i + 1, n): nums[j - 1] = nums[j] n -= 1 # 逻辑长度减一 # 注意:这里 i 不递增 else: i += 1 return n

这段代码有个非常容易踩的坑:当我们删掉nums[i]后,原本在i+1位置的元素已经挪到了i位置,这个新元素可能也等于val,所以i不能马上加一,否则会漏掉连续出现的val。很多第一次写暴力解的人都在这里翻过车,漏删导致返回长度偏大。

暴力解在数据量小时没有任何问题,力扣给的测试样例也不算大,跑起来感受不到性能差异。但它的问题客观存在:每一轮删除都要搬移后面所有元素,最坏情况下数组里全是val,每删一个都要搬移几乎整个数组,总操作次数是O(n²)。而且这种搬移经常是重复劳动——某个元素刚往前挪了一步,下一轮又可能被整体前移一次。面试官看到O(n²)的解法,通常会追问一句“能不能优化到O(n)”,目的就是把你引向双指针。

2. 快慢指针:把“删掉不要的”翻转为“留下要的”

2.1 一个整理书架的场景,把双指针想通

暴力解法之所以慢,是因为它总盯着“要删掉谁”,每删一个就要搬动一堆元素。如果我们换个角度,不说“删掉等于val的元素”,而是说“把不等于val的元素按原顺序留下来”,整个问题瞬间简单了。

打个比方,你在整理书架。一种做法是:看到一本不要的书,就抽出来,然后把后面所有的书一本本往前推,补上这个空位。这种做法跟你抽掉的书数量成正比,书越多越累。另一种做法是:你从左边开始,一本本看过去,遇到要留下的书就放到书架左边第一个空位上,遇到不要的书就跳过,完全不碰它。一圈走完,书架左边已经整齐地排好了所有想留下的书,中间连一次大规模搬动都没有。

第二种做法就是快慢指针(也叫双指针、读写指针)。在移除元素这题里:

  • fast指针负责遍历原数组,相当于检查员,逐个看每个位置上的值。
  • slow指针指向“下一个保留位置”,也就是已经整理好的区域的末尾。
  • 当fast位置的值不等于val时,说明这个元素要保留,就把它写到slow指向的位置,然后slow加一。
  • 当fast位置的值等于val时,什么都不做,fast继续往前走。

你可能会担心:slow指向的位置会不会还没被fast扫描过?不会。因为整个过程中fast始终走在slow前面(或至少相等),slow位置上的旧数据一定是“已经处理过、不再需要”的数据,覆盖它完全安全。这也是这个算法敢原地操作的根本原因。

2.2 代码落地:谁在读,谁在写,什么时候写

快慢指针的代码只有几行,但每一行都有明确职责。我习惯用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 # 新长度 = 保留元素的个数

这里有一个很多初学者纠结的点:nums[slow] = nums[fast]这一步,到底是先赋值还是先移动slow?顺序错了会怎样?答案是必须先赋值再移动slow。因为slow代表“保留区末尾的空位”,第一个保留元素应该写到nums[0],写完后空位变成nums[1]。如果先slow += 1再赋值,第一个元素就会被写到nums[1],nums[0]反而空着了。

还有一点值得说明:当slow和fast相等时,nums[slow] = nums[fast]是一次自赋值,赋值给自己,无害。这种情况发生在数组前部没有任何val的时候,比如nums = [1, 2, 3]且val = 4,你会发现每个元素都被“自己写自己”了一遍。无需担心性能,编译器对这个模式也很友好,而且这是保持代码简洁的必要代价。

为什么这个写法能保持元素的相对顺序?因为fast是从左往右按原顺序扫描的,slow写入的顺序和扫描顺序完全一致。被跳过的val只是在逻辑上不存在了,不等于val的元素之间的先后关系没被破坏。这个性质在后续做“删除有序数组中的重复项”时同样关键。

2.3 手动推演:[3,2,2,3] 的全部过程

光看代码不够,我们手动推演一遍。假设nums = [3, 2, 2, 3],val = 3,目标是移除所有3。

初始状态:slow = 0,fast = 0。

步骤fastnums[fast]动作slow数组状态
103等于val,跳过0[3,2,2,3]
212不等于val,赋值1[2,2,2,3]
322不等于val,赋值2[2,2,2,3]
433等于val,跳过2[2,2,2,3]

最终返回slow = 2,实际有效部分nums[:2] = [2, 2],后面的[2, 3]属于“超出新长度的部分”,题目明确说不需要清理。注意第2步把nums[0]从3改成2时,nums[1]原本就是2,看起来数组变化不大,但逻辑长度已经悄悄更新了。遇到nums = [0,1,2,2,3,0,4,2]、val = 2这种更长的例子,你可以自己在纸上跑一遍,你会发现所有不等于2的元素都会按原顺序落位到数组头部。

复杂度也非常清楚:fast走完整个数组,每个元素最多被检查一次、最多被赋值一次,时间O(n);全程只用了一个额外变量,空间O(1)。

3. 边界条件、常见低级错误与面试追问

3.1 五个必须跑一遍的边界样例

算法题提交后报错,八成是边界没处理好。移除元素这题虽然简单,但下面的样例我一个都建议你亲手跑一遍,直接在编辑器里写完代码后逐条验证:

输入val预期新长度关键验证点
[]10空数组,循环根本不执行
[1,1,1]10所有元素都要删除
[1,2,3]43没有任何元素要删除
[4]40单元素且恰好等于val
[1,2,2,1]22连续重复段,验证不能漏删

跑这些样例时,重点观察两件事:一是返回长度是否正确,二是前slow位元素是否符合预期。对于“全删”的情况,slow最后是0,返回0,逻辑上数组为空;对于“全不删”的情况,数组每个位置被自赋值一次,返回原长度。这两种看似对立的场景,快慢指针都能用一个统一的循环处理,正是这个解法优雅的地方。

还有一个值得说的细节:如果面试官问“删掉了多少个元素”,答案不是返回的slow,而是len(nums) - slow。题目要的是新长度,但很多人被追问时就懵了。这一点要提前想清楚。

3.2 面试官在这题背后真正想问什么

移除元素在面试里出现频率很高,但它太简单,简单到不太适合直接当难题考。面试官出这题,通常是在考察几个更底层的能力。

第一,能不能看出来数组“删除”的本质是覆盖。如果你上来就写remove、pop,对方大概会微笑着让你再想想;如果你能说出“数组连续存储,长度固定,删除只能靠覆盖”,第一关就过了。

第二,能不能从O(n²)优化到O(n)。暴力解法是合格的“第一反应”,但如果停留在那里,说明你还不习惯用双指针压缩重复搬移。面试官会追问“能不能用一次遍历完成”,这时候快慢指针就该上场了。

第三,能不能主动确认需求的约束条件。比如“返回的是新长度还是删掉的个数”“顺序必须保持吗”“允许改变数组原有顺序吗”。很多题目描述里写“元素的顺序可以改变”,这句话不是随便写的,它意味着还有另一类更省搬移的解法。如果你能自己发现这一点并主动和面试官确认,比闷头写一个答案要加分得多。

第四,能不能画清楚指针变化的每一步。面试官经常让你拿一个具体例子,口头跑一遍代码。前面我推演[3,2,2,3]的过程就是标准答案的框架:先定义slow和fast各自的语义,再按流程逐轮说明“谁移动了、谁覆盖了、返回值是什么”。能把这个过程讲清楚,说明你是真懂,不是背代码。

我自己带过一些实习生,发现一个规律:能快速讲清楚“为什么覆盖时不需要考虑slow位置的旧值”的人,后面写滑动窗口、原地哈希一般也不会差;背出一个标准答案却说不清理由的人,换个输入很容易写崩。移除元素就是一面很好的镜子。

4. 另一种解法:顺序允许改变时的两端交换法

4.1 题目末尾那句说明,是官方给的暗示

力扣27题原题末尾有一句话:“元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。”很多人扫一眼就过去了,但在面试题里,这种看似无关紧要的“宽限条件”,往往是考官故意留给你的优化空间。

快慢指针保持了数组元素的相对顺序,这个性质很好,但代价是:几乎所有不等于val的元素都可能被复制一遍。假如数组有一千万个元素,里面只有三个位置上出现了val,快慢指针依然要把一千万个元素全部搬到前面?不对,其实是遍历一遍,赋值也只发生在不等于val的元素位置,但整体扫描是O(n)。这还不算最高效的写法。

既然题目允许顺序改变,我们其实可以换一种思路:既然不要求保持相对顺序,那我从左往右遇到一个等于val的元素时,不一定要把它后面所有的元素都往前挪。我只需要从数组尾部拿一个元素来填这个坑就行了。尾部那些元素本来也没处理过,拿过来再用同样的规则检查一遍即可。这就是两端交换法,也叫双指针相向法。

4.2 两端交换的代码与“先别移动left”的细节

两端交换法的代码长这样:

def removeElement(nums, val): left = 0 right = len(nums) while left < right: if nums[left] == val: # 用尾部的元素覆盖掉要删除的位置 nums[left] = nums[right - 1] right -= 1 # 逻辑长度减一 # 注意:这里 left 不增加 else: left += 1 return right

这里我把right初始化为len(nums),让它指向“逻辑区间的尾后位置”。每次发现nums[left] == val,就把right - 1位置的元素拿过来覆盖left位置,然后right -= 1。为什么覆盖之后不能立刻left += 1?因为从尾部换过来的这个元素可能也等于val,需要站在当前left位置再检查一次。这个细节是整段代码最容易错的地方,仅次于把right初始化为len(nums) - 1然后返回时各种差一。

整个算法的区间不变量是这样的:[0, left)是已经检查过的“保留区”,[left, right)是等待处理的“未知区”,[right, len(nums))是“已删除区”。算法每次从left位置开始,如果它等于val,就把它踢进删除区,同时从未知区尾部捞一个元素补进来;如果不等于val,left前进,把它并入保留区。当left和right相遇时,所有元素检查完毕,right就是新长度。这个不变量理解了,代码就不需要背了。

4.3 快慢指针与两端交换,到底怎么选

很多刷题教程只讲快慢指针,但两端交换法也是标准解法之一,面试时能主动对比两者,是很加分的表现。我把它们的差异整理成一张表:

维度快慢指针两端交换法
是否保持原顺序保持不保证
每个元素的访问次数一次扫描一次扫描,但可能反复检查尾部换来的元素
删除元素少时的搬移量仍可能复制大量元素只复制被删除位置对应的尾部元素
代码简洁度很简洁,适合优先写也很简洁,但有覆盖后不移动left的细节
后续可扩展性可平移到26题、283题等适用面较窄,但适合特定场景

我的建议是:面试一开始默认写快慢指针,因为它不破坏顺序,能覆盖更多后续题目,代码也更好讲清楚。如果面试官追问“能不能减少元素搬移次数”,你再切换到两端交换法,说明你理解两种解法的取舍。如果题目明确要求“顺序无所谓”,两端交换法其实是更优解——在删除元素很少的情况下,它几乎不用搬动什么数据,只在碰到val时才动一次手。

5. 从移除元素延伸出去:一条完整的双指针迁移线

5.1 代码随想录路线里的同门题目

代码随想录的数组章节是按难度递进的:二分查找解决“找”的问题,移除元素解决“改”的问题,后面的题目一个比一个有挑战。移除元素之所以排在这个位置,是因为它植入的“快慢指针”模型,后面几道题全在用。

最有代表性的是第26题“删除有序数组中的重复项”。它几乎是移除元素的同胞兄弟:同样是快慢指针,不同点在于比较条件从nums[fast] != val变成了nums[fast] != nums[fast - 1](或者nums[fast] != nums[slow - 1])。你如果理解了移除元素里“slow指向保留区末尾”的写法,做26题就是改一个判断条件的事。

另一道经典题是第283题“移动零”。它的要求是:把数组里所有的0移到末尾,同时保持非零元素的相对顺序。用移除元素的思路,可以先通过快慢指针把非零元素全部写回数组头部,再把slow之后的位置全部置0。这等于在移除元素的基础上多做了一次“尾部填充”,核心思想完全一致。

还有一道比较有意思的题是844题“比较含退格的字符串”,它可以用双指针倒序遍历:遇到#就跳过下一个有效字符。虽然场景变了,但“一个指针负责读、一个指针负责记录”的底层思维是一样的。刷到这里你会慢慢发现,算法题里的“套路”大多是同一个思维模型在不同场景下的换皮。把移除元素练扎实,相当于给这一整段刷题路线打了地基。

5.2 读指针/写指针思想在工程代码里的应用

这套“读指针+写指针”的模式,不只是面试题,工程代码里也随处可见。我举几个我实际遇到过的例子。

第一个是日志清洗。假设一个请求日志数组里有大量记录,你想保留所有status != 500的记录,同时不新建数组、不改变其他字段的顺序,快慢指针的写法就是一行循环的事,扫一遍把所有有效记录前移,最后截断长度。在内存里处理这种过滤,比创建新数组再拷贝要省得多。

第二个是缓冲区或帧数据处理。在写C语言相关的通信协议处理时,经常需要从接收缓冲区里剔除填充字节。这时候“读指针”和“写指针”分别指向源缓冲区和目标缓冲区,一边读一边按条件写,完全就是移除元素的工程版。很多高性能代码里甚至直接把这些指针命名为rptr和wptr,语义和fast、slow一一对应。

第三个是数据结构内部的原地整理。比如一个自定义队列在做批量出队后,需要把剩余元素集体前移,重新维护头尾标志。这类操作如果借助双指针提前规划写入位置,可以避免无意义的反复memmove。虽然大多数语言提供了现成接口,但理解底层原理,遇到性能瓶颈时才知道怎么优化。

所以这道题的价值不只在LeetCode排名里那一行绿,它让你掌握的是一个可迁移的底层操作模式。

5.3 刷这题的正确姿势:从口述到自测

最后聊点方法论。代码随想录反复强调“五步刷题法”:读题想清楚、先写思路、再写代码、手动举例、复查边界。移除元素恰好是练习这套流程的完美题目,因为它的代码太短,短到如果你只是“看懂了”,第二天很可能就会忘。真正有效的练习方式是这样的:

先合上所有题解,用口头语言描述一次思路:“用fast遍历每个元素,遇到不等于val的就写到slow位置,slow后移,最后返回slow。”如果你能说出“slow指向的是下一个保留位置”这句话,说明核心模型已经建立了。然后从零写代码,写完不用急着提交,先跑一遍我上面列的五个边界样例,再随机造一个自己的数组手动推演一遍。

我实习带人的时候,会要求他们把这个推演过程写在纸上,尤其是[3,2,2,3]这种样例,把每一轮fast、slow的值和数组变化画出来。看起来像是在做小学数学题,但四五道题练下来,对指针类题目的直觉会明显变好。这个方法也适用于后面所有双指针题,提前养成就很划算。

移除元素这道题,难的从来不是那十行代码,而是你脑子里什么时候能把“删掉我不要的”换成“留下我要的”。这个思维反转一旦完成,后面26题、283题、844题基本都不需要再看题解了。把这篇文章里的模拟过程亲手做一遍,比把答案抄十遍都有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询