刷 LeetCode hot100 刷到第 4 题移动零(Move Zeroes)的时候,我第一反应是"这题也配叫 hot 题?"。标题里就带了指针两个字,明摆着是让你用双指针,题目描述简单到一句话能说完:把数组里所有 0 挪到末尾,同时保持非零元素的相对顺序,要求原地修改。可越是这样看着简单的题,越容易在面试里翻车——我见过不少人三分钟写完,结果被追问一句"你确定空间复杂度是 O(1) 吗"就当场卡住。这篇笔记我不打算只贴一个标准答案,而是从暴力解法开始,把双指针到底是怎么一步步推出来的、边界条件怎么测、以及这套指针模型还能迁移到哪些高频题,完整拆开讲一遍。
1. 为什么 hot100 会把移动零放在第 4 位:题目约束与这题的真正考点
移动零在 hot100 榜单里的位置很有意思。排它前面的三题是两数之和、两数相加、无重复字符的最长子串,每一道都有明确的算法标签。到了移动零这里,难度突然掉了一大截,很多人初看会觉得它不够格。但实际刷下去你会发现,它承担的是一个"承上启下"的功能:承上,是巩固数组操作的基本功;启下,则是引出双指针体系里最基础也最重要的一支——快慢指针。
1.1 题目原意与两个硬约束
原题要求很短:给定一个数组 nums,将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。看似没什么信息量,但注意事项里藏着两条硬约束:
- 必须在原数组上操作,不能拷贝额外的数组。
- 尽量减少操作次数。
第一条直接堵死了"开一个新数组,把非零元素放进去,后面补零,再拷贝回来"这条路。很多新手写 Python 时习惯用列表推导式[x for x in nums if x != 0],看起来一行搞定,实际上新建了一个长度和原数组几乎相等的列表,空间复杂度是 O(n),和题目要求完全冲突。第二条则把"遍历一次,遇到 0 就删掉,再在末尾补 0"的做法也打了折扣——数组中间删除是 O(n) 的操作,最坏情况下复杂度会退化成 O(n^2)。
一道题难不难,不看题面多长,看约束多狠。移动零的题面短得像填空题,真正的难度全藏在这两行注意事项里。能写出正确结果不算本事,能解释清楚"为什么我这样做空间是 O(1)、时间是 O(n)",才是面试官真正想听的。
1.2 它在面试题库里的地位
移动零几乎出现在每一家主流公司的入门题库里,校招笔试前两题、电话面试热身题都爱出它。原因很简单:这题能在五分钟之内分辨出候选人有没有最基本的"审题——约束——复杂度"意识。
我见过不止一个候选人在简单题上栽跟头:有人用nums.remove(0)删除再append(0),循环了几次之后数组下标直接错乱;有人用list(filter(lambda x: x != 0, nums)),被问到额外空间时支支吾吾;还有人把交换顺序写反,数组前半段出现一堆重复数据。这些错误本身不高深,但暴露的是同一个问题——平时刷题只求 AC、不求甚解,遇到稍微追问就露馅。
1.3 看完这一篇你能带走什么
我打算把移动零从里到外拆一遍:先从暴力解法推演到双指针,把快慢指针这个模型的构造逻辑讲透;再把代码落地的边界 case 全部过一遍;最后把同一套模型迁移到移除元素、删除有序数组重复项、颜色分类这三道高频题上,并聊聊我在真实面试里被追问过的问题。如果你正处在刷题入门期,或者马上要面试想温习一遍双指针,这篇应该能帮你节省不少时间。
2. 从暴力解到一快一慢指针:一次完整的思维推演
很多题解的写法是直接甩出双指针代码,然后说一句"这题用双指针很简单"。这种讲法对新手极不友好——你看得懂每一行代码,但不理解 slow 和 fast 这两个指针是怎么被思考出来的。我复盘了很长时间,发现从暴力解法到双指针其实是有一条清晰推导路径的,把这路径走一遍,才算真正吃透。
2.1 最初的本能方案:拷贝非零元素再补零
看到这道题,绝大多数人的第一反应是:把数组里的非零元素挑出来,后面补上对应数量的 0 就行。写成代码是这样:
def move_zeroes_v1(nums): # 思路正确,但不满足空间约束 non_zero = [x for x in nums if x != 0] zeros = len(nums) - len(non_zero) nums[:] = non_zero + [0] * zeros这段代码在本地跑,结果完全正确。如果交给 LeetCode 判题,在某些语言里甚至也能通过,因为在 Python 里切片赋值会把整个数组内容替换掉,看起来像"原地修改"。但注意,non_zero + [0] * zeros这行先构造了一个全新的列表,再用它覆盖原数组,额外空间是 O(n),和"不能拷贝额外的数组"直接冲突。
用 Java 写更明显:
public void moveZeroes(int[] nums) { int[] temp = new int[nums.length]; int idx = 0; for (int num : nums) { if (num != 0) temp[idx++] = num; } for (int i = idx; i < nums.length; i++) temp[i] = 0; System.arraycopy(temp, 0, nums, 0, nums.length); }一眼就能看出来,额外开了一个和原数组等长的 temp 数组,空间复杂度就是 O(n)。面试里写完这个版本,面试官大概率会追问一句:能不能只用 O(1) 额外空间?
2.2 错误进阶:删除+末尾补零为什么会变成 O(n^2)
那不用额外数组,直接在原数组上操作行不行?有人会想到:遍历的时候遇到 0,就把这个 0 从数组中间删掉,然后在数组末尾补一个 0。
def move_zeroes_v2(nums): i = 0 n = len(nums) while i < n: if nums[i] == 0: nums.pop(i) nums.append(0) n -= 1 else: i += 1这个写法在 Python 里确实能跑出正确结果,但坑很大。pop(i)删除中间元素,需要把后面的所有元素往前移动一位,单次操作平均复杂度是 O(n)。如果一个数组里有 k 个 0,总复杂度就是 O(k·n) 的,最坏情况——数组全是 0——直接退化成 O(n^2)。题目给的数组长度可能到 10^4,勉强能跑,但面试官问一句"最坏复杂度是多少",基本就露怯了。
暴力并不可怕,可怕的是写完之后说一句"这样就可以了",完全意识不到里面有 O(n^2) 的操作。
2.3 关键转折:把"删除"改成"覆盖/交换",用 slow 占住非零位置
从上面的错误里能提炼出一个新思路:既然删除中间元素是昂贵的,那能不能不删除?直接把非零元素往数组前面搬,最后再把后面的位置统一填成 0?这就是双指针的思想雏形。
慢指针 slow 指向"下一个非零元素应该放的位置",快指针 fast 负责从前到后扫描整个数组。每次 fast 指向的格子不是 0,就把它写到 slow 指向的位置,然后 slow 自增。
def move_zeroes_v3(nums): slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow] = nums[fast] slow += 1 for i in range(slow, len(nums)): nums[i] = 0为什么这样能保持相对顺序?因为 fast 从头到尾按顺序扫描,它遇到的第一个非零元素会放到 slow=0 的位置,第二个非零元素放到 slow=1 的位置。非零元素"落位"的顺序和它们在原数组里出现的顺序完全一致,相对顺序自然就不会乱。这个"覆盖+补0"版本其实非常清晰:第一趟把非零全部搬到前面,slow 最后停下来的位置就是非零区段的结束位置,第二趟把 slow 之后的所有格子清零。
2.4 进阶优化:一趟交换解决,既能 O(n) 又能保持稳定顺序
不过如果你追求"尽量减少操作次数",还有一个更优雅的一趟写法:
def moveZeroes(nums): slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1把"覆盖"改成"交换",逻辑立刻就变得不一样了。每次 fast 找到一个非零元素,就把它换到 slow 当前指向的位置,同时把原来 slow 位置上的 0 换到 fast 的位置上去。这样一来,0 会自动往后"浮",不需要单独补零,一趟循环就全部搞定。
我用具体的例子走一遍,数组是 [0,1,0,3,12]:
- 初始 slow=0,fast=0:nums[0] 是 0,跳过。
- fast=1,nums[1]=1,交换 nums[0] 和 nums[1],数组变成 [1,0,0,3,12],slow=1。
- fast=2,nums[2] 是 0,跳过。
- fast=3,nums[3]=3,交换 nums[1] 和 nums[3],数组变成 [1,3,0,0,12],slow=2。
- fast=4,nums[4]=12,交换 nums[2] 和 nums[4],数组变成 [1,3,12,0,0],slow=3。
最终输出是 [1,3,12,0,0],和题目示例一模一样。
这里有一个非常重要的观察:slow 指针在循环过程中始终指向的是"第一个 0 的位置",fast 则负责在前面找非零元素。每一次交换,都把非零值塞到 slow 指向的那个"空闲位",同时把 0 塞到 fast 原本所在的位置。理解了"slow 永远指向第一个 0"这个不变式,你就真正懂了这段代码的内核,而不是在背代码。
那为什么交换版比覆盖+补0版更好?覆盖版在第一趟把非零元素往前搬时,会直接覆盖掉原本留在原位置的旧值,所以必须用第二趟来补零。交换版用一次 swap 同时解决了"非零前进"和"0 后退"两个问题,一趟完成,代码更紧凑,也不会出现数组尾部残留旧值的中间状态。
3. 代码落地与边界实测:slow 和 fast 的位置关系决定你写不写得对
算法题有 80% 的 bug 出在边界条件上,移动零也不例外。哪怕你完全理解了指针思路,真到写代码的时候,几个细节没注意照样会写错。我把主流语言实现、边界 case 清单和复杂度回答模板整理在下面。
3.1 三种主流语言的实现对比
这道题考察的本质上是指针思维,语言只是载体。我用 Python、Java、C++ 各写一遍,你会发现核心逻辑完全一样,只有交换的写法略有区别。
Python:
def moveZeroes(nums): slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1Java:
class Solution { public void moveZeroes(int[] nums) { int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != 0) { int tmp = nums[slow]; nums[slow] = nums[fast]; nums[fast] = tmp; slow++; } } } }C++:
class Solution { public: void moveZeroes(vector<int>& nums) { int slow = 0; for (int fast = 0; fast < nums.size(); ++fast) { if (nums[fast] != 0) { swap(nums[slow++], nums[fast]); } } } };C++ 版本里swap(nums[slow++], nums[fast])最简洁,但这里slow++是后置自增,swap 先使用当前 slow 值,交换完成后再自增,逻辑正好符合预期。新手最容易写错成swap(nums[++slow], nums[fast]),那就会从 1 号位开始放非零元素,导致 0 号位被漏掉。后置自增和前置自增在这种场景下是有本质区别的,建议 C++ 选手重点记一下。
3.2 边界 case 测试清单:从空数组到全非零
我刷题有个习惯:写完后列一组边界 case 逐个过一遍,确认逻辑没有死角。移动零的边界 case 比较固定,建议至少测试下面这些:
| 输入 | 预期输出 | 说明 |
|---|---|---|
| [] | [] | 空数组,循环不进入 |
| [0] | [0] | 只有单个 0 |
| [1] | [1] | 只有单个非零元素 |
| [0,0,1] | [1,0,0] | 0 全部在头部 |
| [1,2,0,0] | [1,2,0,0] | 0 全部在尾部,数组不变 |
| [0,1,0,3,12] | [1,3,12,0,0] | 标准示例 |
| [0,0,0] | [0,0,0] | 全零数组 |
| [1,2,3] | [1,2,3] | 全非零数组,交换全是原地交换 |
全非零数组这个 case 特别有意思:此时 slow 和 fast 始终指向同一个位置,交换是自己换自己,没有任何副作用,但确实会执行很多无意义的交换操作。如果面试官追问"能不能减少交换次数",你可以补一个if (slow != fast)的判断:
def moveZeroes(nums): slow = 0 for fast in range(len(nums)): if nums[fast] != 0: if slow != fast: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1这样在 [1,2,3] 这种全非零输入上就完全不会触发交换。这个优化不会改变渐进复杂度,本来 O(n) 就是 O(n),但它能减少极端场景下的常数开销,也能体现你考虑问题的细致程度。面试时主动写出这个优化,通常会被当成加分项。
提示:边界测试不是面试里的加分项,而是默认动作。写完代码主动跑几个 case,比等着面试官来挑错要体面得多。
3.3 复杂度分析:如何流利地回答面试官的追问
移动零的复杂度分析很简单,但要说得流利、说得准确:
- 时间复杂度:O(n)。快指针 fast 从头到尾走一遍,每一步只做常数次判断或交换。交换版本是一趟 O(n),覆盖+补0版本是两趟 O(n),但整体量级都是 O(n)。
- 空间复杂度:O(1)。只用了 slow、fast 两个指针变量,没有额外数组。
面试官还喜欢问两个变体问题。第一个:"如果输入是链表而不是数组,这道题怎么做?"链表版本的移动零其实更简单,因为链表删除节点是 O(1) 的,遍历链表时遇到值为 0 的节点拆下来放到末尾就行。但注意,至少要维护一个 tail 指针指向当前链表末尾,否则每次都从头遍历找末尾,复杂度就会退化。第二个:"如果要求保持非零元素的稳定性,你的解法成立吗?"成立。因为 fast 始终按原顺序扫描,非零元素落位的相对顺序不会变。这其实就是快速排序 partition 操作里"一个指针找基准位、另一个指针找小于基准的元素"这个稳定模型的直接应用。
4. 移动零只是外壳,快慢指针模型才是核心:一套模板串起三道高频题
刷题最忌讳"背题"。今天刷了移动零,明天遇到移除元素,觉得像又不像,干脆把代码背一遍,那下次换个包装照样不会。实际上,移动零背后是一个非常通用的"占位指针模型",我给它起了个名字:快慢指针占位模型。
模型的规则只有三条:
- slow 指针:下一个合法元素应该存放的位置。
- fast 指针:遍历所有元素,寻找满足条件的合法元素。
- 每次 fast 找到合法元素,就放到 slow 位置,然后 slow 自增。
你只需要替换"合法元素"的定义,就能解出十几道题。下面这三道都是 hot100 里的常见题,正好可以用来验证这个模型的通用性。
4.1 移除元素:把"非零条件"换成"不等于 val"
LeetCode 27 移除元素,题目大意:给定数组 nums 和一个值 val,原地移除所有等于 val 的元素,返回移除后数组的新长度。这几乎就是移动零的直译,合法条件从nums[fast] != 0变成nums[fast] != val,返回值从 void 变成 slow:
def removeElement(nums, val): slow = 0 for fast in range(len(nums)): if nums[fast] != val: nums[slow] = nums[fast] slow += 1 return slow注意这道题的返回值是数组的新长度 slow,而不是数组本身。LeetCode 的判题系统会拿nums[:slow]和标准答案比对,所以 slow 之后的残留值不需要清空。不少第一次做的同学会困惑:为什么我没把后面的旧值清掉,它还能判我通过?因为判题只看前 slow 位。这个细节一开始很容易忽略。
4.2 删除有序数组中的重复项:让条件从"非零"变成"不相同"
LeetCode 26 删除有序数组中的重复项。给定一个有序数组,原地删除重复出现的元素,使每个元素只出现一次,返回新长度。这道题用到的还是同一个模型,只是合法条件变成了"fast 指向的值与 slow-1 位置的值不同"。因为数组有序,重复元素必然相邻,所以 slow-1 就是最后一个已保留元素的参照位:
def removeDuplicates(nums): if not nums: return 0 slow = 1 for fast in range(1, len(nums)): if nums[fast] != nums[slow - 1]: nums[slow] = nums[fast] slow += 1 return slow注意这里 slow 的初始值变成了 1,不是 0。因为第一个元素无论如何都会被保留,所以直接从第二个位置开始比较。这是占位指针模型的一个小变体:当"第一个位置天然合法"时,slow 就要从 1 开始。很多人在这个细节上栽跟头,写出来要么答案整体差一位,要么数组前几个元素直接丢失。
4.3 颜色分类:从双指针升级到三指针
LeetCode 75 颜色分类:给定一个只含 0、1、2 的数组,原地排序,使得相同数字相邻,按 0、1、2 的顺序排列。这题是占位指针模型的加强版,对应经典的荷兰国旗问题。
移动零把数组分成两个区段:非零区和零区。颜色分类要把数组分成三个区段:0 区、待处理区、2 区。做法是用三个指针,left 维护 0 区右边界,right 维护 2 区左边界,cur 是当前扫描指针:
def sortColors(nums): left = 0 right = len(nums) - 1 cur = 0 while cur <= right: if nums[cur] == 0: nums[left], nums[cur] = nums[cur], nums[left] left += 1 cur += 1 elif nums[cur] == 2: nums[right], nums[cur] = nums[cur], nums[right] right -= 1 else: cur += 1这题和移动零的最大区别在于:遇到 2 的时候,与 right 交换后 cur 不能直接自增,因为从 right 那边换过来的元素可能是 0 也可能是 1,需要停下来再判断一次。这个"交换后不前进"的细节,又是那道经典的坑。
把这几道题放在一起看,你会发现代码骨架几乎一样,变的只是合法条件、slow 初始值和交换对象。我建议刷到这里的人把移动零、移除元素、删除有序数组重复项、颜色分类这四道题放在同一天连续刷一遍,不隔天。那种"原来是同一个东西"的通透感,真的会让人上瘾。
5. 面试实战中的细节:答题顺序、追问应对与常见翻车点
刷题和面试是两回事。刷题只要 AC 就行,面试需要你在几分钟内展示出完整的思维链路。移动零作为热身题,很容易出现"写太快反而露怯"的情况。这里聊聊我在面试和模拟面试里总结出来的实战经验。
5.1 一个建议的答题节奏
如果面试官现场给你移动零,我建议按下面这个节奏来。
第一步,复述题目并确认约束。"所以你的意思是在原数组上修改,不能拷贝额外数组,而且要保持非零元素原本的顺序,对吗?"这一步非常值钱。它确保你理解正确,同时让面试官觉得你会审题。有不少候选人上来就写,结果写了一个list(filter(...))自己都没发现空间超标。
第二步,说暴力解法的局限。"最直接的做法是开一个新数组把非零放进去再拷回来,但额外空间是 O(n)。另一种做法是原位删除再补 0,最坏复杂度 O(n^2)。所以我想用双指针在 O(1) 空间里一趟解决。"这段话说完,面试官基本就点头让你写代码了。
第三步,写代码。因为思路已经提前说清楚,这一步不会紧张。
第四步,主动测试。写完别干等着,自己跑几个 case:空数组、全 0、全非零、标准示例。面试官对主动测试的好感度极高,这比等对方提问再回答要主动得多。
第五步,报复杂度并主动提优化。"这已经是 O(n)/O(1) 了,如果还想减少常数开销,可以加一个 slow != fast 的判断来避免无意义交换。"你把这个问题先提了,面试官就没法再拿它难住你。
5.2 我亲眼见过的翻车现场
写一点反面教材,都是我在模拟面试或真实面试里见过的场景。人物信息隐去,只说事。
翻车一:用nums.remove(0)删除后再nums.append(0),然后循环变量没控制好。Python 的 remove 是 O(n) 操作,而且删除元素会改变后续所有元素的下标,经常出现"漏删"或"数组越界"。写到第五个 0 的时候心态基本就崩了。
翻车二:交换顺序写反,把nums[slow], nums[fast] = nums[fast], nums[slow]写成了nums[fast], nums[slow] = nums[slow], nums[fast],同时把 slow 自增放在 if 外面。这个错误的结果是,非零元素确实和 slow 交换了,但 slow 对每个位置都会自增,最终 slow 大于非零元素的实际个数,数组前面多出一堆"提前放好的旧值",调试时看到数组前段出现重复数据,非常难定位。
翻车三:只背代码,不理解 slow 的含义。面试官问一句"为什么这样能保持相对顺序",居然答不上来。这就是典型的没有把模型内化。我建议每个人刷完这道题之后,尝试用大白话把你的解法讲给一个完全不懂编程的人听,讲不清楚,说明还没吃透。
翻车四:忽略语言细节。Java 里数组长度用的是.length属性,有人写成.size(),那是 List 的 API;C++ 里 vector 的长度是size(),有人写成.length。这种错误虽然低级,但在白板面试里非常伤节奏,会给人"语言基础不扎实"的印象。
5.3 我自己的刷题惯性方法
最后分享一个我长期在用的刷题习惯:每刷完一道题,不仅记题解,还记录这道题对应的"模型"和"变形"。
移动零我就记为:
- 模型:快慢指针占位模型。
- 变形:移除元素、删除有序数组重复项、颜色分类。
- 关联:快速排序 partition 的稳定版本、链表删除操作。
下次再遇到"原地整理数组"类的题目,我会先问自己三个问题:能不能用快慢指针?如果条件允许,slow 初始值应该是 0 还是 1?交换之后需不需要原地再看一眼新元素?这些问题想清楚,解题速度会快很多。
这道题本身确实不难,难的是你有没有建立起"从模型到变形"的思维习惯。移动零只是个外壳,快慢指针占位才是内核。掌握了这个内核,你后面遇到再复杂的双指针题,就都不是从零开始,而是有模型可以套了。