深蓝黑背景封面。左侧是数组重排示意:上方一行五个方格是初始数组 [3, 0, 1, 0, 12],非零格 3、1、12 为青色描边,零格为灰色虚线,非零格下方标有顺序号 ①②③;中间一个向下箭头标注「原地稳定划分」;下方一行五个方格是结果数组 [3, 1, 12, 0, 0],非零顺序号 ①②③ 保持不变,结果下方标注「非零相对顺序不变,第一个 0 落在下标 3」。底部两行小注「要求:一次遍历 O(n)、原地 O(1) 额外空间」「写指针 = 下一个非零应放的位置」。右侧大标题两行「把数组里的 0 全部移到尾部」「还不能打乱顺序」,副标题「双指针一次遍历的原地稳定划分」,小注「返回第一个 0 的下标,一趟扫描搞定」。左上角标注「面试经典 · 算法」,右上角圆角胶囊标注「Day 01 · 11-28」。 图 1:本文封面,一趟原地重排的前后对照。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料整理,与任何公司的当前招聘无关。原题要求写一个函数:把整型数组里的 0 全部移到末尾,其余非 0 整数移到前面且保持原有相对顺序,函数返回结果中第一个 0 的下标。约束很典型——一次遍历、原地操作,考点是「稳定划分」的写指针设计。

先把题意翻译成算法语言。

「把 0 挪到尾部、非 0 保持顺序前移」,本质是按「是否为 0」这个谓词对数组做稳定划分(stable partition):非 0 元素是一个等价类,要保持内部相对顺序;0 是另一个等价类,全部沉底。返回值也不用额外找——划分完成后,非 0 元素恰好占满前 w 个位置,w 就是第一个 0 的下标;如果数组里没有 0,返回数组长度即可。所以一次遍历结束,结果和返回值同时到手,不需要第二趟扫描。

双指针的分工:读指针找非零,写指针收非零。

设写指针 w 指向「下一个非零元素应该安放的位置」,初始为 0;读指针 i 从头到尾扫描。每当 a[i] 非零,就把 a[i] 与 a[w] 交换,然后 w 前进一步。为什么这样就稳定?关键在于写入顺序:非零元素是按被扫描到的先后顺序,依次写到 0、1、2…… 这些连续位置上的,先遇到的先落位,相对顺序天然不变。而 0 不需要被主动搬运——它只是在与非零元素交换时被动让位,自然被挤到后排。循环结束时,w 左边的都是按原序排列的非零元素,w 及其右边全是 0,w 本身就是要返回的下标。时间复杂度 O(n),额外空间 O(1),满足题目全部约束。

用代码跑一遍验证。

下面这段实现附带三组边界用例和一组带标签的稳定性抽查(Node.js 22.15.0,node movezeros.js):

function moveZeros(a) {
  let w = 0;                            // 写指针:下一个非零应放的位置
  for (let i = 0; i < a.length; i++) {  // 读指针:扫描每个元素
    if (a[i] !== 0) {
      [a[w], a[i]] = [a[i], a[w]];      // 非零换到前排,0 被动后移
      w++;
    }
  }
  return w;                             // 第一个 0 的下标(无 0 时为 n)
}
const t1 = [3, 0, 1, 0, 12];
const r1 = moveZeros(t1);
console.log('原数组 [3,0,1,0,12] →', t1, '首个0下标:', r1);
const t2 = [0, 0, 0];
console.log('全0数组 [0,0,0] →', t2, '首个0下标:', moveZeros(t2));
const t3 = [1, 2, 3];
console.log('无0数组 [1,2,3] →', t3, '首个0下标:', moveZeros(t3));
// 稳定性抽查:给非零元素贴标签看重排后的顺序
const tagged = [{v:5,id:'a'},{v:0,id:'x'},{v:7,id:'b'},{v:0,id:'y'},{v:9,id:'c'}];
let w = 0;
for (let i = 0; i < tagged.length; i++) {
  if (tagged[i].v !== 0) { [tagged[w], tagged[i]] = [tagged[i], tagged[w]]; w++; }
}
console.log('非零标签顺序:', tagged.slice(0, w).map(e => e.id).join(','), '首个0下标:', w);

实测输出:

原数组 [3,0,1,0,12] → [ 3, 1, 12, 0, 0 ] 首个0下标: 3
全0数组 [0,0,0] → [ 0, 0, 0 ] 首个0下标: 0
无0数组 [1,2,3] → [ 1, 2, 3 ] 首个0下标: 3
非零标签顺序: a,b,c 首个0下标: 3

四行输出分别验证:常规用例结果正确且首个 0 在下标 3;全 0 数组返回 0,算法不崩溃;无 0 数组原样不动、返回数组长度;标签抽查确认非零元素 a、b、c 的相对顺序与输入完全一致,稳定性成立。有一个细节值得注意:当 i 等于 w 时(前缀暂时没有遇到 0),交换是自己换自己,无害,不必特判。

深蓝黑背景概念图。顶部小字「概念图 · 移动零与稳定划分」,主标题「读指针找非零,写指针收非零,0 自然沉底」。主体左侧是三条横向数组带演示遍历过程:第一条为初始状态 [3, 0, 1, 0, 12],读指针 i 与写指针 w 都指向下标 0;第二条为中间状态 [3, 1, 0, 0, 12],说明文字「a[2]=1 是非零:交换 a[w=1] 与 a[2],w 前进到 2」;第三条为最终状态 [3, 1, 12, 0, 0],写指针 w 停在下标 3,标注「返回 w:第一个 0 的下标」。主体右侧一张要点卡列四条:只把非零按原序写到 w 位置所以稳定;交换让 0 被动后移;一趟扫描 O(n) 时间、O(1) 额外空间;循环结束时 w 就是第一个 0 的下标。底部总结条写「稳定性的来源:非零元素永远按原顺序写入前排,0 只是被动让位」。 图 2:读写指针的推进过程与稳定性的来源。

失分点与答题结构。

常见的三个坑:一是用「从后往前补 0」的思路先压缩再填零,写完才发现返回值还要单独再扫一遍;二是排序或借助新数组,直接违反原地与保持顺序两个约束;三是只写主流程、漏掉全 0 和无 0 两个边界的返回值约定。面试时建议按这个顺序答:先把题意翻译成稳定划分,点明返回值就是划分边界;再给出读写双指针方案并解释稳定性来源(非零按扫描顺序依次落位);然后口述代码,主动补上边界用例;最后给出复杂度。这题的同款模式在工程里很常见——数据清洗管线里把有效样本前移、把占位或无效标记后移,就是同一个原地稳定划分,写对一次处处复用。你刷题时遇到过哪些「划分边界就是答案」的题目?欢迎交流。