☰
原地移除数组元素:双指针算法与工程实践
2026/9/28 13:33:31 网站建设 项目流程

1. 题目拆解:原地、移除、数组,三者如何咬合

先说个面试现场最常见的场面:我让人在黑板上写一段代码,把数组里等于某个目标值的元素统统去掉。很多人抬手就写new ArrayList或者filter,写完后自己还挺满意。等面试官说“题目要求原地”的时候,脸一下就绿了。这里的“原地”指的是不创建新数组,直接在原数组的空间上完成操作。这个要求不只是在考语法,而是在考你对“数组到底是个什么东西”的理解。

在大部分编程语言里,数组都是一段连续的内存空间,长度在创建时就固定了。就算你用的是 Python 的 list、Java 的 ArrayList 这种动态结构,底层的连续内存依然是一整块,扩容也是新开一块再搬过去。所以“移除元素”从物理层面来看,从来不是把中间某个格子抠掉、后面自动往前挪,而是“用后面的元素覆盖掉前面的元素”,然后逻辑上把数组的有效长度缩短。理解了这一点,就会明白为什么很多看起来很好用的“删除”方法,在这个题目里并不适用。

1.1 “原地”的要求为什么是算法思维分水岭

平时开发里用filter、splice、remove这类方法非常顺手,它们封装了复制或搬移的逻辑。但面试题往往刻意把“新数组”这条路堵死,让你必须在原数组上“腾挪”。这一步跨越非常关键:它强迫你从“被工具使用”变成“设计工具的人”。

举个例子,在 JavaScript 里你可以这样写:

const result = nums.filter(x => x !== val);

这行代码极其干净,但底层做了一次全量遍历,创建了一个新数组。如果输入数组有上亿个元素,这种写法会瞬间多出非常大的内存占用。而原地算法只申请几个临时变量,额外的空间复杂度是 O(1),这在内存受限的嵌入式设备、移动端 App 或者高并发服务里,就是天壤之别。

我在一次日志清洗任务里遇到过类似场景:一份千万级的 IP 列表需要剔除黑名单前缀,如果每次处理都新建数组,GC 压力大得吓人。改用原地覆盖后,不仅内存稳住了,处理时间也从几次 FullGC 的抖动里逃了出来。所以“原地”不是面试官故意刁难,它是工程中真实存在的性能需求。

1.2 从“移除元素”看面试官的考察意图

LeetCode 上这道题叫 Remove Element,题目描述很简单:给你一个数组nums和一个值val,你需要原地移除所有数值等于val的元素,然后返回移除后数组的新长度。不用管新长度之后的元素长什么样。

面试官出这道题,通常不是为了考你记不记得 API,而是看三件事:

第一,你知不知道数组长度固定,所谓的“删除”本质是覆盖。第二,你能否用最少的遍历次数完成筛选,这背后是对双指针模型的理解。第三,你处理边界条件是否缜密,比如空数组、全是待删元素、没有待删元素。

这道题还有一个很隐蔽的考察点:你会不会掉进“额外数组”的舒适区。因为正常的业务代码里,我们根本没必要去直接改原数组,直接生成新集合就行。但算法面试考的是你在约束条件下的最优解,这跟日常工程化思维是有冲突的。能不能把“先 copy 再处理”的思维扭过来,决定了这道题能不能拿到满分。

2. 核心解法:双指针覆盖模型

2.1 快慢指针法:一个循环完成过滤

最经典的解法是快慢指针,也叫快慢索引。它的核心思想特别朴素:用一个指针负责“看”(快指针 fast),一个指针负责“写”(慢指针 slow)。fast 从头到尾遍历数组,如果当前元素不等于 val,就把它写到 slow 指向的位置,然后 slow 往后移动一位;如果等于 val,就跳过,fast 继续往前走。

整个过程只有一次循环,每个元素最多被读一次、写一次。循环结束后,数组前 slow 个位置就是所有不需要删除的元素,slow 的值就是新长度。我写个 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; }

如果你觉得抽象,可以想象成搬家整理房间:slow 指向下一件物品应该放置的位置,fast 指着一个一个检查仓库里堆着的旧箱子。凡是没被标记为“丢弃”的箱子,就搬到 slow 指定的空位;标记为丢弃的箱子直接跳过不看。搬完后,仓库前 slow 个位置堆满了好东西,后面还残留着旧箱子的空壳,但我们根本不再理会它们。

