LeetCode 283 移动零:双指针原地算法与复杂度优化实战
2026/9/16 3:20:41 网站建设 项目流程

刷 LeetCode 的人应该都体会过这种时刻:一道题标着“简单”,但自己写出来的解法又臭又长,跑通之后一看题解,别人三五行代码就搞定了。283. 移动零就是这样一道典型的“简单但不简单”的题。说它简单,是因为题目本身一句话就能说清;说不简单,是因为它背后牵出的双指针思想,几乎贯穿了整个 LeetCode 算法体系,从数组操作到链表、字符串处理,到处都有它的影子。

这道题我最早是在准备面试的时候刷到的,当时用了一个极其朴素的“新建数组”解法,虽然能过,但总感觉差点意思。后来把双指针的思路吃透了,才发现这题其实是理解快慢指针、理解“原地操作”、理解时空权衡的绝佳入口。这篇文章就把我从暴力解法到最优解法的完整思考过程写出来,包括代码实现、复杂度分析、边界条件,以及从那之后我在其他题目里反复用到的“双指针通用套路”。不管你是刚开始刷题的新手,还是准备春招秋招的应届生,这篇应该都能给你一些参考。

1. 题目到底在问什么:先读懂需求再动手

1.1 题干拆解与核心考点

原题描述很简洁:给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。

第一遍读题,很多人的第一反应是“就这”?但仔细拆一下,这题其实藏了三个硬性要求:

  • 原地操作:题目虽然没直接说“不能使用额外空间”,但作为高频面试题,它的潜台词就是这个。你不能 new 一个新数组然后把非零元素塞进去,那样空间复杂度就是 O(n),面试官大概率会让你继续优化。
  • 保持非零元素的相对顺序:这句话直接把“对撞指针”这类解法排除掉了。如果你用两个指针一头一尾往中间走,遇到零就扔到后面,确实能把零集中到末尾,但非零元素的相对顺序会被打乱。这一条要求,决定了这道题最合适的解法是“快慢指针”而不是“左右指针”。
  • 数组操作:这意味着你要在内存连续的空间里做元素的移动和覆盖,没法像链表那样简单地“摘除”和“拼接”节点。

所以这题的核心考点其实有三个维度:双指针思想原地算法的空间意识对元素顺序的敏感度。这三个维度恰好也是面试中数组类题目的底层能力,学会这一题,后面再遇到“移动元素”“去重”“压缩数组”这类题,思路会顺很多。

1.2 从“看着简单”到“写对”的距离

我曾经拿这道题给一个刚学完 Java 基础的朋友试手,他 10 分钟就写出了第一版:

public void moveZeroes(int[] nums) { int n = nums.length; int index = 0; int[] res = new int[n]; for (int num : nums) { if (num != 0) { res[index++] = num; } } while (index < n) { res[index++] = 0; } System.arraycopy(res, 0, nums, 0, n); }

这段代码完全正确,LeetCode 也能通过,时间复杂度 O(n) 其实已经很好了。网上有些讨论把这种做法说得一文不值,我不太认同——能在几分钟内写出一个正确且时间上不差的解法,这本身就是一种能力。先写出一个能跑的解法,再去追求更优,这才是我认可的刷题节奏。

但问题在于,这个解法有两个可以被追问的点:第一,它用了 O(n) 的额外空间,如果数组很大,比如几百万个元素,内存开销就很明显;第二,它先复制到新数组再复制回来,做了两次 O(n) 的拷贝,虽然不影响复杂度量级,但实际执行时间会翻倍。面试官如果问“能不能省掉额外空间”,你就得回到双指针这条思路上来。

1.3 适用场景与牵扯的知识储备

这道题在 LeetCode 上属于“热门 100 题”之一,热度一直很高,原因是它非常适合作为双指针专题的入门题。LC 的整个题单里,双指针题目分好几类:

  • 快慢指针:一个走得快一个走得慢,典型如环形链表检测、删除有序数组重复项。
  • 左右对撞指针:一个从左一个从右,典型如两数之和 II、反转字符串、盛最多水的容器。
  • 滑动窗口:本质上也是双指针,只是两个指针同向移动,维护一个窗口区间。

