复写零(Duplicate Zeros),是算法题库里一道非常经典的双指针题目。题面简单到一句话:给你一个固定长度的整数数组,遇到 0 就原地复制一个 0,把后面的元素整体右移,超出数组长度的部分直接丢弃。比如[1,0,2,3,0,4,5,0]操作一遍之后变成[1,0,0,2,3,0,0,4]。但越是这种看着不起眼的小题,越能暴露基本功:很多人第一反应是正序遍历加数组移位,写完以为自己过了,结果不是超时就是悄悄把数据改错。这篇文章我会从暴力解法开始,一步一步推导到“两遍扫描 + 从后往前”的双指针写法,把第一遍为什么要算边界、末尾 0 为什么会坑人、从后往前赋值到底解决了什么,全部掰开揉碎讲清楚,最后附上完整 Java 代码和调试经验。无论你是刚刷题的新手,还是在准备算法面试的老手,这篇都能帮你把双指针这个套路真正焊进脑子里。
1. 题目拆解:复写零考的不只是复制
1.1 题目到底有几个隐藏条件
复写零这道题有三个容易被忽略的硬条件,很多人代码写错就是因为漏了其中一条。
第一,数组长度固定,题目明确要求在原数组上操作,不允许新建辅助数组。这一条直接把很多人的思路堵死,因为如果你能新建一个数组,问题就退化成纯粹的模拟,毫无难度。但原地操作意味着你必须在有限的空间里腾挪,这天然暗示了某种“覆盖”策略。
第二,元素是整体右移,不是替换。遇到一个 0,它会把后面的所有元素往右挤一位,最后一个元素直接掉出数组。注意是“挤出”,不是“轮转”,丢失就是丢失。
第三,0 是被复制成两个,不是把某个值改成 0。一个原始 0 和一个复制出来的 0 长得一模一样,这在正序遍历时会引发连锁污染,后面我会专门讲这个坑。
用大白话打个比方:想象数组是一条固定长度的传送带,0 是一个会“生小 0”的箱子。箱子经过某个位置时,它自己占一个坑,同时立刻在后面再占用一个坑,传送带上的其他东西全部后退一格,退到尽头的东西直接掉下去。题目问的就是这条传送带最终长什么样。
1.2 直觉解法的两宗罪:又慢又会改错数据
先看大多数人的第一反应:遇到 0 就把后面的元素统一右移一位,然后在原位置后面写上 0。这种暴力解法看着合理,实际是双输。
// 初版:遇到0就右移后面所有元素 public void duplicateZeros(int[] arr) { int n = arr.length; for (int i = 0; i < n; i++) { if (arr[i] == 0) { for (int j = n - 1; j > i; j--) { arr[j] = arr[j - 1]; } i++; // 跳过刚复制的0 } } }先说时间代价。最坏情况下数组全是 0,每碰到一个 0 就要把它后面的所有元素往右挪一次,每次挪动是 O(n),总共有约 n/2 个 0,整体复杂度直接到 O(n²)。在长数组上必定超时,这是第一宗罪。
更致命的是第二宗罪:这段代码甚至不能保证结果正确。拿[0, 1, 1]这组数据实测一下。
第一轮 i=0,arr[0]=0,触发复制。内层循环把 arr[2] 改成 arr[1]=1,把 arr[1] 改成 arr[0]=0,数组变成[0, 0, 1]。然后 i++ 到 1。
第二轮 i=1,此时 arr[1] 是多少?是 0。可这个 0 不是“原始的 0”,而是刚才复制出来的“新 0”。程序认不出来,它又触发了一次复制,把 arr[2] 覆盖成 0。最终结果是[0, 0, 0],但正确结果应该是[0, 0, 1](原数组[0,1,1]里第一个 0 复制一份变成[0,0,1,1],截断后是[0,0,1])。
问题本质就是:正序遍历时,复制出来的新 0 和原始 0 长一个样,代码无法区分,于是会反复处理不该处理的 0,一路污染下去。很多人以为这道题只是慢,没想到它还会直接错。
1.3 关键洞察:先算出“哪些 0 真正需要复写”
暴力解法失败的根本原因,是它在“动态生产数据”的同时“动态消费数据”,导致新老数据混淆。换个思路:能不能先一步知道最终结果数组会保留哪些位置?
一个很自然的想法是:如果我能预先算出“复写之后,原数组里哪些元素会最终留在结果数组里”,那接下来就只处理这些元素。0 复写会让数组长度膨胀,位于末尾的那部分元素会因为放不下而被挤出。所以,问题的核心从“如何复制”变成了“边界在哪里”。
这就是双指针方案的起点。带两个指针做一次扫描,一个指针模拟原数组的读取进度,另一个指针模拟“复写后数组”的长度增长。当后一个指针触碰到数组长度上限时,前一个指针停下来的位置,就是原数组最后会保留的元素的下一个位置。有了这个边界,一切就有序了。
2. 双指针推导:两遍扫描怎么把 O(n²) 降到 O(n)
2.1 第一遍扫描:用 i、j 两个指针给结果数组“画线”
第一遍扫描的代码非常短,但含义很深。
int n = arr.length; int i = 0, j = 0; while (j < n) { if (arr[i] == 0) { j++; } i++; j++; }解释一下两个指针各自干的活:
- i 是“原数组读取指针”,它逐个往后走,指向当前正在考察的原始元素。
- j 是“复写后数组位置指针”,它表示如果从头开始模拟复写,结果数组的长度会膨胀到哪里。
- 遇到非 0 元素,结果数组长度只增加 1;遇到 0,长度要额外再增加 1,因为 0 复写后占两个位置。
为什么if (arr[i] == 0) j++之后还要再i++; j++?因为无论当前元素是不是 0,原数组指针和结果数组指针都必须前进一步。0 只是让 j 额外地再走一步,相当于“0 多占了一个坑”。这三行合在一起,表达的就是:非 0 占 1 格,0 占 2 格。
光说不好懂,拿[1, 0, 2, 3, 0, 4, 5, 0]手动推一遍,n=8。
| 轮次 | 进入时 i | arr[i] | 操作 | 本轮结束时 i | 本轮结束时 j |
|---|---|---|---|---|---|
| 0 | 0 | 1 | j 走 1 | 1 | 1 |
| 1 | 1 | 0 | 先 j++ 再 i++、j++,总共 j 走 2 | 2 | 3 |
| 2 | 2 | 2 | j 走 1 | 3 | 4 |
| 3 | 3 | 3 | j 走 1 | 4 | 5 |
| 4 | 4 | 0 | j 走 2 | 5 | 7 |
| 5 | 5 | 4 | j 走 1 | 6 | 8 |
第 5 轮结束后 j=8,循环条件j < n不成立,跳出。此时 i=6、j=8,都刚好比“真正的边界”多走了 1。
这个跳出位置非常关键。它告诉我们:原数组里下标 0 到 5 的元素(1、0、2、3、0、4)是最终能留在结果数组里的“有效区”,而下标 6 的 5 和下标 7 的 0 会被挤出数组。你可以对照一下开头说的期望结果[1, 0, 0, 2, 3, 0, 0, 4],里面确实没有 5 和末尾的 0。
2.2 回退一步:让指针回到真正要操作的位置
第一遍循环结束时,i 和 j 都比目标位置多走了 1,所以统一回退。
i--; j--;回退之后,语义就清晰了:
- i 指向原数组中最后一个“有效元素”,也就是结果数组里还会用到的最后一个原始值。
- j 指向结果数组的最后一个写入位置。
这一步不是可有可无的微调,而是整个方案里最容易被忽略的细节。很多人在第一遍循环里图省事,把退出条件写成while (j <= n),或者把回退写在别处,结果第二遍扫描时要么越界要么漏写。回退的本质是把“循环已经走了过头”的指针拉回合法范围,这是所有“用指针模拟长度增长”类问题里都会遇到的标准操作。
2.3 最坑的边界:数组末尾是 0,且它只剩一个位置
如果第一遍扫描停下来,回退后出现了j == n这种情况,说明什么?
用一个极端小例子[1, 0]来看,n=2。
第一遍循环:
- 第 0 轮:i=0,arr[0]=1,非 0,i 变 1,j 变 1。
- 第 1 轮:此时 j=1 < 2,进入循环,arr[1]=0,触发 j++,j 变 2;再执行 i++、j++,i 变 2,j 变 3。循环结束。
回退之后:i=1,j=2。j == n == 2。
这个状态意味着什么?原本最后一个有效元素是 arr[1]=0,它复写后应该占用两个位置。但结果数组只有 2 个位置,第一个 0 占掉下标 1,第二个 0 已经没有地方放了,只能当场被丢弃。
所以需要专门处理这个“只写了一侧的 0”:
if (j == n) { arr[n - 1] = 0; i--; j -= 2; }这四行代码是这道题最精妙也最容易被搞错的地方。
arr[n - 1] = 0:把最后一个位置直接写成 0,这就是那个只写一次的半截 0。i--:跳过这个已经处理完的原始 0,不让第二遍扫描再碰它。j -= 2:因为一个正常的 0 要占两个坑,现在只占了一个,结果数组剩余可用长度相当于缩了两格;所以 j 要直接回退两格,从 n 退到 n-2。
如果省略这个分支,第二遍扫描里arr[j--]会写到一个不存在的下标 n 上,直接抛数组越界异常。或者踩另一个极端:你多写了几个 0,数组尾部被污染,结果完全错误。这个边界我在后面“常见错误”章节还会再聊,它是全题最容易挂的地方。
2.4 从后往前复写:为什么倒着写就不会覆盖
边界处理完之后,就到了真正的第二遍扫描:
while (i >= 0) { if (arr[i] == 0) { arr[j--] = 0; arr[j--] = 0; } else { arr[j--] = arr[i]; } i--; }逻辑很简单:从原数组最后一个有效元素开始,倒着往前处理。遇到非 0 就写一位,遇到 0 就写两个连续的 0,写的时候从右往左填。
但这里有个绕不开的问题:为什么从后往前写就不会覆盖还没处理的元素?
关键在于双指针的第一条恒等关系:整个过程中,写入指针 j 始终不小于读取指针 i。换句话说,写入的位置永远在原数组读取位置的右边,或者至少相等。
这保证了什么?当你倒着处理时,arr[j] 这个目标位置,要么是早已被挤出数组、没有任何价值的垃圾空间,要么是已经被你写好的结果数据。而 arr[i] 这个源数据,它的左侧还全都没处理。写入修的永远是“右边的旧数据”,读取用的永远是“左边的原始值”,两边永远不碰头。覆盖自然就不会发生。
生活里有个更好的类比:一堆人排队,要求每个人往右挪一个位置。如果从队头开始挪,右边还没挪的人就被左边的人占了位置,队伍直接乱套。如果从队尾开始挪,每个人挪完之后,他的旧位置就空了出来,左边的人再往右挪时,目的地已经没人了,每挪一步都落在一个空位上,整条队伍毫发无损。这正是“从后往前”的精髓。
3. Java 实现与核心代码逐段拆解
3.1 可以直接抄的参考实现
把前面的思路拼起来,就是一份完整的 Java 解法:
class Solution { public void duplicateZeros(int[] arr) { int n = arr.length; int i = 0, j = 0; // 第一遍:模拟复写,算出原数组的“有效边界” while (j < n) { if (arr[i] == 0) { j++; } i++; j++; } // 两个指针都多走了一步,统一回退 i--; j--; // 边界:最后一个有效元素是0,且结果数组只剩最后一个位置 if (j == n) { arr[n - 1] = 0; i--; j -= 2; } // 第二遍:从后往前复写 while (i >= 0) { if (arr[i] == 0) { arr[j--] = 0; arr[j--] = 0; } else { arr[j--] = arr[i]; } i--; } } }这份代码我实测过很多种输入,包括全 0、末尾 0、开头 0、无 0、单元素数组,都能得到正确结果。做算法题时,这种“短小但每一行都有存在意义”的解法,远比冗长的特判堆砌靠谱。
3.2 关键代码块拆开说
先看这个:
if (arr[i] == 0) { j++; }j在这里不是“结果数组的下标”,而是“复写后数组的长度计数器”。遇到 0 时额外加一,是模拟“0 复制后占了两个位置”。如果你把它理解成下标,后面回退、边界的逻辑全都会绕晕。
再看这个:
arr[j--] = 0; arr[j--] = 0;两条赋值语句的执行顺序是先写右边的空位,再写左边的空位。因为 j 是不断递减的。如果你试图反着写,比如先写 arr[j] 再写 arr[j-1],虽然最终效果相同,但语义上非常容易让读代码的人误以为是从左往右写。保持arr[j--]的写法,本身就是一种带有方向性的注释:从右往左填。
最后是i--放在每轮末尾的写法。这里的顺序不能随意调换,因为一旦处理完 arr[i],这个源位置马上就会被右侧写入的 0 覆盖掉。好在读取已经完成,覆盖就覆盖,不影响任何后续判断。这也是为什么第二遍循环中“先读后写、每轮读一次”的顺序如此自然。
3.3 复杂度分析:为什么这是最优解
时间复杂度:第一遍扫描是 O(n),第二遍扫描也是 O(n),合起来还是 O(n)。这里的重点不是“两遍加起来 2n”,而是常系数极小的线性复杂度,大数据量下表现非常稳定。
空间复杂度:O(1)。全程只用到了 i、j 两个整型指针,没有任何额外的数组或容器。这一点必须反复强调,因为题目明确要求原地操作,你要是敢开一个新数组,哪怕逻辑全对,也已经离题万里。
从信息论的角度看,这道题也不可能低于 O(n):原数组的每个元素至少要被读取一次,最终每个位置也至少要被写入一次,单独读写都是 O(n) 的下界。所以两遍扫描的 O(n) + O(1) 已经是这个问题的理论最优形态。面试官问“能不能再优化”时,你完全有底气说这条路已经到底了。
3.4 一组测试用例帮你验收代码
写这种数组题,光靠脑内模拟不保险。这里给你几组必测数据,直接在你的编辑器里跑一遍,全过基本就稳了。
| 输入数组 | 输出数组 | 说明 |
|---|---|---|
| [1,0,2,3,0,4,5,0] | [1,0,0,2,3,0,0,4] | 经典用例,末尾两个元素被挤出 |
| [0,0,0] | [0,0,0] | 全是 0,怎么复写都还是 0 |
| [1,0] | [1,0] | 末尾 0 只来得及写一次 |
| [0,1] | [0,0] | 开头 0 复制,1 被挤出 |
| [1,2,3] | [1,2,3] | 没有 0,原样输出 |
| [0] | [0] | 单元素边界 |
特别说明一下[1,0]这组:很多人会以为答案是[1,0],其实它理应如此,因为复制出来的第二个 0 没有位置可放,只能丢弃。这和[0,1]变成[0,0]、1 被挤出是完全对称的两种边界情况。两组都跑对,你的边界处理就合格了。
4. 实战排错:复写零最容易踩的三个坑
4.1 坑一:正序遍历,把“新 0”当成“老 0”处理
这是所有错误里最具隐蔽性的一个。表面看代码没毛病:遇到 0 复制,然后 i++ 跳过新 0。但麻烦出现在“跳过”这个动作上。
用[0, 1, 1]复盘一遍。第一次复制后数组变成[0, 0, 1],i++ 跳到下标 1,结果下标 1 还是 0。这个 0 是不是原始的 0?不是,它是复制出来的。可 for 循环只会看值,不看血统。它老老实实地又执行了一次复制,把数组变成[0, 0, 0],而正确结果是[0, 0, 1]。
应对这类问题的经验是:只要发现“写入会影响后续读取的判断”,就要立刻警觉。在数组原地操作的题里,正序写入和正序读取往往会互相踩踏。一旦代码里出现这种苗头,优先考虑倒序,或者先扫描后写入,不要硬着头皮在正序里打补丁。
4.2 坑二:为了跳过复制出来的 0,多跳了一个元素
很多改进版代码会写出这种逻辑:
if (arr[i] == 0) { // 把后面元素全部右移 i++; // 我跳过了复制出来的0 }问题在于,右移操作完成后,arr[i+1] 这个位置放的是原数组原本的下一个元素。如果你在 i++ 之后又执行了 for 循环自带的 i++,那下一步 i 会指向下下个元素。也就是说,你跳过了两个位置,其中一个原元素彻底没被检查。
这种 bug 在数组长度为奇数、末尾恰好是 0 时会静默发生,不容易被小用例发现。我的建议是:不要在 for 循环体内修改循环变量,尤其是不要既在判断里 i++,又让 for 的自增再动一次。如果非要跳过,就改用 while 循环,把指针推进的动作全部放到一处,避免两套自增方案互相打架。
4.3 坑三:不处理j == n的边界,直接越界或写错
跑一段[1, 0]看看不处理边界的后果。
第一遍扫描结束后 i=2、j=3,回退后 i=1、j=2。此时 j == n == 2,数组最大下标是 1。如果直接进第二遍循环,arr[j--]第一次就会去碰 arr[2],Java 里当场给你一个ArrayIndexOutOfBoundsException,程序直接崩。
那如果换一种写法,把回退改成j--两次而不是一次呢?j 变 1,数组不越界了,但你会把 arr[1] 原封不动地当成 0 写一遍。逻辑上看着没错,可数组尾部不应该多出来的位置被反复写入,结果数组会多出几个本不该出现的 0。这种错误比崩溃更恶劣——程序没报错,但答案错了,而且很难查。
正确做法就是我前面写的那个特殊分支:if (j == n)时单独把最后一个位置置 0,再跳过源 0、回退 j。这个分支必须出现在第二遍循环之前,且只能处理一次。很多人仿写时会把 j 回退成 n-1 甚至 n-3,全都不对,原因就是没有真正理解“0 占两个坑,现在只剩一个坑”的几何关系。
4.4 调试利器:打印指针轨迹和数组快照
遇到这类指针题,与其死盯着代码发呆,不如直接把指针轨迹打出来。我调试复写零时用的是一份带日志的版本,每次 i、j 变动后都打印一行,跑一遍就能把数组变化的全过程看得清清楚楚。
import java.util.Arrays; class Solution { public void duplicateZeros(int[] arr) { int n = arr.length; int i = 0, j = 0; System.out.println("开始 " + Arrays.toString(arr) + ", i=" + i + ", j=" + j); while (j < n) { if (arr[i] == 0) { j++; } i++; j++; System.out.println("第一遍 i=" + i + ", j=" + j + " -> " + Arrays.toString(arr)); } i--; j--; System.out.println("回退 i=" + i + ", j=" + j); if (j == n) { arr[n - 1] = 0; i--; j -= 2; System.out.println("边界处理 i=" + i + ", j=" + j + " -> " + Arrays.toString(arr)); } while (i >= 0) { if (arr[i] == 0) { arr[j--] = 0; arr[j--] = 0; } else { arr[j--] = arr[i]; } i--; System.out.println("第二遍 i=" + i + ", j=" + j + " -> " + Arrays.toString(arr)); } } }注意看第一遍扫描时数组本身并不会发生变化,打印结果里数组一个字都不动,只有 i、j 在跳。这个反直觉的现象本身就是理解题目的一把钥匙:第一遍只是在“脑内模拟”,并没有真的去写数组。如果你看到第一遍就改数组,那你的思路已经从根上偏了。
我还习惯在调试时准备一支笔和一张纸,画两排格子代表原数组和结果数组,手动标出 i、j 的位置,每走一步更新一次。纸上推演一套[1,0,2,3,0,4,5,0]下来,顶多两分钟,但对分界线和边界条件的理解会直接上一个台阶。
5. 双指针通用套路:从复写零到一类题
5.1 复写零属于哪种双指针分支
双指针在算法题里其实分好几个亚型,别把它们混为一谈。
- 同向快慢指针:一个快一个慢,快指针负责探查,慢指针负责记录,典型代表是“移除元素”“删除重复项”。
- 相向左右指针:一个从左往右、一个从右往左,典型代表是“两数之和(有序数组)”“回文判断”。
- 从后往前双指针:两个指针都从尾部开始往回走,典型代表就是这次的复写零和另一个经典题“合并两个有序数组”。
复写零的特殊之处在于,它把“从后往前”和“预先计算边界”两个技巧捆绑在一起。你不能像相向指针那样直接两头开走,因为你要先知道结果数组从哪里截断。所以它的套路是:第一遍用同向指针算边界,第二遍再用从后往前的指针去写。两遍扫描各干各的事,合起来才是一个完整方案。
5.2 三道同套路题,拿来巩固手感
如果你做完复写零还想趁热打铁,下面三道题套路非常相近,难度也友好。
第一道:移动零。把数组里所有 0 移到末尾,保持非 0 元素的相对顺序。它本质是把“0 往右赶”,和复写零正好反过来。用同向快慢指针,快指针找非 0,慢指针指向下一个该放非 0 的位置,一趟搞定,O(n) 时间、O(1) 空间。
第二道:删除有序数组中的重复项。快指针扫描,慢指针维护“最终保留长度”,遇到不重复的值就放到慢指针位置。这题比复写零更温和,因为不需要考虑复制膨胀,只是压缩。
第三道:合并两个有序数组。给定两个递增数组,要求把第二个数组合并进第一个数组,原地排序。标准解法就是两个指针分别从两个数组的末尾往前遍历,谁大谁放到目标位置的末尾。它和复写零共享“从后往前避免覆盖”的核心逻辑,只是多了两条指针链。做完这道,你对“倒着写”的理解会更深入一层。
这三道题加复写零,基本覆盖了“原地修改 + 保持相对顺序”的全部常用手法。
5.3 判断该用“从后往前”的三个信号
做题时怎么第一时间想到这个套路?我的习惯是套三个信号。
第一个信号:题目说了“必须原地修改”,不能新建数组。这意味着你没法依赖额外空间来做缓冲,所有腾挪都要在原数组内完成。
第二个信号:操作导致元素右移或数组内容变多,但最终长度不变或受限。复写零是 0 变多挤掉尾部,合并有序数组是从尾部补入,移动零是非 0 前移。
第三个信号:从前往后模拟时,写入位置可能覆盖尚未读取的位置。这是最核心最本质的信号。只要你发现“我写下的东西正好是下一步要读的东西”,大概率就该反过来做。
三个信号同时出现,那基本就是“从后往前双指针”的标准模板:第一步扫描算边界,第二步倒序写结果。这个模板比任何口诀都管用,因为它完全是从问题本身的约束推导出来的。
5.4 面试时怎么把思路讲得既快又清楚
技术面试碰到复写零,最忌讳一上来就默写代码。面试官真正想看的是你拆解问题的过程。我建议按下面这个顺序讲:
先说暴力思路:正序遇到 0 就右移,时间复杂度 O(n²),更关键的是,正序会把复制出来的新 0 和原始 0 混淆,存在正确性隐患。这一步让面试官知道你具备“先分析再动手”的意识。
再说优化方向:能不能先算出最终结果数组的边界?于是用第一个指针 i 扫描原数组,第二个指针 j 模拟复写后数组的长度,当 j 触到边界时,i 停下的位置就是原数组最后一个有效元素后面。这一步讲的是方法论。
然后提边界:如果最后一个有效元素是 0 且结果数组只剩一格,那这个 0 只写一次,要单独处理。这一步是最抢分的地方,绝大多数人栽在边界上,你能主动说出来,印象分直接拉满。
最后收尾:第二遍从后往前写,遇到 0 写两个,遇到非 0 写一个。复杂度 O(n)、O(1) 空间。每一步都有明确的“为什么”,整个答案就是一个完整的故事,而不是代码的流水账。
我个人在实际刷题中的体会是,双指针类题目里出错最多的地方永远不是思路的大方向,而是循环结束后那一步“回退”。很多同学代码大体对,但就因为在 while 退出后少写“i--、j--”,整个数组要么多出一截怪数据,要么尾部的 0 全丢。所以我现在拿到任何双指针题,都会在循环退出后专门停下来审视指针位置,问自己一句:“这个指针现在是落在界内还是界外?”这个问题已经帮我抓住过不知道多少处 bug 了。
最后再分享一个小技巧:如果你不确定自己的双指针方案是否安全,就在纸上画两个并排的小数组,一个标“原数组”、一个标“结果数组”,把 i 和 j 分别放在对应位置上,然后模拟第一遍、第二遍各走几步。画出来的视觉感受比自己脑补代码执行要直观太多。复写零这道题,画完那一张表,你就能彻底告别对着屏幕干瞪眼的阶段了。