尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
双指针技巧实战:LeetCode移动零与原地数组优化
双指针这个技巧在很多讲算法的人嘴里会说得特别玄乎好像不刷个几百道题就掌握不了似的。但我一直觉得真正理解双指针根本不需要那么复杂从一道“看起来人畜无害”的题入手就够了。就比如LeetCode hot100里那道高频面试题“移动零”题目本身一句话能说完可它背后藏着的指针思路、边界处理和空间复杂度意识才是我们刷这道题真正该带走的东西。这篇文章我就用这道题把双指针从“能写对”讲到“为什么对”顺便把那些我踩过的坑和面试官真正想看到的东西一次性讲透。题目是这么说的给你一个数组写一个函数把所有的0都移到数组的末尾同时保持非零元素的相对顺序。听起来很简单对吧但动手写的时候很多人第一反应都会掉进几个坑里。我会先从题目拆解开始再讲暴力解法为什么不行然后分别给出两种双指针写法最后补充一些语言层面的细节和常见的错误排查。篇幅不长但每一步我都会解释“为什么”而不是只贴个代码让你背。1. 题目拆解这个“简单题”到底在考什么1.1 先把题目原样还原一遍原题描述并不长给定一个数组nums编写一个函数将所有的0移动到数组的末尾同时保持非零元素的相对顺序。要求是在原数组上操作不能拷贝额外的数组。举例来说输入[0, 1, 0, 3, 12]输出[1, 3, 12, 0, 0]举个例子帮助理解就像排队做核酸队伍里混进来几个“0号”你现在要把这些人全部请到队伍最后但其他人在队伍里的先后顺序不能变。你不能把人从队伍里拽出去重新排只能在现有的队列空间里调整位置。这个约束恰恰是整道题的核心。1.2 隐藏的三个考点原地、稳定性、边界很多人看到“简单”标签就放松警惕结果面试时挂在三个点上。第一个是原地操作。题目明确说了不能复制额外的数组这意味着你脑子里那种“新开一个数组把非零放前面把零放后面”的思路直接就被判了死刑。你只能在传入的nums这个数组本身做文章。第二个是稳定性也就是保持非零元素的相对顺序。什么叫相对顺序输入是[4, 0, 2, 0, 1]最终必须是[4, 2, 1, 0, 0]不能是[2, 4, 1, 0, 0]。如果你用常规思维去“排序”很容易把顺序打乱这一下就错了。第三个是边界条件。空数组、全零数组、全非零数组、首元素就是零、末尾元素就是零……这些情况拿出来逐一跑一遍能暴露出很多实现上的漏洞。比如你写了个循环忘记处理“末尾补零”全零数组测试时就可能得到一个完全没变化的数组。我习惯把这三点写在代码注释的最上面每次动手写题之前都先过一遍。不是形式主义是真的能救命。2. 解法演进从暴力到双指针思维是怎么转变的2.1 第一反应往往是最差的方案很多人的第一反应是这样的从头到尾遍历遇到0就把它删除然后在数组末尾补一个0。这个思路看起来直截了当而且在小数组上跑起来好像也没毛病。但问题在于数组这种数据结构删除一个中间元素意味着后面所有元素都要往前挪一位一次就是O(n)。如果数组里有n个零极端情况下整体复杂度会退化到O(n²)。而且如果你用的是某些语言里的“动态删除”功能遍历删除的同时还在修改数组长度索引很容易越界或者漏掉元素这是新手最容易翻车的地方。另一种常见思路是“另开一个临时数组”遍历一遍把非零元素按顺序放进去再把零补在后面最后复制回来。这种做法确实简单但用了额外空间空间复杂度是O(n)违反了题目“原地操作”的要求。面试官看到这种解法基本会追问一句“能不能不用额外数组”这时候如果你的回答是“那我想想”表现分就已经掉了一截。2.2 双指针为什么是正解双指针的思路本质上是在回答一个问题既然我们不能用额外空间那能不能在数组内部自己维护“两个区域”答案是可以。你只需要一个慢指针slow和一个快指针fast。快指针负责从头到尾扫描每一个位置像一个“稽查员”慢指针则指向“下一个非零元素应该放的位置”像一个“整理员”。两者配合快指针发现非零元素就通知慢指针把它放到前面来快指针扫描完毕再把整理员工作范围之后的所有位置统一填成0。这种做法的精妙之处在于它把数组物理意义上分成了两段。慢指针左边是已经处理好的非零区域慢指针和快指针之间是等待处理的区域快指针右边是还没看过的区域。整个过程只用一个数组没有复制没有删除操作一次性遍历完成时间O(n)空间O(1)。这个概念可能听起来有点抽象打个比方整理书架时你不需要把所有书先搬出来再放回去你只需要一只手按在“已整理好的最后一本书”的位置另一只手从书架这头走到那头看到值得保留的书就递给前面那只手让它塞进已整理区域的末尾。走完全程后再把空出来的位置统一放上“不需要保留”的东西。双指针就是那只递书的手和那只放书的手。3. 核心实现一快慢指针覆盖法推荐3.1 核心逻辑拆解快慢指针覆盖法是我个人最推荐的第一版实现因为它逻辑清晰、不容易错而且在很多语言里都能直接写出来。算法步骤可以拆成三个阶段第一步初始化slow 0表示“下一个非零元素应该落在哪里”。第二步让fast从0开始遍历整个数组。如果nums[fast] ! 0说明当前这个元素需要保留就把nums[fast]覆写到nums[slow]指向的位置然后slow 1。如果nums[fast] 0什么都不做继续让fast往后走。第三步遍历结束后slow指向的位置开始一直到数组末尾全部赋值为0。因为前面已经把所有非零元素按相对顺序挪到了数组最前面后面这些位置原本放的是被“覆写”过的残留数据或者本来就是零统一清成0之后就对了。这里最关键的一点是为什么覆写不会丢数据因为slow永远小于等于fast。当slow fast时说明slow和fast之间一定存在着若干个0这些0是我们要“丢掉”的。把fast位置的非零值覆写到前面等于把有效数据往前提而被覆写的位置已经不是未来需要用到的数据了所以不丢。3.2 代码实现与注释先给一份Python版本这也是我自己刷题最常用的语言。def move_zeroes(nums): # slow 指向下一个非零元素的存放位置 slow 0 # fast 负责扫描整个数组 for fast in range(len(nums)): if nums[fast] ! 0: # 遇到非零元素按顺序放到前面 nums[slow] nums[fast] slow 1 # 剩余位置全部补零 for i in range(slow, len(nums)): nums[i] 0再看一份JavaScript版本思路完全一样只是语法有区别。function moveZeroes(nums) { let slow 0; for (let fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { nums[slow] nums[fast]; slow; } } for (let i slow; i nums.length; i) { nums[i] 0; } }两份实现的时间复杂度都是O(n)空间复杂度都是O(1)。代码短到不能再短但每一步都有明确的意义没有多余操作。3.3 用实例跑一遍指针状态光有代码还不够我习惯用手推的方式验证一遍。拿[0, 1, 0, 3, 12]举例初始slow 0。fast 0nums[0] 0跳过slow仍为0。fast 1nums[1] 1非零nums[0] 1slow 1。fast 2nums[2] 0跳过。fast 3nums[3] 3非零nums[1] 3slow 2。fast 4nums[4] 12非零nums[2] 12slow 3。遍历结束此时数组内部状态是[1, 3, 12, 3, 12]。注意看下标3和4位置还是残留的老数据。接着执行补零阶段把slow 3开始到最后全部置0得到最终结果[1, 3, 12, 0, 0]。这一步就是最容易出错的地方如果你忘记最后那个补零循环得到的结果就是[1, 3, 12, 3, 12]完全是错的。所以我在代码里特意把第二步和第三步分开写就是为了让逻辑边界清晰。4. 核心实现二左右指针交换法更省赋值操作4.1 为什么可以直接交换覆盖法需要两步走先覆写非零元素再补零。其实还有另一种更“干脆”的做法当fast扫描到非零元素时直接和slow位置交换。为什么能交换因为我们维护了一个不变量slow左边的所有元素都是非零slow到fast之间的所有元素都是0或者尚未被处理但在实际运行中处理过的部分一定都是0。所以当fast找到一个非零元素时它要么和slow指向同一个位置中间没有零交换无意义但无害要么slow指向的位置是0把它换到fast的位置正好把这个0“扔”到了后面。这个方法直观上更像“排列”而且不需要最后单独补零。每一步做完数组里被处理过的部分就已经满足“前非零后零”的要求了。4.2 完整实现与细节说明Python版本def move_zeroes(nums): slow 0 for fast in range(len(nums)): if nums[fast] ! 0: nums[slow], nums[fast] nums[fast], nums[slow] slow 1JavaScript版本function moveZeroes(nums) { let slow 0; for (let fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { [nums[slow], nums[fast]] [nums[fast], nums[slow]]; slow; } } }拿[0, 1, 0, 3, 12]再走一遍流程slow 0fast 0遇到0跳过。fast 1遇到1交换nums[0]和nums[1]数组变成[1, 0, 0, 3, 12]slow 1。fast 2遇到0跳过。fast 3遇到3交换nums[1]和nums[3]数组变成[1, 3, 0, 0, 12]slow 2。fast 4遇到12交换nums[2]和nums[4]数组变成[1, 3, 12, 0, 0]slow 3。对比覆盖法交换法的好处是代码更短不需要额外一轮补零循环而且每一步完成时处理过的前缀区域已经处于最终状态方便在循环中打印调试。但它的缺点是交换次数可能更多即使slow和fast相等时原本不需要交换代码还是会执行一次自身交换。对于普通题目来说这不是问题但如果你在极端环境下追求极致的少赋值覆盖法其实更优因为它只有非零元素需要移动时才会赋值一次。我在面试时一般会先写覆盖法因为它更好解释“为什么不会丢数据”写完如果面试官问有没有更简洁的写法再顺势给出交换法。两种都要会因为面试官很可能让你比较一下。5. 复杂度分析和语言层面的隐藏陷阱5.1 时间与空间复杂度到底怎么算两种双指针写法的时间复杂度都是O(n)其中n是数组长度。快指针fast从头到尾走一遍每个位置只被访问一次慢指针slow最多也走n步所以总体是线性时间。空间复杂度是O(1)因为只用了slow、fast两个额外变量没有依赖输入数组的规模创建新结构。这就是这道题作为“原地操作”经典题目的核心价值在很多真实业务场景里数组可能非常大比如几十万条日志或者上百万条用户数据这时候哪怕复制一个同样大小的数组内存都可能扛不住必须用O(1)空间的方案。可能有同学会问既然交换法里fast和slow每个都走n步那是不是O(2n)复杂度分析里常数项是可以忽略的所以还是O(n)。同理覆盖法里多了一个从slow补到n的循环整体步数大约是n n依然是O(n)。这里不要被“两个循环”吓到要理解复杂度本身描述的是增长趋势。5.2 Python里最隐蔽的坑以为改了原数组其实没有这道题用Python刷的时候有个非常经典的陷阱。很多人会写出一行很“优雅”的代码nums [x for x in nums if x ! 0] [0] * nums.count(0)这行代码逻辑上完全正确新的列表确实是把所有非零放前面、零放后面。但问题在于在LeetCode的函数式框架里这种写法只是把局部变量nums重新绑定到一个新列表函数外部的原数组根本不会变。你跑测试用例时系统检查的是传入的那个数组对象有没有被修改结果它发现压根没动直接判错。正确的原地写法有两种。一种是用切片赋值nums[:] [x for x in nums if x ! 0] [0] * nums.count(0)这样nums指向的还是原来那个列表对象只是内部元素被替换了。另一种就是前面写的双指针覆盖法全程在数组内部操作。老实说第一版用“列表推导式加切片”也挺好的代码确实简洁但它在运行时会额外创建新的列表空间复杂度是O(n)。这违反了题目原意。所以这道题的正确性判断标准不是“结果对不对”而是“有没有在给定空间里完成操作”。这一点我强调过很多次因为我真的见过有人面试时洋洋洒洒写了一行推导式结果面试官看完摇摇头因为这不是原地算法。类似的坑在别的语言里也有。比如JavaScript里如果你写nums nums.filter(x x ! 0).concat(...)同样只是重新赋值原数组没变。想要原地修改数组对象得用splice之类的操作。所以无论用什么语言先搞清楚“你操作的是数组对象本身还是一个新数组”非常重要。6. 常见错误、边界测试与排查思路6.1 高频错误汇总我整理了这几个最常见的错误每个都是我实际辅导别人刷题时见过的不是凭空编的。错误类型现象根本原因排查方法忘记补零结果出现重复非零值零没移干净只做了覆盖没有把后续位置置零检查是否遗漏从slow到末尾的补零循环使用删除再追加结果正确但时间超限删除数组中间元素的成本是O(n)改用双指针覆盖或交换新列表局部赋值提交后原数组没变化只是换了局部变量指向没改原对象用nums[:]切片赋值或直接原地操作交换法指针方向搞反非零元素跑到后面去了把slow和fast的职责弄混牢记slow是存放位fast是扫描位边界数组处理失败空数组或单元素数组报错循环逻辑没有覆盖空数组写测试用例时把空数组放到第一位6.2 一组值得固化的边界测试用例我每次给别人讲这道题都会让他们先跑这组用例跑通了再谈优化。空数组[]期望结果[]单元素非零[1]期望结果[1]单元素零[0]期望结果[0]全零数组[0, 0, 0, 0]期望结果[0, 0, 0, 0]全非零数组[1, 2, 3, 4]期望结果[1, 2, 3, 4]零在开头[0, 5, 6]期望结果[5, 6, 0]零在结尾[5, 6, 0]期望结果[5, 6, 0]零在中间[1, 0, 2, 0, 3]期望结果[1, 2, 3, 0, 0]全零数组是一个特别容易暴露问题的用例。比如有些人在覆盖法里写的是“遇到非零就交换”全零数组上循环一次都不走数组还是原样看起来好像没问题。但如果他写的是“遇到零就把它往后推”在全零数组上可能因为无限循环或者索引越界而崩溃。所以全零数组一定要测。调试小技巧在循环体的关键节点加一行打印观察slow和fast的变化以及数组当前的状态。不要只盯着最终结果因为有些中间态虽然最终对了但逻辑上可能是歪打正着。7. 延伸思考这道题背后的指针套路能用在哪7.1 移除指定值同一套逻辑的通用化“移动零”其实是一个更通用问题的特例把数组中所有等于某个值val的元素移到末尾或者从数组中“移除”它们。如果你把条件从nums[fast] ! 0改成nums[fast] ! val代码几乎不用变。def move_value_to_end(nums, val): slow 0 for fast in range(len(nums)): if nums[fast] ! val: nums[slow] nums[fast] slow 1 for i in range(slow, len(nums)): nums[i] val这个泛化版本在真实的业务里很有意思。比如日志处理时想把某个错误状态码对应的记录统一移动到数组尾部方便批量归档再比如排行榜系统里想把“封禁用户”的元素沉底不影响正常用户展示。核心操作完全一致只是判断条件变了。7.2 从数组到链表双指针思想的迁移双指针不仅适用于数组也适用于链表。比如“移除链表倒数第N个节点”这个经典题用的就是快慢指针快指针先走N步然后慢指针和快指针同步走快指针到达末尾时慢指针刚好停在倒数第N个节点的前一个位置。还有链表环检测的“龟兔赛跑”也是快慢指针的经典应用。不过需要注意链表里没有“下标”指针是真正的节点引用不能像数组那样slow 1而是slow slow.next。但底层思维是一样的用一个慢变量维护“结果指针”用一个快变量负责探索整个数据结构。我在刷题时经常用这个类比帮助自己理解新题效果很好。7.3 奇偶分离、正负分离稳定性是关键如果面试官继续追问还可能会问这样一个变形题把数组里的奇数放到前面偶数放到后面同时保持相对顺序不变。这个题目和“移动零”几乎一模一样你可以把“0”换成“偶数”套用覆盖法或交换法。但如果把条件改成“把所有负数放在前面正数放在后面但不要求保持相对顺序”那就可以用更快的相向双指针一个指针从左边找正数一个指针从右边找负数找到就交换。这种做法的优点是交换次数可能更少但缺点是不稳定相对顺序会被打乱。所以我在实际编码前一定会先问自己一个问题这个问题对稳定性有要求吗有要求就用快慢指针没要求再考虑相向双指针。这个决策过程就是所谓“算法思维”的一部分不是靠背题背出来的。8. 个人经验这道题值得反复咀嚼的三点体会第一点不要小看简单题。我见过不少人一上来就跳过“移动零”觉得太简单直接去刷困难题结果面试时遇到一个看似复杂的问题拆到最后发现核心就是一个“快慢指针维护有效区间”。这时候如果基本功不扎实现场很容易卡壳。“简单题”的价值在于它把最核心的思想用最简洁的形式暴露出来了。第二点代码规范比炫技重要。覆盖法那个版本虽然比交换法多一个循环但它每一步都符合直觉出错概率低而且解释起来不需要绕弯子。我自己的习惯是面试时先写可读性最高的版本运行通过后再提优化方向。这既体现了代码能力也体现了沟通能力。第三点这道题背下来不难但只有理解了“为什么指针可以这样移动”才能灵活运用到其他题。断点调试、手推状态、边界测试这三件事在简单题上也值得做一遍。我每次带人刷题都会强调不要把AC当成终点把“能不能讲清楚每一步的不变量”当成终点。等你某天发现不用看代码就能在脑子里完整推演一遍指针的全部移动路径这道题才算真的吃透了。
RELATED

