☰
解析移动零:双指针与原地算法背后的思维升级
2026/10/22 19:51:02 网站建设 项目流程

最近一次模拟面试里,我让候选人A写那道某在线题库里的经典题“移动零”。题目描述不长:给定一个数组,把所有的0移动到数组末尾,同时保持非零元素的相对顺序,要求在不复制数组的前提下原地完成。A同学三十秒交出答案:遍历原数组,把非零元素挑出来,再在结尾补上若干个0。我追问了一句:“那你申请的新数组放在哪了?”他盯着屏幕,愣是三分钟没动手。

这道题在题库里标的难度是“简单”,但在我这些年参与技术评审和带新人的经历里,它恰恰是最容易翻车的一道。不是候选人不会写,而是很多人把它当成了“删除零元素再补零”的字符串题,完全没意识到题目里那句“原地”才是真正要考的东西。这篇文章我想把这道题掰开揉碎,讲讲从直觉解法到最优解法的思维升级过程。它背后藏着一种在整个算法训练里都极其重要的能力:把模糊的需求翻译成精确的指针语义。

1. 一道“送分题”怎么成了面试翻车重灾区

1.1 题目本身有多简单:先把需求拆干净

给定一个数组 nums,例如 [0, 1, 0, 3, 12],要求输出 [1, 3, 12, 0, 0]。就这么点事。但题目里其实藏着三个隐性约束,少看一个,解法就会变味:

  1. 不复制数组,原地操作,只能在这一个数组的内存空间里做文章;
  2. 非零元素的相对顺序不能变,1 必须在 3 前面,3 必须在 12 前面;
  3. 尽量减少操作次数,不是“能跑就行”,而是要在时间复杂度和赋值成本上都有交代。

“保持相对顺序”这条最容易被轻视。我见过有人一看到移动零,马上想到左右双指针:左指针找0,右指针找非零,然后交换。这个思路用在快速排序的partition上没问题,但用在这里会出事。比如数组 [1, 0, 0, 2],左边指针指向下标1的0,右边指针从末尾找到下标3的2,交换后变成 [1, 2, 0, 0],看似正确,但再来一个例子 [0, 0, 1, 2]:左边找到下标0的0,右边找到下标2的1,交换后变成 [1, 0, 0, 2],非零元素1跑到2前面去了,相对顺序被破坏。

这就是“简单题”的陷阱。需求有三个约束,缺一个看起来也能跑,但面试官看的就是你有没有完整理解约束。空间复杂度和时间复杂度不是加分项,是硬性指标。

1.2 三份常见答卷,错得各有千秋

这些年我收集了不少候选人写移动零的初版代码,踩坑方式高度集中在三种:

解法套路典型写法主要问题
新数组拼装遍历原数组挑出非零,再根据原长度补零复制到新数组,空间复杂度O(n),违反“原地”
删除后补零循环删除0,再在数组尾部追加0删除操作本身是O(n),最坏退化成O(n^2)
冒泡式后移遇到0就和后面最近的元素逐位交换方向正确,但交换次数多,最坏也是O(n^2)

这三种写法我都实际见过。第一种最气人,因为写起来最短,看起来最“对”,但完全是空间换时间,题目直接不让你用额外数组。第二种常见于用惯了脚本语言的人,Python的list、JavaScript的splice都能删元素,但数组中间删元素意味着后面所有元素都要左移,一堆0的话每删一次都要搬一遍,性能惨不忍睹。第三种至少方向对了,知道要原地,也保持顺序,但把“移动0”理解成了“0一点一点往右挪”,没有想清楚谁才是“不需要动”的主角。

1.3 从失败答卷里提炼出的两个隐含考点

看多了这些答案,你会发现面试官真正想考的不是“会不会写这道题”,而是两个更底层的意识。

第一个是空间意识。很多候选人写代码时完全没想过自己申请了多少额外内存。题目说“原地”,他第一反应是“我建个新数组不就完了吗”。这背后是脑子里缺少复杂度模型,没有把额外空间当成和运行时间一样宝贵的资源。

