ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

力扣283移动零详解:双指针原地修改的经典入门题

力扣283移动零详解:双指针原地修改的经典入门题 很多刷题攻略把力扣283“移动零”排在入门第一梯队我也一直这么推荐给身边刚开始刷题的朋友。原因很简单它看起来毫无门槛但真提交起来至少有一半人会踩到“原地修改”和“保持相对顺序”这两个暗坑。这篇笔记我不想只贴一个答案而是把这道题从暴力解法一路推到最优解把我自己调试时的测试用例、踩过的雷、面试里被追问的变化全写出来希望能帮你在力扣热题100 里先把这一关稳稳过掉。1. 题目拆解这题到底在考什么1.1 原题描述与三个隐藏要求力扣283的题目描述很短给定一个数组nums编写一个函数将所有0移动到数组的末尾同时保持非零元素的相对顺序。举个例子输入[0,1,0,3,12]输出[1,3,12,0,0]。但真正决定你能不能AC的是题目里没明说、但LeetCode判题系统写死的三个隐藏要求必须在原数组上操作不能拷贝额外的数组。这一句话就把很多人“新建一个列表把非零元素放前面、零放后面再返回”的思路直接判死。保持非零元素的相对顺序。这意味着你不能后半段和前半段随便交换比如[2,1,0,3]你交换成[3,1,2,0]虽然0到末尾了但非零元素顺序全乱了直接错误。尽量减少操作次数也就是时间上希望达到 O(n)不是 O(n²)。这三点叠加起来这道题其实不是在考“你会不会把0挪走”而是在考你能不能在一个数组里只用一次遍历、常数级额外空间完成数据重排。这就是双指针里最经典的“快慢指针覆盖”场景。我第一次刷这题的时候脑子里第一反应是“遍历一遍遇到0就把它跟后面的非零元素交换”。这个思路方向是对的但实现起来很容易掉进顺序错乱和索引越界的坑。后来我才意识到这道题正确的思考方式应该是反向的不要想着“把0推到末尾”而要想着“把非零元素按顺序往前提”。0最后落在哪里是提完非零元素之后自然形成的空位。1.2 为什么它是热题100的“钉子户”力扣热题100是很多人的必刷清单而在那100道里面283属于典型的“高性价比”题目代码短、思路经典、面试频率高。它背后涉及的双指针技巧在后面的27题移除元素、26题删除有序数组的重复项、甚至80题里都能复用同一套思维框架。很多Python新手把这题当作“练手题”但我更愿意把它当成“双指针第一课”。因为你在283里学会的“一个指针指向写入位置一个指针负责扫描”这个套路是往后大量中等难度数组题的地基。地基不牢后面做75题颜色分类、88题合并两个有序数组的时候你会明显感觉吃力。2. 思路演进从最自然的想法到最优解2.1 方法一借助额外数组——能跑通但不算数先说大多数新手最容易想到的方案新建一个列表把所有非零元素按顺序放进去再把0补在后面最后把结果赋给原数组。def moveZeroes(self, nums): result [x for x in nums if x ! 0] result [0] * (len(nums) - len(result)) nums[:] result这段代码在本地跑结果是完全正确的[0,1,0,3,12]会变成[1,3,12,0,0]。但是注意我最后用了nums[:] result而不是nums result。这是个关键细节nums result只是把局部变量指向了新列表原来的nums在LeetCode的判题上下文里根本没变nums[:] result才是真正把原列表的内容替换掉。这种做法虽然正确但额外开了一个和原数组等长的临时列表空间复杂度是 O(n)。如果面试官要求“原地操作”这一版基本过不了。不过它有个价值用来对拍验证后续优化解法的正确性很方便。我在本地测试时会先写完这个暴力版再做优化版然后用随机数组跑几轮assert保证两个版本输出一致。2.2 方法二先压缩再补零——最稳的O(1)空间解法既然不让用额外数组那思路就变成了“能不能在原数组里玩两个指针”。这里的关键是分两步走第一步用一个慢指针write标记“下一个非零元素该放的下标”用一个快指针read从头扫描。每遇到一个非零元素就把它写到nums[write]然后write加一。第二步扫描结束后write到数组末尾的位置全部填0。这个思路之所以好是因为它把“移动零”这个听起来很动态的操作拆解成“先压缩非零元素”加“补零”两个静态操作逻辑非常清楚。def moveZeroes(nums): write 0 for read in range(len(nums)): if nums[read] ! 0: nums[write] nums[read] write 1 for i in range(write, len(nums)): nums[i] 0我用生活里的场景来类比你有一排座位要求所有坐着的同学都靠左坐空位全部挪到右边。最快的方式不是让同学们一个个往右挪而是从头扫一遍喊一个站着的同学坐到左边第一个空椅子上然后左边空位指针往后移一格。全部喊完之后右边剩下的椅子自然全是空的了。这个解法时间复杂度 O(n)空间复杂度 O(1)符合题目所有隐藏要求。但我在实际写的时候发现又两个细节特别容易错write指针必须在判断nums[read] ! 0之后才移动。如果写在for循环外面或者提前移动会导致写入位置错位。第二步补零循环不能写成while i in range(write, len(nums))Python里直接for i in range(write, len(nums))最不容易出错。2.3 方法三遍历中直接交换——省掉补零的循环如果你觉得“先压缩再补零”要跑两次循环还想要更简洁的版本可以换成交换思路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这个版本的执行逻辑是slow永远指向“当前这一段里第一个为0的位置”fast负责往前找非零元素。找到之后把它跟slow指向的那个0交换slow前进一位。它的好处是只需要一次遍历不需要最后的补零循环。因为交换本身就把0往右送了非零元素永远排在左边连续段里。这里有一个特别重要的说明这个交换不是冒泡式的两两相邻交换。很多人一看“交换”两个字就写成了“发现0就跟后面最近的非零交换”或者“用一个内层循环找到下一个非零”结果复杂度变成 O(n²) 或者顺序错乱。这里的交换是“慢指针指向的位置和快指针指向的位置交换”两个指针之间可能隔着很多0但交换一次就能把非零元素放到正确位置。我实际对比过方法二和方法三的运行时间在普通数组规模下差别不大方法三的代码赏心悦目一些也好记忆。但如果这是笔试我更推荐方法二因为它不需要在脑内反复确认交换后会不会破坏什么状态逻辑上更直白不容易出错。3. Python实现细节与性能对比3.1 用for循环还是while循环缩进和指针步进的坑很多人在从C思维转Python的时候习惯把快指针步进写在if内部比如slow 0 fast 0 while fast len(nums): if nums[fast] ! 0: nums[slow], nums[fast] nums[fast], nums[slow] slow 1 fast 1 # 灾难fast只在nums[fast]!0时前进 # 这里缺了else fast 1上面这个代码一旦遇到0fast就会卡在原地进入死循环。正确的写法是fast的步进在每一轮都要执行要么写在while循环体的最后统一fast 1要么直接用for fast in range(len(nums))让Python帮你管理。我个人的习惯是双指针扫描类题目只要快指针是逐格移动一律用for循环。这能消灭一大类由步进位子写错引起的低级Bug。只有当快指针需要跳跃前进时才用while循环自己控制步进。3.2 最省事的Pythonic写法切片赋值有一类Python玩家喜欢用列表推导式一行解题def moveZeroes(nums): nums[:] [x for x in nums if x ! 0] [0] * nums.count(0)这行代码的原理是先统计出非零元素和零的数量然后重新拼一个新列表最后通过切片赋值写回nums。从结果上看它完美通过了力扣的全部测试用例。那问题来了这道题到底该不该用它我的看法是如果你在写自己的本地练习随便用开心就好。如果你在准备面试请主动给出双指针版本并在最后提一句“Python里还可以用切片赋值一行实现但那是O(n)空间”。因为面试官看到一行流可能会觉得你Python功底不错但更可能在心里给你贴上“没理解原地操作”的标签。而且从性能角度讲列表推导式 count 列表拼接要遍历数组两三遍时间上并不比双指针快空间上还开了一个临时列表。它只是写法优雅不是性能优雅。3.3 完整可运行的本地测试代码下面是一份完整的、可本地运行的测试脚本包括LeetCode解法、暴力对拍版本、边界测试用例。你直接存成test_move_zeroes.py就能用。from typing import List import random class Solution: def moveZeroes(self, nums: List[int]) - None: Do not return anything, modify nums in-place instead. slow 0 for fast in range(len(nums)): if nums[fast] ! 0: nums[slow], nums[fast] nums[fast], nums[slow] slow 1 def brute_force(nums): result [x for x in nums if x ! 0] result [0] * (len(nums) - len(result)) return result def run_test(nums): expected brute_force(nums) solution Solution() solution.moveZeroes(nums) assert nums expected, f失败: 输入 → {expected}, 实际 → {nums} print(f通过: {nums}) if __name__ __main__: run_test([0, 1, 0, 3, 12]) # 力扣示例 run_test([0]) # 单元素 run_test([]) # 空数组 run_test([1, 2, 3]) # 全非零 run_test([0, 0, 0]) # 全零 run_test([0, 0, 1]) # 零都在前 run_test([1, 0, 0, 2]) # 零在中间 # 随机数组对拍 for _ in range(1000): arr [random.randint(0, 5) for _ in range(random.randint(1, 50))] expected brute_force(arr) solution Solution() solution.moveZeroes(arr) assert arr expected, f随机测试失败: 输入 {expected}, 实际 {arr} print(随机对拍1000次全部通过)这份脚本的调试价值在于用暴力版本当裁判用1000次随机数组做压力对拍。你以后写任何数组重排算法都可以用同样的模式验证自己的双指针逻辑。3.4 三种解法的时间和空间复杂度对比解法时间复杂度空间复杂度代码行数能否原地面试推荐度额外列表法O(n)O(n)3行不能不推荐双指针覆盖法O(n)O(1)7行能最推荐双指针交换法O(n)O(1)6行能推荐列表推导式切片赋值O(n)O(n)1行表面能不推荐用于面试时间上四种方法都是O(n)因为即使列表推导式要遍历两三遍常数翻倍仍算O(n)。空间上覆盖法和交换法是最优的O(1)。这个“原地 O(1)空间”的组合才是力扣判题系统真正希望你掌握的。4. 边界条件与隐藏的坑我在提交时踩过的雷4.1 写着写着发现LeetCode不给返回值力扣283的方法签名是def moveZeroes(self, nums: List[int]) - None:注意返回类型是None。很多刚刷题的朋友会困惑我不返回结果它怎么知道我算对了答案在签名里的nums: List[int]和题目描述里的“原地修改”。LeetCode判题时会先保存原数组再调用你的函数最后检查你传入的那个nums列表是否被改成了目标状态。它根本不看你的返回值。所以如果你写def moveZeroes(self, nums): return [x for x in nums if x ! 0] [0] * nums.count(0)即使函数返回的列表完全正确LeetCode也会判错因为传入的nums原封没动。这个错误在第一次刷题的人里出现概率极高。4.2 千万别在遍历列表时修改它的长度有些新手会想到“把0从中间删掉再在末尾添加0”于是写出这种代码def moveZeroes(nums): for x in nums: if x 0: nums.remove(0) nums.append(0)这代码有双重灾难第一remove本身是O(n)的查找删除放进循环里总复杂度至少 O(n²)第二当你遍历一个列表同时又修改它的长度Python的迭代器会跳过元素。比如[0, 1, 0, 3, 12]第一次迭代删掉一个0并追加到末尾迭代器跳到下一个位置第二个0可能被跳过最后结果根本不对。正确的方式就是回到双指针扫描和写入分离扫描指针永远不会看到被自己修改搞乱的长度变化。4.3 超出时间限制的常见原因我在力扣评论区和朋友的面经里看到过很多TLE也就是超时报错基本都是以下几种操作造成的每次都调用nums.index(0)找0的位置然后跟后面的非零交换。index是线性查找外层再来一层循环复杂度O(n²)。用insert(0, 0)和pop()组合试图把0移到末尾。insert(0, ...)会把整个数组后移一位每次都是O(n)。在循环里用nums nums_new而不是nums[:] nums_new导致虽然函数内部看起来改了但外层引用没变提交后不断测试失败而不是超时。判断一个方案是否可能超时脑海里估算一下最坏情况操作次数就好数组长度10万O(n²)就是100亿次操作无论Python还是C都扛不住。力扣的测试用例规模就是奔着“逼你优化”去的。4.4 我整理的边界测试用例清单下面这个表格建议你直接收藏。我每次换一种写法都会用这些用例跑一遍输入期望输出关键验证点[][]空数组不崩溃[0][0]单元素、元素为0[1][1]单元素、元素非0[0, 0, 1][1, 0, 0]多个0在开头[1, 0, 0][1, 0, 0]0全部在末尾不应改变[0, 1, 0, 3, 12][1, 3, 12, 0, 0]力扣官方案例[0, 0, 0, 0][0, 0, 0, 0]全0数组无变化[1, 2, 3, 4][1, 2, 3, 4]全非零数组无变化[0, 0, 1, 0, 2][1, 2, 0, 0, 0]非零元素穿插多个0[2, 1, 0, 0][2, 1, 0, 0]非零在前零连续在末尾这些用例覆盖了“空、单、全0、全非0、零在开头、零在中间、零在结尾”七种核心情况。你把这组用例放在测试文件里以后做任何类似“原地重排”题都能复用。5. 这道题的面试延伸与举一反三5.1 面试官常问的三个追问做完283之后面试官一般不会让你轻松走人。常见的追问有三个层层递进追问一如果要把所有0移动到数组最前面同时保持非零元素相对顺序怎么写比如[1,0,2,0,3]变成[1,2,3,0,0]是283变成[0,0,1,2,3]就是新问题。思路依然用双指针只不过这次慢指针从数组末尾向前移动快指针也从末尾向前扫描把非零元素从后往前填。本质上就是“对称版”的283。追问二如果数组里只有少数几个0比如一百万个元素里只有三个0怎么优化这时候“先压缩再补零”扫描一遍就已经足够好没必要额外优化。但如果要求“最少写入次数”你可以记录所有0的位置然后批量移动非零块块与块之间用切片拷贝。这种优化在日常工程里意义不大但能体现你考虑到了写入放大的问题。追问三如果不要求保持相对顺序还要不要用双指针不要求保持相对顺序时可以用“对撞指针”左边找0右边找非零找到就交换直到两个指针相遇。这样交换次数更少但会打乱顺序。面试官抛这个追问是想考察你能否根据约束条件灵活调整算法。答案不是“必须双指针”而是“哪种约束对应哪种方案”。5.2 一道283打通一串数组题283在力扣里的位置很有意思它的解法几乎是另外几道经典题的模板27. 移除元素把等于某个值的元素“移除”本质是“把不等于val的元素压缩到前面”和283的先压缩思路一致只不过最后不补零。26. 删除有序数组中的重复项快指针扫描慢指针指向下一个不重复元素的写入位置一模一样。80. 删除有序数组中的重复项 II允许每个元素最多出现两次给慢指针加一个计数器即可。75. 颜色分类三指针把0、1、2排好序可以看作是283的强化版。如果你把283当作双指针的“练习册第一页”那后面这些题就是针对同一个技巧不同侧面的反复训练。我自己在刷题时的一个体会是不要贪多把一道经典题吃透到能复述、能变形、能写对比你一口气刷十道浅尝辄止有用得多。283就是那类值得吃透的题。5.3 Python刷题环境里的一个顺手配置最后说个跟题目本身关系不大但能提升刷题体验的点用vscode刷力扣时建议在settings.json里配置好Python扩展的格式化工具和类型检查。我自己的配置里会打开python.analysis.typeCheckingMode为basic这样List[int]这样的类型标注写错时会有提示。力扣刷题虽然不是工程开发但养成“输入输出类型清晰”的习惯对你以后写正式项目帮助很大。环境配置跑通了你才能在本地快速跑我上面那份测试脚本把时间花在算法上而不是折腾环境。这道题做多了之后我养成了一个习惯看到题目里出现“原地”“相对顺序”“不要使用额外空间”这几个词第一反应就是双指针。你会发现数组类的原地操作题十道有八道是快慢指针或左右对撞指针的变体。283就是那个让你把“快慢指针”刻进肌肉记忆的起点值得认真对待。
返回列表