news 2026/10/16 11:21:10

数组原地操作与双指针技巧:从移动零到移除元素的算法模板解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
数组原地操作与双指针技巧:从移动零到移除元素的算法模板解析

最近我在整理自己的刷题清单,算法系列的第二篇,我选了数组原地操作这个方向。这个专题里最典型的组合,就是我自己清单里编号238的移动零,和编号27的移除元素。这两道题初看都很简单,移动零是把数组里的0都挪到末尾,移除元素是把等于某个值的元素都丢掉,但真正上手之后你会发现,它们其实是同一个双指针套路换了两副面孔。我第一次刷移动零的时候,第一反应是新建一个数组,把非零元素先放进去再补零,结果一看题目要求:原地修改,不能复制数组。瞬间被打回原形。后来我把移除元素同时拿出来对比做,才慢慢想明白这类题应该怎么下手。这篇就把我从暴力、到双指针、再扩散到其他变体的完整过程拆开讲讲,适合刚开始接触双指针的读者,也适合已经刷过这两题但想彻底吃透模板的人。

1. 这两道题为什么值得放在一起刷

1.1 一道题的两种马甲

移动零的题面:给定一个数组nums,把所有的0移动到数组末尾,同时保持非零元素的相对顺序,原地修改。移除元素的题面:给定一个数组nums和一个值val,原地移除所有等于val的元素,返回移除后数组的新长度。

一眼看过去,一个在挪0,一个在删val,好像没什么关系。但你把这两道题的要求拆开,会发现骨骼几乎一样:都要把"不符合条件的元素"处理掉,都要让"符合条件的元素"保持原来的先后顺序,都限制"不能使用额外数组空间"。移动零里的"0"就是移除元素里的"val",只不过移动零在删完0之后,还要求在数组尾部用0把空缺补齐,移除元素则不用管最后几位残留什么。这就像同一个人穿了两件马甲,一件叫"移动零",一件叫"移除元素"。

我做这类题的习惯,是先从生活场景里找一个等价动作。想象你在整理书架,要把所有漫画书抽出来放到最右边一排,其他书顺序不能变。你不能把整排书全部推倒重来,只能一本一本看过去,看到的非漫画书就往左边挪,挪出来的空位最后用来放漫画。这个过程几乎就是移动零的代码逻辑。有了画面感之后,再看双指针的移动规则,基本就不晕了。

1.2 双指针到底在解决什么本质问题

很多人刚开始会问:为什么不能用一个指针搞定?我们推演一下单指针的方案。用for循环从头到尾扫描,遇到0就把后面的所有元素整体往前移动一格,再在数组末尾补一个0。这个思路不能算错,问题在于一次移动就是O(n),如果数组里有k个0,总共要O(k*n),当n到10万甚至百万级别时基本跑不动。

单指针的真正痛点,在于它把"扫描"和"写入"这两个职责混在一起了。你一边要判断当前元素要不要保留,一边还要决定把它放到哪个位置,这些信息全压在同一个下标上,代码很快就绕晕。

双指针的核心解耦思路是这样:慢指针slow专门记录"下一个可以写入的位置",快指针fast专门负责"从前往后扫描每个元素"。fast看到符合条件的元素,就告诉slow"这个要留下",slow写入一个元素后自动后移一格。fast看到不符合条件的元素,直接跳过,slow原地不动。两个指针各管一件事,整个数组只需要扫描一遍,时间复杂度从O(n²)降到O(n)。空间上因为没有额外数组,只是在原数组上做交换或覆盖,所以空间复杂度是O(1)。

写这类原地操作题,有一个判断标准:只要题面出现"原地""不使用额外数组""保持相对顺序"这组关键词,直接往快慢指针上想,大概率不会错。

1.3 暴力解法的效率瓶颈在哪

接下来把两种暴力方案拿出来晒一晒,你就知道为什么非用双指针不可。

