“复写零”这道题,我在很多次笔试和模拟面试里见过它。题目描述短到一眼就能看完:给定一个固定长度的整数数组,遇到0就把它复写一份,其余元素全部右移,超过数组长度的部分直接截掉,而且必须原地修改。很多人看到之后觉得“这不就是个模拟吗”,结果一动手就翻车:新建数组的人违反空间限制,用插入法的人写出了O(n²),原地正序遍历的人把还没处理的元素覆盖了。真正让这道题从“会写”变成“写对”的,是双指针里那个“从后往前”的直觉和一层很容易被忽略的边界条件。这篇就把这个题完整拆开,讲清楚两趟扫描的原理、边界特判到底在防什么,以及同类的双指针题怎么举一反三。
1. 先看懂题目:复写零到底在干什么
1.1 题面与示例
题目本身不长,翻译成人话是这样的:给你一个长度固定的数组,你把每一个0都变成两个0,其他数字不变,顺序也不变,整体向右挤压,凡是超出原数组长度的元素全部丢弃。关键约束是“原地修改”,也就是不能另外开一个等长的数组来承接结果。
举一个最典型的例子:
输入[1, 0, 2, 3, 0, 4, 5, 0],输出[1, 0, 0, 2, 3, 0, 0, 4]。
我们拆开看逻辑展开的过程:
- 1 变成 1
- 第一个 0 变成 0, 0
- 2 变成 2
- 3 变成 3
- 第二个 0 变成 0, 0
- 4 变成 4
- 5 变成 5
- 最后一个 0 变成 0, 0
逻辑上完整的新序列是[1, 0, 0, 2, 3, 0, 0, 4, 5, 0, 0],长度已经变成11,而原数组长度是8,所以最后三个格子5, 0, 0直接被丢弃,最终数组就是[1, 0, 0, 2, 3, 0, 0, 4]。
没有0的情况也顺一遍:[1, 2, 3]复写后还是[1, 2, 3],因为没有一个元素发生翻倍。
这道题本身逻辑不复杂,但它的陷阱正好藏在“原地”和“截断”这两个词上。
1.2 为什么最直觉的解法反而不行
正常人第一次看到这个题,脑子里冒出来的多半是“那我重新建一个数组,遍历原数组,遇到0写两遍,遇到非0写一遍,最后截取前n个,完事”。这个做法结果完全正确,时间也是O(n),但它开辟了额外空间,空间复杂度是O(n)。
题目里“原地修改”这四个字直接堵死了这条路。
换个思路,有人会想“我直接在原数组里插入0”。以 Python 的list.insert为例,每一次在位置i插入0,都需要把i后面的元素整体后移一位,这一步就是O(n)的。如果数组里有m个0,总体复杂度逼近O(n*m),在0密度高的数据上就是O(n²),比如全0数组会卡到怀疑人生。C++里用vector::insert同理。
所以这道题表面上考“数组模拟”,实际考的是:能不能在O(1)额外空间里完成扩容式的拷贝。一旦意识到这个约束,双指针几乎是注定要走的路。
1.3 用“整理书架”理解原地扩容
为什么从前往后会出事,从后往前就不会?可以想象一排书架上摆了8本书,现在要求把其中所有的“0书”都变成两本,其余书保持相对顺序,最终还是只摆8本。
如果你从左往右整理,当你把第3本书复制成两本、往右塞的时候,第4本书的位置会被占掉。你还没处理它呢,它的原内容就被覆盖了。等你读到那个位置时,原书已经没了。
如果反过来,从书架最右边开始整理,每次把一本书放到它最终该在的位置,左边那些还没移动的书完全不受影响。因为你动的是右边已经处理完的区域,永远碰不到左边还没读的书。这就是这道题最核心的操作直觉:目标数组比原数组长,原地操作时优先从后往前写。
2. 双指针切入:第一趟找边界,第二趟倒着写
2.1 双指针在这里不是快慢,而是“读指针”和“写指针”
说到双指针,很多人第一时间想到链表里的快慢指针,或者二分查找里的左右夹逼。但数组原地修改类的题目里,更常用的双指针模型其实是“读指针”和“写指针”:一个指针负责遍历原始数据,另一个指针负责往结果位置写入。
复写0这个题里,读指针和写指针的关系有点特殊:因为数组要变长,写指针的移动速度会比读指针快。遇到一个0时,读指针往前走一格,写指针却要往前走两格。正因为两个指针不同速,才导致直接用一趟从头到尾的遍历无法完成——写指针会超过读指针,覆盖掉还没读取的数据。
解决办法很朴素:既然从头到尾会越读越乱,那就从尾到头。可问题又来了:从尾到头写的时候,写指针一开始应该落在哪个位置?直接落在n-1对吗?不对,因为逻辑上数组扩容后的尾部并不是原数组的尾部。必须先知道扩容后哪些元素会被截断。
于是就有了经典的两趟结构。
2.2 第一趟:扫描“虚拟扩容后”的数组,确定有效前缀
我们可以想象数组扩容后的样子,虽然实际不能开新数组,但逻辑上可以算出来。
设n = len(arr),zeros记录扫描到当前位置为止复写0带来的额外空间占用。每遇到一个非0元素,它在扩展后序列里占1个位置;每遇到一个0,它在扩展后序列里占2个位置。
第一趟扫描的循环条件写成while i + zeros < n,这个条件的意思是:当前这个原始元素如果写进扩容后的数组,它的最后一个位置是否还能落在原数组长度之内?如果能,说明它活着;如果i + zeros已经到边界甚至超过边界,说明它以及它后面的所有元素都会在截断中消失。
以[1, 0, 2, 3, 0, 4, 5, 0]为例,我做一个完整追踪表:
| 步骤 | i | arr[i] | 进入前 zeros | i + zeros | 是否满足< n=8 | 操作后 zeros |
|---|---|---|---|---|---|---|
| 1 | 0 | 1 | 0 | 0 | 是,保留 | 0 |
| 2 | 1 | 0 | 0 | 1 | 是,保留 | 1 |
| 3 | 2 | 2 | 1 | 3 | 是,保留 | 1 |
| 4 | 3 | 3 | 1 | 4 | 是,保留 | 1 |
| 5 | 4 | 0 | 1 | 5 | 是,保留 | 2 |
| 6 | 5 | 4 | 2 | 7 | 是,保留 | 2 |
| 7 | 6 | 5 | 2 | 8 | 否,退出 | 2 |
循环退出时i = 6,说明索引6和7的元素都会被截断,有效前缀是索引0到5这一段。把i--之后,i = 5就是最后一个存活的原始元素下标。
这一步的本质是:用虚拟下标去卡边界,找出哪些原始数据能活到最终数组里。
2.3 第二趟:从有效前缀末尾开始倒着复写
当第一趟确定了最后一个存活的原始元素的坐标之后,第二趟就很直接了。
初始化写指针write = n - 1,也就是最终数组真正的尾部。读指针read从第一趟算出的有效前缀末尾开始。
每一轮循环做这样的事:
- 如果
arr[read]是0,就往arr[write]和arr[write - 1]各写入一个0,写指针前进两格; - 如果
arr[read]不是0,就把原值写到arr[write],写指针前进一格; - 读指针每次前进一格。
因为读指针从有效前缀的末尾出发,写指针从数组末尾出发,而有效前缀的末尾一定不晚于数组末尾,所以写指针始终在右侧,就算连续写两个0,写指针左移两格,也不太可能越过读指针。即使某个极端情形下写指针短暂到达读指针附近,读指针此时已经不会再需要那些位置上的原始数据了。整个遍历过程天然避免了覆盖未读数据。
还是[1, 0, 2, 3, 0, 4, 5, 0],第一趟结束后zeros = 2,有效前缀末尾是索引5。第二趟从read = 5,write = 7开始,倒着写一圈就能得到[1, 0, 0, 2, 3, 0, 0, 4]。
3. 完整解题方案与多语言实现
3.1 分步骤拆解算法流程
整个算法整理成三步:
- 第一趟统计:用
zeros记录复写0带来的额外占用,循环条件卡住数组边界,找出有效前缀的末尾位置。 - 边界特判:如果最后一个存活元素正好是0,且它只占据了数组的最后一格,那么这个0只能复写一次,需要单独处理。这是全题最容易出错的地方。
- 第二趟倒写:读指针从有效前缀末尾向左走,写指针从数组末尾向左走,根据当前元素是否为0决定写一格还是写两格。
3.2 边界特判到底在防什么
很多人的代码死在这一步。看一个具体例子:
输入[1, 0, 0, 2],第一趟扫描后zeros = 2,i最终指向索引2。这里i + zeros = 2 + 2 = 4,恰好等于n = 4。这说明最后一个存活元素是索引2的0,但它在逻辑展开后占两个位置,而数组只剩末尾一个空位了。也就是说,这个0只能写一次到arr[3],第二个0要被截断。
如果不在第二趟开始前把这个事处理掉,倒写时读指针读到索引2的0,会往arr[3]和arr[2]连写两个0,结果把数组搞得面目全非。
正确的特判是:
if zeros > 0 and i + zeros == n: arr[n - 1] = 0 write -= 1 read -= 1先把能写的那一个0放到数组末尾,同时写指针和读指针各前移一格。这样索引2的0就被视为“已经完成了单次复制”,不会再进入第二趟循环。推演一遍:
- 特判后 read 指向索引1,write 指向索引2
- 读到索引1的0,往
arr[2]和arr[1]写0 - 读到索引0的1,往
arr[0]写1
最终数组是[1, 0, 0, 0],和人工推演完全一致。
3.3 Python 实现
def duplicateZeros(arr): n = len(arr) zeros = 0 i = 0 # 第一趟:找出有效前缀边界 while i + zeros < n: if arr[i] == 0: zeros += 1 i += 1 # i 此时指向第一个不可能存活的位置,减1得到有效前缀末尾 i -= 1 write = n - 1 # 边界特判:最后一个有效元素是0且只能写一次 if zeros > 0 and i + zeros == n: arr[write] = 0 write -= 1 i -= 1 # 第二趟:从后往前倒写 while write >= 0: if arr[i] == 0: arr[write] = 0 write -= 1 arr[write] = 0 write -= 1 else: arr[write] = arr[i] write -= 1 i -= 1这段代码里最值得注意的两个地方:第二趟的循环条件用write >= 0而不是i >= 0,可以防止读指针越界;边界特判里写指针和读指针都要同步前移。
3.4 C++ 与 Java 实现
C++版本:
void duplicateZeros(vector<int>& arr) { int n = arr.size(); int zeros = 0; int i = 0; while (i + zeros < n) { if (arr[i] == 0) zeros++; i++; } i--; int write = n - 1; if (zeros > 0 && i + zeros == n) { arr[write] = 0; write--; i--; } while (write >= 0) { if (arr[i] == 0) { arr[write] = 0; write--; arr[write] = 0; write--; } else { arr[write] = arr[i]; write--; } i--; } }Java版本:
public void duplicateZeros(int[] arr) { int n = arr.length; int zeros = 0; int i = 0; while (i + zeros < n) { if (arr[i] == 0) zeros++; i++; } i--; int write = n - 1; if (zeros > 0 && i + zeros == n) { arr[write] = 0; write--; i--; } while (write >= 0) { if (arr[i] == 0) { arr[write--] = 0; arr[write--] = 0; } else { arr[write--] = arr[i]; } i--; } }三种语言的核心逻辑完全一致,只是语法细节不同。Java里连续两次write--简写在一行里,注意别把指针移动次序搞反就行。
3.5 复杂度分析
第一趟循环最多扫描到n个元素就停,第二趟倒写也只需要处理有效前缀长度范围内的元素。两趟加起来是严格O(n),即使是全0数组,第一趟也会因为i + zeros >= n快速退出。空间方面只用了zeros、i、write三个整数变量,没有开辟任何数组,空间复杂度O(1)。
时间上可能有人会问:第二趟循环里遇到0会连写两次,会不会多一次操作?不会,因为写入这两个0正好对应它复写后的两个格子,写入次数和最终数组长度是一一对应的。总体操作次数仍然和数组长度成正比。
4. 最容易翻车的边界条件
4.1 末尾是0:复写一次还是两次
这是整道题最大的坑,单独拿出来说。
判断逻辑是:如果最后一个“存活”的原始元素刚好是0,而且它复写后占两个格子,但数组只剩一个空位,那么这个0只能复写一次。写成代码就是i + zeros == n这个条件。
我用四个例子做个速查表:
| 输入 | 逻辑展开后前n位 | 代码结果 | 说明 |
|---|---|---|---|
[1, 0, 0, 2] | [1, 0, 0, 0] | [1, 0, 0, 0] | 最后一个存活元素0只写一次 |
[0, 0, 1] | [0, 0, 0] | [0, 0, 0] | 边界0占据最后格子 |
[0, 0, 0] | [0, 0, 0] | [0, 0, 0] | 前两个0占4格,只剩3格 |
[0, 1, 0] | [0, 0, 1] | [0, 0, 1] | 第二个0直接被截断 |
这几个用例看着差不多,但如果不做边界特判,反应在结果上就是末尾多了一个0或中间少了一个0。
4.2 空数组和单元素数组
空数组就不说了,什么循环都不会进入,直接返回。
单元素数组要看元素是不是0:
[0]:复写后逻辑上是[0, 0],截断成[0]。第一趟扫描时i + zeros < 1,i=0时遇到0,zeros变成1,i变成1,退出;i=1减到0,i + zeros = 1 == n,触发特判,把arr[0] = 0后 write变成-1,read变成-1,第二趟不执行,最终是[0]。[3]:没有0,第一趟扫描后 zeros=0,不触发特判,第二趟把arr[0]原样写回,最终[3]。
这两个小用例用来验证代码的健壮性特别好,刷完题之后可以顺手测一下。
4.3 手工推演一个全0数组
全0数组是很多人写崩的场景。拿[0, 0, 0]来说,逻辑上复写后是6个0,截断成3个0,看起来结果没变化,但代码必须正确处理。
第一趟扫描:
- i=0,zeros=0,进入循环,arr[0]=0,zeros变成1,i变为1
- i=1,zeros=1,
1 + 1 < 3成立,arr[1]=0,zeros变成2,i变为2 - i=2,zeros=2,
2 + 2 < 3不成立,退出
退出后i = 2,减一变成1。此时i + zeros = 1 + 2 = 3 == n,触发特判:arr[2] = 0,write变成1,i变成0。
第二趟从 i=0 开始,读到一个0,写两格到arr[1]和arr[0]。最终仍然是三个0。
如果不做特判,第二趟从 i=1 开始,写到后面就会发现 write 已经变负但读指针还有元素没处理,或者最后多写一个0导致数组被写穿。运行时报错还是小事,静默返回错误结果更可怕。
4.4 常见错误速查表
| 错误写法 | 错误后果 | 正确做法 |
|---|---|---|
第一趟循环用for i in range(n) | 多统计被截断的0,有效前缀偏移 | 用while i + zeros < n卡边界 |
第二趟直接从read = n - 1开始 | 末尾原始数据可能被错误复制,中间被覆盖 | 从有效前缀末尾开始 |
| 边界0不做特判 | 数组末尾出现连续两个0以外的错位 | 用i + zeros == n判断并单写一次 |
第二趟条件写成while i >= 0 | 写指针可能已经出界但读指针仍有残留,触发越界 | 用while write >= 0 |
| 特判后只移动读指针,不移动写指针 | 边界0被写两次,后面数据错位 | 读指针和写指针都前移一格 |
5. 一法通百法:双指针题的迁移思路
5.1 三种双指针模型
数组题里的双指针大致能分成三类:
- 快慢指针:一个指针走得快,一个走得慢,常用于链表找环、数组去重。
- 左右对撞指针:一个从左边走,一个从右边走,常用于有序数组求和、反转数组。
- 读写指针:一个负责读原数组,一个负责往结果位置写,常用于原地删除、原地移动。
“复写零”属于第三种。这类题的核心判断方式是:如果目标数组比原数组短,一般从前往后用快慢指针;如果目标数组比原数组长,基本要从后往前用读写指针。
5.2 和“移动零”“原地去重”的对比
原地去重这道题,目标数组比原数组短,所以用一个慢指针指向“下一个不重复元素该放的位置”,快指针从头扫描原数组,发现新元素就写过去。这种从前往后没问题,因为快指针一定在慢指针前面或相等。
“移动零”也是同一个道理:先把所有非零元素集中到前面,再把后面全部补0。它不需要扩容,所以从前往后写完全安全。
“复写零”反过来,目标长度变长,每一个0都要多占一个位置。此时从前往后写,写指针会超过读指针,覆盖未读数据,所以必须倒过来。合并两个有序数组也是这个思路:第一个数组尾部预留了足够空间,从后往前比较并写入,谁大谁先放,根本不需要额外数组。
总结成一句话:目标变长,先找边界,再从后往前写;目标变短,快慢指针直接从前扫。
5.3 面试里怎么快速识别这类题
你看一个数组题,如果要求原地修改,先把最终长度和原始长度做比较。最终长度大于原始长度,八成要用到“先扩容再倒写”的思路;最终长度小于或等于原始长度,大概率是快慢指针的前向覆盖。
“复写零”表面上是模拟,实际是“扩容式原地修改”的入门题。理解了它,再去做“合并两个有序数组”“字符串中的空格替换”这类题就会顺畅很多,因为它们共用同一套从后往前的直觉。
6. 面试现场:怎么在几分钟内讲清楚解法
6.1 先给暴力解,再谈优化
面试遇到这题,不用一上来就甩双指针。先快速说一句“最直接的办法是新开数组,遇到0写两遍,时间复杂度O(n),但空间O(n)不符合原地要求”。这能让面试官知道你对复杂度有意识。
然后说第二层:“如果要在原地做,就必须考虑覆盖问题。数组要变长,正序写会覆盖后续元素,所以大概率倒序;但倒序前必须知道哪些元素能活到最终数组里,于是第一趟先数0、卡边界。”
6.2 把双指针方案压缩成三句话
整个方案用三句话就能说清:
- 第一趟用
i + zeros < n扫描,找出有效前缀的末尾位置。 - 如果末尾元素是0且它只够写一次,单独处理这个边界0。
- 第二趟从有效前缀末尾出发,读指针和写指针一起向左移动,遇0写两格,遇非0写一格。
口述时强调“读指针和写指针不同速”“从后往前不会覆盖未读数据”“边界0只写一次”这三个点,面试官基本就会点头。
6.3 我个人最推荐的代码模板
我在面试和平时刷题时,习惯用的就是上面那套 Python 模板。它有几个好处:第一趟直接卡边界,不像某些写法要先数完所有0再二次判断;边界特判用i + zeros == n一目了然;第二趟循环条件用write >= 0,从根本上杜绝越界。
def duplicateZeros(arr): n = len(arr) zeros = 0 i = 0 while i + zeros < n: if arr[i] == 0: zeros += 1 i += 1 i -= 1 write = n - 1 if zeros > 0 and i + zeros == n: arr[write] = 0 write -= 1 i -= 1 while write >= 0: if arr[i] == 0: arr[write] = 0 write -= 1 arr[write] = 0 write -= 1 else: arr[write] = arr[i] write -= 1 i -= 1这个模板可以直接背,更重要的是每次写完都手动跑一遍[1, 0, 0, 2]、[0, 0, 1]、[0, 0, 0]、[1, 0, 2, 3, 0, 4, 5, 0]这四个用例,基本就能确认边界处理没有问题。
6.4 刷题建议与复盘心得
这道题很适合放进“数组原地操作”专题里反复练习。第一次做,建议先不看题解,自己把正序的失败过程推演一遍,感受数据被覆盖的瞬间;第二次做,再去看双指针解法,重点理解第一趟为什么能卡边界;第三次做,可以把代码默写出来,并主动给面试官讲“如果最后一个有效元素是0会怎样”。
我自己带人刷题时,发现最容易卡住的地方不是双指针本身,而是那个边界特判。很多人第一趟扫描写对了,但第二趟忘记处理“只够写一次的0”,导致结果在末尾多出一个错位。把[1, 0, 0, 2]这个用例刻进脑子里,这个坑基本就不会再踩。
“复写零”这类题还有一个隐藏价值:它逼你跳出“从前往后”的思维惯性。实际开发里,处理缓冲区扩容、日志滚动、历史版本合并时,从后往前写都是一个很实用的技巧。刷题的价值有时候不在记住某个模板,而在把这种“倒过来想”的直觉训练成本能。