283 移动零属于典型的快慢指针(或者说同向双指针)应用。理解它之后,再去做 27. 移除元素、26. 删除有序数组中的重复项,你会发现它们的骨架几乎一模一样,差别只在于“移动零”要求把零放到末尾,实际上就是“移除元素”的镜像操作;不信的话可以把问题反过来想:先把所有非零元素按顺序放到前面,再在数组尾部补零。

2. 双指针解法:最优解背后的“为什么”

2.1 快慢指针的直觉来源

我更喜欢把这道题的快慢指针理解成“蚂蚁搬家”或者“整理书架”:

想象一个书架上有一排书,其中几本是你不要的“零”,你要做的不是把每本“零”书一本一本挪到最右边,那是冒泡排序式的笨办法。更聪明的做法是:把那些“非零”的好书一本一本往前挪,填补到书架左侧一个个空位上,最后把右边空出来的位置统一摆上“零”。这样每个元素最多被移动一次,干净利落。

对应到代码里就是两个指针:慢指针 slow 指向当前已整理好的“非零区”的下一个位置,快指针 fast 负责从头到尾扫描整个数组。每当 fast 遇到一个非零元素,就把它赋值到 slow 指向的位置,然后 slow 前进一步。扫描结束后,slow 之前的区域全都是非零元素,并且相对顺序没变,接下来只要把 slow 到数组末尾的区域全部填成 0 就可以了。

2.2 覆盖 vs 交换:两种写法,你选哪种

这个解法在网上有两种常见实现,第一种是“覆盖后补零”:

public void moveZeroes(int[] nums) { int slow = 0; // 第一趟:把非零元素往前覆盖 for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != 0) { nums[slow++] = nums[fast]; } } // 第二趟:末尾补零 while (slow < nums.length) { nums[slow++] = 0; } }

第二种是“原地交换”:

public void moveZeroes(int[] nums) { int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != 0) { int temp = nums[fast]; nums[fast] = nums[slow]; nums[slow] = temp; slow++; } } }

两种写法的时间复杂度都是 O(n),空间复杂度都是 O(1)。区别在于:

  • “覆盖后补零”的思路更直白,但会改变数组中某些元素的“位置轨迹”。比如[1, 0, 2, 0, 3],第一步把 nums[0]=1 放到 nums[0],第二步当 fast=2 时把 nums[2]=2 放到 nums[1],原数组中下标 2 位置的 2 就被覆盖了,随后在第二趟把下标 3 和 4 补成 0。最后结果是[1, 2, 3, 0, 0],正确。
  • “原地交换”更严谨,因为它保持了“非零区”之外空间的语义:slow 到 fast 之间的元素都是零,一次遍历就完成全部工作,连补零都不用,所以少一趟循环。

在代码简洁性上我更推荐第二种。虽然第一次接触时会觉得“交换”这个动作有点绕,但写多了就会发现,这种“慢指针维护结果区,快指针扫描待处理区”的模式,能直接迁移到很多题上。

2.3 时间复杂度、空间复杂度与极端情况

  • 时间复杂度:无论是覆盖还是交换,fast 指针都只遍历数组一次,所以是 O(n)。覆盖法还多了一趟补零的 while 循环,这个循环最坏情况会跑 n 次(全零数组),但加起来是 O(2n) = O(n),仍然属于线性复杂度。
  • 空间复杂度:全程只用了常数级别的临时变量,没有额外开辟数组,所以是 O(1)。这也是这题最重要的考点之一。