移动零的暴力方案,很多人的第一版代码是这样:从前往后扫描,碰到0就把它后面的所有元素往前覆盖一格,然后数组末尾补一个0。这个方案有隐性问题。比如数组是[0,1,0,3,12],第一次碰到下标0的0,需要把后面四个元素全往前挪,变成[1,0,3,12,0];继续扫到下标1,发现是0,又把后面的三个元素挪一遍,变成[1,3,12,0,0]。每个0都触发一次整段搬移,零越多越慢。

移除元素的暴力方案更隐蔽,因为它容易让人依赖语言自带的删除接口。在C++里用erase逐个删除等于val的元素,每次删除都会把后面的元素全部往前移,而且for循环里一边遍历一边erase,很容易出现跳过相邻相同元素的情况。Python里用list.remove逐个删值,同样逃不掉O(n²)的搬移成本。我也见过有人用筛选生成新数组再拷贝回去,结果一提交就报"没有原地修改"。

看完这几种写法,你会意识到,暴力解法的问题不仅是慢,更危险的是错得悄无声息。逻辑看着对,数据一大就超时,或者边界一特殊就跳过元素。所以我建议这类题干脆别走暴力路线,直接练双指针模板,一步到位。

2. 移动零的手把手拆解

2.1 快慢双指针的移动规则与手推过程

先把规则完整列出来,后面所有代码都围绕这几句话转:

  • slow从0开始,含义是"当前已经整理出的非零序列的下一个写入位置"。
  • fast从0到n-1扫描整个数组。
  • 当nums[fast]不等于0时,把它写入nums[slow],随后slow加1。
  • 当nums[fast]等于0时,什么都不用做,fast直接前进。
  • fast扫描结束后,从slow到n-1这些位置统一填0。

这里有两点必须解释清楚。第一,为什么非零元素的相对顺序不会乱?因为fast严格从左往右扫描,遇到非零就按顺序放到前面的位置,所有非零元素被写入的顺序,就是它们在原数组里出现的顺序。第二,为什么最后还要补零?因为在覆盖过程中只是把非零元素往前搬,数组长度没变,后面的位置还残留着之前的值,必须显式置0才能得到正确结果。如果你用的是后面会讲的交换法,零已经被交换到后面,就不需要再补。

拿[0,1,0,3,12]完整手推一遍覆盖法的过程,初始slow=0。

fast=0,nums[0]=0,跳过。 fast=1,nums[1]=1,写入nums[0],数组变成[1,1,0,3,12],slow=1。 fast=2,nums[2]=0,跳过。 fast=3,nums[3]=3,写入nums[1],数组变成[1,3,0,3,12],slow=2。 fast=4,nums[4]=12,写入nums[2],数组变成[1,3,12,3,12],slow=3。 最后补零:nums[3]=0,nums[4]=0,得到[1,3,12,0,0]。

注意中间过程里数组出现了重复元素,这是覆盖法的正常现象,别慌,最后一步统一补零后就是正确答案。这是很多新手在纸上跑的时候会怀疑"数组怎么变成这样了"的地方。

2.2 交换法:代码更短的另一种写法

交换法的思路是:快指针遇到非零元素,就与nums[slow]交换,slow前进;遇到0就跳过。实现起来比覆盖法更简洁,因为它不需要最后再补一轮零。

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

C++版本同样简洁:

void moveZeroes(vector<int>& nums) { int slow = 0; for (int fast = 0; fast < nums.size(); fast++) { if (nums[fast] != 0) { swap(nums[slow], nums[fast]); slow++; } } }

再看一个例子,数组是[1,0,2,0,3]。fast=0时nums[0]=1,与nums[0]自己交换,slow=1;fast=1看到0跳过;fast=2看到2,与nums[1]交换,数组变成[1,2,0,0,3],slow=2;fast=3看到0跳过;fast=4看到3,与nums[2]交换,变成[1,2,3,0,0]。零在这个过程中被自动"挤"到了后面。