第二个是操作次数意识。题目描述最后那句“尽量减少操作次数”经常被忽略。在工程场景里,对一个几百万长度的数组做大量无意义的赋值,是实打实的性能损耗。面试里所谓“最优解”,通常不是写出来最舒服的版本,而是对CPU和内存都友善的版本。

这两点才是“简单题”背后的算法思维升级。接下来两节,我会从覆盖法和交换法两个角度,把这套思维真正落到代码上。

2. 覆盖法:把“删除零”变成“保留非零”

2.1 新数组解法为什么违规:原地约束才是灵魂

新数组解法写出来大概是这样的:

def move_zeroes(nums): non_zeros = [x for x in nums if x != 0] for i in range(len(nums)): nums[i] = non_zeros[i] if i < len(non_zeros) else 0

表面上看,它最后又把结果写回原数组,好像“只是临时借用了一下内存”。但内存峰值是O(n),数据量一大,峰值内存直接翻倍,甚至更多。原地要求的本质是额外空间O(1),不管你最终有没有把结果写回。

为什么原地很重要?一个现实原因是数组可能非常大。我处理过模拟项目X里的图像帧数据,一帧就是几十MB的浮点数组,你要在那块内存上反复做变换,再申请一块同样大小的内存,内存带宽和缓存压力立刻上来。另一个原因是,这常常是更大系统里的一小步,后面还要在同一个数组上做其他操作,中间插一次大分配,对整个流水线都是负担。把“原地”当成硬约束去训练,写出代码的工程价值会高出很多。

2.2 慢指针的语义:一个变量维护一条完整队列

覆盖法的设计思路可以这样想:既然把0往末尾移动很费劲,那不如把所有非零元素“往前压缩”,压缩完剩下来的位置天然就是0。整个过程只需要两个指针:

  • fast指针负责巡逻,每一步向后移动一个位置;
  • 当fast指向的元素不是0时,把它写入nums[slow],然后slow向后移动一位;
  • 当fast指向的是0时,什么都不做,继续前进。

等fast走完整个数组,区间[0, slow)里存的就是所有非零元素,而且保持了原有顺序。此时再把从slow到数组末尾的位置全部置0。代码非常短:

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

为什么这个办法有效?因为slow和fast之间这段区域,在覆盖过程中已经变成“语义上的垃圾区”。慢指针不是在删除零,而是在保留非零。把fast处的非零值写到slow位置时,覆盖掉的是slow处原本的0,或者可能是还没处理的数据。等等,如果是后者怎么办?不会,因为slow永远小于等于fast,当fast指向非零时,slow到fast之间要么是0,要么是已经被处理过的位置。被覆盖的位置一定是0,这保证了不会有活数据丢。这个思想跟内存池的compact、垃圾回收里的标记-压缩非常像,都是“把活对象往前搬,一口气清掉尾部”。

2.3 操作次数与复杂度的真实账本

遍历数组一次,赋值次数等于非零元素个数;第二轮补零次数等于零元素个数。总体赋值次数加在一起刚好等于数组长度n。时间O(n),空间O(1)。

对比一下几种解法的账本:

解法时间复杂度空间复杂度符合原地保持顺序赋值/交换次数
新数组拼装O(n)O(n)否是约2n
删除后补零O(n^2)O(1)是是最坏O(n^2)
冒泡式后移零O(n^2)O(1)是是最坏O(n^2)
覆盖法O(n)O(1)是是n次赋值
交换法O(n)O(1)是是最多n次交换

覆盖法已经站在最优解门槛上。它唯一的“不完美”在于:赋值和置零分成了两轮,而且当数组本来没有0时,第二轮会白跑一遍。虽然复杂度仍然是O(n),但工程上完全可以在发现数组里没有0的时候直接返回。问题是你怎么知道有没有0?可以在第一次遇到0时打一个标记,这样全非零数组就能省掉一整轮循环。这个小优化正好引出了交换法:能不能一次遍历,顺便把零也放到该放的位置,根本不补第二轮?

