移动零 (Move Zeroes)
题目描述
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
请注意,必须在不复制数组的情况下原地对数组进行操作。
示例 1:
-
输入:
nums = [0,1,0,3,12] -
输出:
[1,3,12,0,0]
示例 2:
-
输入:
nums = [0] -
输出:
[0]
提示:
-
1 <= nums.length <= 10^4 -
-2^31 <= nums[i] <= 2^31 - 1
进阶:
你能尽量减少完成的操作次数吗?
解法核心:双指针交换法
要实现“原地”且“保持相对顺序”,同时满足进阶要求“尽量减少操作次数”,最有效的机制是设立两个指针,在一次遍历中完成非零元素的提拔和零元素的后置。
我们可以为这两个指针赋予具体的角色:
-
fast(探测指针):它的任务是无条件向前推进,扫描整个数组,寻找非零元素。 -
slow(锚点指针):它的任务是标记下一个非零元素应该被存放的安全位置。
运行机制:
当 fast 遇到 0 时,说明当前位置不需要保留,fast 继续前进,而 slow 停留在原地,它指向的正是这个 0(也就是一个“空出的坑位”)。
当 fast 继续扫描,遇到下一个非零元素时,直接将 fast 指向的非零元素与 slow 指向的 0 进行互换。换完之后,slow 向前推进一步,准备迎接下一个非零元素。
JavaScript 完整实现
JavaScript
/**
* @param {number[]} nums
* @return {void} Do not return anything, modify nums in-place instead.
*/
var moveZeroes = function(nums) {
let slow = 0;
for (let fast = 0; fast < nums.length; fast++) {
// 当探测指针 fast 发现非零元素时,准备处理
if (nums[fast] !== 0) {
// 核心优化:如果 fast 和 slow 指向同一个位置,说明还没有遇到过 0
// 此时它们指向同一个非零元素,不需要发生无意义的原地自换
if (fast !== slow) {
// 将探测到的非零元素,与 slow 停留位置的 0 进行交换
let temp = nums[slow];
nums[slow] = nums[fast];
nums[fast] = temp;
}
// 只要处理过非零元素,锚点 slow 就必须向前移动一位
slow++;
}
}
};
详细用例剖析
我们以最容易让人产生疑问的场景为例,逐步拆解 nums = [0, 1, 0, 3, 12] 的执行全过程。
初始状态: nums = [0, 1, 0, 3, 12],slow = 0,fast = 0
-
第 1 轮 (
fast = 0):-
nums[0]是0。 -
fast探测到0,忽略。slow留在索引 0,它现在标记着第一个 0 的位置。 -
数组状态:
[0, 1, 0, 3, 12]
-
-
第 2 轮 (
fast = 1):-
nums[1]是1(非零)。 -
触发条件。此时
fast(1) !== slow(0),执行交换:将nums[1]的1与nums[0]的0互换。 -
交换后,
slow前进到 1。 -
数组状态:
[1, 0, 0, 3, 12]
-
-
第 3 轮 (
fast = 2):-
nums[2]是0。 -
忽略。
slow留在索引 1,它标记着当前的第一个 0。 -
数组状态:
[1, 0, 0, 3, 12]
-
-
第 4 轮 (
fast = 3):-
nums[3]是3(非零)。 -
触发条件。此时
fast(3) !== slow(1),执行交换:将nums[3]的3与nums[1]的0互换。 -
交换后,
slow前进到 2。 -
数组状态:
[1, 3, 0, 0, 12]
-
-
第 5 轮 (
fast = 4):-
nums[4]是12(非零)。 -
触发条件。此时
fast(4) !== slow(2),执行交换:将nums[4]的12与nums[2]的0互换。 -
交换后,
slow前进到 3。 -
数组状态:
[1, 3, 12, 0, 0]
-
遍历结束,任务完成。你会发现,slow 和 fast 之间的区域,永远都是被推平的 0。fast 就像推土机一样把非零元素往前扔,把 0 往后滚。
评估与进阶分析
-
时间复杂度:
O(n)。无论数组内容如何,fast指针精确地遍历数组一次,时间开销随数组长度线性增长。 -
空间复杂度:
O(1)。全程只借用了fast、slow和temp几个临时变量,没有占用额外的集合空间。
关于“尽量减少操作次数”的深度对比:
存在另一种常见的双指针思路是“先覆盖,后补零”:即 fast 遇到非零就直接赋值给 slow (nums[slow] = nums[fast]),遍历完后,把 slow 后面的位置全部强行填为 0。
为什么我们采用的交换法+优化更好?
-
如果数组是
[0, 0, 0, ..., 1]:-
覆盖法:执行 1 次写入(将 1 写到头部),然后要在末尾执行大量补 0 的写入操作。
-
交换法:只在最后一步执行 1 次交换(包含极少的读写)。
-
-
如果数组是
[1, 2, 3, 4, 5](没有 0):-
覆盖法:执行 5 次自我写入覆盖。
-
交换法(带
fast !== slow判断):判断拦截了所有无意义的交换,写操作次数直接降为 0。
-
通过加入指针位置是否重叠的判断条件,我们在所有极端场景下都把内存写入操作压缩到了真正的物理极限,这直接回应了题目最后的进阶要求。
核心质疑:双指针交换逻辑的稳健性分析
问题描述:
在执行“移动零”的双指针算法过程中,是否存在这样一种中间状态:慢指针 (slow) 指向的是一个非零元素,而快指针 (fast) 指向的是另一个非零元素,且此时 fast > slow?
质疑点:
如果上述状态成立,代码中的交换逻辑 [nums[slow], nums[fast]] = [nums[fast], nums[slow]] 就会演变为“两个非零元素之间的互换”。
由于算法的要求是“保持非零元素的相对顺序”,一旦发生这种非零元素间的乱序交换,是否会导致最终数组的相对顺序被破坏?请论证在算法运行的任何时刻,只要触发交换,slow 所指向的一定是 0。
这是一个非常棒的深度思考!你观察到了算法逻辑中最核心的 “不变性”(Invariant) 问题。
直接给出结论:在程序运行过程中,只要 fast 和 slow 不相等(即 fast > slow),slow 指向的位置【一定】是一个 0。
所以,你担心的“两个非零数互换导致乱套”的情况,在逻辑上是被阻断的。我们来看看为什么这个结论成立:
1. 指针的“分道扬镳”点
在数组开头阶段,如果全是非零数(比如 [1, 2, 3...]),fast 和 slow 是并肩前行的。
-
fast扫到 1,slow也指向 1,处理完后两人一起跳到下一个。 -
这时候
fast === slow,触发不了交换逻辑。
重点来了: 只有当 fast 遇到了数组中的第一个 0,两个人的节奏才会打乱。
-
fast看到 0,不进if逻辑,直接fast++跑向下一个。 -
slow却被挡在了这个 0 的位置,原地待命。
2. 为什么 slow 不可能指向非零?
一旦两人分开了,slow 就变成了一个 “坑位守护者”。
-
此时
slow指向的是那个刚才被跳过的 0。 -
fast在前面继续找。直到fast找到下一个非零数(比如 3)。 -
此时执行交换:把
fast指向的 3 和slow指向的 0 交换。 -
交换后: 3 到了前面,而 0 被换到了
fast原本的位置上。 -
关键动作:
slow++。
关键点: slow 只往后挪一格。这一格原本是什么?
因为在遇到第一个 0 之后,fast 扫过的路径上如果还有 0,这些 0 都会被留在原地。slow 挪到的下一格,要么是下一个原本就在那里的 0,要么是之前交换留下的 0。
3. 逻辑推演(用你的疑问做测试)
假设数组是 [1, 0, 0, 2, 3]:
-
fast=0, slow=0: 指向 1。fast === slow,不交换。slow++变成 1。 -
fast=1, slow=1: 指向 0。fast发现是 0,不处理。slow停在索引 1(它是 0)。 -
fast=2, slow=1: 指向 0。fast发现是 0,不处理。slow停在索引 1(它是 0)。 -
fast=3, slow=1: 指向 2。发现非零! 且fast(3) !== slow(1)。-
交换:
nums[1](0) 和nums[3](2) 交换。 -
结果:
[1, 2, 0, 0, 3]。 -
此时
slow变成 2。
-
-
fast=4, slow=2: 指向 3。发现非零! 且fast(4) !== slow(2)。-
交换:
nums[2](0) 和nums[4](3) 交换。 -
结果:
[1, 2, 3, 0, 0]。
-
总结
你可以把 slow 指针的行为理解为:
“我绝不轻易挪窝。我脚下必须踩着一个非零数我才肯走。如果我脚下是 0,我就死死守在这里,直到
fast给我运来一个非零数把这个 0 换走,我才往后挪一步。”
由于 slow 是从左往右一个一个坑位填过来的,在它右边、fast 左边的区域,全是被跳过的 0 或者换过来的 0。
所以:
-
当
fast > slow时,nums[slow]必为 0。 -
当
fast === slow时,逻辑不触发交换。
这种机制完美保证了:每一次交换,一定是把远处的“宝贝”(非零数)精准地扔进最近的“坑”(零)里。