这个解法超稳定:不管 val 出现在哪、出现几次,最终都能保证相对顺序不变。这正是很多场景下的硬性要求,比如按时间排序的流水数据,如果你把元素顺序打乱,后面的逻辑就全乱了。

2.2 首尾指针法:删除大量元素时的优化

快慢指针虽然好,但它有个弱点:当待删除元素特别多时,它仍然需要把大量保留元素逐个“搬”一遍。想象一个长度为 100 的数组,里面只有 5 个元素需要保留,那快慢指针得把 95 个保留元素挪一遍。有没有更省事的办法?有,就是首尾指针。

思路是这样:把左指针放在数组开头,右指针放在数组末尾。左指针找等于 val 的元素,找到后,把右指针指向的元素拿过来覆盖它,然后左指针右移一位,右指针左移一位。如果左指针当前元素不是 val,就直接左指针右移。右指针在移动过程中,如果指向的元素也等于 val,没关系,直接左移跳过,因为这种元素最终要被舍弃。

这样做的效果是:被删除元素少时,左指针能快速扫过大多数保留元素;被删除元素多时,每次遇到待删元素,都是从数组尾部“拉”一个保留元素来填充,移动次数大约等于被删除元素的个数。在某些分布下,这比快慢指针搬移得更少。代码可以写成这样:

int removeElement(int* nums, int numsSize, int val) { int left = 0, right = numsSize - 1; while (left <= right) { if (nums[left] == val) { nums[left] = nums[right]; right--; } else { left++; } } return left; }

注意,这里我们用的是“覆盖”而不是“交换”。当nums[left] == val时,直接把nums[right]的值赋给nums[left],然后 right 递减。因为nums[right]这个位置的值已经被“搬运”到前面了,后面不再需要它,所以不用做多余的数据交换。这个操作有个副作用:它改变了元素的相对顺序。如果题目没有明确要求保持顺序,我一般会优先考虑这种写法,因为它更快。但如果你需要保留原来的顺序,就必须用快慢指针。

2.3 复杂度分析:为什么这是最优解

两种双指针方法的时间复杂度都是 O(n),空间复杂度都是 O(1)。很多人会问:能不能用二分之类的更快?答案是不能。因为你至少要遍历一遍所有元素,才能确认哪些值等于 val。哪怕你把数组排了序,也还是要对每个目标值附近做处理,最坏情况依然要面对全数组扫描。所以 O(n) 就是信息论意义上的下限,没有更快的可能了。

空间上 O(1) 也是下限,因为题目要求原地。如果你写出一个 O(n) 空间的解法,比如list = [x for x in nums if x != val],那只是在字面意义上用新数组完成了筛选,不是题目要的东西。复杂度分析能帮你确认自己写没写错:只要额外空间里出现了“新数组”三个字,基本就废了。

顺便说一句,有些同学会纠结“快慢指针和首尾指针到底哪个更强”。我的看法是:快慢指针更通用、更安全、代码更简单;首尾指针在特定数据分布下更高效,但牺牲了顺序。真正的高手不是只会一种模板,而是能根据题目要求当场选型。我面试的时候,会先确认“是否允许改变元素顺序”,然后决定用哪个方案。

3. 多语言实现与细节差异

3.1 C/C++ 版本:指针遍历与引用

C 语言没有动态数组,数组作为函数参数时只传入首地址和长度。所以你必须自己维护slow和fast这两个整数索引。C 版本和 Java 版本几乎长得一样,只是没有nums.length可用,要把长度单独传进来。

int removeElement(int* nums, int numsSize, int val) { int slow = 0; for (int fast = 0; fast < numsSize; fast++) { if (nums[fast] != val) { nums[slow++] = nums[fast]; } } return slow; }

C++ 就有意思了。如果你用的是vector<int>,标准库提供了一个叫std::remove的算法,它本质上就是快慢指针的封装:

auto newEnd = std::remove(nums.begin(), nums.end(), val); nums.erase(newEnd, nums.end());

