
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 就有意思了。如果你用的是vectorint标准库提供了一个叫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是最容易让人误入歧途的方法。返回值是全新数组当然不是原地。如果你追求原地可能第一反应是splicelet 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], val1。这种数据对首尾指针特别考验因为 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是值删除底层是顺序查找加搬移复杂度高不能滥用。JavaScriptdelete 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 三题连在一起刷。它们的解法高度一致区别只在于条件判断一次学会三题双指针这个模型才算真正长在自己脑子里了。等这三题都闭着眼能写出来再去看“原地哈希”“原地矩阵旋转”这些进阶题你会发现底层逻辑还是同样那套覆盖与交换的思维。算法这行万变不离其宗。