拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

力扣283移动零:双指针原地修改与保持相对顺序的精讲

力扣283移动零:双指针原地修改与保持相对顺序的精讲

刷力扣 Hot100 进入第四天,今天轮到 283 这道“移动零”。说句实话,这题在 Hot100 里不算难,但它的价值一点不比那些中等题低——它考察的是数组操作里最容易被忽视的两个点:原地修改和保持相对顺序。很多人在面试时一上来就开新数组,或者用各种诡异的交换方式,最后不仅没满足题目要求,还把思路搅成一团浆糊。这道题刷透了,之后遇到 27 移除元素、26 删除有序数组中的重复项,甚至是一些滑动窗口的题目,你会发现自己对“指针”这个工具的理解会上一个台阶。

这篇就把我做 283 的完整过程写下来,包括最开始的暴力想法、最终的双指针解法、各种边界情况的处理、我踩过的坑,以及从这题延伸出去的一些联想。标题是 Day4,所以这篇文章本身也是我刷题记录的一部分,你可以把它当成“第五天的前菜”,也可以直接照着我这个思路自己动手写一遍。无论你是刚开始刷力扣的新手,还是已经刷了几十题想回头补基础的老手,这题的几个关键细节都值得你花十分钟看完。

1. 题目拆解与整体思路

1.1 先看清楚题目到底在问什么

题目原文很短:给定一个数组nums,编写一个函数将所有0移动到数组的末尾,同时保持非零元素的相对顺序。注意要求的是“原地”操作,也就是说你不能新建一个数组来拷贝结果,必须直接在原数组上做修改。

这三个条件缺一不可。第一是“原地”,直接排除了复制数组这种最直觉的解法;第二是“保持非零元素的相对顺序”,这意味着不能用简单的排序思路,也不能把非零元素随便换位置;第三是“移动所有 0 到末尾”,注意不是删除 0,而是让它们整体挪到后面。

我刷题有个习惯,拿到题目先不急着写代码,而是先拆条件。因为很多题目的难点不在于算法本身,而在于你是否看懂了题目里每个隐藏约束。这道题如果你忽略了“保持相对顺序”,直接用双端队列或者排序来做,代码可能也能跑,但面试官一问“你的算法是稳定的吗”,你就卡住了。如果你忽略了“原地”,那解法就变成了空间复杂度 O(n) 的简单题,根本不值 Hot100 的含金量。

1.2 从暴力解出发,找到优化的方向

我第一次做这题的时候,第一反应是:能不能把所有非零元素按顺序拿出来,放到一个临时数组里,然后数一数有多少个零,最后把数组重新拼起来?这个思路是对的,但它违反了“原地”要求,空间复杂度是 O(n)。

再往下想一步:不新建数组,那我能不能像冒泡排序那样,把每个 0 一个一个往后冒?每遇到一个 0,就跟后面的元素交换,一直换到数组末尾。这个思路也是对的,但时间复杂度会变成 O(n²),因为最坏情况下数组全是 0,每次交换都要遍历到最后。

这两种解法都不是最优,但它们在思路上给了我们一个非常重要的提示:要找非零元素,并且要把它们按原来的顺序依次往前提。那么问题就变成了——能不能用 O(1) 的额外空间,在一次遍历里完成这个操作?答案是肯定的,因为我们需要的信息只有“当前遍历到了哪里”和“下一个非零元素应该放在哪里”。这两个信息各需要一个变量来记录,这就是双指针的由来。

1.3 为什么双指针是这道题的最佳答案

双指针之所以适合这道题,是因为它把“找元素”和“放位置”两个动作分开了。快指针负责在数组里从头到尾找非零元素,慢指针负责标记“下一个非零元素该放到哪个位置”。整个过程像两条流水线:一条负责搬运,一条负责标记,互不干扰。

这种方式的好处是只需遍历一遍数组,时间复杂度 O(n),同时只需要两个下标变量,空间复杂度 O(1)。更重要的是,因为快指针永远在慢指针前面或者相同位置,而且我们把非零元素按顺序往前提,所以非零元素的相对顺序天然保持住了。用一句话总结:双指针把一个看似需要拷贝数组的问题,压缩成了原地交换或覆盖的问题。