std::remove返回一个新的迭代器,指向逻辑尾部,然后erase把多余元素真正删掉。这里有个经典陷阱:单独调用remove并不会改变vector的size(),只会把需要保留的元素移动到前面,把不需要的元素挤到后面。很多新手以为调完remove就完事了,结果vector长度没变,原值还残留在末尾,debug 半天才发现要配合erase。

在实际的 C++ 工程里,remove配合erase就是你想要的“原地移除”,因为remove本身没有新开内存,erase只是调整了容器大小。这也印证了前面说的思想:先覆盖出有效元素,再缩短逻辑长度。

3.2 Java 版本:数组长度固定带来的认知转变

Java 数组一经创建,长度就永远固定,无法真正“缩短”。因此removeElement返回的 int 是让调用方知道“数组的前多少个位置是有效的”,而不是把数组长度改掉。

很多 Java 新手会想用ArrayList的remove方法,因为看起来真的很方便。但如果你在ArrayList的循环里直接list.remove(i),会出现一个经典问题:删除一个元素后,后面的元素会自动往前移,导致索引变化,漏删元素。此外,remove(Object)和remove(int index)的重载选择也容易踩坑,比如list.remove(val)到底是删值还是删下标,要看val的类型。这些都会把简单题复杂化。

正统的 Java 写法就是双指针加返回值,前面已经给出。还有一个细节:for-each循环里不能修改数组结构,但可以修改数组元素值。不过在这里我们不需要删除结构,只需要覆盖,所以用普通for就行。面试时我一般直接写基础数组,这样最直观,也能顺便展示对数组长度固定这个特性的理解。

3.3 Python 版本:列表的动态性与切片陷阱

Python 的list虽然是动态数组,但它的“动态”体现在可以自由append、pop、insert。如果题目允许你原地修改,并且返回新长度,你其实可以用很多方式做,但面试中要避开几个大坑。

最不应该写的答案是:

nums = [x for x in nums if x != val]

这行代码确实达到了“过滤”的效果,但它创建了一个全新的列表,不是原地操作。如果真的要在原对象上修改,你可以这样写:

nums[:] = [x for x in nums if x != val]

这个写法通过切片赋值把新列表内容覆盖回原列表,原对象的引用没变。但它内部仍然创建了临时列表,空间复杂度不是严格的 O(1),不能算最优解。

严格的最优解是手动双指针:

def removeElement(nums, val): slow = 0 for fast in range(len(nums)): if nums[fast] != val: nums[slow] = nums[fast] slow += 1 return slow

还有一种更“Pythonic”但非最优的原地暴力法:

while val in nums: nums.remove(val)

这个方法每次remove都要从左到右扫描一次,最坏时间复杂度达到 O(n^2)。在数据量大时简直灾难。所以别看它写得短,效率是最差的。

3.4 JavaScript 版本:filter 到底是不是原地

JavaScript 的filter是最容易让人误入歧途的方法。返回值是全新数组,当然不是原地。如果你追求原地,可能第一反应是splice:

let i = 0; while (i < nums.length) { if (nums[i] === val) { nums.splice(i, 1); } else { i++; } } return nums.length;

splice是在原数组上删除连续元素,并且让后续元素自动左移。这种写法一次只能删除一个,最坏情况下splice内部的搬移成本叠加,会变成 O(n^2),同样不推荐。

正确且高效的双指针写法是:

function removeElement(nums, val) { let slow = 0; for (let fast = 0; fast < nums.length; fast++) { if (nums[fast] !== val) { nums[slow++] = nums[fast]; } } return slow; }

如果你特别希望调用方拿到的数组长度真的是“逻辑长度”,可以在最后加上nums.length = slow,把多余的部分截断掉。这同样是在原数组上操作,符合原地要求。需要注意:题目通常只要求返回新长度,不会强制截断,所以加不加这行要看题意。加上了更符合直觉,但会有“修改数组长度”的副作用,面试时最好先和面试官确认。

我见过有人用nums = nums.filter(...)后自信满满地交给面试官,结果检查函数对原数组的引用时傻眼:原数组根本没变。这个例子非常适合解释为什么“原地”是一个强约束。

4. 从经典题到工程实践

4.1 数据清洗中的“原地去重”迁移

和 Remove Element 几乎同构的一道题是“删除有序数组中的重复项”(LeetCode 26)。你看代码会发现,核心逻辑只改了一个条件:

public int removeDuplicates(int[] nums) { int slow = 0; for (int fast = 1; fast < nums.length; fast++) { if (nums[fast] != nums[slow]) { nums[++slow] = nums[fast]; } } return slow + 1; }

这里nums[fast]和nums[slow]比较,而不是和 val 比较。慢指针指向最后一个保留元素,快指针负责寻找下一个不同的元素。通过这个变体,你很容易看出“移除元素”和“去重”本质上都是“条件过滤”,只不过条件从“不等于某个固定值”变成了“不同于前一个保留值”。

我在实际数据清洗中经常遇到这类需求:从用户上传的一列设备 ID 里去掉黑名单中的 ID。黑名单可能很长,但逻辑和 val 完全相同。如果这批数据还要保持原始顺序,我会用快慢指针在原数组上做覆盖,避免反复创建新列表。如果数据量大到连原数组都放不下,那就得考虑分区处理了,但“覆盖指针”的思想依然是底层核心。

4.2 批量移除指定值:从数组到链表思想

数组的“原地移除”思想,稍微变形一下就能用到链表上。链表移除节点时,需要一个prev指针跟着当前节点走,发现当前节点值等于目标值,就让prev.next跳过它。这和数组的快慢指针异曲同工:快指针负责找块,慢指针/前驱指针负责维护“有效链”的尾部。

但数组和链表有一个关键差异:数组可以通过覆盖实现“伪删除”,链表可以用一个引用断开实现“真删除”。这也是为什么很多算法题会把数组和链表模型放在一起考,本质都是“在遍历过程中维护有效区域”。我之前维护过一个任务队列,任务状态需要从“待处理”流转到“已处理”,每天都要从数组中移除大量已完成状态的任务。当时我直接把数组重新留下需要保留的任务,不让它频繁新建对象,接口的响应时间一下就稳定了。

4.3 原地算法扩展:区间保留、缩容量、内存复用

“移除等于 val 的元素”可以扩展成更一般的问题:移除值落在某个区间的元素、保留只属于白名单的元素、把零元素移动到数组末尾(移动零)。这些问题几乎都能用两指针框架解决,只是在“什么条件下移动 slow”上做变化。

比如移动零题目的解法是先“移除”掉所有 0(方式是把非 0 元素搬到前面),然后把后面剩余位置全部填 0。这其实就是将“移除元素”反过来用:

public void moveZeroes(int[] nums) { int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != 0) { nums[slow++] = nums[fast]; } } while (slow < nums.length) { nums[slow++] = 0; } }

这种“先覆盖、后填空”的思路,在内存池管理、消息队列压缩、Cache 清理里都能见到影子。懂得这套模型后,再遇到“只保留某个区间内的元素”“同时移除多个条件值”时,你只需要把 if 条件换成多个判断或一个谓词函数,代码骨架不用变。

5. 易错点与面试实战宝典

5.1 边界条件自查清单

我每次做完这道题,都会用下面四类用例过一遍代码:

空数组:nums = [],循环根本不会进去,直接返回 0。如果函数里写错了,比如返回slow + 1,这里立刻爆炸。

所有元素都等于 val:快慢指针里 fast 每次都跳过,slow 一直停在 0,最后返回 0。首尾指针里 right 不断左移,最后 left 也变成 0,同样正确。

没有元素等于 val:快慢指针里每个元素都被搬到原位(自我赋值),返回原长度。首尾指针里 left 一路走到数组末尾,返回数组长度,也正确。

连续多个元素等于 val:比如[1,2,2,2,3],快慢指针不会因为连续跳过而漏掉后面的 3,因为 fast 一直在递增,最后会把 3 搬到 slow 位置。这个用例最能检验一个人的手稳不稳。

还有一个额外用例:val出现在数组末尾且全部相同,例如[1,1,1,1], val=1。这种数据对首尾指针特别考验,因为 left 第一次就命中,right 一直左移,可能会移穿。所以判断条件要用left <= right而不是left < right,否则会漏掉最后一个可以覆盖的位置。

5.2 指针遍历顺序:覆盖操作是否安全