极端情况我也建议你想一下:

  • 空数组[]:两个循环都不会进入,直接返回,没问题。
  • 全零数组[0,0,0,0]:fast 扫描时一个非零都遇不到,slow 一直在 0 不动,交换法等价于没有交换,补零法最后 whole 数组重新填一遍零,结果正确。
  • 全部非零数组[1,2,3,4,5]:slow 和 fast 几乎是同步前进的,交换法是原地和自己交换,虽然多做了一些无用功,但结果正确。这里有一个小优化点:可以让slow != fast时才执行交换,不过对于 LeetCode 的运行环境来说,这点判断带来的性能差异几乎可以忽略,反而多写一个判断会让代码的语义不那么纯正,我个人建议别加。
  • 零在开头[0,1,2]:这是交换法最典型的场景。slow 一开始指向下标 0,fast 到下标 1 时发现 1 是非零,交换 nums[0] 和 nums[1],数组变成[1,0,2],slow 变成 1;fast 到下标 2 时交换 nums[1] 和 nums[2],数组变成[1,2,0],完美。

提示:刷题时很多人会忽略边界条件的检验,但面试官最爱的就是在这种“小地方”挖坑。提交之前,建议先在脑内跑一遍空数组、单元素数组、全零数组、全非零数组这四类用例。

3. 为什么不能用“遇零就往后挪”的暴力法

3.1 朴素思路的白板推演

很多人第一眼看到这题,脑子里蹦出来的解法是:从前往后遍历,遇到一个 0,就把它后面的所有元素整体前移一位,然后在这个 0 的位置补到最后面。

[0, 1, 0, 3, 12]为例:

  • 指针 i=0 发现 nums[0] 是 0,把后面[1, 0, 3, 12]整体前移一位,并在末尾补一个 0,数组变成[1, 0, 3, 12, 0]
  • 指针 i=1 发现 nums[1] 是 0,再把后面[3, 12, 0]前移一位并末尾补 0,数组变成[1, 3, 12, 0, 0]

最终结果也是对的,但问题在于:每遇到一个零,就要做一次 O(n) 的搬运,外层还要遍历一遍数组,所以最坏情况的时间复杂度是 O(n²)。面试官脸上可能不露声色,但心里已经在想“这哥们时间复杂度分析得不太行”。

3.2 为什么它不符合面试期待

有些朋友会问,既然 LeetCode 上 O(n²) 也能通过(毕竟 n 最大才 10⁴),那为什么要写最优解?

我的看法是:刷题不只是为了通过用例,更是为了训练工程判断力。真实业务里,一个方法可能被调用几万次,每次传入的数组可能是百万级数据。O(n²) 和 O(n) 在这个量级下的差距不是百分之五十,而是几百倍。你写代码时如果对复杂度没概念,线上出了问题往往连从哪里排查都不知道。

再从面试角度来说,面试官问这道题,极少是为了考你会不会“把零放末尾”——这个行为本身太简单了。他们真正想听的是你如何分析一个算法的时间复杂度和空间复杂度,如何在约束中做出取舍。所以哪怕你一眼就看出了最优解法,也要在讲思路时把“为什么 O(n²) 不行”提一句,这能直接拉满面试官对你的印象分。

3.3 暴力法唯一的好处:作为“基线版本”

不过暴力法也不是毫无价值。写代码也好,做算法题也好,一个很重要的策略就是“先写一个能跑的版本,再去优化”。暴力法能帮你验证自己对题意的理解是否正确,也能给你一个可以对比性能的基线。

我刷题时的习惯是:拿到中等及以上的题,先不急着写最优解,而是把暴力的思路用注释写在一旁,然后问自己三个问题:

  1. 这个思路的时间复杂度是多少?空间复杂度是多少?
  2. 哪一步是耗时的主要来源?能不能去掉?
  3. 如果出现“重复计算”或者“多余搬运”,能不能用指针、哈希表、前缀和等方式避免?

283 这题暴力法的耗时来源就是“反复搬运数组元素”,而双指针解法恰恰通过“一次搬运到位”避开了这个瓶颈。这其实就是算法的本质——用更聪明的数据结构或指针设计,减少不必要的计算

