最近在给团队做算法面试辅导,发现一个特别有意思的现象:LeetCode 283 这道“移动 0 的位置”几乎是人人都刷过的题,但每次让我看候选人现场写代码,十个人里有七八个会写出不太干净的版本。要么边界条件处理得拖泥带水,要么没意识到这题考察的其实是双指针里最基础的“读写指针”思想。
这篇文章把这道题从头到尾彻底拆一遍,包括暴力解法的局限、双指针的演进过程、我见过的一个反直觉的边界 bug,以及它在实际业务场景里的变体应用。适合刚入门算法的朋友建立思维框架,也适合准备面试的同学查漏补缺。
1. 问题的本质:一条看似简单但暗藏陷阱的数组操作题
题目描述直白得不像个中等题:给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。必须原地操作,不能拷贝额外数组。
我见过最典型的错误理解是:这题不就是排序吗?把 0 全排到后面去。这么想也不能说全错,但很容易引导出错误的解法。因为题目要求“保持非零元素的相对顺序”,排序恰恰会破坏这个约束。
举个例子,[1, 0, 3, 12],你要的结果是 [1, 3, 12, 0],不是 [12, 3, 1, 0]。这意味着你只能移动 0,不能重排非零元素之间原本的先后关系。这个约束条件一加,排序思路基本就废了。
换个角度重新理解这个问题:我们不是在“移动 0”,而是在“搬运非零元素到前面”,剩下的坑位自动就是 0。 这句话看着像文字游戏,实际上是两种完全不同的实现路径。
- 如果按“移动 0”来想,你需要考虑 0 被交换到后面之后,后续遍历要不要跳过、会不会重复处理。
- 如果按“搬运非零元素”来想,思路就清晰多了:从头到尾扫描一遍,遇到非零的就往前面放,扫描完了,后面全部补 0。
很多人觉得这题简单,但真正动手写的时候才发现,双指针的退出条件、交换逻辑、补 0 的位置,每个环节都有出错的余地。
提示:这道题的通用叫法是 Move Zeroes,在 LeetCode 上是第 283 题,属于“数组 + 双指针”的经典入门题。它的变体在字符串压缩、磁盘碎片整理、日志文件归档等场景里都有体现。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从朴素解法到双指针:一趟遍历背后的思考链路
2.1 先承认一个事实:暴力解法不是不能用
我第一次刷这道题的时候,直接写了个两层循环:外层遍历数组,找到一个 0 之后,内层循环往右找下一个非零元素,然后交换。代码跑通了,但总感觉有点不对劲。
python复制def move_zeroes_naive(nums):
n = len(nums)
i = 0
while i < n:
if nums[i] == 0:
j = i + 1
while j < n and nums[j] == 0:
j += 1
if j < n:
nums[i], nums[j] = nums[j], nums[i]
i += 1
return nums
这个解法最坏情况是 [0, 0, 0, ..., 1]——所有的 0 都在前面,只有最后一个元素是 1。这时内层循环每次都要扫到数组末尾,时间复杂度退化到 O(n²),空间复杂度 O(1)。
为什么 O(n²) 不能接受? 如果你在真实业务里处理的是 10 万条日志里夹杂着 3 万个空字段,O(n²) 意味着十亿次级别的操作,这在客户端或网关层几乎必然导致卡顿。这也是面试官大概率会追问的点:能优化到 O(n) 吗?
2.2 双指针的两种经典写法:前后指针 vs 快慢指针
既然暴力解法慢在“每次找非零元素都要重新扫描”,那自然想到用指针缓存住已经扫描过的位置。这就是双指针的由来。
写法一:快慢指针(a.k.a. 读写指针)
这个写法的核心思路是:维护一个 left 指针指向“下一个应该放置非零元素的位置”,用 right 指针遍历整个数组。当 right 指向的元素非零时,把它复制到 left 位置,left 右移一位。遍历结束后,left 之后的所有位置补 0。
javascript复制function moveZeroes(nums) {
let left = 0;
for (let right = 0; right < nums.length; right++) {
if (nums[right] !== 0) {
nums[left] = nums[right];
left++;
}
}
while (left < nums.length) {
nums[left] = 0;
left++;
}
return nums;
}
这个写法的精妙之处在于:它把“移动”操作拆成了两个阶段——先把所有非零元素按顺序搬运到前面,再一次性把后面的位置全部归零。整个过程只遍历了一遍数组(第二个 while 循环主要用来赋值,实际遍历范围是从最后写入位置到数组末尾),时间复杂度 O(n),空间复杂度 O(1)。
写法二:交换式双指针
同样是快慢指针,另一种更符合“移动”语感的实现是:遇到 right 指向非零元素时,直接交换 nums[left] 和 nums[right]。
python复制def move_zeroes(nums):
left = 0
for right in range(len(nums)):
if nums[right] != 0:
nums[left], nums[right] = nums[right], nums[left]
left += 1
return nums
这个写法的好处是省掉了最后补 0 的一轮循环,因为 0 会随着交换自动“挤”到后面去。但这也带来一个和我最开始提到的思路冲突的地方:它本质上交换的其实是“当前非零元素”和“最靠前的 0”,所以 left 始终停留在第一个 0 的位置。这可以看作在维护一个“0 的滑动窗口”。
3. 边界条件里的细节:为什么说这题“简单,但又不简单”
前面给了两种标准解法,看着都挺漂亮。但实际手写代码时,我见过太多在边界条件上翻车的情况。这些细节才是这道题真正的考察点。
3.1 数组长度为 0 或 1 的情况
这个比较简单,代码天然就兼容了——for 循环不执行或只执行一次,left 要么还停留在 0,要么最多移动到 1,不做越界操作。
3.2 全是 0 的数组
[0, 0, 0] 这种输入,快慢指针写法中 right 永远不会进入 if 分支,left 一直是 0。最后补 0 的循环把整个数组再赋一遍 0,问题不大。但如果用交换式写法,left 也一直是 0,交换操作实际上是自己和自己交换,虽然多做了无用功,但结果正确。
3.3 没有 0 的数组
[1, 2, 3],快慢指针写法的 if 分支每次都成立,left 和 right 同步前进,相当于每个位置都和自身复制了一遍。这里有个隐藏的性能优化点:当 left === right 时,可以跳过赋值,避免不必要的内存写入。
javascript复制function moveZeroesOptimized(nums) {
let left = 0;
for (let right = 0; right < nums.length; right++) {
if (nums[right] !== 0) {
if (left !== right) {
nums[left] = nums[right];
nums[right] = 0; // 顺手把当前位置清零
}
left++;
}
}
return nums;
}
注意这版和前面的一个关键差异:我在把 nums[right] 复制到 nums[left] 之后,顺手把 nums[right] 置 0 了。这样就省掉了最后补 0 的循环,而且 left 指针之后位置的值本来就是 0,不需要再处理。当 left 和 right 相等时,说明当前位置本身就是非零元素,站在原地不动就好。
3.4 一个反直觉的 bug:交换后指针相撞
再分享一个我实际面试中遇到的真实案例。有个候选人用交换式双指针,但是 left 用倒序找 0,right 用正序找非零,结果在 [1, 0, 2, 0, 3] 上跑出了 [1, 2, 3, 0, 0] 之后,下一个 right 指着索引 4 的 3,left 倒序找 0 找到了索引 3,交换完之后,3 被放到了索引 3,但索引 4 变成了 0,结果成了 [1, 2, 0, 3, 0]。
问题的根源在于:用倒序找 0 的方式,无法保证选中的 0 一定位于非零元素后面。 双指针不只是“两个指针这么简单”,本质上它是在用空间换时间——两个指针之间夹着的区间,就是算法认为“已经处理完”的区域。两个指针的移动方向必须保证这个“处理完”的区域是单调增长的,一旦方向错乱,前面处理完的数据会被后续操作重新污染。
为什么交换式双指针是正确的? 因为 left 始终指向“第一个 0 的位置”,它前面的元素(包括左指针本身指向的那个 0 已经交换成了非零元素)都是处理完毕的。right 每往前一步,处理完的区域就扩大一格。循环不变量清晰,代码就不容易出错。
4. 复杂度分析:为什么不能小看 O(n) 和 O(1) 的组合
简单说一下复杂度,因为这道题的复杂度分析是面试官最爱追问的点,而且追问的方式往往很刁钻。
时间复杂度:O(n)。 无论快慢指针的哪个版本,最外层只有一次遍历。注意第二个版本我在循环里顺手置 0,有些人会误以为多了一个操作会导致 O(2n) => O(n) 的常数增大。实际上,在“没有 0 的数组”这个最坏场景下,left === right 的优化会让复制操作为一个简单的比较,成本极低。就算在“全是 0”的场景下,交换也都是跳过或自交换,总体仍然是一趟线性扫描。
空间复杂度:O(1)。 这是这道题的核心价值之一。如果你有 10GB 的数组,你不能简单地 filter 之后再加 0 组成新数组,那样内存直接翻倍。原地操作的意义在于:你只有一块内存,必须在它内部完成重排。这种约束在嵌入式开发、单片机内存有限、大数据量日志处理等场景下是刚性需求。
注意:这里 O(1) 空间复杂度是“额外”空间复杂度,不算输入本身占用的空间。有些初学者分不清这一点,面试时被问到“为什么空间复杂度是 O(1)”可能答不上来。
最优性证明的直觉: 每个非零元素必须被移动一次(或至少比较一次),才能确定它该留在原位还是向前移动。所以任何基于比较的排序/移动算法的下界是 O(n)。既然我们是 O(n),已经达到了理论上限——不可能更优了。
5. 实际业务场景里的变体:这个题不只是面试八股
“移动 0 的位置”看起来像纯算法题,但实际上它的变体思想在业务代码中非常常见,尤其是涉及“分区”和“稳定排序”的场景。我这里举几个我实际遇到过的例子。
5.1 日志系统中的非空字段前置
处理日志时,每行日志可能对应多个字段,有些字段为空字符串或 null。如果要在界面上展示,通常需要把非空字段集中在前面,空字段折叠到后面。这和 moveZeroes 的规则几乎一模一样——把“非空字段”视为“非零元素”,把“空字段”视为“零元素”,保持非空字段的相对顺序。
typescript复制function compactFields<T>(fields: (T | null | undefined)[]): (T | null | undefined)[] {
let writeIndex = 0;
for (let readIndex = 0; readIndex < fields.length; readIndex++) {
const field = fields[readIndex];
if (field !== null && field !== undefined) {
fields[writeIndex] = field;
if (writeIndex !== readIndex) {
fields[readIndex] = null; // 顺手清理原位置
}
writeIndex++;
}
}
return fields;
}
这段代码模式上和第 3.3 节的优化版本完全一致。用 null 代替 0,用 !== null && ! == undefined 代替 !== 0。你只需要换掉判断条件,核心逻辑一行都不用改。
5.2 碎片整理:把有效块合并到前端
在文件系统或内存碎片整理里,经常需要把正在使用的内存块集中到一起,然后把空闲块统一移到末尾。这和 moveZeroes 的思路也是相通的——使用中的块是不能改变相互顺序的,空闲块就是“0”。
这类场景有个额外的考量:内存块可能是大对象,复制成本很高。所以实际实现里会先记录元数据,再用 DMA 批量搬运。但无论怎么优化,最基础的分区思想仍然基于“稳定分区”算法,双指针遍历就是最朴素的实现。
5.3 数据库查询结果的 NULL 排序
有些数据库的 ORDER BY ... NULLS LAST,本质上也是把 NULL 当作 0 聚合到尾部,同时保持非 NULL 数据的排序顺序。你甚至可以把它理解成 moveZeroes 在 SQL 世界里的表亲。
6. 扩展思维:同一道题能有几种解?从交换到覆盖
很多人刷完 283 就算过了,其实这道题很适合用来练习“从多角度解同一道题”的思维。我建议每个初学者都试着用不同思路重新实现一遍,收获会大很多。
解一:暴力交换(O(n²))——先跑通再说
前面已经写过,重点在于理解为什么慢。这种解法适合作为思路的起点,不适合作为最终答案。
解二:快慢指针复制 + 尾部补零(O(n))
最稳妥的写法,逻辑清晰,不容易踩坑。适合面试时作为第一版答案。如果你担心面试官追问“为什么不是 O(1) 空间”,这个版本也最好解释——我们只用了两个指针变量。
解三:交换式双指针(O(n))
相比解二,少了一个补 0 循环,但要注意交换时 left 和 right 的关系。如果你对循环不变量理解足够深入,优先推荐这个版本,它更体现对双指针思想的理解。
解四:STL 风格 partition(如果你熟悉语言库函数)
在 C++ 里可以用 std::stable_partition,在 Python 里可以用 sorted 配合自定义 key,但这些都隐式引入了额外内存或复杂度。就这道题来说,手写双指针更符合面试场景的考察意图。
这种“多解法对比”的做法,我强烈建议刷题的时候每道题都做一遍。它能帮你把一道题从“背答案”变成“真正理解问题空间”,面试时候不管怎么追问,你都心里有底。
7. 实战审查:用测试用例验证你的代码真的没问题吗
代码写完不代表就过了,自己动手写清楚测试用例才是真功夫。这里列出我常用的测试集,覆盖了这道题的所有典型边界。
| 输入数组 | 预期输出 | 考察点 |
|---|---|---|
[] |
[] |
空数组边界 |
[0] |
[0] |
单元素数组 |
[1] |
[1] |
单元素数组(非零) |
[0, 0, 0] |
[0, 0, 0] |
全是 0 |
[1, 2, 3] |
[1, 2, 3] |
没有 0,验证不做多余交换 |
[0, 1, 0, 3, 12] |
[1, 3, 12, 0, 0] |
经典场景 |
[1, 0, 0, 2, 0, 3] |
[1, 2, 3, 0, 0, 0] |
连续多个 0 的场景 |
[0, 0, 0, 0, 1] |
[1, 0, 0, 0, 0] |
最坏情况:0 全在头部 |
写测试的时候有个细节:不能只看输出 [1, 3, 12, 0, 0] 就把代码交给测试。 要盯着 left 指针在每一轮循环后的位置变化,确保它始终指向“第一个为 0 的位置”(交换式写法)或“下一个要填充的位置”(复制式写法)。如果 left 在某个节点指向了非零元素,说明之前的交换逻辑有 bug。
我自己检查这类代码时有个习惯:在循环体里打日志,打印每一轮结束后数组的完整状态和 left/right 指针位置。这一步对新手特别有帮助,能把抽象的指针移动具象成一步步的状态变化。你不需要真加日志,在脑子里模拟两三个用例就行。
8. 常见面试追问与应对:为什么这题适合当面试题
之所以说这道题是绝佳的面试题,是因为它的考查梯度非常清晰:能写出来只是第一层,能讲清楚为什么这样写才是第二层,能应对追问是第三层。我整理了几个高频追问,顺便给出参考回答的思路。
追问一:为什么不能用常规排序?
因为排序会改变非零元素的相对顺序。题目明确要求保持相对顺序,这是稳定排序的特性。如果你用不稳定的排序算法,比如快速排序,可能直接破坏顺序。用稳定排序(如归并排序)呢?复杂度又上去了。所以排序在这个场景下既不符合要求,也不高效。
追问二:如果数组特别大,内存不够用怎么办?
这就要回到原地操作的意义。空间复杂度 O(1) 保证了不需要额外的大块内存。如果用的是第二个优化版本,连尾部的补 0 循环都省了,内存访问更紧凑,对缓存更友好。
追问三:能不能用 filter + 拼接实现?
能,但不满足“原地”要求。filter 会创建新数组,空间复杂度 O(n)。如果你只是处理一个临时变量,那无所谓,但在大规模数据处理中,这种方案会让内存峰值翻倍。面试官问这个问题,主要就是想考察你知不知道“原地”两个字的含义。
追问四:这和“移动 1 的位置”有什么区别?
本质上没区别,只是目标值从 0 换成了 1。但如果你面试时被问到“把 1 移到前面,0 移到后面”,比如 [2, 1, 0, 3, 1] 拆分成 [1, 1, 2, 0, 3],那是另一个问题——荷兰国旗问题的变体,需要三指针而不是双指针。别把这两个问题搞混了。
追问五:能写出只有一次遍历、且不额外赋值的版本吗?
第 3.3 节的优化版已经接近这个目标了。它在 left !== right 时才复制,在 nums[right] = 0 时有额外赋值,但这是必要的——否则原来的位置会残留非零元素。如果连这个额外赋值都想避免,那就只能用交换式写法,在交换的一瞬间“免费”把 0 移到后面。
9. 踩坑现场:我怎么把“移动 0”写成了“移动负 0”
分享一个我自己早年编程时踩过的坑,算是一点花絮。
有一次我在处理一个数据清洗任务,要把所有 0 移动到数组末尾。我直接套用了“交换式双指针”的写法,判断条件写成了 if (nums[right]),结果遇到 [1, -0, 2] 这种输入直接就懵了。在 JavaScript 里 -0 === 0 为 true,其实不会出错,但在某些强类型语言或用 Object.is 判断的场景里,-0 和 0 不是一回事。如果判断条件是 if (nums[right] === 0),那 -0 确实等于 0,移动没问题;但如果业务里需要区分 -0 和 0,你可能就要把判断条件改成 Object.is(nums[right], -0) 之类的写法。
这道题里不涉及这个边界,但它提醒我:任何看似“天然如此”的边界条件,在不同语言、不同环境下可能完全不一样。 写代码之前先明确语言规范对 0、空字符串、null、undefined 的等价关系,能省掉很多排查时间。
10. 我的实操建议:这道题值得花多大力气准备
最后给点实在的建议。
从面试准备的角度看,“移动 0 的位置”属于热身题,不应当作为你刷题计划的终点。它能帮你建立双指针的基本感觉,但它本身太简单,不值得花整天反复研究。建议控制在 30 分钟内:15 分钟看题 + 写暴力解和双指针解,10 分钟分析复杂度和思考变体,5 分钟写测试用例。
从工程实践的角度看,比这道题本身更重要的是——你能不能在看到一个业务需求时,主动抽象出“稳定分区”这个模式。 我在文章开头说的日志字段压缩、碎片整理、NULL 排序,都是例证。当你手里有这种模式识别的能力,写代码就不再是“一个需求一个实现”,而是“一类需求一套模板”,效率会提升一大截。
我个人的感觉是,像 moveZeroes 这种“看起来太简单、以至于很多年不屑于看”的题,恰恰值得每隔一段时间重做一遍。每次重新实现,你都能发现自己对边界条件的理解和上一次不一样。这也算是一种代码审查的练习吧。