很多同学写快慢指针时心里会犯嘀咕:我把nums[fast]写到nums[slow],会不会把还没读过的数据覆盖掉?答案是绝对不会,因为slow <= fast始终成立。当slow == fast时,自我赋值无伤大雅;当slow < fast时,说明slow的位置早就被处理过了——它要么是已经被搬走的旧位置,要么是正好等于 val 的废弃位置,反正不是还没读过的数据。这个不变式是理解双指针正确性的关键。

首尾指针的安全边界稍微反直觉一些。当nums[left] == val时,我们用nums[right]覆盖nums[left],然后right--。这时候right位置上的旧值就没有作用了,哪怕它等于 val 也没关系,因为后续 right 继续左移,不会再读它。唯一要注意的是,覆盖后的nums[left]可能还是等于 val(如果右指针找到的也是 val),所以下一轮循环还要再用while检查一次。这也就是为什么循环里要用while而不是if,或者用else分支保证 left 只有在非 val 时才自增。

5.3 语言相关的隐藏陷阱

C/C++:用std::remove时忘记配合erase导致size()没变;或者以为传入 const 引用就能修改数组,实际上只能用非 const 指针/引用。

Java:数组长度固定,返回的 int 不被注意,调用方继续遍历整个nums, 把尾部残留的旧值当成有效数据。我之前面试时就看到一个候选人明明写对了,但测试时用了Arrays.toString(nums)输出整个数组,发现后面还有 val,紧张半天,其实只要输出前 length 个就好。

Python:在for x in nums循环里删除元素,导致迭代器跳过元素;或者用切片赋值时没意识到临时新数组的空间开销。Python 的remove是值删除,底层是顺序查找加搬移,复杂度高,不能滥用。

JavaScript:delete nums[i]并不会让数组长度缩短,只会把元素变成empty,遍历时会出现空洞。splice虽然能删,但循环内使用会影响索引,写成for (let i = 0; i < nums.length; i++)配合splice时,删除后必须i--,否则漏删。

5.4 常见问题与避坑速查表

我整理了一张速查表,基本覆盖了这道题能踩到的所有坑:

问题现象根本原因推荐解法
返回了原数组长度,但中间还有 val只遍历没覆盖用 slow 记录有效区终点
数组顺序被打乱使用了首尾覆盖法明确需求后选择快慢指针
调用Array.filter后原数组没变filter 返回新数组改用双指针或splice
循环中splice漏删元素删除后索引没有回退用 while 结构或反向遍历
Python 列表推导式看似原地实际创建了新列表用切片赋值或双指针
用std::remove后 vector 长度没变误以为 remove 等价于 eraseremove 后调用 erase
首尾指针漏掉最后一个相等元素循环条件写成left < right改成left <= right
快慢指针自我赋值被误认为多余未理解覆盖安全性记住slow <= fast不变式

面试的时候,写完代码可以先口头跑一个[3,2,2,3], val = 3。快慢指针会输出slow = 2,前两个元素分别是 2 和 2,这样能快速自检。如果能顺手讲清楚每个边界条件的走向,面试官基本就会放心让你过。

6. 我的个人实操体会

刷这道题刷了无数遍之后,我自己沉淀出一个特别管用的心得:只要题目没说“不能改变顺序”,我就先想首尾指针;只要题目默认要求稳定顺序,我就直接写快慢指针。因为大部分业务场景里,顺序稳定性比那一点点的搬移次数重要得多,所以实际使用最多的反而是更“笨”的快慢指针。

还有一个小技巧:写代码时给指针起名不要只用 a、b、i、j,我会用slow和fast。这两个名字会把意图直接写进代码里:一个是负责“写入有效区”的竹竿,一个是负责“探路”的箭头。阅读代码的人看到名字,不用猜就知道它在做什么。这种变量命名习惯,在项目多人协作时特别能减少沟通成本。

最后,如果你刚接触这类题目,建议把 Remove Element、Remove Duplicates from Sorted Array 和 Move Zeroes 三题连在一起刷。它们的解法高度一致,区别只在于条件判断,一次学会三题,双指针这个模型才算真正长在自己脑子里了。等这三题都闭着眼能写出来,再去看“原地哈希”“原地矩阵旋转”这些进阶题,你会发现底层逻辑还是同样那套覆盖与交换的思维。算法这行,万变不离其宗。

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

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

立即咨询