4. 实操过程与核心环节实现

4.1 从思路到代码:完整实现步骤

我把双指针解法的完整推导过程拆成四步,这样哪怕你之前完全没接触过双指针,也可以照着这个流程写出来:

第一步,定义两个指针。慢指针 slow 从 0 开始,表示“下一个非零元素应该放置的位置”;快指针 fast 也从 0 开始,负责遍历整个数组。

第二步,快指针扫描。fast 从 0 遍历到数组末尾,每次检查nums[fast] != 0是否成立。如果成立,说明这个元素应该被挪到前面去。

第三步,交换或覆盖。如果采用交换法,就把 nums[slow] 和 nums[fast] 交换,然后 slow 前进一步。这个交换隐含了一个信息:nums[slow] 要么是 0,要么和 nums[fast] 是同一个位置,所以不会丢失任何非零元素。

第四步,确认结果。交换法不需要第二趟补零,因为每次交换都会把 0 自然“甩”到后面去;覆盖法则需要在最后统一补零。

4.2 代码逐行讲解与易错点标注

先用交换法的完整代码,配上重点注释:

class Solution { public void moveZeroes(int[] nums) { // 慢指针:指向当前已经处理好的非零区域的尾部 int slow = 0; // 快指针:从数组头扫到尾,找非零元素 for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != 0) { // 将非零元素交换到 slow 指向的位置 int temp = nums[fast]; nums[fast] = nums[slow]; nums[slow] = temp; // slow 后移,维护非零区域的边界 slow++; } } } }

这里有几个初学者容易踩的坑:

  • 忘记让 slow 前进:这种情况下,重复的交换只会把非零元素原地“弹”回去,最后数组没任何变化。刷题时最怕这种“编译通过但逻辑错误”的 bug,由于没有报错,排查起来相当浪费时间。
  • 把 if 条件写成nums[fast] != 0 && slow != fast:逻辑上没问题,能避免自己交换自己,但会让代码更啰嗦。如果你追求极致的可读性,建议保持简单。
  • 在交换法中又补零:交换法本质是“每碰到一个非零,就把它和前面的零交换一次”,数组尾部自然堆积零。如果你画蛇添足再加一个循环补零,可能会把原本正确的数组破坏掉。

4.3 覆盖法的完整流程

再来仔细看覆盖法的流程,方便你在两种写法间自由切换。覆盖法的执行过程可以分为两个阶段:

阶段一,压缩非零区。在遍历过程中,把所有非零元素按顺序向前覆盖,慢指针 slow 记录了非零区的边界。比如[0,1,0,3,12],fast 依次扫过 nums[1]=1 时把它覆盖到 nums[0],扫过 nums[3]=3 时覆盖到 nums[1],扫过 nums[4]=12 时覆盖到 nums[2],此时数组变成[1, 3, 12, 3, 12],可以看到后面的元素暂时是脏的,但没关系,下一阶段会清掉它们。

阶段二,尾部补零。从 slow 开始到数组结束,全部赋值为 0。上一步的[1,3,12,3,12]经过nums[3]=0nums[4]=0之后变成了[1,3,12,0,0]

完整代码:

class Solution { public void moveZeroes(int[] nums) { int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != 0) { nums[slow] = nums[fast]; slow++; } } while (slow < nums.length) { nums[slow] = 0; slow++; } } }

这段代码的优点是特别容易理解,面试现场你一边讲“先把非零的往前排,再把后面补零”,一边写这段,思路非常顺畅。

4.4 Java 版本特别提醒

用 Java 刷这题时有几个语言层面的点,顺便说一下:

  • 方法的入参是数组引用,你在方法内部做的所有修改都会直接影响原数组。不需要 return,直接原地改即可。这和 C++ 里的指针传递、Python 里的列表引用是一个道理。
  • nums.length是属性,不是方法调用,所以别写成nums.length()。这种笔误在面试手写代码时特别常见,写完后如果时间充裕,建议默读一遍代码,检查这类低级问题。
  • 交换两个 int 值时,用临时变量最稳妥。虽然可以用a ^= b; b ^= a; a ^= b;这种异或交换,但在算法题中意义不大,反而增加了理解成本,没必要。