2. 核心双指针解法详解

2.1 解法一:两次遍历的覆盖法

先讲一个相对容易理解、也最容易写对的写法——两次遍历覆盖法。第一次遍历,我们用一个慢指针j记录“下一个非零元素应该放的位置”,快指针i从 0 开始遍历数组。当nums[i]不等于 0 时,就把nums[i]赋给nums[j],然后j加 1。这样第一遍完成后,所有非零元素都被按顺序搬到了数组前面,j正好等于非零元素的个数。

第二次遍历就简单了,从j开始到数组末尾,把所有位置都赋值为 0。因为前面非零元素搬走之后,它们原来的位置相当于被“复制”了一份,末尾的多余位置我们手动清零。

这个解法的时间复杂度是 O(n),空间复杂度 O(1),而且非常好理解。我第一次看题解时就是这个版本。不过它有一个小缺点:如果数组里本来没有 0,比如nums = [1, 2, 3, 4],这个解法会把数组复制一遍再清空后半段,做了不少无用功。虽然不影响复杂度,但效率上可以再优化一点。

2.2 解法二:一次遍历的交换法

既然目标是把 0 移到末尾,那么我们可以反过来想:每遇到一个 0,就把它和后面的非零元素交换。这样 0 自然就往后面跑了。但直接交换会破坏相对顺序,所以我们需要一个更巧妙的操作方式。

具体做法是:慢指针j仍然表示“下一个非零元素应该放的位置”,快指针i遍历数组。当nums[i]不等于 0 时,如果i != j,就交换nums[i]和nums[j];如果i == j,说明当前位置本来就不是 0,不需要交换,但j需要跟着加 1。用代码写就是:

def moveZeroes(nums): j = 0 for i in range(len(nums)): if nums[i] != 0: if i != j: nums[j], nums[i] = nums[i], nums[j] j += 1

为什么这样能保持相对顺序?关键在于j指向的位置要么是 0,要么是已被处理过的位置,而i始终往后走。当我们把一个非零元素交换到j位置时,它前面所有的位置都已经放好了之前出现的非零元素,所以相对顺序不会乱。而 0 被交换到i位置后,会在后续遍历中被动地向后挪,最终集中在数组末尾。

这个解法只需要一次遍历,时间复杂度和空间复杂度与解法一相同,但省去了第二遍清空数组的操作。实测下来,在面对“数组中有大量 0”的情况时,解法二的交换次数更多一些;而在“没有 0 或 0 很少”的情况下,解法二更快。整体上两者性能差异不大,但解法二写起来更优雅,面试时也更受青睐。

2.3 两版解法的对比与选型建议

我自己的建议是:新手先掌握覆盖法,因为它逻辑简单、不容易写错;等你对双指针有了感觉,再换成交换法,因为交换法的“稳定性”思想在很多进阶题里都会用到。

这两版解法之间还有一个隐藏的共同点:它们都是把“非零元素前移”和“零元素后移”看成同一个操作的两面。覆盖法先搬家再补零,交换法边找边换。理解了这个共同点,你在写 283 的任何变体时都不会跑偏。

另外再说一个很多人纠结的点:要不要判断i != j再交换?其实这个判断是纯优化,不加它也能跑,只是会有多余的“自己和自己交换”操作。加上它可以让代码在极端情况下(比如全非零数组)避免不必要的赋值,性能有一点点提升,也显得你考虑得比较周到。

3. 多种语言实现与复杂度分析

3.1 Python 参考实现

把上面两个解法的完整代码写出来,附上几个典型测试用例,方便你直接跑。

覆盖法:

def move_zeroes(nums): j = 0 for i in range(len(nums)): if nums[i] != 0: nums[j] = nums[i] j += 1 for k in range(j, len(nums)): nums[k] = 0

交换法:

def move_zeroes(nums): j = 0 for i in range(len(nums)): if nums[i] != 0: if i != j: nums[j], nums[i] = nums[i], nums[j] j += 1

测试用例:

nums1 = [0, 1, 0, 3, 12] move_zeroes(nums1) assert nums1 == [1, 3, 12, 0, 0] nums2 = [0] move_zeroes(nums2) assert nums2 == [0] nums3 = [0, 0, 1] move_zeroes(nums3) assert nums3 == [1, 0, 0] nums4 = [1, 2, 3] move_zeroes(nums4) assert nums4 == [1, 2, 3]

3.2 Java 与 C++ 版本的关键差异

Java 里没有 Python 那种同时赋值交换的语法,所以写交换时一般用一个临时变量:

class Solution { public void moveZeroes(int[] nums) { int j = 0; for (int i = 0; i < nums.length; i++) { if (nums[i] != 0) { if (i != j) { int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp; } j++; } } } }

C++ 可以用std::swap简化:

class Solution { public: void moveZeroes(vector<int>& nums) { int j = 0; for (int i = 0; i < nums.size(); i++) { if (nums[i] != 0) { if (i != j) { swap(nums[i], nums[j]); } j++; } } } };

语言本身不是重点,重点是逻辑一致。只要把握住“j 指向待写入位置,i 负责扫描”这个核心,换什么语言都是同一套思路。

3.3 时间与空间复杂度到底怎么算

关于复杂度,很多人直接背 O(n) 和 O(1),但面试官可能会追问一句“为什么”。这里简单解释一下:无论覆盖法还是交换法,i指针都只从 0 遍历到 n-1,不会回头,所以操作次数和 n 成正比,时间是 O(n)。覆盖法虽然有两个循环,但第二个循环最多执行 n 次,加在一起仍然是 O(n)。空间方面,我们只用了两个整数变量i和j,不随数组长度变化,所以是 O(1)。

如果你在面试里被问能否优化到 O(log n) 或 O(1) 时间,可以明确否认:因为每个元素至少需要被检查一次,所以 O(n) 已经是最优时间。这一点说清楚,会让面试官觉得你不仅会写代码,还理解复杂度的下界。

4. 边界条件、常见错误与排查实战

4.1 最容易踩的坑:遍历方向与交换顺序

我第一次写交换法的时候,犯了一个挺低级的错误:把j和i的更新顺序写反了。当时写成了先判断再交换,然后i先加 1,最后j加 1。这样跑出来结果完全乱了。正确顺序应该是:先判断nums[i]是否为 0,然后交换(如果需要),最后j加 1。j的更新必须发生在判断之后,不能提前。

还有一个坑是覆盖法的第二遍循环很容易写成从 0 开始清空,那就把所有非零元素都抹掉了。一定要从j开始,因为j记录了非零元素的个数,同时也是第一个应该被清空的位置。

4.2 边界情况自查清单

我把这题常见的边界情况整理成了一个表格,刷题时可以直接对照:

测试用例期望结果容易错的地方
[0][0]跳过交换逻辑,直接返回即可
[1][1]同上
[0, 0, 0][0, 0, 0]没有任何非零元素,注意 j 一直为 0
[0, 0, 1][1, 0, 0]多个连续零,交换时要保证 1 能一路往前换
[1, 0, 0][1, 0, 0]一个非零元素,两个零在后面,交换法里i == j判断很重要
[1, 2, 3][1, 2, 3]全非零,交换法应避免无意义的自交换
[0, 1, 0, 1, 0, 1][1, 1, 1, 0, 0, 0]交替出现,最容易验证相对顺序是否保持

4.3 从错误信息反推问题根源的实战复盘

有一次我在本地跑测试,看到输出[3, 12, 1, 0, 0],一眼就发现非零元素的相对顺序被打乱了。排查了一下,发现是我在交换时用了nums[i] = nums[j]而不是交换,导致开头的一个非零元素被覆盖丢失了。这种情况通常不是算法错了,而是“覆盖”和“交换”两个概念没分清楚。覆盖法是因为后面我们还会手动补零,所以可以放心覆盖;交换法则必须保留被覆盖位置的值,也就是必须做真正的交换。

另一个常见问题是用 Python 的remove和append来解这道题。比如先把 0 全部删掉再在末尾补上对应数量的 0。这个思路本身维护了相对顺序、也是原地修改,但remove底层是数组移位,最坏情况下时间复杂度会退化到 O(n²)。刷题可以这么写,但面试时如果被追问复杂度就露馅了。

