双指针这个技巧,在很多讲算法的人嘴里会说得特别玄乎,好像不刷个几百道题就掌握不了似的。但我一直觉得,真正理解双指针,根本不需要那么复杂,从一道“看起来人畜无害”的题入手就够了。就比如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 = 0,nums[0] = 0,跳过,slow仍为0。fast = 1,nums[1] = 1,非零,nums[0] = 1,slow = 1。fast = 2,nums[2] = 0,跳过。fast = 3,nums[3] = 3,非零,nums[1] = 3,slow = 2。fast = 4,nums[4] = 12,非零,nums[2] = 12,slow = 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 = 0,fast = 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当成终点,把“能不能讲清楚每一步的不变量”当成终点。等你某天发现不用看代码就能在脑子里完整推演一遍指针的全部移动路径,这道题才算真的吃透了。