3. 交换法:用“滚动零区间”做无痛搬运

3.1 为什么需要交换而不是赋值

覆盖法的两个循环可以被合并成一个,秘密就在“交换”这两个字上。

交换法的指针语义和覆盖法略有不同:

  • slow指针在覆盖法里指向“下一个非零元素应该放的位置”;
  • 在交换法里,它同时指向“当前第一个0元素的位置”;
  • fast指针依然负责扫描整个数组;
  • 当nums[fast] != 0时,交换nums[slow]与nums[fast],然后slow++。

关键点在于:从slow到fast之间存在连续的0。第一次交换把非零元素放到slow位置,把一个0换到fast位置。这个0会随着fast继续前进,被推到越来越靠后的地方,形成一段会滚动的“零区间”。每次交换都保证区间[0, slow)内的非零元素顺序不被打乱。

代码:

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

当fast和slow指向同一个非零元素时,交换等于原地自交换,没有副作用。当数组里连续好几个非零时,slow会一直等于fast,代码会做很多次没有意义的自交换。要不要优化掉这个点?后面我会专门说,这里先记住一个结论:交换法用一次遍历,同时完成“非零前移”和“零后移”两件事。

3.2 循环不变量:区间[0, slow)永远是对的

面试里能区分高下的,不是“能不能写对”,而是“能不能说清楚为什么对”。这就要用到循环不变量。

交换法在每次循环执行前,都维持三条事实:

  1. 区间[0, slow)内所有元素非零,并且保持它们在原数组中的相对顺序;
  2. 区间[slow, fast)内所有元素都是0;
  3. 区间[fast, n)是尚未扫描的原始数据。

初始化时slow=0,fast=0。区间[0,0)是空区间,第一条成立;区间[0,0)也是空区间,第二条也成立。进入循环后:

  • 如果nums[fast]==0,只需要fast++,这时一个0被并入[slow, fast)区间,第二条仍然成立;
  • 如果nums[fast]!=0,交换nums[slow]和nums[fast]。由于[slow, fast)全是0,交换后slow位置变成这个非零元素,fast位置变成0。随后slow++、fast++,三条不变式继续成立。

循环结束时fast=n,因为第二条不变式成立,所以[slow, n)全是0;因为第一条成立,[0, slow)非零且有序。这恰好就是题解。

这个证明方法在算法训练里叫“用不变量证明循环正确性”,比跑十组用例都管用。你能随口说出这套逻辑,面试官基本就不需要再追问边界情况了。

很多人讲双指针,一上来就是“左指针、右指针”,好像双指针只能是相向夹逼。移动零这种快慢指针同向走的题型同样常见。所谓双指针,本质上是指针代表两个局部状态:一个维护已处理好的部分,另一个探索未知区域。理解了这一层,比背题目重要得多。

3.3 边界测试:空数组、全零与连续非零

我写完一个数组变换算法,习惯固定用五组用例去验证,移动零也不例外:

  1. 空数组 []:slow和fast都不会越界,方法直接返回空数组;
  2. 全零数组 [0, 0, 0]:所有元素都不会触发交换,结果和原数组一致;
  3. 无零数组 [1, 2, 3, 4]:每一次fast都等于slow,不断自交换,结果是原数组本身;
  4. 单个零在中间 [0, 1, 0, 3, 12]:验证非零相对顺序是否保持;
  5. 非零集中在开头 [1, 2, 0, 0, 3, 4]:验证零区间滚动的完整过程。

跑这些用例不是为了“测试”,是为了理解算法语义。比如第3类用例会暴露一个细节:完全没有0时,交换法仍然会对每个元素做一次自交换。工程上想省掉这些无用操作,只需要加一个判断:

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

加了这个条件之后,无零数组一次交换都不做,零在中间的场景也只在必要时机交换。复杂度不变,但代码更贴近“尽量减少操作次数”的要求。