两种写法的时间复杂度都是O(n),空间复杂度都是O(1),但底层操作次数不太一样。覆盖法每个非零元素写一次,最后还要补k次零;交换法每次交换在C++里是三次移动。我的建议是面试手写优先用交换法,代码最短、最不容易漏步骤。

维度覆盖法交换法
尾部处理最后需要统一补零零自动换到后面
每个元素的写次数非零元素1次,补零k次非零元素约3次移动
代码长度稍长很短
出错风险容易忘补零低
我个人的选择场景需要精确控制写操作时面试手写、日常刷题

2.3 边界条件与实测表现

边界条件往往是这类题真正拉开差距的地方。我把常见的几类用例整理出来,你可以直接当成自测清单:

  • 空数组:fast循环不进入,slow=0,函数正常返回。
  • 全零数组[0,0,0]:fast扫描完slow=0,交换法里所有元素都和自己交换,结果不变,正确。
  • 全非零数组[1,2,3]:fast和slow同步走,每次都是同一个下标交换,结果不变,正确,但会产生n次无意义交换。
  • 零在开头[0,0,1,2]:fast扫到1时slow=0,交换后1到最前,零往后走,正确。
  • 零在结尾[1,2,0,0]:fast扫描完slow=2,后面本来就是0,正确。

全非零数组这个case可以做一个很小优化:只有当fast和slow指向不同下标时才交换。写成if (nums[fast] != 0 && fast != slow),能省掉一批自我交换。不过不加也不影响正确性,面试时提到这个优化点反而是加分项。

实测下来,n=10万的随机数组,交换法和覆盖法耗时都在几毫秒级别,差距很小。真正让我推荐交换法的原因是它不需要额外补零步骤,逻辑上少了一个"收尾动作",提交到在线判题平台后不容易因为忘了补零而翻车。当然,如果你在一个对写操作次数有严格要求的场景里,覆盖法可能更优,这个在第五部分展开说。

3. 移除元素的完整拆解

3.1 保留顺序的快慢指针版本

移除元素这个题,题目要求返回新数组长度,同时原数组的前length个位置需要是保留元素。默认情况下要求保持相对顺序,所以第一反应应该是快慢指针。

思路和移动零几乎一样:slow指向下一个保留位置的写入下标,fast从头扫描。当nums[fast]不等于val时,把这个元素复制到nums[slow],slow加1;等于val时直接跳过。fast扫描结束后,slow的值就是新数组长度。

这里有一个和移动零不一样的点:移动零用了交换,移除元素用覆盖就够了。因为删除操作不要求保留被删元素的位置,直接把后面的保留元素往前覆盖,尾部残留的旧值没人关心,反正返回的长度只是slow。

def remove_element(nums, val): slow = 0 for fast in range(len(nums)): if nums[fast] != val: nums[slow] = nums[fast] slow += 1 return slow
int removeElement(vector<int>& nums, int val) { int slow = 0; for (int fast = 0; fast < nums.size(); fast++) { if (nums[fast] != val) { nums[slow] = nums[fast]; slow++; } } return slow; }

拿[0,1,2,2,3,0,4,2],val=2走一遍。fast=0时0不等于2,写入nums[0],slow=1;fast=1时1不等于2,写入nums[1],slow=2;fast=2和3都是2,跳过;fast=4时3不等于2,写入nums[2],slow=3;fast=5时0不等于2,写入nums[3],slow=4;fast=6时4不等于2,写入nums[4],slow=5;fast=7是2,跳过。最终slow=5,数组前5位是[0,1,3,0,4],返回5。

注意:这个版本的数组末尾会残留[4,2]这些旧值,这完全没问题。判题系统只检查"返回长度之前的部分是不是正确",后面的残留不影响结果。

3.2 允许乱序的相向指针版本

有些变体题目会明确说"元素顺序可以改变"。一旦顺序不做要求,就有更省写操作的方案:左右指针相向夹逼。