注意:如果你在 IDE 里跑这段代码,想查看结果,可以直接用Arrays.toString(nums)打印,但 LeetCode 的判题系统只认数组内容本身,不会管你怎么打印。

5. 常见问题与排查技巧实录

5.1 为什么用交换法时结果总是原封不动

这是很多人第一次写完交换法后最容易遇到的问题。举个例子,输入[1, 0, 2, 0, 3],fast=0 时 nums[0]=1,不为零,交换 nums[0] 和 nums[0](自己换自己),slow 变 1;fast=1 时 nums[1]=0,跳过;fast=2 时 nums[2]=2,交换 nums[1] 和 nums[2],数组变成[1, 2, 0, 0, 3];fast=4 时 nums[4]=3,交换 nums[2] 和 nums[4],数组变成[1, 2, 3, 0, 0]。结果完全正确。

那为什么会“原封不动”?最常见的原因是慢指针没有更新。只要在每次 if 里忘了写slow++,下一次交换就会把刚换过去的非零元素又换回来,整个过程就像“原地踏步”。

另一个可能的原因是用 foreach 遍历数组的同时尝试修改数组元素。用增强 for 循环时,你拿到的num是数组元素的副本,直接num = 0是没用的,必须用下标访问去改原数组。这是个特别隐蔽的错,排查起来很费劲。

5.2 数组越界到底是怎么发生的

数组越界在这个解法中不太容易发生,但如果你把 while 补零写成了while (slow <= nums.length),就会越界。原因是数组最大下标是 length-1,当 slow 等于 length 时就应该停止。

另外,有一些写法会先统计零的个数,再根据个数决定循环范围,比如:

int zeroCount = 0; for (int num : nums) { if (num == 0) { zeroCount++; } }

这个思路没问题,但如果你后面再去写“从后往前填充零”的循环,边界条件就很容易记混。我的建议是:逻辑越简单越不容易错,尽量让 slow 自己维护边界,而不是额外记一个 zeroCount。

5.3 刷题自测用例清单

我给自己整理了一套“数组题通用自测用例”,每次写完这道题,我都会在本地跑一遍:

用例输入期望输出测试意图
常规混合[0, 1, 0, 3, 12][1, 3, 12, 0, 0]正常情况
零在开头[0, 0, 1][1, 0, 0]连续零
零在结尾[1, 2, 0, 0][1, 2, 0, 0]不需要移动的情况
全零[0, 0, 0][0, 0, 0]极端边界
全非零[1, 2, 3][1, 2, 3]不需要移动的情况
单元素[0]/[1][0]/[1]最小输入
空数组[][]边界情况

你可以把这份清单当成模板,套到几乎所有数组类问题上。写完之后跑一遍,能覆盖绝大多数逻辑边界,省去反复提交试错的麻烦。

6. 举一反三:双指针是同一套骨架

6.1 变式一:27. 移除元素

原题要求:原地移除所有数值等于 val 的元素,返回移除后数组的新长度。这题和 283 简直是一个模子刻出来的,区别只有两处:

  • 283 移除的是固定的“0”,27 移除的是参数传入的“val”。
  • 283 要求把零放到数组末尾,27 只要求返回移除后的长度,对末尾剩余元素没有要求。

所以解法可以直接复用 283 的覆盖法思路,连补零都不用做,因为题目不关心 slow 之后的位置:

public int removeElement(int[] nums, int val) { int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != val) { nums[slow] = nums[fast]; slow++; } } return slow; }

6.2 变式二:26. 删除有序数组中的重复项

原题要求:给定一个有序数组,原地删除重复出现的元素,使每个元素只出现一次,返回新长度。

