数组原地移除元素:从双指针原理到多语言实战解析
1. 问题本质先搞懂“原地”与“移除”到底在说什么数组的“原地移除元素”这个问题刷过题的朋友一眼就能认出LeetCode 27题“移除元素”是经典入门题。但我更想说的是这不仅仅是刷题它背后是C语言开发中每天都在用的基本功。数组不像链表它的物理内存是连续分布的所以“移除”一个元素本质不是把这个元素“抹掉”而是把后面的元素整体往前挪一位把那个位置覆盖掉。数组的长度是固定的这个结构决定了你没法真正“删除”一个格子你能做的只有覆盖。这里要先区分两个概念逻辑长度和容量。比如你声明了 int arr[10]那你这个数组的容量是10但逻辑上有效的数据可能只有5个。原地移除元素的操作改变的是逻辑长度而不是物理容量。这个理解很关键因为很多新手拿着数组去“删除”把数组后面那些脏数据也一并认为是有意义的最后遍历的时候就懵了。再说“原地”。原地in-place严格来说要求空间复杂度为O(1)也就是除了输入数组本身最多用几个临时变量不允许再开一个新数组来辅助。为什么要强调这个因为在实际开发里数组可能非常大。我之前做过一个嵌入式图像处理项目一张1080p的灰度图就是两百万个字节你要是每处理一步就开一个新数组内存瞬间爆掉。所以原地操作不是炫技是被现实逼出来的。还有个细节值得注意“移除元素”并不意味着数组后面的元素被清成0或者NULL它们还在原来的内存位置上只是逻辑上不再属于这个数组了。所以用双指针法移除元素之后你会看到数组后面残留着旧值这是正常的。如果你好奇为什么官方题解里最后返回的length是新的逻辑长度遍历只看前length个元素这就是原因。铺垫这么多其实就是想说明理解数组的物理本质比背解法重要得多。这个问题的所有高效解法都建立在一个核心思路上——用覆盖代替删除用指针记录位置。这个思路想通了后面所有变体题你都能自己推出来。2. 双指针法核心思路与为什么它是最优解2.1 快慢指针的“错位追赶”模型先给出最标准、最推崇的解法快慢指针。代码很短但背后的模型可以讲得很深。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]; slow; } } return slow; }这个解法的时间复杂度O(n)空间复杂度O(1)一次遍历搞定。很多资料会把这个过程叫“快慢指针”但我觉得更准确地说这是一个“错位追赶”模型。fast指针永远走在前面去“探路”负责判断当前元素要不要slow指针慢悠悠地跟在后面但只在遇到“保留”元素时才往后挪一步。于是两个指针之间逐渐拉开差距这个差距的大小恰恰就是已经被“移除”掉的元素个数。我打个比方你就懂了。想象一个班级要筛选出参加比赛的学生老师在走廊里一个一个目测fast看到合格的就让学生进教室站好往slow位置放不合格的就直接走过去学生原地站着但没人登记。最后教室里的队伍就是筛选后的结果队伍的长度就是slow停下的位置。那些不合格的学生还站在走廊里但名单上已经没有他们了。这个模型的精妙之处在于它不需要额外的标记数组、不需要临时数组、不需要先计数再处理只用一个for循环连嵌套都没有。我可以很负责地说这是这个问题的所有解法里最接近“最优”的一个。2.2 为什么不能边遍历边删除我见到过太多新手第一反应“我直接遍历遇到val就删不就行了”在Python里你可能写 list.remove(val)在C里用 vector.erase()看起来干净利落。但问题在于数组删除操作的复杂度不是O(1)的。你要删除下标i的元素需要把从i1到末尾所有元素都往前挪一位单次删除就是O(n)的代价。删除时下标会错乱。假设数组是 [1, 2, 3, 2, 4]你要删2。你遍历到下标1发现是2删掉数组变成 [1, 3, 2, 4]原本下标2的3变成了下标1但你下一次循环访问的下标是2就把元素2原来的下标3漏掉了。这是遍历时修改容器的经典陷阱几乎所有语言里都有这个问题。所以边遍历边删除看起来“直观”实际是时间复杂度和正确性双重翻车。双指针的高明之处就是绕开了这两个坑用覆盖绕开删除的搬家成本用快慢指针的错位绕开下标错乱问题。2.3 代码里的细节返回值和逻辑长度的关系你光会写这个函数还不够你得知道函数返回的slow是什么。它是新数组的逻辑长度不是物理长度。调用方拿到这个返回值后只能遍历 [0, slow) 这个区间。比如面试官会追问“你移除完元素之后数组最后那几个位置是什么”答案是残留的旧值可能等于val也可能不等于但这已经不重要了因为逻辑上它们已经不属于这个数组了。这个细节在实际工程里很重要。你在C语言里做完原地操作后如果习惯性地接着用 sizeof(nums)/sizeof(nums[0]) 来计算长度拿到的还是物理长度那就废了。一定要记着原地移除之后数组的“有效部分”是函数返回的那个值。换句话说调用方和函数之间需要通过返回值来传递“新长度”这个信息而不是自己重新算。3. 变体与进阶都原地、都高效、都一个套路3.1 删除有序数组中的重复项LeetCode 26题“删除有序数组中的重复项”标准解法也是双指针而且思路几乎一模一样。区别只在于判断条件从“不等于val”变成了“不等于前一个已保留元素”。int removeDuplicates(int* nums, int numsSize) { if (numsSize 0) return 0; int slow 1; for (int fast 1; fast numsSize; fast) { if (nums[fast] ! nums[slow - 1]) { nums[slow] nums[fast]; slow; } } return slow; }注意这里初始化 slow 1因为第一个元素一定保留从第二个位置开始比较。fast从1开始每次和“当前已保留序列的最后一个元素”nums[slow-1]比较不相等才保留。这个题能让你更深刻地理解slow指针的语义它不只是下标它代表了“已确认保留的序列长度”所以 nums[slow-1] 是最后一个保留值。这道题和“移除元素”放在一起看就是双指针的两个经典变式一个用固定目标值过滤一个用相邻元素比较去重。但核心模型没变还是快慢指针错位追赶。3.2 原地移除多个指定值先聚合再覆盖实战中你可能会遇到“一次性移除多个指定值”的需求比如删掉数组中所有等于2或3的元素。你当然可以循环调用两次单值双指针但这样要遍历两遍。更好的方式是“先聚合条件再统一覆盖”int removeElements(int* nums, int numsSize, int* targets, int tSize) { int slow 0; for (int fast 0; fast numsSize; fast) { int shouldRemove 0; for (int t 0; t tSize; t) { if (nums[fast] targets[t]) { shouldRemove 1; break; } } if (!shouldRemove) { nums[slow] nums[fast]; slow; } } return slow; }这个版本时间复杂度是O(n*m)m是待删除值的个数空间复杂度还是O(1)。如果m很小比如2个、3个这个方案的性能依然很优秀。如果m很大你可以把targets用哈希表或者排序优化判断但“快慢指针覆盖”的主干完全不变。这种“条件可扩展”的双指针写法在实际工作里比写死单个val要实用得多。3.3 移除后保持相对顺序 vs 允许乱序还有一个经常被面试官追问的变体如果不要求保持相对顺序能不能更高效能。思路是用“首尾交换”代替“逐个覆盖”。比如从右边拉一个元素来填充被删掉的位置这样每个被删空位都可以用一次交换填补最多遍历一遍。int removeElementUnordered(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; }这种写法最坏情况下也是O(n)但它改变了数组的顺序。比如 [1, 2, 3, 2, 4]移除2这个算法可能得到 [1, 4, 3]顺序跟原来不一样了。那什么时候用这个当你对顺序没有要求但非常在意写入次数的时候。在嵌入式、外部Flash存储这类场景里写入寿命是有限的减少覆盖次数比保持顺序更有价值。所以没有银弹选择哪种取决于你的约束条件。这些变体联系到一起看你就明白了双指针原地操作是一套“思维模型”不是死记某道题的答案。抽象出来就是那三步快指针探路、条件判断、慢指针覆盖。条件换成“不等于val”“不同于前一个”“不属于targets集合”都行。这才是刷题和工程应用之间真正有价值的那座桥。4. 工程视角不同语言里的“原地移除”到底该怎么写4.1 C语言没有容器全靠手动搬C语言没有封装好的删除操作一切都得手动来。这也是为什么前面我一直在用C语言写核心代码。在实际项目里C语言的“移除”操作往往还伴随着一个需求需要返回新的长度同时调用方要知道哪些数据被删了。一个常见做法是同时输出一个“删除标记数组”但这会破坏O(1)空间所以大部分情况下直接覆盖返回长度就够了。我再提一种C语言里经常被忽略的编码细节函数参数里的指针和数组名是等价的。写 int* nums 和 int nums[] 在参数声明里没有本质区别所以千万别在函数里对数组名用 sizeof 求长度那是指针的大小8字节不是数组的大小。正确做法是传入 numsSize 参数。4.2 JavaScriptsplice 的坑与 filter 的误区前端同学看到这个题可能第一反应是 JS 里我有 splice。没错splice 是原地修改数组的方法但很多人误以为 splice 很高效。实际上 splice 删除一个元素的时间复杂度是O(n)因为后续元素要往前搬。如果在一个循环里用 splice 删除匹配项总时间可能变成O(n²)。我用一个简单的例子说明这个坑const arr [1, 2, 3, 2, 4]; for (let i 0; i arr.length; i) { if (arr[i] 2) { arr.splice(i, 1); i--; // 如果不处理下标就会漏删 } }正确处理是删除后 i-- 补偿下标偏移或者倒着遍历。但就算你处理对了下标性能依然不好看。array.splice 内部是 memmove 级别的批量移动单次还行循环里反复调用就伤了。那 filter 呢filter 的返回值是一个新数组完全不满足“原地”要求。你再喜欢函数式写法在这道题面前也必须老实写循环。function removeElement(nums, val) { let slow 0; for (let fast 0; fast nums.length; fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; slow; } } nums.length slow; // 关键截断数组 return nums.length; }注意最后那一行 nums.length slow这直接把物理长度截断这样调用方再遍历整个数组也不会碰到残留脏数据。这个细节是很多前端开发者容易漏掉的。JS 里给 length 赋值可以原地截断数组等效于“物理删除”这是它在语言层面比C语言多出来的便利。4.3 Pythondel 与切片赋值但要小心迭代陷阱Python 里原地删除可以直接 del nums[i] 或者 nums[:] [x for x in nums if x ! val]这个实际是新列表赋值但赋给了原切片算是原地改内容。但最经典的高效写法还是双指针在Python里反而有点“入乡随俗”的意思def remove_element(nums, val): slow 0 for fast in range(len(nums)): if nums[fast] ! val: nums[slow] nums[fast] slow 1 # 截断后半截 del nums[slow:] return slowPython 的 del nums[slow:] 这个截断操作很常用但注意 for fast in range(len(nums)) 里 len(nums) 是在进入循环前就计算好的不会因为你在循环体里操作 nums 而动态变化这在原地修改时反而是个安全保证。比起 while 循环手动管理下标这种写法相对不容易出bug。不过 Python 里如果你真要追求极致的简洁也可以直接用列表推导式生成新列表但那就不是“原地”了。面试官要是要求空间O(1)列表推导式直接出局。4.4 Cvector::erase 的 remove-erase 惯用法C 开发者有一个非常地道的“原地删除”惯用法叫 erase-remove idiom。它的代码极短#include algorithm #include vector std::vectorint v {1, 2, 3, 2, 4}; v.erase(std::remove(v.begin(), v.end(), 2), v.end());很多人第一次看到这行代码觉得很魔幻但其实拆开看就清晰了std::remove 并不真的删除元素它做的事情就是把不需要的元素“挪”到容器末尾并返回一个迭代器指向新的逻辑结尾。然后 v.erase 再把这个迭代器到 end() 之间的元素真正销毁配合容器的析构释放内存。这一步拆开看std::remove 本质上就是双指针法。标准库的算法和你手写的双指针是同一套逻辑而 erase 负责的是容器层面的清理。我用一个生活例子解释 erase-removeremove 相当于搬家时把不要的家具堆到客厅角落erase 相当于最后把那堆垃圾清出房子。两步都做才叫彻底清理。5. 实操现场处理“一列数中确定哪些数据和等于固定值”这类热搜需求5.1 需求场景与暴力思路的复杂度在整理这篇博文的过程中我特意去看了一下热搜里那条“一列数已知固定数值如何确定数组中的哪些数据和等于固定值”。这个需求和“原地移除”看起来不相关但实际在做数据处理时经常连着出现——比如你从Excel导入一列数字需要把其中几个数组合起来凑成一个目标金额这在财务对账里非常常见。最朴素的暴力解法枚举所有子集算和和目标值比较。n个元素的数组有2^n个子集n20时已经是一百万量级n30时十亿量级基本不可行。工程上真正能落地的是“双向枚举”或者“前缀和哈希”的思路。5.2 双向枚举Meet in the Middle的落地思路把数组分成两半各枚举出所有子集和存成两个列表。然后对其中一个排序遍历另一个用二分查找在排好序的列表里找目标补数。时间复杂度从2^n降到约2^(n/2) * log(2^(n/2))空间大约2^(n/2)。n40的数据量暴力直接算不动双向枚举可以秒出。这在算法竞赛里是标准套路在财务对账里也完全适用。我把这段Python代码写出来供你直接抄from itertools import combinations def find_subset_sum(nums, target): nums.sort() n len(nums) left nums[:n // 2] right nums[n // 2:] def gen_sums(arr): res [] for mask in range(1 len(arr)): s 0 for i in range(len(arr)): if mask (1 i): s arr[i] res.append((s, mask)) return res left_sums gen_sums(left) right_sums gen_sums(right) right_map {} for s, mask in right_sums: right_map[s] mask # 有重复时可取mask更小的 for s, mask in left_sums: need target - s if need in right_map: r_mask right_map[need] res [] for i in range(len(left)): if mask (1 i): res.append(left[i]) for i in range(len(right)): if r_mask (1 i): res.append(right[i]) return res return None # 示例在 [3, 1, 4, 2, 2, 9, 5, 6] 中找和为 10 的元素组合 nums [3, 1, 4, 2, 2, 9, 5, 6] print(find_subset_sum(nums, 10))这个实现有几个值得注意的工程细节。第一先排序可以让结果更好读但不是算法必需。第二右半边的和用字典存key是和value是掩码如果多个组合得到同一个和只留一个也能解题如果不放心可以留一个列表存多个组合。第三如果只需要“能否凑出”其实可以省掉掩码记录只存布尔值代码会更简。我这份代码是“返回具体组合”的版本实用性更强。5.3 连续子数组求和问题前缀和哈希另一个高频变式是“找连续的一段数组和等于固定值”。这个场景在Excel取数、股票收益统计里很常见。暴力两重循环是O(n²)但用前缀和配合哈希表可以做到O(n)。原理简单前缀和数组 prefix[i] 表示前 i 个元素之和那么从 j 到 i 的子数组和就是 prefix[i] - prefix[j-1]。要找等于target的连续段就是找是否存在 prefix[i] - target 等于之前的某个 prefix[j-1]。def find_continuous_subarray(nums, target): prefix 0 seen {0: -1} # 前缀和 - 下标 for i, x in enumerate(nums): prefix x if prefix - target in seen: start seen[prefix - target] 1 return nums[start:i 1] seen[prefix] i return None这个实现的精妙在于它只用了一个变量 prefix 滚动更新不需要额外的前缀和数组。哈希表记录的是每个前缀和最早出现的下标。如果遍历到某个位置 i发现 prefix-target 之前出现过说明从那个位置的下一个到当前位置的和就是 target。我第一次看到这个解法的感觉是“还能这样”后来才意识到前缀和本质上就是把区间求和问题转化成了“两数之差”的问题套路和“两数之和”完全一脉相承。5.4 从“找数据”到“移除数据”的工作流回到这篇博文的主线。很多热搜词的背后其实是一个完整的工作流你有一列数据Excel、数组、json数组先要筛选出匹配某个条件的数据比如凑够目标金额然后把用过的数据从原数组里“移除”。先定位再删除每一步都有对应的算法需求阶段核心算法时间复杂度空间复杂度找子集和等于固定值双向枚举 二分/哈希O(2^(n/2) log)O(2^(n/2))找连续子数组等于固定值前缀和 哈希O(n)O(n)找到之后移除双指针原地覆盖O(n)O(1)你看把真实需求拆成阶段之后每一段都是经典的、有现成解法的算法问题。这也是我想通过这篇博文传达的核心思维不要试图用一个技巧解决整个业务问题而是先拆解流程让每个环节用最合适的算法。6. 常见问题深度剖析为什么“按顺序删除”总是翻车6.1 删除后下标错位我在实际项目中踩过的坑我做数据清洗时遇到过一次典型的坑。当时从传感器读回一串温度数值偶尔会出现几个异常值比如大于100度的跳变我写了个for循环遇到异常就从数组里删掉。结果删完之后发现某些正常值莫名其妙地没了。排查了很久才意识到——删除一个元素后后面的元素集体前移但我循环里的下标还在继续自增等于跳过了紧跟在删除位置后面的那个元素。这个问题的本质是遍历和删除是两种有冲突的操作模式。遍历需要下标连续、每个位置只看一次删除则会导致后续元素位置变化破坏下标的连续性。双指针的“快慢指针”就是针对这个矛盾设计的快速指针负责遍历慢速指针负责写覆盖两者互不干扰。很多新手看不懂双指针的代码其实就是没理解它是在“两套坐标”里工作。6.2 数组移动开销被低估删除不等于“擦掉”还有一种翻车是性能上的。有人觉得删除元素不就是把那一位清空吗清空之后数组长度减一不就行了但我们说过数组是连续内存清空一个格子后面的元素并不会自动往前挪程序访问时那个位置就是一个洞。正确做法必须整体搬运。所以删除一个元素意味着O(n)的数据移动。如果你在循环里删多个元素最坏情况下每次删除都要O(n)移动总代价O(n²)。这种性能劣化在小数组上感觉不到但数据量到十万、百万级直接卡到你怀疑人生。我之前在处理一批日志数据时就吃过这个亏那时候才真正理解为什么标准库里全是“标记-清扫”这种两阶段设计先标记哪些要删最后一口气清理而不是边遍历边处理。6.3 边界条件空数组、全删、无一删除这些边界情况是面试里最喜欢挖的陷阱。用双指针法空数组时 fast 循环根本不进来slow 返回0代码自然安全。全删时 fast 一直走但 slow 不动返回0也安全。无一删除时 fast 和 slow 同步前进数组原样返回也安全。这套解法最厉害的地方就是天然免疫边界条件你几乎不需要加任何特殊判断。但有几个变体会在这些边界上翻车。比如“首尾交换”写法如果 left 和 right 相遇时处理不当可能会把同一个元素交换两次导致结果错误。还有一种极端场景数组里全是同一个值而且等于待删除值此时 slow 始终为0那么最终返回0这没问题但如果调用方拿到0后仍然用旧长度去遍历就会读到一堆残留数据。所以函数返回值的语义必须和调用方达成共识——这是所有 API 设计的通用教训不只是这道题。6.4 内存碎片与容量为什么“原地”对性能影响很大最后说一个容易被忽略但工程上非常重要的点原地操作对内存分配器的压力远小于非原地操作。如果你每处理一次就 new 一个新数组那意味着一次分配、一次复制、一次释放。数据量大时这个来回搬运加上内存碎片化的影响足以让程序跑慢几个数量级。尤其是嵌入式、实时系统这种对延迟敏感的环境原地操作基本是唯一选择。我之前做过一个相机图像处理的模块图像帧就是一整块 buffer每帧都要去掉某些无效像素。一开始我用拷贝的方式帧率掉了将近三分之一。改成原地双指针覆盖后帧率立刻回去了。这让我深刻体会到算法书上的“空间O(1)”不是抽象概念它直接对应到嵌入式设备里的堆栈压力、缓存命中率和程序的实时性。现在我看到“原地”两个字的题目第一时间想到的不是“省内存”而是“减少分配器压力、减少拷贝次数、避免缓存失效”。7. 实用心得我把这套思路迁移到哪些场景7.1 Excel/VBA数组处理VBA数组原地去重的实战方案热搜词里“vba数组对比最快”“vba数组”出现频率很高说明Excel重度用户也有类似需求。VBA数组本质上就是内存数组处理方式和C语言几乎一样。我自己写过一个VBA宏用来从Excel里删除某一列中的所有重复项并保留首次出现顺序核心就是双指针思路Sub RemoveDup(arr) Dim i As Long, slow As Long, j As Long Dim exists As Boolean slow 0 For i LBound(arr) To UBound(arr) exists False For j LBound(arr) To slow - 1 If arr(j) arr(i) Then exists True Exit For End If Next j If Not exists Then arr(slow) arr(i) slow slow 1 End If Next i slow 就是去重后的有效长度 End Sub这个写法在VBA里属于直觉型解法因为VBA的数组操作本来就不算高效但胜在逻辑清晰。如果要追求更大的数据量建议在VBA里调用Dictionary对象详见“对象数组去重”相关话题会让“是否存在”的判断从O(n)降到O(1)。但双指针的骨架没变慢指针存数据快指针探索判断条件换成“是否已存在”。7.2 前端表格/数据网格对象数组按条件批量移除热搜里还有一条“es6提取数组对象一部分”这在前端表格操作里很常见。如果你想原地移除数组中所有满足某个条件的对象ES6的写法其实可以很优雅但这就不算是严格O(1)了。我个人的经验是前端开发中数据量在几千条级别以内直接用一个splice循环或者 reduce 生成新数组都行完全没必要抠O(1)空间。但当渲染层的数据达到上万条甚至你是在维护一个实时更新的数据网格能原地修改数组避免触发整个列表的重新渲染这个价值是实实在在的。具体做法就是快慢指针的JS版本最后再用 length 截断数组一举两得。7.3 图像像素处理把“移除”理解为“压缩”另一个我做得比较多的领域是图像处理。比如需要把一张扫描图像里的红色参考线去掉。所有像素逐个判断如果是红色就跳过否则写入输出位置整个过程可以做到单buffer原地完成。这里比起严格意义的“数组移除”更像是“数据压缩”——把符合条件的像素“挤”到前面。这个思想在做时间序列数据清洗时也适用剔除坏点、补零、坑填都可以用同一个双指针模板。所以你会发现“原地移除”这个问题的解法本质上是一套“数据压缩”思维。在内存受限、性能敏感的一切真实开发场景里这套思维都能复用。8. 最后的体会我把这片博文的主干线捋完心里最大的感受是数组的原地移除看起来一分钟能讲完但真正吃透它需要理解内存布局、理解遍历与删除的冲突、理解不同语言容器操作的底层代价、理解业务场景对空间和顺序的取舍。这一连串问题串起来才构成一个完整的工程师视角。我自己在实际面试候选人的时候也很喜欢从这道题切入因为它能一次性考察代码能力、边界条件和复杂度分析。但相比让候选人默写双指针我更看重的是他能不能讲清楚“为什么不能边遍历边删”“返回值的语义是什么”“什么时候用首尾交换更合适”。能把这些讲明白的人通常工程底子也不会差。如果你正在准备面试或者工作中刚好遇到数组移除、去重、按条件过滤这类需求我建议你把这篇博文里提到的所有变体代码都手写一遍同时问自己三个问题这个解法的时间复杂度是多少空间复杂度是多少如果调用方拿到的数组有残留值我有没有说清楚问完这三个问题你对“原地操作”这四个字就有了自己的理解。剩下的就是在项目里多踩几次坑多回头看几眼当初的代码慢慢就成了肌肉记忆。