移动零这道题,在LeetCode热题100里排得很靠前,也是面试中被反复问到的经典。我第一次刷这道题的时候,觉得"这不就是把0挪到后面吗",简单到怀疑人生。真到动手写,才发现里面有挺多讲究:是开新数组还是原地操作?怎么保证非零元素相对顺序不变?最优复杂度能做到多少?这篇文章我就把移动零从头到尾拆开讲,从暴力做法到最优解法,每一步的取舍和"为什么"都说清楚,顺便聊聊这类"数组原地变形"题目的通用套路,看完你不仅能AC移动零,还能顺带解决好几道同类型的题。
1. 移动零的题目到底在考什么
1.1 先看题目本身
题目描述很简洁:给定一个数组nums,编写一个函数将所有0移动到数组的末尾,同时保持非零元素的相对顺序。
举例来说:
- 输入:
[0, 1, 0, 3, 12] - 输出:
[1, 3, 12, 0, 0]
题目还强调了一个关键要求:必须在原数组上操作,不能拷贝额外的数组。这个限制是整道题的核心约束,也是面试官真正想考察的点。
1.2 三个隐藏的考察点
很多人在面试时栽跟头,不是因为不会写,而是没 get 到这道题背后的三个潜台词:
第一,原地操作换来的空间复杂度要求。 题目说"不能拷贝额外的数组",翻译过来就是空间复杂度要尽量做到O(1)。如果你一上来就 new 一个新数组,哪怕代码写得再漂亮,也已经违背了出题者的意图。在实际工程里,内存是稀缺资源,处理大数组时一次性拷贝的开销可能比计算本身还大,所以"原地"这个字眼往往意味着更高的工程价值。
第二,非零元素的相对顺序必须保持稳定。 这一点最容易忽略,却恰恰是筛选算法优劣的分水岭。保持稳定意味着你不能简单地把所有0挑出来扔掉、再把非零元素往前塞——这样做的话顺序和稳定性都有可能被破坏。比如输入[0, 1, 0, 3, 12],如果不维护稳定性,直接把非零元素按某种排序塞到前面,得到[3, 1, 12, 0, 0]这类结果,虽然合法,但对于一般情况就错了。数组元素的稳定性和现实生活中的排队逻辑一样,你在排队时插队虽然能到前面,但队伍的整体顺序就乱了,程序中这是不允许的。
第三,时间复杂度的上限追求。 这道题最直白的暴力做法是双重循环,复杂度是O(n²)。但数组类的简单题,通常都期望你能用一次遍历、甚至双指针在O(n)内解决。能否从O(n²)想到O(n),是区分"背过题解"和"真会做题"的关键。
1.3 这题在热题100中的地位
LeetCode热题100是很多人刷题的第一站,而移动零又是很多人真正开始接触"双指针"的启蒙题。它的难度是"简单",但覆盖面非常广:涉及数组遍历、元素交换、指针维护、稳定性保证,几乎囊括了初级算法面试的大部分核心要素。我在面试候选人的时候,也经常会用这道题做热身,既能考察代码基本功,又能通过追问看出对方是死记硬背还是真正理解。
所以别小看这个"简单题",把它吃透,等于给后面的中等题、难题打了一个很好的基础。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从暴力解法到双指针的思维演进
2.1 最简单直觉的暴力解法
先说说绝大多数人第一次会想到的做法。既然要把0挪到末尾,那最朴素的思路就是:
- 开一个和原数组一样大的新数组。
- 遍历原数组,遇到非零元素就按顺序放到新数组前面。
- 遍历完后,新数组后面的位置全部补0。
- 把新数组内容拷回原数组。
这个思路非常符合人类直觉,等于"另起炉灶",把符合要求的元素挑出来排好,再填上默认值。
如果用代码表示就是:
python复制def move_zeroes(nums):
n = len(nums)
temp = [0] * n
index = 0
for num in nums:
if num != 0:
temp[index] = num
index += 1
for i in range(n):
nums[i] = temp[i]
这段代码逻辑完全正确,能 AC 也不奇怪。但它的问题很明显:使用了额外数组,空间复杂度是O(n),不符合题目"原地操作"的要求。而且有两轮遍历,一轮是读非零元素,一轮是回写,虽然仍然可以视为O(n)时间复杂度,但本质上不是一个"漂亮"的解法。
很多人的误区在于:能 AC 就觉得足够了。 刷题不是为了过样例,而是为了在面试时展现出你的思考深度。面试官递给你这道题,期待的第一反馈往往是"原地操作",而不是"要不要开个新数组"。这就好比你去别人家帮忙收拾客厅,主人告诉你"东西别搬出去,就在屋里归置",结果你直接叫了个卡车把家具拉到外面再去重新搬回来——虽然最终客厅也收拾好了,但显然不是主人想要的做法。
2.2 从"开新数组"到"原地变形"的思维转换
那既然不能开新数组,怎么在原数组上完成这个任务呢?关键在于思考角度的转变。
不要想着"怎么把0移到后面",而是去想"怎么把非零元素都‘挤’到前面"。数组变形的本质是你只关心目标元素的位置,而0作为"占位符",只要非零元素都到位了,剩下的位置自然全是0。这就好比整理书架:你不需要先清空所有书再重新规划(那太费劲了),只需要把要保留的书依次紧凑地排到前面,书架后面的空位自然就腾出来了。
在这个思路下,一个很自然的做法是:用一个指针记录"下一个非零元素应该放到的位置",遍历数组,每遇到一个非零元素就把它放到这个指针指向的位置,然后指针往后移一位。遍历结束后,把指针后面的所有位置全部设为0。
python复制def move_zeroes(nums):
n = len(nums)
pos = 0
for i in range(n):
if nums[i] != 0:
nums[pos] = nums[i]
pos += 1
while pos < n:
nums[pos] = 0
pos += 1
这个解法空间是O(1),时间是O(n),已经符合要求了。而且它有一个很直观的理解方式:我们不是在"移动0",而是在"移动非零元素"。把问题从"删除/移动0"转变成"保留/前移非零元素",一下子就把复杂度降了下来。
我在给别人讲这道题时经常用一个类比:假设图书馆的书架上有些书放错了位置,你要把某类书全部集中到一侧。你不会来回调整每一本错位的书,而是从头往尾扫一遍,把符合条件的书依次放到目标位置,最后把剩余空位用"占位本"补满。这个"扫描 + 放置 + 补位"的过程,就是上面这段代码在做的事。
2.3 双指针思路是如何自然诞生的
有了"非零元素前移"的视角,再从代码里抽象一下,你会发现其实有两个指针在配合:
- 一个指针
pos(也叫写指针),指向当前已经排好序区的末尾,即下一个非零元素应该写入的位置。 - 一个指针
i(也叫读指针),负责遍历整个数组,寻找非零元素。
读指针遇到非零元素时,把值交给写指针,写指针前进一格;读指针走完整个数组,任务就完成了一大半。这两个指针的移动方向一致、速度不同——读指针比写指针跑得快,所以这种模式也叫快慢指针,是双指针家族中最基础的一种。
双指针的思路难点不在于"两个指针",而在于"想清楚每个指针的职责"。很多初学者在写双指针时晕头转向,就是因为把两个指针的语义搞混了。我习惯把指针的职责写在注释里,先明确"谁负责找元素,谁负责放元素",再动手写循环,这样代码不容易乱,讲给别人听也够清楚。
3. 双指针最优解的核心原理与边界处理
3.1 交换法的精妙之处
上面那版"覆盖后补0"的解法已经可以用了,不过偶尔会遇到一个尴尬的场景:如果数组全是非零元素,那while pos < n循环还是要跑一遍,且白白地把每个元素重新赋一遍值。能不能更进一步,连"补0"环节都省掉?
答案是使用交换法。核心思想是:遍历数组时,维护一个指针left指向当前"最靠左的0"的位置。遇到非零元素,就把它和left位置交换,然后left向右移动一位。这样遍历结束后,所有0自然就都被"推送"到了数组末尾。
举个例子,数组[0, 1, 0, 3, 12],初始时left = 0:
i = 0,nums[0] = 0,说明当前位置就是0,等后面找到非零元素再换。i = 1,nums[1] = 1,非零,和nums[0]交换,数组变成[1, 0, 0, 3, 12],left变成1。i = 2,nums[2] = 0,跳过。i = 3,nums[3] = 3,和nums[1]交换,数组变成[1, 3, 0, 0, 12],left变成2。i = 4,nums[4] = 12,和nums[2]交换,数组变成[1, 3, 12, 0, 0],结束。
整个过程只用一次遍历加交换操作,完全没有多余动作,空间复杂度O(1),时间复杂度严格O(n)。代码是:
python复制def move_zeroes(nums):
left = 0
for i in range(len(nums)):
if nums[i] != 0:
nums[left], nums[i] = nums[i], nums[left]
left += 1
很多题解会把left解释为"下一个非零元素的位置"或"慢指针",但从"最靠左的0的位置"来理解往往更直观:每次遇到非零元素就把它和这个0交换,等于"把0往右赶了一格"。这种感觉很像玩消消乐时连续交换同一类元素,让它们集体向目标方向移动。
3.2 为什么交换法也能保证稳定性
交换法一个很自然的疑问是:交换会不会打乱非零元素的相对顺序?打个比方,如果交换像洗牌一样把后面的元素直接甩到前面,那相对顺序可能就变了。但我们仔细走一遍流程会发现,left指针在没有0干扰的情况下,实际上等于走在前面的非零元素的"正确目标位置"。
关键在于一个事实:left指向的位置一定是0(或者在当前元素之前的一个0)。当我们把非零元素和这个0交换时,这个非零元素正好落在所有之前已经排好的非零元素之后。因为left是逐步递增的,不会跳变,所以先来的非零元素总会排在后来的非零元素前面。这就是稳定性的来源。
说得再直白一点:假设非零元素的"队伍"是[a, b, c],中间的0就像几个临时插队的人。交换法不会把c塞到a前面去,因为每次交换时,left只会在队伍末尾++,等于永远在队伍后面"续人",不会插入到队伍中间。所以相对顺序是天然安全的。
3.3 边界条件和特殊情况的处理
代码虽然短,但边界情况一定要想清楚,这也是面试官最爱追问的点。
情况一:数组全为0。 比如[0, 0, 0]。遍历时nums[i]全等于0,永远不会进入交换分支。left保持0,数组不变。结果正确,而且不会出错。
情况二:数组全为非零。 比如[1, 2, 3]。遍历时每个元素都非零,每次都和自己交换,left和i同步前进。最终数组不变。这里有一点小优化空间:当left == i时,自交换完全没必要,所以可以加一个判断:
python复制if nums[i] != 0:
if left != i:
nums[left], nums[i] = nums[i], nums[left]
left += 1
情况三:0在开头或结尾。 0在开头时,left一开始就指向0,非常契合交换法的语义。0在结尾时,最后一个元素是0,跳过即可,不影响前面已经排好的非零序列。
情况四:空数组或长度为1的数组。 循环一次都不进或者只进一次,要么直接跳出,要么left == i自交换,结果都是原数组。代码的健壮性没问题。
还有一个经常被忽略的细节:交换法里的left不会落后于i,这是一个隐含的不变量。 因为left只在遇到非零元素时前进,而i每轮都前进,所以left永远小于等于i。这意味着nums[left]一定是一个已经被扫描过的位置,交换是安全的,不会把尚未处理的元素交换到不该去的地方。写代码时不需要显式判断,但理解这个不变量,能帮你避免很多奇奇怪怪的 bug。
3.4 覆盖法和交换法怎么选
很多读者会问:覆盖法(先写非零再补0)和交换法(边遍历边交换),到底哪个更好?我来说说实际工程里的感受。
覆盖法的优点是逻辑极其清晰,先做"主要任务",再做"收尾任务",容易理解和维护;缺点是当数组几乎全为非零时,会多出一轮"把剩余位置设0"的无用操作,虽然复杂度仍为O(n),但常数项确实多了一点。
交换法的优点是每一步都是"就地处理",没有任何冗余操作;缺点是自交换的情况会白白执行一次三元交换,如果数组规模很大且几乎全是非零元素,这种自交换也会有一点点浪费。所以我在代码里加了left != i的判断来规避。
从面试角度来说,两种写法都能接受,但我更推荐交换法,因为它在原理上更优雅,也能体现出你对双指针的深入理解。而在实际工程中,如果只是处理普通规模数组,两者的性能差异几乎可以忽略,选你觉得好读、好维护的那版就行。
4. 测试、复杂度分析与常见误区
4.1 用几组典型用例验证代码
写算法题最怕"对着示例调通了就交",因为你永远不知道隐藏的条件会在哪里踩雷。我习惯在写完代码后,至少用六类用例测一遍:
| 用例类型 | 输入 | 期望输出 | 说明 |
|---|---|---|---|
| 题目示例 | [0, 1, 0, 3, 12] |
[1, 3, 12, 0, 0] |
常规情况 |
| 全为0 | [0, 0, 0] |
[0, 0, 0] |
元素全部相等 |
| 无非零元素 | [1, 2, 3] |
[1, 2, 3] |
无0可移 |
| 0在首位 | [0, 5, 6] |
[5, 6, 0] |
首元素是0 |
| 0在末尾 | [5, 6, 0] |
[5, 6, 0] |
末元素是0 |
| 空数组 | [] |
[] |
边界 |
这六类用例覆盖了绝大多数正常与异常路径。如果都能通过,基本可以放心提交。我在本地加这组用例时,还用 Python 的assert直接断言结果,省去了每次手动看输出的麻烦:
python复制def test_move_zeroes():
assert move_zeroes([0, 1, 0, 3, 12]) == [1, 3, 12, 0, 0]
assert move_zeroes([0, 0, 0]) == [0, 0, 0]
assert move_zeroes([1, 2, 3]) == [1, 2, 3]
assert move_zeroes([0, 5, 6]) == [5, 6, 0]
assert move_zeroes([5, 6, 0]) == [5, 6, 0]
assert move_zeroes([]) == []
print("all tests passed")
4.2 时间和空间复杂度到底怎么算
这道题最优解的时间复杂度是 O(n),空间复杂度是 O(1)。我逐个解释一下:
时间复杂度: 不管是覆盖法还是交换法,都只对数组做了一次遍历。循环体内是常数时间的判断和赋值/交换操作,所以总耗时和数组长度n成线性关系,记为O(n)。不存在嵌套循环,所以不可能是O(n²).
空间复杂度: 除了几个临时变量(left、i),没有额外开辟与n相关的存储空间。无论数组多大,额外占用都固定不变,因此是O(1)。这也是题目"原地操作"的要求所对应的最优表现。
这类题目有一个共性:如果一道题要求"原地操作"且是单数组变形题,空间复杂度O(1)通常是必须满足的底线;时间复杂度则要看题目有没有额外的排序或查找要求。移动零的核心动作就是"扫描一遍 + 有限次交换",所以O(n) + O(1)就是它的最优解。
4.3 刷题时的常见误区
我见过很多人在移动零这道题上犯同样几个错误,这里集中说一下,希望能帮你少走弯路。
第一个误区是“把事想复杂了”。 有人一看到数组变形就想着要排序、要用哈希表,甚至想用栈、队列。实际上这道题和排序无关,只和“保持相对顺序”有关,用不上任何高级数据结构。遇到简单题,最怕的是思维定式太重,硬往复杂方向靠。
第二个误区是“忘记稳定性”。 有人会写出这样的代码:先统计非零元素个数,然后把前面全填上非零元素,后面统一置0。这样做有一个隐患:如果你在找非零元素时不是按原顺序取的(比如用了集合、哈希),或者你边遍历边从尾部填0,都可能导致非零元素的相对顺序被破坏。只要顺序不对,哪怕最终结果看起来“非零都在前、0都在后”,也是错的。
第三个误区是“边界条件没想清楚就直接写”。 大部分 bug 出现在数组长度为0、全0、全非零这三种特殊用例上。很多人写完跑例子能过,就把代码提上去了,结果提交时被边界用例毒打。建议每次写完都主动检查边界,不用等系统教育你。
第四个误区是“不重视原地优化,直接用库函数”。 比如有的同学会想到 Python 里remove+append循环删0再补0。虽然代码很简短,但remove每一次操作都是O(n)的,最坏情况会退化到O(n²)。刷题时用库函数偷懒可以,但面试时如果别人问你复杂度,你答不上来,反而留下不好的印象。
5. 从移动零看一类“数组原地变形”的万能套路
5.1 同类题目串讲
移动零这个题目,本质上属于“数组原地变形”这个大类。你如果吃透了双指针写法,下面这三道题几乎可以顺手解决:
第一道:LeetCode 26. 删除有序数组中的重复项。 给定一个有序数组,需要在原地删除重复出现的元素,使每个元素只出现一次,返回新长度。这道题的核心思路和移动零几乎一样:写指针slow维护去重后的数组末端,读指针fast遍历,遇到和上一个元素不同的元素时就把它放到slow的位置。区别只在于“移动”的条件换了,从“非零”变成了“不同”。
第二道:LeetCode 27. 移除元素。 给定一个数组和一个目标值val,需要原地移除所有等于val的元素。这题比移动零还少一个“末尾补0”的步骤,只用“写指针 + 读指针”即可完成。仔细想想,它其实就是“把不等于val的元素往前放”,跟移动零的覆盖法几乎完全一致。
第三道:LeetCode 283. 移动零(就是题目本身)。 如果先做了第27题,再做移动零,你会发现移动零就是“移除所有0 + 末尾补0”的组合。一道题能串起三道题,这就是刷题结构的价值。
这三道题在 LeetCode 热题100里都出现过或类似过,掌握了一个双指针骨架,等于同时拿到了三个题的解。
5.2 通用的解题套路与思考模型
从代码层面抽象一下,这类“原地变形”题都有同一个模板:
code复制def in_place_transform(nums):
slow = 0 # 写指针
for fast in range(len(nums)): # 读指针
if 符合保留条件(nums[fast]):
nums[slow] = nums[fast]
slow += 1
# 需要的话,对 slow 之后的元素做收尾处理
for i in range(slow, len(nums)):
nums[i] = 收尾值
这个模板一出来,你只需要解决三个问题:
- 保留条件是什么?(移动零:非零;去重:不同于前一个;移除元素:不等于val)
- 写指针如何更新?(一般就是每写入一次,右移一格)
- 是否需要收尾处理?(移动零要补0;删除重复项不需要,因为只要求返回长度)
算法题最怕“背题”,但这种模板不是死记硬背,而是从多道题中抽象出的通用规律。真正理解这个模板后,你看到任何“数组原地变形题”,第一反应就不再是“这题我做过吗”,而是“这题的保留条件、指针语义是什么”。这种举一反三的能力,才是刷题最大的收获。
5.3 面试时如何讲解这道题
面试时如果遇到移动零(或者类似题),我的建议是不要上来就写代码,而是先说思路,再动手。可以按这个顺序讲:
第一步,先明确题目的约束:原地操作、保持非零相对顺序。用一个指针表示“写位置”,另一个指针负责遍历,这是双指针的基本框架。
第二步,举一个非常简单的例子,比如[0, 1, 0, 3, 12],手动走一遍流程给面试官看,展示你是真正理解而不是背答案。
第三步,写代码,同时说清楚边界情况:数组为空时直接返回,全为0时nothing happens,全为非零时自交换可以优化跳过。
第四步,主动说复杂度:时间O(n),空间O(1)。
面试官如果继续追问“能不能再优化”,你可以从两个方向想:一是去掉自交换的冗余操作;二是思考是否有“无交换”的写法,比如覆盖法。在面试中主动展示多种方案的比较,通常比只给一个答案更能加分。
6. 实战扩展:关于LeetCode热题100的整体刷法建议
6.1 为什么移动零排在热题100的前列
我在刷 LeetCode 热题100 的时候,一直觉得这个题目顺序不是随便排的。移动零排在很前面,背后有它的道理:它足够简单、足够基础,又足够典型。如果你拿它作为热身,能很快进入“刷题状态”;如果你拿它作为复习,也能在几分钟内把双指针的思路快速过一遍。
更重要的是,移动零涉及的是一个“贯穿整个热题100”的核心技巧——快慢指针。后面你会碰到很多看似无关的题目,比如链表相关的环形链表、数组相关的滑动窗口,本质上都是“两个指针按不同速度或不同节奏移动”的变体。先在移动零上把双指针的节奏感练出来,后面遇到中等难度题才会更从容。
6.2 结合热题中的其他题目一起练
热题100里和移动零关联度高的题目其实不少。我举个例子:长度最小的子数组。它也是用双指针维护一个“滑动窗口”,但指针的移动逻辑和移动零不太一样——移动零的指针是同向且一个快一个慢,滑动窗口的指针是一个负责扩张、一个负责收缩。先理解了“两个指针如何协同负责一件事”,再去学滑动窗口,会觉得顺理成章很多。
另一个例子是 三数之和,它用的是“排序 + 左右指针相向移动”的双指针变体,和移动零的“同向快慢指针”刚好互补。很多人觉得双指针难,就是因为它有太多形态,没有一个统一的“长得像什么样的指针”可以死记。不如反过来:先找一个最简单的模板(移动零)把“指针职责分离”这个思想吃透,再遇到其他形态时,你天然就会去想“这两个指针各自负责什么”。
6.3 把算法题的思路迁移到日常工作
可能有人觉得 LeetCode 刷题和工作无关,其实不尽然。移动零这个“原地整理”的思路,我在实际开发中也用过不止一次。
举个例子,处理日志数据时经常需要“把错误级别的日志挑出来放到一个区间,把正常日志放到另一个区间”,而且还不能改变日志之间的相对顺序。这个时候用双指针,几乎就是移动零的翻版。
再比如,做数据清洗时,需要把某些字段为空的记录集中到结果集末尾,同时保留非空记录的相对顺序。这个需求在 SQL 中可能一个ORDER BY就解决了,但在内存里处理一个大型数组时,双指针的“扫描+安置+补位”模型仍然是最省内存的解法之一。
所以我一直觉得,刷题不建议只当作面试敲门砖。很多简单题的思路,放到工程中就是最朴素的“原地整理”策略,能让你写出更省内存、更少拷贝的代码。这种能力不是靠背题得来的,而是靠理解原理、反复练习得来的。
就我个人经验来说,移动零这道题的价值不在于“今天AC了一道题”,而在于它是你进入双指针世界的一扇门。门后的路还很长,但把这道题吃透,你已经拥有了看地图的基本能力。