这题的快慢指针就更加精妙了:快指针负责探路,慢指针负责维护“已去重区域”的边界。因为是有序数组,所以重复项是相邻的,当 fast 发现nums[fast] != nums[slow]时,意味着遇到了新元素,把它放到 slow+1 的位置:

public int removeDuplicates(int[] nums) { int slow = 0; for (int fast = 1; fast < nums.length; fast++) { if (nums[fast] != nums[slow]) { slow++; nums[slow] = nums[fast]; } } return slow + 1; }

这道题几乎是 283 的“精神续作”。你已经掌握了 283 的覆盖法思路,再看这段代码,应该能瞬间理解 slow 和 fast 各自的职责。

6.3 变式三:283 的“值移动”思路在链表中的应用

双指针不仅能处理数组,链表里的经典问题比如 876. 链表的中间结点、141. 环形链表,用的都是快慢指针,只不过一个走一步一个走两步。当你把数组题的指针抽象成“遍历位置”,把链表题的指针抽象成“节点引用”,会发现它们背后是同一套思维模型:用一个快的指针去探索未知区域,用一个慢的指针维护已经处理好的区域。

这也是为什么我建议每个刷题的人都要专题化地练习。只做一道题,你记住的是解法;做一类题,你掌握的才是方法。

7. 面试现场与刷题心态的几点建议

7.1 手撕代码时的表达节奏

如果你在面试中遇到这道题,我建议按照下面这个顺序表达,能体现你的逻辑层次感:

先复述题目,确认约束条件:“所以我理解为需要原地操作,并且要保持非零元素的相对顺序,对吗?”这一步很关键,既能确认题意,又能展示你的沟通意识。

再说思路:“我可以用双指针。慢指针维护已经处理好的区间,快指针去遍历整个数组,遇到非零元素就交换到前面来。这样每个元素最多被移动一次,时间复杂度 O(n),空间复杂度 O(1)。”这段话 30 秒不到,但面试官能立刻明白你是有备而来。

然后写代码,边写边讲:写int slow = 0;时说“这个指针代表非零区的边界”,写交换逻辑时说“每次交换就把一个非零元素放到了正确的位置,同时把 0 甩到了后面”。

最后主动做复杂度分析:“一次遍历,O(n)。只有常数空间,O(1)。不过有一个极端情况是全部非零,这时交换两个相同位置的元素属于无效操作,但不会影响正确性。”主动提极端情况是加分项,因为它展示了你对代码边界条件的敏感度。

7.2 从刷题到面试的迁移心态

我知道很多人在刷题阶段会很焦虑,觉得自己连“简单题”都写得磕磕绊绊。但以我带过的人和我自己的经历来看,这非常正常。我刚接触算法题时,283 这种题也要查半天题解,甚至看了题解都反应不过来为什么慢指针不往回走。

后来我调整了方法:不去背题解,而是把每个标签下的经典题按类型刷两三道,然后自己总结一套“骨架”。比如双指针的骨架就是“一个慢指针维护结果区,一个快指针扫描原数组”,在这个骨架之上,不同题只是改变了比较条件、移动时机和收尾动作。一旦形成这种抽象能力,你遇到新题时就不会慌,而是会想“这是在哪个骨架上加了一点变化”。

7.3 刷题记录的一个小技巧

最后分享一个我个人的习惯:每刷完一道题,都会在题解末尾写一段“这题教会了我什么”。283 这题我当时写的是:“覆盖法和交换法在空间上等价,但交换法的语义更严谨;快慢指针中慢指针的位置很重要,它所谓的“慢”不是走得慢,而是它只在满足特定条件时才前进。”

这个习惯听起来很简单,但坚持半年之后,你回看自己的刷题笔记,会发现自己对算法的理解有一根非常清晰的成长线。这不仅对面试有帮助,对你读源码、做架构设计时也有潜移默化的影响。

用一道简单题练熟一套底层方法,这买卖无论如何都划算。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询