相关推荐

树莓派4B多GPIO口字符设备驱动实战:从寄存器到批量控制

树莓派4B多GPIO口字符设备驱动实战:从寄存器到批量控制

做树莓派的内核驱动,尤其是要一口气操作十几个GPIO口,很多人的第一反应是用sysfs或者python的gpiod库。但等到你真要同时控制一批引脚、对时序有要求、或者需要在中断上下文里做处理时,还是会老老实实回到内核态来写驱动。这篇文章就基于树莓…

📅 2026/10/20 9:25:17
纯文本预训练竟能赋予大模型视觉先验?ICLR Oral 论文深度解析

纯文本预训练竟能赋予大模型视觉先验?ICLR Oral 论文深度解析

作为长期做多模态和大模型相关研究的人,我有个很深的体感:很多视觉能力的讨论,默认前提都是“模型得见过图才能谈视觉”。但前阵子读到一篇让我反复琢磨的论文——【ICLR26-Oral Paper-Meta】先见之明:揭秘语言预训练中大型语言模…

📅 2026/10/20 9:20:15
用合成人生测试AI记忆:为什么AI总在关键时记错人?

用合成人生测试AI记忆:为什么AI总在关键时记错人?

“你上周不是说过最讨厌椰奶拿铁吗?”聊天AI平静地打出这句话时,我愣了一下。我确实最讨厌椰奶拿铁,但我清楚记得,这件事我从没对它说过。这个瞬间让我下决心认真对待一件事:为什么AI总在关键时候“记错”人&#xff1…

