ARTICLE DETAIL

资讯详情

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

移动零LeetCode 283双指针解法:原地操作与O(1)空间复杂度详解

移动零LeetCode 283双指针解法:原地操作与O(1)空间复杂度详解 1. 题目解读与核心思路1.1 这道题到底在考什么刷题群里经常有人问 moveZeroes也就是 LeetCode 283 题“移动零”。如果只看题目本身它说的是给定一个数组 nums编写一个函数将所有 0 移动到数组的末尾同时保持非零元素的相对顺序。表面上看这不过是一个数组操作的入门题很多人在拿到题之后的第一反应是“这有什么难的把零挑出来扔到最后不就完事了”。但真正动手写代码的时候你才会发现这里面其实藏着一个非常关键的限制条件那就是必须在原数组上操作不能拷贝额外的数组。这个限制直接把很多“投机取巧”的方案给堵死了。比如你先遍历一遍把非零元素都存到一个新数组里再补上零然后拷贝回原数组这种做法虽然逻辑上完全正确但如果面试官要求空间复杂度必须为 O(1)那这条路就走不通了。所以这道题真正想考察的是你在不能使用额外空间的约束下能不能想到用双指针这样的经典技巧来原地完成数组元素的重新排列。它考察的不只是你会不会写这段代码更是你对数组操作中“指针移动”和“元素交换”这两个基本动作的理解深度。1.2 适合谁来刷这道题这道题非常适合三类人群。第一类是刚入门算法、准备找实习的在校学生。moveZeroes 是数组类题目里少有的“看着简单、写对不容易”的题目它没有复杂的递归也没有高级的数据结构但你必须把双指针的思路理得非常清楚才能写出优雅的解。第二类是准备跳槽、需要面试大厂的社招选手。说实话我在面过不少候选人之后发现真的能一次性把这个题写得干净利落的人比例并不高。很多人能说出思路但一到手写代码就暴露出边界处理不当或者指针更新位置搞错的问题。第三类是工作中经常要和数组、列表打交道的后端工程师。虽然日常业务里很难直接遇到“移动零”这种需求但“原地整理数据”“保持相对顺序”“降低空间复杂度”这些思想在日志处理、缓存整理、数据库记录清洗等场景中都是极其通用的。如果你属于以上任何一种情况这篇文章把这道题从暴力解到最优解全部拆开讲清楚该避开哪些坑、面试时怎么答、代码怎么写最稳都有了。2. 双指针解法的原理拆解2.1 为什么暴力解不推荐很多人第一眼看到这道题脑子里冒出来的解法是遍历数组遇到零就把它删掉然后在末尾补一个零。这个思路本身没什么问题如果你用的是 Python可能两三行就能写出来。比如用列表的 remove 或者用一个临时数组做过滤再拼接。但问题在于这种做法的效率并不理想。如果你采用边遍历边删除的方式每删除一个元素数组后面的所有元素都需要往前移动一位这在最坏情况下会让时间复杂度来到 O(n²)。如果数组长度是几万甚至几十万这个性能损耗就非常明显了。还有人会用额外的数组来存非零元素最后再把零补齐。这个思路很直白也很好理解但它的空间复杂度是 O(n)在 LeetCode 的题目要求里它并不满足空间复杂度 O(1) 的隐含要求。虽然这道题的判题系统不一定强制检查空间复杂度但作为面试答案它绝对不是面试官想听到的最优解。所以当你面对这道题的时候最好直接就朝着“原地操作 线性扫描 常量额外空间”这个方向去想。这样一来答案自然就会收敛到双指针上面。2.2 双指针的两种实现思路双指针解法的核心思想其实非常朴素用一个指针去遍历数组找非零元素用另一个指针记录下一个非零元素应该放到的位置。第一种是“快慢指针”的思路。慢指针 slow 从 0 开始快指针 fast 也从 0 开始快指针往右扫描遇到非零元素就跟慢指针位置的元素交换然后把慢指针往右挪一格。这个思路很好理解快指针负责“找货”慢指针负责“占位”。每找到一个非零元素就把它放到前面应该待的位置上。因为快指针扫描过的区域里所有非零元素都已经排好了慢指针指向的位置就是“下一个待放置位”。第二种思路是“单指针 覆盖”。这个方法只用一个指针 pos 指向当前要填的位置从头遍历数组遇到非零元素就把它写到 nums[pos] 的位置同时 pos 加一。遍历结束后从 pos 位置开始到数组末尾全部写成 0。这两种方式各有优势。交换的方式保持了数组里元素的“原样性”不会丢数据代码也简洁。覆盖的方式需要最后统一补零但少了交换的开销在某些语言里性能会更好。从我自己的经验来说面试时建议先说清楚交换法的思路然后补充一句“如果面试官允许最后统一补零也可以用覆盖法减少交换次数”。这样既展示了你有优化意识也展示了你的代码能力。3. 实操代码实现与细节打磨3.1 基础版双指针交换法先看最经典也最不容易出错的版本用 Java 写是这样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; } } }这个代码的思路非常清晰。慢指针 slow 初始指向数组开头快指针 fast 从头扫描到结尾只要发现非零元素就把它跟 slow 位置的元素交换然后 slow 加一。你可以把慢指针理解成“队伍前排的最后一个空位”。一开始空位在 0 号位置快指针找到第一个非零元素后直接把它放到 0 号位空位挪到 1 号位。接着继续往后找第二个非零元素放到 1 号位空位再挪到 2 号位。循环往复所有的非零元素就被依次排到了数组前面。由于交换的时候慢指针位置原有的元素一定是零或者快指针和慢指针指向同一个位置所以整个交换过程不会打乱非零元素的相对顺序。这一点非常关键也是这道题最容易出错的地方。对于 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 13.2 覆盖版双指针补零法如果你想减少交换带来的额外赋值的开销可以改用覆盖加补零的思路。这个思路同样使用一个指针但逻辑上更直接def moveZeroes(nums): pos 0 for i in range(len(nums)): if nums[i] ! 0: nums[pos] nums[i] pos 1 while pos len(nums): nums[pos] 0 pos 1先遍历一遍数组把所有非零元素按顺序平移到数组的前端。因为平移是把后面的元素往前面覆盖所以不用担心把还没处理到的元素弄丢。等遍历结束以后pos 停下来的位置就是第一个“应该放零”的位置从这个位置开始到结尾全部覆盖成 0 就完成了。用这段代码跑一个例子假设输入数组是 [0, 1, 0, 3, 12]过程如下pos 初始为 0i 从 0 开始扫。i0 时遇到 0跳过。i1 时遇到 1nums[0]1pos 变成 1。i2 时遇到 0跳过。i3 时遇到 3nums[1]3pos 变成 2。i4 时遇到 12nums[2]12pos 变成 3。最后从 pos3 开始补零得到 [1, 3, 12, 0, 0]。这个思路跟交换法一样保持了非零元素的相对顺序而且每次只需要一次赋值不需要交换的临时变量在某些语言里性能更好。3.3 两个版本的执行结果对比我用一些典型的测试数据把两个版本的运行结果整理成了一张表方便你直观对比输入数组交换法输出覆盖法输出非零元素相对顺序[0, 1, 0, 3, 12][1, 3, 12, 0, 0][1, 3, 12, 0, 0]1, 3, 12[0, 0, 0, 0, 0][0, 0, 0, 0, 0][0, 0, 0, 0, 0]无[1, 2, 3, 4, 5][1, 2, 3, 4, 5][1, 2, 3, 4, 5]1, 2, 3, 4, 5[0, 0, 1][1, 0, 0][1, 0, 0]1[1, 0, 0, 2, 0][1, 2, 0, 0, 0][1, 2, 0, 0, 0]1, 2从结果上看两个版本没有任何区别。实际选择哪一个完全取决于你的口味和面试时想展示的重点。3.4 复杂度分析到底该怎么算这段代码的时间和空间复杂度分析是面试时必然会问到的点一定要熟练掌握。时间复杂度快指针或遍历指针需要完整扫描一遍数组所以时间开销跟数组长度 n 成正比时间复杂度为 O(n)。无论你用交换还是覆盖都只需要遍历一次区别只是每次循环内部做交换还是做赋值。空间复杂度整个过程只用了一个或两个额外变量不随数组长度变化所以空间复杂度为 O(1)。这个特性是这道题最核心的价值之一也是面试官最看重的一点。对比一个很常见的错误答案也就是用额外数组存储非零元素再复制回来那种做法的时间复杂度是 O(n)但空间复杂度是 O(n)。在两段代码跑出来的结果完全一样的情况下空间复杂度的差异就成了拉开水平的决定性因素。3.5 为什么快慢指针不会改变非零元素的相对顺序这一点我单独拿出来讲是因为它非常容易被忽视。快慢指针交换的时候慢指针永远指向从左往右第一个等于 0 的位置快指针永远在当前扫描位置。当快指针找到一个非零元素时它和慢指针指向的 0 交换这个非零元素就被放到了“当前所有非零元素的最后面”。由于快指针是从左往右扫描的它遇到的第一个非零元素会被放在 0 号位第二个非零元素会被放在 1 号位以此类推。这个顺序天然就是它们在原数组里的顺序。如果你把整个数组想象成一个在排队检票的队伍慢指针就是检票口快指针就是走来走去寻找乘客的工作人员。工作人员每带回来一个乘客就安排到检票口的位置下一个带回来的乘客只能排在刚才那个乘客后面不可能插到前面去。这样一来队伍里非零乘客的先后顺序就永远不变。4. 常见错误、边界情况与排查技巧4.1 最容易踩的坑忘记处理指针移动条件我见过不少人写的版本是在 if nums[fast] 0 的情况下才移动慢指针这个逻辑看起来似乎合理但实际跑起来就会发现完全不对。正确的逻辑应该是遇到非零元素交换然后移动慢指针遇到零什么都不用做快指针继续往前走。如果你写反了变成遇到零的时候才移动慢指针那你等于把零都堆在了数组的前面而不是后面整个结果就会完全颠倒。这里有一个很好用的自检方法无论如何慢指针的移动次数一定等于数组里非零元素的个数。4.2 边界情况数组长度为 0 或全是非零元素一个很容易被忽视的细节是空数组的处理。如果数组长度是 0代码应该直接返回不能有任何越界访问。无论是 for 循环还是 while 循环只要条件里正确判断了 nums.length 或 len(nums)空数组不会造成任何问题。另一种极端情况是数组里一个零都没有比如 [1, 2, 3, 4, 5]。这种情况下快指针每次遇到非零元素就和自己或者前面的元素交换相当于什么都没变最终数组保持原样结果也是正确的。还有一类极端情况是数组全部都是零比如 [0, 0, 0, 0]。这时候任何指针都不会发生交换最终输出还是 [0, 0, 0, 0]符合预期。在实际的比赛或面试中建议你在写完代码之后马上把这些边界情况口述一遍再对着代码过一遍执行流程这样做几乎可以杜绝所有的低级失误。4.3 变种题的延展思考moveZeroes 的解法熟练以后有些变种题你应该顺便想一想。如果把“0”换成“某个特定的值”呢比如把所有的负数都移动到数组末尾或者把所有的奇数移动到数组前面思路是完全一样的只是判断条件从 nums[fast] ! 0 变成了其他的条件表达式。这类问题本质上都属于“按条件分区”也就是把数组元素按照某个条件分成两类一类在前、一类在后同时保持相对顺序。再进一步如果题目要求不是“把零移动到最后”而是“把所有奇数放在偶数前面”很多人会本能地去用对撞双指针一个从头找偶数一个从尾找奇数然后交换。但那样做会破坏相对顺序因为对撞双指针本身就是无序的。如果你需要保持相对顺序快慢指针依然是最稳的方案。当你把 moveZeroes 背后的这种思路想透之后你会发现自己再看很多数组题的时候就不是一道一道背了而是能看到它们之间的联系。4.4 实战中的性能优化建议如果你的场景里数组特别大比如上百万个元素交换法每次都要做三次赋值操作而覆盖法只需要一次赋值两者的性能差距可以到两倍以上。虽然时间复杂度都是 O(n)但常数项在数据量大的时候依然值得关注。所以如果是超大规模数据的场景我更推荐覆盖法。如果你用的是 C开启编译器优化后这段代码的耗时几乎可以忽略不计。另外如果你处理的数据不是原始的整型数组而是一个自定义对象数组比如一个包含大量字段的结构体数组交换整个结构体的成本远大于交换一个整数指针。这种情况下更合理的做法是维护一个索引数组或者直接使用覆盖法减少对象移动的次数。5. 用 Rust 重写一次体会所有权的乐趣5.1 Rust 版交换法Rust 的所有权机制决定了你不能像 C 那样随便把变量赋来赋去但 moveZeroes 这种题目恰好能展示 Rust 在处理可变引用时的安全优势。第一次实现的时候我写的是这样pub fn move_zeroes(nums: mut Veci32) { let mut slow 0; for fast in 0..nums.len() { if nums[fast] ! 0 { nums.swap(slow, fast); slow 1; } } }Vec 提供了 swap 方法直接在内部完成两个下标的交换不会触发借用检查的问题。slow 和 fast 都是 usize 类型的索引循环0..nums.len()也避免了对空数组的越界访问。5.2 Rust 版覆盖法如果不想依赖 swap也可以写成覆盖法pub fn move_zeroes(nums: mut Veci32) { let mut pos 0; let len nums.len(); for i in 0..len { if nums[i] ! 0 { nums[pos] nums[i]; pos 1; } } while pos len { nums[pos] 0; pos 1; } }这里有一个我踩过的坑在遍历 nums 的同时修改 numsRust 的借用检查器会直接拒绝你的代码。你必须把 nums.len() 先存到一个变量里再对 nums 进行可变操作。这个细节体现了 Rust 在并发和内存安全方面的严格性和 C 或 Java 那种“随便改”的风格形成了鲜明的对比。如果你用 Rust 写这道题建议先理解清楚可变引用和借用检查的基本规则否则会遇到很多编译错误。6. 面试时怎么写才能稳拿分6.1 先讲思路再写代码最后验证很多面试者拿到题就开始埋头写代码这是一个很大的误区。正确的方式应该是先和面试官确认几个关键点。第一确认是否可以修改原数组。虽然题目说了可以但最好还是确认一下。第二确认空间复杂度有没有限制。如果有限制就不能用额外数组。第三确认是否需要保持非零元素的相对顺序。这道题明确要求了但其他变种题不一定。确认完这些之后再用一两句话讲清楚你的思路比如“我用一个慢指针维护当前已排好的非零元素的位置快指针负责找到下一个非零元素找到就交换然后慢指针往前移”。说完思路再动手面试官会觉得你的思考过程是成熟的。6.2 代码写完之后的自检流程写完之后别急着说“写完了”先自己在心里或者草稿纸上跑一遍我通常建议按照这个顺序检查空数组代码会不会越界显然不会for 循环直接跳过。一个元素且非零会不会做无意义的交换会但不影响正确性。一个元素且为零会不会有任何输出变化不会保持原样。两个元素 [1, 0]交换后会不会变成 [0, 1]不会因为 slow 在 0fast 在 1 时发现 nums[1] 是 0不交换。两个元素 [0, 1]交换后变成 [1, 0]正确。全部为零任何指针都不动结果不变。把这几类情况过一遍以后再跟面试官说“我觉得可以了”。这在面试官眼里是一个非常大的加分项因为很多候选人写完了根本不做验证边界错误一抓一大把。6.3 一个容易翻车的追问为什么不需要考虑交换次数面试官可能会问“如果数组里零特别多交换法不是会做很多次无意义的交换吗”这时候你要冷静地分析。比如数组是 [1, 0, 2, 0, 3]快指针遇到 1 时和 slow0 交换因为位置相同数组没变化。遇到 2 时和 slow1 交换那个位置是第一个零结果是 [1, 2, 0, 0, 3]遇到 3 时和 slow2 交换结果是 [1, 2, 3, 0, 0]。如果全是非零元素比如 [1, 2, 3]那么每次都和自己交换白白做了三次赋值。虽然浪费了一点时间但这种浪费是常量级的不会影响整体复杂度。真正的零元素越多交换的效率反而越高因为慢指针会一直停留在零的位置上快指针扫过多少个零就有多少次不需要交换。6.4 高频追问能否用其他算法解决面试官偶尔会追问除了双指针还有没有别的解法。你可以回答对于这种需要保持相对顺序的 partition 操作双指针是标准解法。如果不需要保持相对顺序还可以用类似快排 partition 的对撞双指针一头找零、一头找非零然后交换但那样会乱序不满足本题要求。也可以提一下“稳定排序”的思路——把非零元素视为较小值、零视为较大值用稳定的排序算法排序也能得到答案但那样复杂度至少是 O(n log n)完全没有必要。这样回答既展示了你的知识广度也展示了你对复杂度优劣的判断力。7. 从一道题到一种能力如果看到这里你会发现 moveZeroes 这道题虽然只有短短几行代码但它背后的东西一点都不少。它把数组操作中最基本的两个概念指针和原地修改用一道非常朴素的题目串了起来。一旦你真正理解了双指针的“快慢”思想后面碰到很多问题都会有一种豁然开朗的感觉。比如找链表中点用快慢指针判断链表是否有环用快慢指针数组去重用快慢指针甚至双端队列的某些应用也带着这种影子。我自己在实际工作中遇到过类似的需求有一张日志表要把所有 status 不等于 0 的记录按顺序提到最前面同时保留原顺序。我当时的第一个念头就是这道题。虽然业务代码里不能直接搬数组的交换逻辑但“维护一个写入位置另一个指针负责扫描”的思路完全是一样的。这就是刷题的价值所在不只是为了应付面试而是让你在遇到真实问题的时候脑子里能多出几种可以随时调用的思维模型。最后再分享一个小技巧。如果你在纸上模拟双指针经常搞混就准备两支笔左手代表慢指针右手代表快指针从左往右一格格地走。当你亲手“走”过一遍之后这个算法的直觉会牢牢长在你的记忆里比死记代码强一百倍。
返回列表