数组类问题永远是算法学习绕不过去的门槛,而“移动零”和“移除元素”这两道题,可以说是最经典的双指针入门组合。很多朋友一看到题目就觉得简单,可真到笔试时却能错得千奇百怪:有人把零挨个冒泡,结果超时;有人为了删除元素新建了一整个数组,被面试官问得哑口无言。这篇文章就把这两道题掰开揉碎,从题目约束、双指针原理、代码实现到边界坑点一次聊透。不管你是在准备校招笔试还是想潜心补算法基础,只要把这两题吃透,后面再遇到“原地数组操作”类题目,思路绝对会顺很多。
1. 先看清题目:两个看似简单实则暗藏条件的数组操作
1.1 移动零:零要全部挪到末尾,其他数的顺序不能乱
题目通常长这样:给定一个数组,比如[0, 1, 0, 3, 12],要求把所有 0 移动到数组末尾,同时保持非零元素的相对顺序,并且只能在原数组上操作,不能使用额外数组。这里最容易被忽略的就是“相对顺序”四个字。很多新手第一次做这道题,脑子里立刻冒出“直接排序”的想法,或者先拷贝一个新数组,再把非零元素按顺序放进去,最后补零。但题目一旦限定“原地”,这两种方案都被否掉了。
这道题的本质其实是在数组内部完成一次“分区”:把非零元素按原来的先后顺序推到数组左侧,把零批量甩到右侧。你可以把数组想象成一条传送带,上面的人按照先后顺序排好队,中间有一些空位,现在要把空位统一挪到队尾。注意“按先后顺序排好”这个条件,不能随便交换位置,所以后面我们才要选择一种不破坏相对顺序的遍历方式。
1.2 移除元素:删掉指定的值,剩下的元素顺序并不强制
另一道题也很典型:给定数组[3, 2, 2, 3],要求移除所有值为 3 的元素,返回移除后数组的新长度,同时要求原地修改。比如这个例子,移除后数组长度是 2,前两个元素可以是2和2,顺序无所谓。这里的关键在于“顺序并不强制”。为什么说这个条件重要?因为它直接决定了你能不能用“从末尾拿一个元素来补位”这种少移动的策略。
数组的“删除”本身就是伪命题,因为连续内存没法真正挖掉一个位置,所谓删除,不过是把后续元素往前覆盖,然后记住新的有效长度。既然顺序不重要,那我删除一个元素时,就不必像排队一样让后面所有人往前移,我完全可以把数组末尾的元素拿过来填坑。这样一来,移动次数可能大幅减少。这道题表面是考双指针,其实更是在考你能不能根据条件变化选择不同的优化策略。
1.3 为什么这两道题要放在一起学
很多刷题清单都喜欢把“移动零”和“移除元素”放在连续的位置,我自己在刷的时候也觉得它们像一对孪生兄弟。它们共享同一个核心工具:双指针。移动零用的是快慢指针,移除元素既能用快慢指针,也能用左右指针,通过一道题就能看到双指针两种形态的典型用法。
更重要的原因是,把它们放在一起对比,你会看透一个底层机制:数组的“删除”和“移动”其实都是覆盖。所谓原地操作,就是在一个连续的内存块里,通过指针的先后配合,把元素从原来的位置搬到目标位置,最后截断一下长度。想明白这件事,你再看后面大量跟数组相关的题目,比如去重、压缩、分区、旋转,都不会再觉得它们互相孤立,本质上都是同一套“指针打配合”的玩法。
2. 移动零:一次遍历搞定,但指针的移动逻辑要掰碎
2.1 最直觉的解法:遇到零就往后搬,为什么通常不行?
我见过不少新手第一次写这道题,用的办法是双重循环:从头到尾扫,遇到一个 0,就把后面所有的元素往前挪一格,然后把这个 0 放到数组最后。思路很直白,既然要“移除零并让零沉底”,那就逐个把零“冒泡”到尾部。但问题出在“把后面所有元素往前挪”这一步,它是 O(n) 的操作,外层再套一个遍历,总体复杂度直接变成 O(n²)。数组长度小的时候看不出来,一旦长度上万,笔试环境里很容易超时,而且代码写得越长越容易错。
这里顺便说一个基础但容易被忽略的知识点:数组是连续内存,插入和删除的代价之所以高,是因为你挪一个位置,后面的所有元素都得跟着错位。所以只要看到“数组原地操作”这几个字,脑子里第一反应应该是“尽量减少元素的移动次数”,而不是真的去逐个搬。
2.2 正确姿势:快慢双指针的“覆盖法”思路
移动零的正宗解法是快慢指针,也叫双指针中的“同向双指针”。我们定义两个下标:
- 慢指针
slow:指向下一个应该放置非零元素的位置,也可以理解成“已处理区”的边界。 - 快指针
fast:从头到尾遍历数组,负责找出非零元素。
具体过程很简单:fast一路往后走,只要遇到的元素不是 0,就把它复制到slow指向的位置,然后slow加一。fast遇到 0 就直接跳过,不处理。遍历结束后,slow前面的位置全部是非零元素,slow及之后的位置我们统一填成 0,这样数组就算处理完了。
我拿[0, 1, 0, 3, 12]一步步模拟一遍:
- 初始
slow=0,fast=0。 fast=0,nums[0]=0,是 0,跳过,fast=1。fast=1,nums[1]=1,不是 0,执行nums[0]=nums[1],此时数组变成[1,1,0,3,12],slow=1,fast=2。fast=2,nums[2]=0,跳过,fast=3。fast=3,nums[3]=3,不是 0,执行nums[1]=nums[3],数组变成[1,3,0,3,12],slow=2,fast=4。fast=4,nums[4]=12,不是 0,执行nums[2]=nums[4],数组变成[1,3,12,3,12],slow=3,fast=5,结束。- 最后把下标 3 和 4 的位置都填成 0,得到
[1,3,12,0,0]。
这个结果是对的,但注意过程中我们覆盖了一些旧值,比如原本的 1 被覆盖后还在数组里出现了两次,但因为我们随后会把尾部统一清 0,所以最终结果不受影响。这个方法的时间复杂度是 O(n),额外空间是 O(1)。
2.3 更优雅的版本:用交换代替覆盖,省掉最后的补零
覆盖法虽然好懂,但最后需要再写一个for循环补零,稍微有点冗余。更清爽的做法是:当fast遇到非零元素,就把nums[slow]和nums[fast]交换,然后slow++。这样零会自动被换到后面,不需要额外补零。举个例子,同样[0, 1, 0, 3, 12],第一次遇到nums[1]=1时,交换nums[0]和nums[1],数组变成[1, 0, 0, 3, 12];后续遇到 3 和 12 时分别与slow=1和slow=2的位置交换,最后数组天然就是[1,3,12,0,0]。
这里还有一个面试官特别爱问的点:为什么交换法不会破坏非零元素的相对顺序?因为slow和fast都在从左往右移动,slow指向的位置永远是下一个非零元素该去的位置,而这个位置之前的元素都是已经被放好的非零元素,所以新调过来的元素只会紧跟在这些元素之后,顺序自然保持。另外,当slow == fast时,交换自己和自己没有意义,代码层面可以加一个if (slow != fast)的判断来省一次操作,不过不加也不影响正确性。
2.4 代码实现:以 C++ 和 Python 为例
我用 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; } } }Python 版本同样简洁:
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如果你更喜欢覆盖法,也可以写,但注意结束后要从slow开始把所有位置设成 0。两种方法复杂度一样,都是时间 O(n)、空间 O(1)。
2.5 这道题其实是一个更大的套路
移动零本质上是在做“对数组进行条件分区”:把满足某个条件的元素放到一侧,不满足的放到另一侧。所以以后遇到类似的需求,比如把偶数放前面、奇数放后面,或者把负数放前面、正数放后面,只要题目要求“保持相对顺序”,你都可以直接套用这个快慢指针模板。唯一的区别就是把判断条件从nums[fast] != 0换成nums[fast] % 2 == 0之类。这就是刷一道题带出一类题的价值。
3. 移除元素:两种双指针策略,对应不同需求
3.1 方法一:快慢指针覆盖法,保留剩余元素的相对顺序
移除元素首先可以套用移动零的快慢指针模板,而且更简单,因为我们不需要关心被移除值最后落到哪里。思路是:用slow当作新数组的有效长度,用fast遍历原数组。只要nums[fast] != val,就把nums[fast]覆盖到nums[slow],然后slow++;如果nums[fast] == val,直接跳过。遍历结束后,slow的值就是移除后数组的长度。
我用[3, 2, 2, 3]、val=3来走一遍:
fast=0,nums[0]=3,是val,跳过,slow=0。fast=1,nums[1]=2,不是val,执行nums[0]=nums[1],数组变成[2,2,2,3],slow=1。fast=2,nums[2]=2,不是val,执行nums[1]=nums[2],数组变成[2,2,2,3],slow=2。fast=3,nums[3]=3,是val,跳过,循环结束。
最终返回slow=2,数组前两个元素是[2,2],剩余元素保持相对顺序。这种解法代码短、逻辑清晰,而且能保持剩余元素的相对位置,适合大多数场景。
3.2 方法二:相向双指针,顺序不重要时的最优解
如果题目没有要求保留相对顺序,我们还能更进一步,用“相向双指针”来减少元素的赋值次数。核心思想是:从左往右找要删除的元素,从右往左找一个可以补位的元素,用这个补位元素覆盖要删除的位置。具体来说:
- 初始化
left=0,right=nums.size()-1。 - 循环条件
left <= right。 - 如果
nums[left] == val,就把nums[right]赋值给nums[left],同时right--。注意此时left不要急着加一,因为从右边换过来的元素可能是val,需要在下一次循环里再判断。 - 如果
nums[left] != val,说明当前位置不需要删除,直接left++。 - 循环结束后,返回
left,它就是新数组的长度。
同样用[3, 2, 2, 3]、val=3模拟:
left=0,right=3,nums[0]=3是val,所以nums[0]=nums[3]=3,right=2。注意现在数组还是[3,2,2,3],因为被换过来的值也是 3。left=0仍指向 3,再次走到if,nums[0]=nums[2]=2,right=1。数组变成[2,2,2,3]。left=0变成 2,不是val,left=1。left=1,nums[1]=2,不是val,left=2。- 此时
left=2,right=1,循环条件left <= right不成立,退出。返回left=2。
这个版本的精髓在于两个指针从两端往中间夹逼,每次删除操作都会把数组右侧的某个元素搬到左侧,相当于把“删除”变成了“交换填充”,减少了很多无意义的元素整体移动。
3.3 两个方法怎么选?看题目缺不缺“保持顺序”四个字
用哪套方案,其实做题第一眼就该决定。如果题目写了“保持剩余元素的相对顺序”,那就直接用快慢覆盖法,顺序天然保留。如果题目没说顺序,或者明确说“顺序任意”,那我个人建议优先用相向双指针。为什么?虽然两个都是 O(n),但相向法的交换次数通常更少。比如数组里要删除的元素只有少量几个时,覆盖法还是要把所有非目标元素往前搬,而相向法可以把末尾元素直接拉过来,虽然顺序乱了,但速度很快。
不过也要提醒一点:相向法有一个隐患,就是“从右边换过来的元素仍然是要删除的值”这种情况。比如我刚举的例子,第一次从末尾换过来的 3 依然是目标值,所以left不能移动。这个细节一定要记牢,否则很容易写出错误的left++; right--双向移动,导致最后结果不对。
3.4 代码实现:移除元素的两种写法
先看覆盖法,保留顺序:
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]; } } return slow; }再看相向法,不保留顺序:
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; }Python 的覆盖法:
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 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两个版本的返回值都是新长度,注意题目通常只要求你保证数组前k个元素正确,不需要真正把后面的元素清零。只要你返回的长度正确,哪怕数组后面还残留着旧值,评测系统也不会在意,因为有效区间只到k-1。
4. 从这两题延伸出去:双指针的三大范式与实战联想
4.1 快慢指针、左右指针、滑动窗口——原来它们是一家
很多初学者会觉得双指针是一个很高深的名词,其实它就是两个数组下标互相配合的一种思路。常见的形态有三类:
第一类是快慢指针,两个指针从同一个起点出发,速度不同,或者一个负责在前面探索,一个负责在后面记录。典型场景有数组去重、链表环检测、原地压缩。移动零和移除元素的覆盖法都属于这一类。
第二类是左右指针,两个指针分别指向数组首尾,然后向中间移动。典型场景有二分查找、反转数组、两数之和。移除元素的相向法就是这种形态。
第三类是滑动窗口,本质上也是两个指针,只不过它们之间的区间是一个不断变化的窗口,通过移动左右指针来收缩或扩大窗口,常用于处理子串、子数组问题,比如最长无重复子串、最小覆盖子串。
理解了这三个分类,你就发现很多题目的解法不是孤立记忆的,而是有共同骨架的。面试时如果你能跟面试官讲“这道题可以用快慢指针,和刚才那道移动零的思路类似”,至少你会显得更有体系。
4.2 经典延伸题:删除有序数组中的重复项、颜色分类、数组分隔
我觉得“移除元素”最直接的延伸题是“删除有序数组中的重复项”。那道题要求你在有序数组里原地去重,并保持相对顺序。核心思路依然是快慢双指针,只不过判断条件从nums[fast] != val变成了nums[fast] != nums[slow-1]或者直接和已写入的最后一个元素比较。理解了覆盖法的“写入 + 推进”模式,这道题就是换个判断条件的事。
还有一个很有意思的经典题叫“颜色分类”,数组里只有 0、1、2 三种数字,要求把它们排成 0 在左、1 在中间、2 在右。这其实是双指针的加强版:你用一个左指针放 0,一个右指针放 2,遍历中遇到 0 就往左放,遇到 2 就往右放,遇到 1 就跳过。这个题的思考过程和移除元素的相向法几乎同源,只不过多了一个“中间值”要处理。如果你能先吃透移除元素,再去想颜色分类,会觉得顺畅很多。
4.3 为什么刷题不能只背代码?从“为什么”切入
我见过不少人能默写移动零的答案,但面试官换个小变体,比如“把 0 移到数组开头,其他元素相对顺序不变”,他就慌神了。原因就是他当时只背了代码,没有真正理解指针的作用。什么叫理解?是你能解释清楚:
- 为什么
slow从 0 开始?因为它指向第一个待写入的位置。 - 为什么
fast要遍历完整个数组?因为它要扫描每一个元素并判断是否满足前提条件。 - 为什么
nums[fast] 不等于 0时就交换?因为我们要把非零元素安排到左侧。 - 为什么结束之后数组就是正确的?因为所有非零元素都被放到了
slow之前的位置,且顺序不变。
你把这些问题答明白之后,哪怕题目改成“把 2 移到数组中间去”,你也能快速调整判断条件和指针位置,而不是对着旧代码改一个变量就完事。
4.4 时间和空间复杂度不是背的,是推出来的
面试官常问:“你的解法复杂度是多少?”很多人条件反射地回答 O(n),但被追问为什么时又卡住。其实推导很简单:无论移动零还是移除元素,每个fast指针最多遍历一次数组,slow指针最多移动 n 次,所以总操作次数是 O(n)。额外空间我们只用了几个整数变量,没有新建数组,所以空间是 O(1)。如果你是用了相向双指针的移除元素版本,left和right合起来遍历次数不超过 n+1,也是 O(n)。把这个推导过程说出来,比单纯报一个答案要有说服力得多。
5. 踩坑实录:这些边界条件决定了你笔试能不能过
5.1 空数组和单元素数组
空数组是最容易在边界出问题的情况。比如移动零里,如果数组长度为 0,fast循环根本不会进入,slow保持 0,代码什么都不做,这没问题。但如果你在代码里先写了nums[0]相关的判断,就可能越界。移除元素的覆盖法同样,空数组时直接返回 0 即可。单元素数组也要测:比如[0],移动零时fast=0,nums[0]=0,跳过,最后数组还是[0],符合要求。移除元素[val]时,无论用覆盖法还是相向法,返回 0 也都是对的。
5.2 指针移动顺序:相向法里为什么不能随便 left++?
这个坑我至少见过三个人踩过。在移除元素的相向双指针写法里,如果判断nums[left] == val然后赋值完后,顺手写了left++,很可能出错。因为从右边拿来的元素可能是“漏网之鱼”,你直接跳过它就等于没删除干净。正确的做法是让下一轮循环用同一个left位置继续检查。我建议你把这个场景写成一个固定心智模型:当nums[left]是要删除的值时,我们“先用右边的值填坑,再看坑里新填的值是否也要删”,只有确定不是目标值了,才把left往前推进。
5.3 移动零覆盖法:别忘记最后的补零操作
有些同学觉得覆盖法代码更简洁,确实,它能省去交换的判断,但容易忽略最后一步“把尾部重置为 0”。比如[1,0,3],覆盖法执行完后数组会变成[1,3,3],如果不补零,最后一个 3 是残留的旧值。所以如果你选覆盖法,必须记得从slow开始到数组末尾全部填 0。我的建议是平时写移动零就统一用交换法,这样既不用补零,又更直观,面试时少一个隐患。
5.4 用一套自测用例提升通过率
写算法题别写完就交,养成自测习惯能省下很多笔试罚时。我一般会准备这么几组用例:
- 全部是 0:
[0,0,0] - 全部非 0:
[1,2,3] - 0 和非 0 交替:
[0,1,0,2,0,3] - 负数夹杂 0:
[-1,0,-2,0] - 目标值出现在开头和末尾:
[3,2,2,3] - 目标值连续出现:
[1,1,1,1]
把这几组用例跑通,基本能覆盖绝大多数边界情况,也能帮你发现指针顺序是不是写错了。
最后再分享一个我自己的习惯:刷完双指针题目之后,我会把“题目约束”和“指针策略”放到一个表里对照,比如“保持顺序”对应快慢指针,“不要求顺序”对应左右夹逼。时间久了,看到新题就能快速判断该用哪套模板。移动零和移除元素是很优秀的双指针入门题,但别停在“能写对代码”这一步,试着把题目改一改,给自己出一个“把 0 移到开头”或者“移除元素后顺序保持”的变体,再动手写一遍。练到这种程度,面试官再追问你边界和变体,你都能接住。