思路是这样:left从0开始,right从n-1开始。如果nums[left]等于val,就把nums[right]的值搬过来覆盖掉这个位置,然后right减1,但left不动。为什么left不动?因为从右边搬过来的元素有可能也是val,需要留到下一轮再检查一次。如果nums[left]不等于val,left加1,继续看下一个位置。循环条件是left <= right,循环结束时left就是新数组长度。

def remove_element(nums, val): left, right = 0, len(nums) - 1 while left <= right: if nums[left] == val: nums[left] = nums[right] right -= 1 else: left += 1 return left
int removeElement(vector<int>& nums, int val) { int left = 0, right = nums.size() - 1; while (left <= right) { if (nums[left] == val) { nums[left] = nums[right]; right--; } else { left++; } } return left; }

举个例子,[3,2,2,3],val=3。初始left=0,right=3。nums[0]等于3,把nums[3]的值3搬过来,数组还是[3,2,2,3],right=2,left仍为0。nums[0]还是3,再把nums[2]的值2搬过来,数组变成[2,2,2,3],right=1。nums[0]=2不等于3,left=1。nums[1]=2不等于3,left=2。left>right,循环结束,返回2。数组前两位是[2,2],正确。

这个版本的优势在于,当val出现的频率很高时,它能把尾部的"好元素"直接搬过来,每个被删除位置只搬一次,而快慢指针版本要把每一个保留元素都往前移动。两种版本怎么选,核心就看题目允不允许改变顺序。

维度快慢指针版本相向指针版本
相对顺序保持不保持
写操作次数每个保留元素写一次每个被删位置搬一次
代码风险低中等,注意换回来的元素
适用场景题目要求保持顺序题目明确允许乱序

3.3 统一模板:把两道题塞进一个函数

把移动零看作移除元素的一个特例:先调用removeElement把0全部移除,得到新长度length,然后把length之后的位置全部补0。也就是"移除0 + 尾部补零"。

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

这个视角非常重要。它意味着你不需要分别记两套解法,只需要把一个模板吃透,剩下都是微调。面试的时候如果你能讲出这一层关系,面试官通常会认为你确实理解了本质,而不是背了两道题。

4. 实操中的常见问题与避坑指南

4.1 指针边界越界与循环条件

快慢指针最容易犯的错误是while循环里fast的推进写错。我曾经见过这样的代码:

while (fast < n) { if (nums[fast] != 0) { swap(nums[slow], nums[fast]); slow++; } fast++; // 这一步要是漏了,就是死循环 }

如果fast++写到了if分支里面,遇到0时fast不再前进,程序卡死在循环里。我的建议是直接用for循环来遍历fast,从语法层面杜绝这种问题。for循环天然保证每次迭代fast都加1,你只需要关心slow怎么变。

相向指针版本的边界更微妙。循环用left <= right,左边等于右边时也要进循环处理一次,这样才能覆盖"数组只有一个元素且它恰好等于val"的情况。如果写成left < right,这种方法在某些实现里会漏掉最后一个元素。我当时踩过这个坑以后,凡是相向指针,一律用left <= right作为循环条件,不再纠结。

4.2 原地修改的陷阱

很多人在本地跑通,一提交到在线判题平台就报错,绝大多数情况是原地修改没做对。移除元素这个题要求的是"在传入的数组上修改,并返回新长度",判题系统会用你返回的长度去检查原数组的前几位。如果你在函数内部新建一个数组来收集结果,即使返回值正确,原数组根本没变,照样不通过。

Python里还有一个非常隐蔽的坑:nums = result只是重新绑定局部变量,不会影响外部传入的列表。要看懂按引用传递和按值传递的区别,正确做法是nums[:] = result,用切片赋值去修改原列表的内容。C++里vector作为引用传参时,直接修改vector里的元素即可,不需要去调用erase收缩size,因为题目只看返回值指定的逻辑长度。

下面这张表格是我整理的问题速查,基本覆盖了新手会遇到的绝大多数情况:

现象原因解决方案
程序卡住不结束fast没有在每轮循环推进用for fast in range(n)遍历
数组尾部残留非零值移动零忘了补零覆盖后统一把slow之后置0
返回长度偏大slow初始值设置成1slow初始化为0
返回长度偏小用left < right做相向循环改用left <= right
本地输出正确但提交报错没有原地修改原数组Python用nums[:]赋值
删除后跳过了相邻重复值for循环里直接erase改用双指针覆盖,不要边遍历边删
全零数组结果不对交换法里fast和slow同步导致自我交换加fast != slow判断,或者接受无意义交换

4.3 快速自测的方法

写完代码之后,我会用一组固定的用例快速验证。这组用例是专门为数组原地操作题准备的:

  1. 空数组,空数组不需要输出,但函数不能崩。
  2. 单元素数组,分别测等于目标值和不等于目标值两种情况。
  3. 全部元素都是目标值,比如[0,0,0]或者[2,2,2]。
  4. 完全没有目标值,比如移除val=5时传[1,2,3]。
  5. 目标值在开头、在中间、在结尾分别测一轮。

每轮测试的时候,我还会在代码里临时加一行打印,输出每轮处理后的数组和slow、fast的位置。肉眼观察指针的移动轨迹,比只看最终结果更容易发现问题。比如移动零覆盖法,如果你看到中间数组[1,1,0,3,12]时没有意识到这是正常的,打印出来就能观察到"非零正在被搬运,尾部还没清零"这个状态。

4.4 写法取舍:交换法还是覆盖法

刷这两道题刷到后期,我基本固定了选择标准:默认写交换法,面试时优先解释交换法的逻辑。原因是它不需要补零,代码结构最利落。但如果题目对"写操作的次数"有明确要求,或者数据量极大,覆盖法加统一补零往往更优,因为它对每个非零元素只写一次,补零也是直接批量赋值。

踩过几次坑之后的体会是,不要为了优化那点性能去写让人看不懂的代码。两道题的核心考点是双指针思维,不是n毫秒内的极致性能。你要是为了省一两次交换,把代码写得连自己都解释不清,面试官反而会因为沟通不清楚而不给分。先用最标准的写法通过,再在口述中补充优化思路,这才是稳妥路线。

5. 跳出这两道题:面试追问和变体地图

5.1 原地数组压缩的通用模板

把移动零和移除元素放在一起,可以抽象出一个通用模板,我管它叫"原地数组压缩"。

slow = 0 for fast in 遍历整个数组: if 当前元素满足保留条件: 把 nums[fast] 放到 nums[slow] slow += 1 返回 slow(或者继续做后续处理)

这个模板的适用条件是:需要保留一部分元素,保持相对顺序,原地修改。只要满足这三个条件,都可以直接套。面试时我会先在白纸上写出这个模板,再根据具体题目填充"保留条件"和"后续处理"。有了这一步,移动零和移除元素就不再是两道孤立的题,而是同一条线上的两个点。

5.2 删除排序数组中的重复项

有一个经典变体是"删除排序数组中的重复项":给定一个有序数组,原地删除重复出现的元素,使每个元素只出现一次,返回新长度。

这个题仍然可以用快慢指针,只不过保留条件变成了"当前元素和前一个已保留元素不同"。

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

slow从1开始,因为第一个元素一定保留。fast从1开始扫描,每次和nums[slow - 1]比较,如果不相等,说明遇到了新元素,写入slow位置。你会发现它和移除元素的代码结构几乎一致,只是保留条件从"不等于val"变成了"不等于前一个保留元素"。

5.3 荷兰国旗问题:三指针的自然延伸

移动零把数组分成两类:0和非0。如果把数组分成三类:0、1、2,就变成了经典的荷兰国旗问题。这道题是三指针思路的自然延伸,面试官如果顺着移动零追问,很可能会问到这里。

void sortColors(vector<int>& nums) { int left = 0, mid = 0, right = nums.size() - 1; while (mid <= right) { if (nums[mid] == 0) { swap(nums[left], nums[mid]); left++; mid++; } else if (nums[mid] == 1) { mid++; } else { swap(nums[mid], nums[right]); right--; } } }

三个指针的分工很清晰:left维护0区边界,right维护2区边界,mid负责扫描。遇到0就扔到左边,遇到2就扔到右边,遇到1就留在中间。关键点在于,遇到2交换后mid不能前进,因为换回来的可能是0,需要再判断一次。这个细节和移除元素的相向指针版本如出一辙。

5.4 面试官最常追问的三个问题

第一,为什么双指针的复杂度是O(n)?因为每个元素最多被快指针处理一次,慢指针最多移动n次,两者加起来是2n次操作,所以是O(n)时间、O(1)空间。回答时把"每个元素只被访问常数次"这个点讲清楚就行。

第二,能不能进一步减少写操作?要分情况回答。如果非零元素很少、零很多,交换法在数据不理想时会产生较多交换;覆盖法可以做到每个非零元素只写一次,最后补零批量处理。但如果题目只是要求通过,O(n)的常规实现完全够用,优化属于加分项。

第三,如果顺序要求变了,算法要怎么换?保持顺序用快慢指针,允许乱序用相向指针。我见过不少人在笔试环境里没仔细读题,题目明明写了"元素的顺序可以改变",还花大把时间写快慢指针的覆盖逻辑,其实用相向指针更省事。先判断题目的顺序约束,再决定用哪个模板,这个判断顺序本身就是得分点。

我个人刷完这两道题最大的体会是:刷算法题别贪多,把一组相似题吃透,比乱刷五十题有用。移动零和移除元素就是最好的练手组合,一次把快慢指针、原地修改、边界检查、模板抽象全都练到了。最后再分享一个小技巧:我每刷一类新题,会在笔记里记下这类题的模板伪代码和典型坑,下次遇到新题先套模板,再按题面差异做微调。如果你能把这道题的做法讲清楚,说明白为什么slow最后能代表新长度,双指针这一关就算真正过了。

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

从零搭建毕业设计成绩管理系统:SpringBoot+Vue+MyBatis实践

又到了一年毕业季&#xff0c;办公室里的导师们开始翻着Excel表格来回核对评阅成绩&#xff0c;答辩秘书则抱着打印版评分表一份份誊抄。一个学生的总成绩&#xff0c;要汇总指导老师评分、评阅老师评分、答辩小组评分三份数据&#xff0c;中间只要有一个数字录错&#xff0c;最…

作者头像 李华
网站建设 2026/10/16 11:20:35

AI一键生成PPT:原理、实操步骤与自动化排版全指南

说实话&#xff0c;做汇报类PPT这件事&#xff0c;简直是我工作里最不想碰的活。以前每到月底、季度末&#xff0c;就得手动调对齐、改字号、挪文本框&#xff0c;一页一页抠&#xff0c;一折腾就是两三小时。后来我试着用 AI 跑大纲、再生成内容&#xff0c;但是生成出来的Wor…

作者头像 李华
网站建设 2026/10/16 11:20:31

用Grok Bot搭建AI视频制作流水线:从一句话需求到全平台成片

做视频这件事&#xff0c;我以前一直觉得最花时间的环节是剪辑&#xff0c;后来发现根本不是。最花时间的是从“我有一个想法”到“终于可以动手剪”的漫长过程&#xff1a;想选题、搭结构、写逐字稿、找画面素材、配音、对着时间线一点点对字幕。一套下来&#xff0c;一条90秒…

作者头像 李华
网站建设 2026/10/16 11:19:52

大模型训练数据管线全解析:从采集清洗到质量分级与配比

1. 大模型训练数据的底层逻辑与核心挑战1.1 为什么数据质量决定模型上限搞大模型训练的人都有一个共识&#xff1a;模型能力的上限&#xff0c;在数据层面就已经被锁死了。你用什么架构、调什么超参、堆多少张卡&#xff0c;这些都是在逼近那个上限&#xff0c;而不是突破它。我…

作者头像 李华