3.4 语言差异:Python与C++的交换细节

Python的a, b = b, a交换很方便,但要注意一个问题:当slow和fast指向同一位置时是自交换,不会有数据丢失。C++里用std::swap对内置类型通常被编译成几条指令,逻辑上没有区别。但如果数组元素是自定义结构体,std::swap可能设计移动构造和移动赋值,成本比简单赋值高不少。

如果工程里对象的复制代价很大,覆盖法反而更友好,因为它第一轮只需要把对象往前搬,第二轮只用整数赋值清零,不涉及复杂对象的交换:

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

两种解法没有绝对高下,覆盖法在“少写赋值”上更好,交换法在“一轮完成”上更漂亮。真正的高手是两种都写过,并且知道在什么场景选哪一种。

4. 从一道题到一类题:稳定分区思维

4.1 移动零其实是稳定partition的特例

如果你把“非零元素”看成“通过筛选的元素”,“零元素”看成“未通过筛选的元素”,移动零做的就是:在保持筛选后元素相对顺序的前提下,把所有通过筛选的元素移到数组前面。算法里有一个专门的名字:稳定分区(stable partition)。

快速排序里的partition通常是“基准值左边小、右边大”,但那种经典分区用两端交换做,是不稳定的,会打乱相等元素的原始顺序。移动零的覆盖法和交换法,则是稳定分区的一种具体实现:慢指针维护已分区区间的尾部,快指针扫描未分区区间,遇到满足条件的元素就把它“追加”到已分区区间末尾。

一旦你建立起这种抽象,马上就会发现:移动零不是在“处理一个奇怪的数组”,而是在“实现一个具有稳定性的谓词分区函数”。这个模板可以解决一大串变体题。

4.2 荷兰国旗问题:从两分区到三分区

荷兰国旗问题给一个只含0、1、2三种元素的数组,要求原地排序成 [0...0, 1...1, 2...2]。常规解法是三指针:左指针维护0区,右指针维护2区,扫描指针遍历中间。本质上就是从“两分区”升维到“三分区”。

理解了移动零是稳定partition后,荷兰国旗就相当于“在同一个数组上做两次分区”:第一次把0和非0分开,第二次在非0区间把1和2分开。当然,直接三指针一次遍历更省,但分区视角能让解法变得更好理解。C++三指针版本:

void sortColors(vector<int>& nums) { int p0 = 0, p2 = nums.size() - 1, i = 0; while (i <= p2) { if (nums[i] == 0) { swap(nums[p0++], nums[i++]); } else if (nums[i] == 2) { swap(nums[p2--], nums[i]); // 交换来的未知元素需要重新检查 } else { ++i; } } }

注意最后一行注释里的细节:当把2交换回去时,i不能前进,因为nums[p2]原来的值可能是0、可能还是2,都得重新处理。这个边界是面试高频追问点,也是很多候选人写错的地方。

4.3 变体题串讲:移除指定值、奇偶分离、正负分隔

移动零的模板能直接套到这些题上:

  • “移除元素”:给定一个值val,把数组里所有不等于val的元素放到前面。判断条件从“非零”改成“不等于val”,返回slow作为新长度,末尾不必清空;
  • 奇偶分离:把偶数放前面、奇数放后面,要求保持相对顺序时用同向慢指针,不要求顺序时用两端交换更快;
  • 正负分隔:把大于等于0和小于0分成两段,原理相同;
  • 把某种特定元素移动到数组首部或尾部:只是把判断条件从“等于target”改成“不等于target”;
  • 字符串里的连续空格压缩:slow记录有效字符位置,fast扫描,也能看到同样的影子。

我举一个最简单的例子。移除元素的代码:

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

和移动零的覆盖法只有两处不同:判断条件从“不等0”换成了“不等val”,以及省掉了末尾清零。这两题如果放在一起学,效率极高,因为指针语义完全复用。

4.4 稳定分区在工程里的影子

稳定分区不只是在面试题里转圈。做数据库索引整理时,要把有效记录和失效记录分离,同时保持主键顺序,很多压缩和迁移逻辑就是这种稳定分区。做视频帧批处理时,要过滤掉坏帧又保留时间顺序,用的也是同向双指针。内存分配器整理碎片时要搬移对象,搬移过程中必须维护对象之间的相对关系,这也是稳定分区。

可以说,稳定分区是一种“在有限空间里有序整理数据”的通用操作。一道简单题能练出这样的抽象能力,是它最大的价值。很多程序员刷了几百道题,遇到新题还是不会,就是因为只记解法,不做抽象。移动零恰恰是性价比最高的抽象入口。

5. 把“简单题”的价值榨干:面试呈现与学习路径

5.1 面试时,怎么讲这道题才能体现思维升级

如果只是写出答案,这道题在面试官那里只能拿到及格分。真正的加分项是从头到尾讲清思路,顺序大概是:

  1. 先口头确认约束:原地、保持相对顺序、尽量减少操作次数,三个约束分别对应什么解法;
  2. 主动淘汰错误方案:说明新数组为什么不行、删除法为什么复杂度差;
  3. 从覆盖法讲到交换法,同时解释慢指针语义和循环不变量;
  4. 用一组边界用例手动走一遍;
  5. 顺嘴提一句变体:这是稳定partition的特例,荷兰国旗、移除元素都可以复用。

我见过不少候选人,键盘敲得飞起,代码全对,但被问“为什么这样写是对的”就卡住。算法面试真正想考察的不是“能不能运行通过”,而是“遇到约束条件时,能不能把直觉翻译成严谨逻辑”。移动零就是最好的试金石。你在讲这道题时能主动说出“循环不变量”这个词,就已经和只背答案的选手拉开差距了。

5.2 边界用例清单与复杂度自查模板

我整理了一个极简的自查模板,适用于绝大多数数组原地变换题:

  • 输入是空数组吗?
  • 数组里全是目标值,或全不是目标值?
  • 目标值集中在开头、结尾,还是连续多个?
  • 数组长度是奇数还是偶数?
  • 循环过程中指针会不会越界?
  • 如果从一维扩展到二维,这个思路还成立吗?

复杂度自查问自己三件事:每个元素最多被访问几次?额外空间是常数还是和数据规模有关?操作次数能不能在常数次遍历内完成?移动零的两种解法都能回答:每个元素访问常数次,额外空间O(1),一到两轮遍历。如果你的解法里出现了排序、嵌套循环、重建数组,就要停下来想一想有没有更优的结构。

5.3 这道题练完,下一个该啃什么

移动零是一个“锚点题目”,掌握了它,可以顺藤摸瓜去啃这些内容:

  • 合并两个有序数组:同样用指针,但要从尾部向前走,避免覆盖未处理数据;
  • 删除有序数组中的重复项:用“保留区间的最后一个元素”做比较,本质上还是快慢指针维护有效区间;
  • 快速排序的partition实现:理解为什么经典版本不稳定,以及什么时候可以用不稳定版本;
  • 荷兰国旗问题:学习三分区指针的边界处理;
  • 容器盛水、最长连续序列这类题:双指针的使用场景从“同步扫描”扩展到“夹逼游标”。

我自己带新人时,通常把移动零作为双指针训练的第一课。因为它代码量小,适合逐行推敲,又能引出循环不变量、复杂度分析、稳定分区三层递进。很多人做完这道题只记住一个结论“用快慢指针”,没记住“为什么快慢指针能交换”。这样就把一道好题浪费了。

最后分享一个我实际用的小技巧:刷这类数组题时,不要急着在编辑框里写整段代码,先在纸上画出数组和两个指针的位置,手动模拟两三步。移动零这道题更是如此,你只要在纸上画一次零区间滚动,就再也不会忘了它为什么能保持顺序、为什么一轮交换就能收工。很多正确答案不是敲出来的,是在纸上试出来的。

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

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

立即咨询