📅 2026/10/20 9:20:15
MORE NEWS

更多资讯

📰

从Notebook到生产环境:cait_xxs36_384.fb_dist_in1k 推理部署完全指南(PyTorch、safetensors与模型转换)

【免费下载链接】cait_xxs36_384.fb_dist_in1k 项目地址: https://ai.gitcode.com/hf_mirrors/timm/cait_xxs36_384.fb_dist_in1k 点击查看 免费下载 cait_xxs36_384.fb_dist_in1k 是一个基于 CaiT(Class-Attention in Image Transformers)架…

📰

AI攻击实战演示与防御升级:从行为基线到零信任的攻防新范式

说实话,过去几年参加行业安全大会,谈AI安全大多还在讲“未来趋势”“理论风险”。但这一次完全不一样——在2026全球数贸会的一场安全论坛上,我连续看到三次AI发起真实攻击的现场演示,每一次都让台下安静好几秒。当AI学会了自己找…

📰

高校就业管理系统:基于状态机的Spring Boot+Vue高分毕设实战

简介:这是一套面向计算机专业本科生的高质量毕业设计项目资源,基于Spring Boot后端与Vue.js前端构建的学生就业管理系统,完整覆盖毕设全流程需求,包括系统开发、论文撰写与答辩展示。资源包含923个文件,涵盖179个Java后…

📰

Kettle实战:MongoDB到HBase数据迁移完整流程与调优

数据迁移这件事,最怕的不是数据量大,而是数据模型对不上。MongoDB里面存得挺规整的一条JSON文档,到了HBase这边要拆成一行行带rowkey的单元格,稍不留神就把嵌套字段搞丢了。最近帮一个团队做Kettle把MongoDB数据往HBase迁移的活儿…

📰

从提示工程到Agent架构:2026年AI使用者核心能力进阶指南

2025年聊AI,所有人都在教你怎么写提示词,恨不得把角色的“人设背景”写到三千字,仿佛提示词越长,模型就越听话。到了2026年,风向明显变了——大家开始关心一件事:怎么让AI自己把一个流程完整走完&#xff0…

📰

C# OPC UA 客户端实战:订阅、回调与 SQL Server 批量落库

简介:这是一份面向工业自动化、物联网及企业级数据集成方向开发者的C#实战项目源码,核心演示如何基于OpcUaHelper开源库构建OPC UA客户端,连接服务器读写数据,并将采集结果以字符串形式存入SQL Server关系型数据库,适合…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