5. 从移动零延伸出去:双指针题型与刷题方法论

5.1 一个套路管三道题:27、26、283

Hot100 里有两道题跟 283 非常像:27 移除元素和 26 删除有序数组中的重复项。这三道题可以归结为同一个模板:用一个慢指针记录结果数组的末尾,快指针扫描原始数组,根据条件决定是否把快指针指向的元素放到慢指针位置。

  • 27 移除元素:条件变成“不等于 val”,其他几乎一样。
  • 26 删除有序数组中的重复项:条件是“与前一个非重复元素不同”,所以需要额外记录上一个保留元素。
  • 283 移动零:条件是“不等于 0”,并在最后补零。

也就是说,你只要把 283 吃透了,就有资格说“双指针入门”。很多刷了几十道题的人看到这三道题还是觉得陌生,本质上就是没有把这一类“原地数组筛选”问题抽象成同一个模型。

5.2 面试中怎么把这道题讲出彩

如果你在面试中遇到 283,不要上来就甩代码。我建议分三步讲:

先讲暴力解。直接说“最直观(但不满足要求)的方式是复制非零元素到新数组,然后补零”,主动点破空间复杂度 O(n),表明你知道它不是最优。

再讲覆盖法的思路,说明你如何用两个指针把空间复杂度降到 O(1)。这一步重点讲“为什么第二个循环要从 j 开始”,展示你对边界条件的敏感。

最后对比交换法,告诉面试官交换法在无 0 情况下避免了额外赋值,同时继续保持稳定性。这样由浅入深,面试官能看出你不是背题,而是真的理解了题目。

5.3 刷题记录怎么写才不白刷

很多人的刷题记录就是把代码贴上去,过两天回来看完全想不起当时怎么想的。我在 Day4 的笔记里除了贴解法,还额外写了三行:这题考了什么、第一个 bug 是什么、和哪些题归类。这比代码本身有用得多。

以 283 为例,我的笔记是这么写的:

  • 考了什么:原地数组操作、双指针、稳定性。
  • 第一个 bug:交换法里忘记对i != j做判断,导致全非零数组出现多余交换。
  • 归类:与 27、26 一起,归入“同向双指针原地筛选”题型。

下次看到 27 的时候,直接把笔记翻出来,五分钟就能上手。

5.4 多久回看一次比较合适

我个人经验是,简单题当天刷完、三天后回看一次就够了,不用天天看。回看时不是重新做一遍,而是看着笔记里的“第一个 bug”回忆当时的卡壳点,如果能立刻说出原因,说明已经消化了;如果说不出来,就再手写一遍代码。283 这类基础题值得偶尔回看,因为它沉淀出来的双指针模板,是后续做滑动窗口、链表快慢指针、甚至是二分查找变形题的基础。

6. 写在最后的个人体会

刷了这么多天的 Hot100,我发现 283 虽然是简单题,但它是一道特别适合用来练习“如何把暴力解优化成双指针解”的题目。它不像那些动辄需要 DP 状态定义的中等题一样曲高和寡,也不像纯语法题一样没有营养。它刚好处在一个让你能摸到“算法优化”门槛的位置——只要你愿意多想一步,就能从 O(n²) 走到 O(n),从 O(n) 空间走到 O(1) 空间。

我以前做这道题时也走过不少弯路。最早是复制数组,后来是冒泡式交换,再后来才学会双指针。回头看,每一步“笨办法”其实都在为最终解法铺路。你如果没有那些多余尝试,很难真正理解为什么双指针能保持相对顺序。所以我特别建议,新手不要直接背双指针的代码,先按自己的直觉写一个解法,哪怕很慢、很笨,再一点一点优化,这个过程才是刷题最大的收获。

如果你今天也刷到了 283,可以参考我上面给的两种解法都写一遍,然后跑一遍边界测试场景。等你觉得这两种解法都像呼吸一样自然,再去看 27 和 26,会发现自己解锁了一个全新的刷题视角。

返回列表