ARTICLE DETAIL

资讯详情

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

力扣Hot100移动零详解:双指针入门题与变体攻略

力扣Hot100移动零详解:双指针入门题与变体攻略 我到现在都记得自己第一次看到力扣第283题“移动零”时候的反应这不就是冒泡循环里加个判断结果真上手去写连续踩了三个坑——新建数组、左右交换导致顺序乱掉、双指针写成了重复赋值。后来把这道题放进Hot100的DAY4打卡里认真啃了一遍才发现这道题几乎是整个数组双指针系列最好的“入门题压舱石”。如果你是刚开始刷力扣、准备面试、或者想系统过一遍Hot100的同学这篇文章会把移动零这道题从暴力解到最优解、从边界条件到同系变体全部拆开。我还会分享自己刷题一周以来总结的几个非常实用的刷题习惯尤其是怎么利用Weekly打卡把Hot100题量拆进每天可以完成的节奏里。文章最后会结合搜索热词里大家常讨论的“力扣刷题攻略”“hot100题”来聊一聊怎么安排刷题顺序而不是只给你一道题的答案。1. 为什么Hot100里必然有这道题移动零的定位与价值1.1 题目本尊与现场还原力扣第283题“移动零”的题目描述是这样的给定一个数组nums编写一个函数将所有0移动到数组的末尾同时保持非零元素的相对顺序。示例输入: [0,1,0,3,12] 输出: [1,3,12,0,0]注意两点限制必须在原数组上操作不能拷贝额外数组尽量减少操作次数。这两条限制直接决定了这道题的做法走向。这个题在Hot100里的位置很有意思。Hot100是力扣官方按高频面试题整理的一个精选题单算是刷题社区里流传最广的“必做题清单”。移动零虽然难度只挂了个“简单”但它在Hot100里的地位一点不低因为它同时覆盖了数组操作里的三个高频考点原地修改、双指针、稳定性。我第一次刷这道题是在做DAY4打卡任务时。DAY4在常见的Hot100刷题节奏里通常对应数组/链表章节的前几道题移动零很多同学都会在这天遇到。但说实话如果没有认真分析过思路只是把答案抄一遍那这道题就算白刷了。1.2 这道题考的不是“你会不会做”而是“你会不会写干净”移动零的解法代码量很少跟那些动辄上百行的动态规划比起来看起来非常“小”。但这恰恰是面试里经常被用来做“暖场题”的原因面试官想看看你拿到一道题目之后能不能快速判断出最优解、能不能把代码写得干净利落而不是绕来绕去。这里我多说一句力扣上标注“简单”的题目并不意味着可以用来划水。我见过很多人刷题只挑中等难度以上觉得简单题没价值结果在面试中做热身题时越写越乱甚至因为边界条件处理不好被追问到卡壳。移动零就是这样一道典型题你甚至可以在同一个面试中被要求用两三种不同方法实现再问你是否能做到“操作次数最少”。所以如果你刚开始刷Hot100千万不要跳过这道题。它的价值不在于让你找到“哦原来有双指针”这个结论而在于让你理解为什么很多数组类的题目都在反复使用同一个套路并且要求你在这个基础上写出无懈可击的代码。2. 先放弃“新建数组”的念头暴力解法的推演与局限2.1 最直觉的两次遍历写法大多数人在不考虑限制条件时第一反应是新建一个数组然后扫两遍原数组第一遍把非零元素依次放进新数组第二遍把零填进新数组剩余位置。逻辑上确实最直白def moveZeroes(nums): n len(nums) new_arr [0] * n idx 0 for num in nums: if num ! 0: new_arr[idx] num idx 1 for i in range(len(nums)): nums[i] new_arr[i]这段代码可以跑过示例时间复杂度是O(n)申请了一个与原数组等长的额外数组空间复杂度O(n)。在本地测试时完全看不出问题但在力扣上提交就会撞上题目“不能拷贝额外数组”的要求。2.2 复杂度账时间OK空间违规我们先算算账。假设数组长度是n两个循环分别遍历一次时间复杂度是O(n)加O(n)还是O(n)。从时间复杂度看这个解法并没有比双指针慢因为双指针也是O(n)。不过空间复杂度上这个方法直接用了O(n)的额外空间违背了“原地修改”的约定。为什么很多面试题都强调“原地操作”因为在实际生产中比如嵌入式、数据处理流程里内存往往是瓶颈你再想想如果数组非常大比如几千万个元素新建一个同样大小的数组就可能直接让内存翻倍这是不可接受的。所以力扣上的数组操作题只要不是特殊情况都会要求原地修改这是为了模拟真实工程环境中的内存约束。2.3 面试官的潜台词别让我看到new int[n]我自己的刷题经验是看到“原地”两个字就应该先条件反射地禁用“新建数组”方案。很多人可能觉得那我再把两次遍历改成“用另一个数组存储非零元素最后再copy回来”就行但其实问题的本质是只要你创建了和原数组规模相关的新数组面试官就可以追问内存优化即使你之后把空间压缩到O(1)之前花在暴力解法上的讨论也会显得多余。不过这里我也不是让你完全不做暴力解法。恰恰相反力扣刷题攻略里常提到一个原则先给出暴力解再逐步优化。在面试里这样做其实有它的合理性因为它能体现出你有一个“从笨办法到聪明办法”的思考过程。关键是你要能自己指出暴力解法的瓶颈然后快速给出优化方案。所以暴力解不是不能提而是不要停在第一层。移动零这道题的暴力约束还引发了一个很好的练习你可以把“新建数组”的版本改进为“在原数组上先把非零元素挪到前面剩余位置直接补零”。这就是从暴力到优化的一个中间态下一章我会重点讲最终的双指针方案为什么更优雅。3. 一次遍历完成原地移动双指针的完整推导3.1 快慢指针的“车间流水线”类比如果不用额外数组怎么一次性移动零这里的关键思路是快指针负责“巡视”整个数组慢指针负责记录“下一个非零元素应该放置的位置”。用一个生活化的例子来比喻想象你是一个工厂流水线的质检员传送带上有一堆零件其中有些是合格品非零元素有些是废料零。你的任务是让所有合格品都往左排所有废料都往右扔。双指针的做法就是——你手里攥着一个“安置点标记”慢指针从传送带起点开始走每遇到一个合格品就把它放到标记位置然后标记往后挪一格。走完全程合格品自然就全部排在了左边右边的空隙里全是废料。这里的重点在于快指针一旦遇到非零元素就把它“取出来”放到慢指针的位置同时把慢指针位置原来的值覆盖成0。为什么可以直接覆盖因为快指针已经走过这个位置了如果它是零覆盖成零没影响如果它是非零它也早就被之前的慢指针处理过了。3.2 核心代码Python/C/Java三份实现双指针解法非常稳定基本是所有题解区的标准答案。Python版本大概是这样的def moveZeroes(nums): slow 0 for fast in range(len(nums)): if nums[fast] ! 0: nums[slow] nums[fast] if slow ! fast: nums[fast] 0 slow 1C版本void moveZeroes(vectorint nums) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! 0) { swap(nums[slow], nums[fast]); slow; } } }Java版本同样class Solution { public void moveZeroes(int[] nums) { int slow 0; for (int fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { int temp nums[slow]; nums[slow] nums[fast]; nums[fast] temp; slow; } } } }看到这里你会发现C直接用swap最简洁Python里写成nums[slow], nums[fast] nums[fast], nums[slow]也可以。但要注意一点用swap和用“覆盖再清零”思路是一样的只是实现风格不同。区别在于当slow等于fast时交换自身是多余操作但不影响正确性而在Python的覆盖写法里我特意加了if slow ! fast这个判断可以避免无意义赋值。3.3 手动模拟从[0,1,0,3,12]看指针移动我们来手动走一遍你会更清楚双指针发生了什么。初始状态nums [0, 1, 0, 3, 12]slow 0。fast 0nums[0] 0什么都不做slow保持0。fast 1nums[1] 1非零把nums[1]放到nums[0]再把nums[1]置0。此时数组变为[1, 0, 0, 3, 12]slow变为1。fast 2nums[2] 0跳过slow保持1。fast 3nums[3] 3非零放到nums[1]nums[3]置0。数组变为[1, 3, 0, 0, 12]slow变为2。fast 4nums[4] 12非零放到nums[2]nums[4]置0。数组变为[1, 3, 12, 0, 0]slow变为3。最终结果为[1, 3, 12, 0, 0]完全符合题目要求。整个过程只遍历了一次数组快指针从头走到尾慢指针只在遇到非零元素时前移。时间复杂度O(n)空间复杂度O(1)。我写这道题的时候还有个习惯把每一步的数组变化直接写在注释里这样回头看代码的时候记忆最深的是过程而不是结论。4. 边界条件与易错点空数组、全零、指针失配4.1 必测的4类用例与预期输出很多人在LeetCode上提交一次就过但面试时却会被边界条件问倒。移动零这个题目虽然简单边界条件却不少。我总结了自己踩过的坑整理出下面几组用例用例类型输入预期输出说明空数组[][]什么也不做只有一个元素且为0[0][0]不发生交换全零数组[0, 0, 0][0, 0, 0]慢指针始终不动零在开头[0, 2, 3][2, 3, 0]每次交换都有效零在末尾[2, 3, 0][2, 3, 0]无实际变化你发现没有双指针写法天然对空数组和全零数组“宽容”因为fast循环根本不会执行或者不会走进非零分支。但如果你写的是“先统计零的个数再把非零前移最后补零”那种方案就要格外小心全零情况否则很容易被补零循环操作边界。4.2 常见的三种写错姿势我在刷Hot100题时发现移动零这题的常见错误集中在三个地方。第一种错误是用“左右指针靠拢交换”的思路。有些同学看到“移动零”就想着左右双指针从数组两头夹逼遇到左指针为0、右指针非零时交换。但这个做法会破坏非零元素的相对顺序。比如[0, 2, 3]如果用左右交换左指针遇到0右指针从末尾找到非零值3交换后得到[3, 2, 0]顺序变成了3,2题目要求是非零元素保持原来的2,3这就错了。所以移动零这道题只能用同向快慢指针不能用对撞指针。第二种错误是在覆盖写法里忘记把原位置清零。很多人只写nums[slow] nums[fast]然后slow但没处理nums[fast]导致数组变成[1, 1, 0, 3, 12]这样的重复数据。逻辑上是因为fast位置的原值没有被清掉。最稳妥的办法是用交换直接省去“忘记清零”的问题。第三种错误是把slow误当成“当前遍历位置”在判断时写了nums[slow] 0之类的条件。实际上slow永远指向下一个非零元素的落脚点它本身的值没有判断意义它只用来记录“写到哪里了”。4.3 稳定性为什么不能用左右交换上面已经提到左右交换会破坏相对顺序这里我再深挖一下。在计算机科学里“稳定”的意思是如果两个元素相等操作前后它们的相对顺序不变。移动零要求非零元素保持相对顺序本质上就是在要求这个排序过程是“稳定”的。如果你用对撞交换本质上做的是“把两端的零和非零互换”会直接改变非零元素之间的先后关系。举个更夸张的例子[1, 2, 0, 3, 0, 4]如果左右交换你会发现交换过程极其混乱。快慢指针则完全不会碰到这个问题因为它只是把非零元素按原顺序依次搬到前面天然满足稳定性要求。这也是面试里经常会加问的一点“如果允许非零元素顺序改变那还能不能有更好的解法”这时候你才可以用头尾双指针把零和非零直接交换。所以理解“稳定”这一点是举一反三的关键。5. 一题多解与变体把一道题吃出三道题5.1 解决方案横向对比移动零这道题在网上能搜到很多种写法我整理了一下常见的几类方案方案核心思路时间复杂度空间复杂度优缺点新建数组两次遍历复制元素O(n)O(n)不符合原地要求非零前移末尾补零第一次遍历统计非零并前移第二次遍历末尾补零O(n)O(1)可行但需要两个循环双指针覆盖快慢指针非零覆盖到slow位置并清零O(n)O(1)一次遍历推荐双指针交换快慢指针非零与slow位置交换O(n)O(1)最简洁C首选我推荐你在实际面试中优先使用双指针交换版本因为它写起来最不容易出错而且在遇到末尾连续零的数组时交换的次数也很少性能不会受影响。但“非零前移末尾补零”的思路同样是值得掌握的它和“删除有序数组中的重复项”的解法有异曲同工之妙。5.2 变体题移动零到前面、移动指定元素如果题目改成“把零移动到数组前面同时保持非零元素相对顺序”怎么做其实思路完全一样只是方向相反。把双指针改为从右边开始扫用一个slow指向从右往左的“安置点”遇到非零元素就把它放到后面的位置自然就把零挤到前面了。或者更简单一点先算出数组中零的个数然后从后往前填入非零元素。还有一道常见变体给定一个数组和一个目标值把所有等于目标值的元素移动到数组末尾保持其他元素相对顺序。这个变体的代码跟移动零一模一样只是判断条件从! 0换成! target。这类题目练熟了你就等于同时掌握了LeetCode第27题“移除元素”的核心逻辑。5.3 从移动零延伸到同系题目27、26、80Hot100刷题攻略里经常提到一个很重要的策略把某一类题集中起来做做完之后总结套路。移动零正好可以作为“数组同向双指针”的入门题和它同系的题目至少有这三道力扣27移除元素。给定数组和一个值val原地移除所有等于val的元素返回新长度。解法可以完全沿用快慢指针的“非零前移”思路把!0换成!val即可。力扣26删除有序数组中的重复项。使用快慢指针遇到nums[fast] ! nums[slow]时把nums[fast]移到slow1位置。本质上是“保留唯一值”也是同向双指针。力扣80删除有序数组中的重复项II。在26的基础上允许每个元素最多出现两次需要引入一个计数变量或更精细的指针控制但核心还是同一个套路。建议你把283刷完之后立刻去刷27和26。这样一来每天打卡一道题实际上等于三天内串起了四道同类型题目。这也是我在DAY4之后的整个刷题策略不追求单题数量而是追求题目之间的迁移性。6. 从DAY4打卡说起Hot100刷题策略与时间管理6.1 我的一天一道题的节奏Hot100总共100道题很多人一开始兴致勃勃列了一个“一个月刷完”的计划结果刷到第10题就没有然后了。我自己更推荐的策略是一天只安排1-2道核心题加上一些同系变体控制在1小时以内。以DAY4为例我会把283这道题放在第一步先花15分钟独立思考。如果15分钟没有思路再去看题解。看完题解之后不能直接跳过我会关掉题解自己把双指针思路默写一遍然后跑通示例和边界用例。最后再找同系题目继续巩固。整个过程大概40分钟不会让刷题变成一种负担。这个方法其实来源于热词里大家常搜的“力扣刷题攻略”。我在看很多人提问“hot100题怎么刷效率高”时发现高赞回答里往往都会强调一件事独立思考时间与题解学习时间的配比。如果一开始就看答案你的大脑会误以为自己会了实际上动手写的时候还是不会。6.2 刷题时如何避免“看答案式刷题”这里分享一个很容易被忽略的细节在做“移动零”这类简单题时不要直接看题解。为什么因为简单题是你建立“解题直觉”最好的材料。如果连简单题都依赖题解那么中等难度的题目就会更没信心。我自己的实操方法是“ABC法”AAlone独自尝试哪怕只能写出暴力解也一定要先写出来BBrief用一句话概括“最优解的核心机制”比如双指针、原地覆盖不写代码CCode合上题解从头写一遍代码跑几个用例验证。283就是一个非常适合ABC三步法的题。按照A步骤你可能会写出新建数组版本按照B步骤你总结出“快慢指针同向移动”按照C步骤你重新写出一次遍历的版本。这个过程比直接复制粘贴10道题的答案有价值得多。6.3 记录刷题日志的价值最后我强烈建议你给自己的刷题过程建立一份简单的日志。不需要花里胡哨的表格只要记三件事日期、题目编号/名称、关键词。比如“DAY4-283-移动零-双指针覆盖、原地操作、稳定性”。搜索热词里出现过“力扣1875将雇员相同的分组”这种题目说明大家刷力扣时经常会搜具体的题号。如果你有自己的日志未来回顾的时候就方便很多看到“移动零”能联想到“快慢指针”“稳定顺序”这些关键词以后遇到“把某种元素移动到另一侧”的题目时就能自动想起这道题的存在。我自己现在翻看几张前几天的打卡记录最大的感受是刷题没有捷径但绝对有技巧。最重要的技巧不是题目刷得有多快而是每道题是否在你的脑子里留下了“模式”。移动零这个模式就是“用同向双指针在原数组上做稳定搬运”。这个模式一旦内化你就会发现Hot100里很多数组题都长得很像。如果你刚开始刷力扣不用纠结自己当天到底打卡到DAY几也不用跟别人比进度。我自己刷到DAY4时才写了几道题但已经把双指针这一类的基础打扎实了。后面继续看Hot100里的其他题目时明显会觉得数组类题目的底层逻辑是互通的。保持每天一道题比周末一次性刷十道题有效得多。希望这篇“移动零”的拆解能帮你在DAY4顺利打卡更重要的是能让你借此摸透一类题。
返回列表