
做过算法题的人应该都听过“双指针”这个词。我第一次系统性接触双指针就是从“移动零”这道题开始的。题目本身很简单给一个数组把所有 0 移到末尾同时保持非零元素的相对顺序。看起来像是一个数组操作的小练习但它背后藏着的双指针思路却是一整套解题方法的基础。这篇内容我打算把这道题彻底拆开讲清楚双指针到底在干什么、为什么能原地解决、有哪些细节容易踩坑以及从这道题延伸出去的几个高频场景。适合刚开始刷题的人也适合想重新理解双指针原理的老手。1. 移动零题目的本质与双指针核心思想1.1 移动零到底在考什么先还原一下题目场景。假设输入数组是[0, 1, 0, 3, 12]期望输出是[1, 3, 12, 0, 0]。很多人看到第一反应是把零挑出来放到后面不就行了确实可以但题目往往会加一个限制条件“必须在原数组上操作不能拷贝额外的数组”。也就是说你不能新建一个数组然后把非零元素填进去再把零补到末尾。这个限制直接排除了最直观的解法逼着你用原地算法思考。这里有个容易被忽略的点题目要求“保持非零元素的相对顺序”。也就是说1, 3, 12的顺序不能变。有些人直接把数组从后往前扫看到零就丢到末尾结果可能导致非零元素顺序被打乱这就错了。所以移动零这道题表面上是在处理零元素实际上是在考验你如何高效地“压缩”数组把所有的非零元素紧凑排列到前面剩余位置全部补零。如果让我总结它的本质其实就是两个字筛选。你关注的目标不是“零”而是“非零”。把非零元素稳定地提取出来再对后续位置做统一置零整个问题就清晰了。很多初学者一上来盯着零做文章思路就容易绕进去因为你会纠结“遇到一个零怎么和后面的非零交换”。如果你反过来想——“我不关心零我只关心非零”双指针的思路就水到渠成了。1.2 双指针法为什么适合这道题双指针顾名字就是使用两个指针来遍历或操作数据结构通常是数组或者链表。在数组问题里指针可以理解为数组下标。双指针的核心优势是通过两个下标之间的配合在一次遍历中完成原本需要多次遍历或额外空间完成的操作。回到移动零。我们需要把非零元素往前挪这本质上是一个“稳定原地过滤”操作。稳定意味着保持相对顺序原地意味着不能开新数组。这时双指针恰好是天然的解法一个指针负责“向前探索”找出非零元素另一个指针负责“记录放置位置”把探索到的非零元素放到正确位置。探索指针跑得快放置指针跑得慢两个指针一快一慢通常被称为“快慢指针”。为什么快慢指针能保证稳定因为探索指针是从左往右逐个遍历数组的它发现非零元素的顺序就是原始顺序。而放置指针也是从左往右逐个位置增长每次把一个非零元素放到前面的目标位置不会跨越其他非零元素因此相对顺序天然被保留。整个过程只需要一次遍历时间复杂度 O(n)空间复杂度 O(1)完全满足题目限制。对比暴力解法里常见的“遇到零把后面所有元素前移一位”的做法那种方式最坏时间复杂度是 O(n²)而且容易写错边界快慢指针明显更优雅。这里还有一个生活化类比想象一队人排队里面有几个“特殊人员”需要移到队尾。快慢指针的做法不是去拽那些特殊人员而是让普通人员依次往前走自动把特殊人员挤到后面。你只需要一个“当前空位”的标记和一个人群扫描的标记就能完成整个整理过程。1.3 从暴力解法到双指针的演进过程不急着直接写最优代码我们先拆一下暴力思路为什么不行。最直观的暴力做法是遍历数组遇到 0就把后面的元素整体往前移一位然后在数组末尾补一个 0。每移动一个 0都要搬动后面的一批元素所以越到后面越慢。举个极端例子如果数组全是 0每个元素都会被反复搬动复杂度直接 O(n²)。而且还要小心“连续多个 0”的情况第一个 0 移走以后后面的 0 又移过来处理起来非常容易漏。另一个看起来可行但实际有问题的思路是从后往前遇到 0 就跟后面某个非零交换。但“某个非零”到底是谁如果找最后一个非零会破坏顺序如果找相邻元素交换零会一步一步慢慢“冒泡”到最后复杂度也是 O(n²)而且代码写起来很绕。稍微改进一点的做法是额外开一个数组先遍历一遍把非零放进去再遍历一遍补零。这个方法时间复杂度是 O(n)但空间复杂度变成了 O(n)不满足“原地操作”的要求。所以双指针的出现其实是一个顺理成章的发展我们想要 O(n) 的时间又想 O(1) 的空间还想稳定那就必须在一个循环里同时完成“找出非零”和“放置非零”两件事。两个指针各有分工互不干扰这就是双指针的雏形。理解了这层演进你再去记代码就不会死背了而是知道每一步在干什么甚至以后面试中遇到变体也能灵活调整。2. 快慢双指针的完整实现与细节剖析2.1 标准快慢指针解法非零前移末尾补零先给出最经典的两遍扫描实现我习惯叫它“覆盖 补零”策略。思路是第一个循环用快慢指针把所有非零元素往前覆盖第二个循环把剩余位置全部赋值为 0。def move_zeroes(nums): slow 0 for fast in range(len(nums)): if nums[fast] ! 0: nums[slow] nums[fast] slow 1 while slow len(nums): nums[slow] 0 slow 1理解这段代码的关键是慢指针slow的语义它总是指向下一个可以放置非零元素的位置。快指针fast遍历整个数组遇到非零元素就放到slow指向的位置然后slow前移。这个过程完成后slow前面的部分就是所有非零元素按原顺序排列的结果slow及之后的区域则全部填充为零。为什么第二个循环要开一个while而不是直接切片用切片写nums[slow:] [0] * (len(nums) - slow)虽然也能在 Python 里通过但不是所有语言都支持对这种“原地修改”的写法而且在某些 OJ 平台测试场景下切片创建了新列表可能不符合“不要使用额外数组”的精神。为了通用性和严谨性还是用循环逐个赋值更靠谱。你可能会说这样不是经历了两次遍历吗确实是两次但总的时间复杂度还是 O(n)因为每个元素最多被访问一次或两次都算是线性级。有人会问能不能一次遍历就搞定不补零可以的那就需要在覆盖的同时把原位置置零这样会多出一些写操作。比如当fast ! slow时把nums[fast]赋给nums[slow]后顺手把nums[fast]置为 0。但这种写法有一个细节如果fast slow且当前元素非零就不需要自赋值再置零否则会浪费时间。所以整体代码会更复杂一点不值得推荐。真正优雅的是一次遍历交换法下一节会讲。2.2 一次性遍历的交换法实现既然刚才提到了一次遍历很多人印象里的“双指针移动零”其实是另一种等价写法用一个指针slow记录非零位置遇到非零元素就与slow位置交换然后slow前移。代码长这样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 1这段代码的精妙之处在于当fast slow时交换就是原地自我交换不影响结果当fast slow时slow指向的往往是某个零元素所以交换的结果就是把零换到了后面。因为slow总是指向第一个“还没确定非零归属”的位置而fast扫描过的地方slow前面的区域已经全部是非零了所以不存在把后面的非零打乱相对顺序的问题。我用一个具体例子走一遍。数组是[0, 1, 0, 3, 12]初始slow 0。fast 0时nums[0] 0跳过。fast 1时nums[1] 1非零交换nums[0]和nums[1]数组变成[1, 0, 0, 3, 12]slow 1。fast 2时nums[2] 0跳过。fast 3时nums[3] 3交换nums[1]和nums[3]数组变成[1, 3, 0, 0, 12]slow 2。fast 4时nums[4] 12交换nums[2]和nums[4]数组变成[1, 3, 12, 0, 0]slow 3结束。可以看到非零元素全是和零交换且交换是相邻范围内的移动相对顺序完全没变。这种方法的优势是只用一个循环代码简洁且没有显式的“补零”过程。从工程角度它比“覆盖 补零”更少写赋值语句但交换本身在 Python 里是三条赋值操作实际运行时傻快傻快区别不大。面试时我更推荐写这个版本因为它一次遍历语义清晰还能顺带引出“双指针交换”的思想。2.3 边界条件与容易踩的坑不管是覆盖还是交换边界条件都必须想清楚。第一个问题是空数组和单个元素数组。空数组循环根本不执行返回空单个元素如果它是 0slow 不前进最终补零正确如果非零交换后不变。所以无需特判代码天然兼容。第二个问题是“全是零”的情况。比如[0, 0, 0]快慢指针扫描时遇到零都不动最后覆盖版会补三个零交换版数组不变结果都正确。第二个问题是“没有零”的情况。比如[1, 2, 3]交换版每次都自交换数组不变覆盖版会把每个非零元素原样放在原位置再补零循环不执行也正确。这里的问题在于“自交换”虽然在逻辑上没问题但会在 Python 中执行很多无意义的操作。真要追求极致性能可以加一个判断if fast ! slow:再交换。但通常测试数据量小加不加无所谓。不过这个细节如果能在面试中主动提出来会显得你考虑问题周全。第三个容易踩的坑是很多人把slow初始化为 0然后在循环里写成while nums[fast] ! 0或者把slow写成fast的依赖导致重复扫描。记住slow独立前进它只依赖于已经发现非零的个数而不是fast的位置。只要记住“快指针负责看慢指针负责放”口诀基本不会写歪。还有一个语言细节Python 里交换元素用nums[slow], nums[fast] nums[fast], nums[slow]但如果你自己写临时变量temp nums[slow]千万别漏了交换后的slow 1。漏了自增是初学者最容易犯的错。每次交换或覆盖之后slow必须顺手加一这个动作相当于“放置位置占满下一个位置腾出来”。我见过不少人在这个自增上翻车写完后数组只移动了第一个零后面全乱套。3. 代码实战多语言实现与测试方案3.1 Python 实现与逐行注释直接给一个适合面试的完整 Python 版本包含注释from typing import List def move_zeroes(nums: List[int]) - None: Do not return anything, modify nums in-place instead. slow 0 # slow 指向下一个非零元素应放置的位置 for fast in range(len(nums)): # fast 负责扫描整个数组找到非零元素 if nums[fast] ! 0: # 将非零元素换到 slow 处或者与自身交换 nums[slow], nums[fast] nums[fast], nums[slow] # slow 前进因为当前位置已经确定了非零归属 slow 1注意题目通常要求返回None函数直接修改nums。LeetCode 对这种题会直接检验nums数组不是返回值。如果你在本地写测试一定要打印nums而不是函数返回值否则会疑惑为什么输出是None。3.2 Java 实现与常用写法Java 没有 Python 这种灵巧的交换语法需要借助临时变量代码会长一点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; } } } }Java 里如果把交换改成“覆盖 补零”的写法要注意第二个循环从slow开始遍历到nums.length - 1依次置零。这在 Java 中也很常用尤其当数组元素是对象时覆盖可以避免大量的对象引用交换更高效。但移动零这种简单整数场景用交换更直观。C 实现补充C 里可以直接用标准库swapclass Solution { public: void moveZeroes(vectorint nums) { for (int slow 0, fast 0; fast nums.size(); fast) { if (nums[fast] ! 0) { swap(nums[slow], nums[fast]); slow; } } } };注意这段代码把slow定义在循环初始化区了它其实不是循环变量只在循环外初始化。这种写法比较紧凑但可读性稍差刷题时我一般拆开来写。C 的swap在std命名空间里直接用即可。3.3 测试用例设计思路说实话很多人刷题只跑一遍示例就提交遇到边界条件挂掉才后悔。移动零的测试用例应该覆盖以下类型我列成一个速查表用例类型输入期望输出说明示例场景[0,1,0,3,12][1,3,12,0,0]普通混合全零数组[0,0,0][0,0,0]没有非零无非零数组[1,2,3][1,2,3]不需要移动零在前[0,0,1,2][1,2,0,0]多个零聚集头部零在中间[1,0,0,2][1,2,0,0]多个零夹在中间零在末尾[1,2,0,0][1,2,0,0]本就是目标状态单个元素[0]/[1][0]/[1]边界最小输入交替分布[1,0,2,0,3][1,2,3,0,0]零和非零交替这些用例我建议用断言测试跑一遍盘确认输出。另外如果你提交到 OJ系统还会用超大数组测时间。虽然 O(n) 能过但如果你写了 O(n²) 的版本数据量一大就会超时。所谓“通过”不是只看结果还要关注耗时。我看到很多人在这道题上用了“从后往前删零再 append”的思路也就是每遇到零就del nums[i]然后append(0)这其实是 O(n²) 级别的操作因为删除中间元素会导致后续元素整体移动数据量小看不出问题数据量大了就会卡在超时边缘。4. 双指针的更多应用场景与进阶思考4.1 快慢指针的经典原地去重与元素删除移动零做完之后你会发现它的本质是“原地过滤 保留顺序”。同样的框架稍做修改就能解决一系列问题。最典型的是有序数组去重给定一个有序数组原地删除重复元素使每个元素只出现一次返回新的长度。解法就是把“非零判断”改成“当前元素是否和前一个不同”def remove_duplicates(nums): if not nums: return 0 slow 1 for fast in range(1, len(nums)): if nums[fast] ! nums[slow - 1]: nums[slow] nums[fast] slow 1 return slow这里slow表示已去重部分的长度同时是下一个不重复元素放置的位置。和移动零相比唯一的变化是判断条件从“不是 0”变成了“不等于前一个已放置的元素”。因为数组是有序的所以重复元素必然相邻用nums[slow - 1]作为比较基准即可。这个题在工程中对应“数据清洗”场景比如日志去重、传感器数据压缩。另一个变体是“移除元素”给定一个数组和一个值val原地移除所有等于val的元素。解法几乎和移动零一模一样把! 0改成! val就行而且不需要末尾补零。你可以把移动零看成“移除元素”加“补零”的组合这有助于建立题型之间的联系。4.2 相向双指针从一维移动走向两侧逼近快慢指针是一前一后同向移动还有一种双指针是两个指针分别从两端向中间移动叫相向双指针。典型题目是“有序数组的两数之和”在一个递增数组中找到两个数使它们的和等于目标值。正常暴力是 O(n²)相向双指针可以把复杂度降到 O(n)。思路很简单左指针初始指向 0右指针指向数组末尾。计算当前左右指针对应元素的和如果等于目标值直接返回如果小于目标值说明需要增大数值左指针右移如果大于目标值说明需要减小数值右指针左移。因为数组有序所以每一步调整都是合理的不会漏掉正确答案。这个思路可以用“在一个有序价格清单里找组合价”来理解。假设商品价格从低到高排列你要找两件总价恰好等于预算的商品。如果最低价加最高价都低于预算说明最低价和谁配都不够只能提高最低价去试如果最低价加最高价都高于预算说明最高价和谁配都超预算只能降低最高价去试。这样两边向中间逼近每一步可以排除一个候选线性时间就能找到答案。另一个非常经典的相向双指针题目是“盛最多水的容器”给定一堆竖线选择两条线作为容器壁求能装最多水的面积。这个题目也是两根指针从两端开始每次移动高度较小的一边不断更新最大面积。为什么要移动较矮的一边因为面积由较短边的长度和两线距离决定如果你移动较高的一边距离虽然可能变短但高度还是由较矮边决定面积不可能增加而移动较矮的一边才有可能遇到更高的线增大面积。这个“舍弃劣势候选”的思路正是相向双指针的精髓。4.3 双指针的复杂度本质与易混淆点双指针题目虽然形态很多但复杂度分析几乎都遵循一个原则每个指针在遍历过程中只朝一个方向移动总移动次数不超过数组长度所以整体时间复杂度 O(n)。空间复杂度则取决于是否只使用了有限的几个变量。移动零、去重、两数之和都是 O(1) 额外空间的典型。如果你能用双指针解决一个问题通常意味着你能在不借助额外存储的情况下把时间压到接近线性。这里有几个易混淆点需要强调。第一“双指针”不一定只有两个“指针”有的题目里是三个指针比如数组三数之和往往是一个外层循环加内部双指针整体 O(n²)。第二“双指针”也不一定都用于数组链表里也有快慢指针比如判断链表是否有环快指针每次走两步慢指针每次走一步如果快指针追上慢指针就说明有环。但链表里的双指针和数组里的双指针虽然共享一个名词操作模式完全不同需要区分对待。第三双指针的适用前提是问题具备某种“单调性”比如数组有序、或者问题要求稳定过滤。如果数据没有任何顺序或规律双指针并不一定是最好的选择这点很多人容易忽略。还有一个我在面试中经常看到的误区有人以为双指针一定比哈希表好。在两数之和这道题里如果数组无序双指针需要先排序排序本身是 O(n log n)而哈希表法可以是 O(n)此时反而不如哈希表。但如果数组有序双指针显然更优。所以工具没有绝对好坏关键看场景。移动零这道题里因为顺序信息必须保留双指针几乎是唯一的最优解。5. 实操经验与常见问题速查5.1 调试双指针代码的实用技巧写双指针代码最容易懵的就是“指针位置”理解错。我调试这类代码有一个土办法在循环里打印每个步骤的slow、fast和整个数组状态。比如在交换版里加一行print(ffast{fast}, slow{slow}, nums{nums})然后跑几个测试用例观察变化。通常你会在第一两个用例中发现自增位置不对或者比较符号写反一眼就能定位。另一个技巧是在纸上模拟小例子。很多人觉得写代码熟练后不需要手算但在双指针这种“多个变量协同移动”的算法里手写一遍过程能够非常有效地加深理解。我建议准备一个三行表格第一行是fast的移动轨迹第二行是slow的移动轨迹第三行是数组每个位置在不同时刻的值。这种表格在解释给别人听的时候尤其好用面试官往往喜欢看到你能把过程可视化出来。如果题目要求不返回新数组只修改原数组那么调试时要注意打印的时机。比如在 LeetCode 风格的方法签名里函数会在内部修改nums你如果直接打印返回值会得到None误导自己。正确做法是在调用方法后打印nums。本地测试时可以用一个包装函数def test_case(nums): move_zeroes(nums) print(nums)像这样把修改后的数组打出来就不会搞混了。5.2 移动零常见问题排查表结合我平时答疑见到的典型错误整理一个排查速查表症状可能原因排查要点输出结果和输入一样零没移动循环条件写成了if nums[fast] 0确认是在处理非零而不是零非零元素顺序被打乱从后往前移动时没考虑顺序快慢指针必须都从前向后移动数组没有实现原地修改函数内切片赋值或返回新数组检查是否用nums[:] ...或直接修改元素零没有全部补到末尾覆盖版本忘了第二段置零循环确认slow之后的位置都赋值为 0出现数组越界slow在循环里多加了一次或fast越界检查自增位置和for循环边界空数组报错未处理长度为 0 的情况双指针逻辑天然兼容空数组无需特判大数组超时用了del或list.remove改用索引覆盖或交换避免中间元素移动5.3 如何从移动零举一反三我建议所有学习者做完一道题都问自己三个问题这道题用了什么模式这个模式还能解决哪些问题如果改一点点条件解法会怎么变移动零对应模式是“快慢指针 稳定原地过滤”。往左扩展它可以是“移除元素”“删除排序数组中的重复项”“压缩字符串”等往右扩展它可以演化成“三指针分区”比如荷兰国旗问题把数组按 0、1、2 三色排序这种题在工程里对应“按权重分桶”或“三分类数据整理”。如果你把移动零的条件改一下要求把数组中的全部零移动到开头并且保持非零元素相对顺序不变怎么做其实只要把非零判断改成“遇到 0 就往前放”或者反转数组后用原解法处理再反转回来。也可以调整判断条件把! 0改成 0但要注意非零顺序的稳定性因为零没有顺序要求所以简化后可以用更灵活的操作。这个变体我见过出现在一些公司笔试里实际上就是移动零的“零在前”版本。再改一下如果要求把负数放到前面非负数放到后面但正数和正数之间、负数和负数之间不需要保证原有顺序那就可以用相向双指针的交换法类似快速排序第一次 partition。如果要求正负各自保持原有顺序那只有快慢指针或额外数组能做。所以你看顺序要求是决定算法选择的关键条件。移动零这道题“保持非零顺序”这个约束决定了它必须用快慢指针而非简单的左右交换。理解到这一层你就真正掌握了这道题。结尾我的实操体会在我自己刷题和带新人过程中移动零一直是我推荐的双指针入门第一题。它不像链表反转那样需要很多前置知识也不像动态规划那样需要抽象建模就是简简单单一个数组两个下标却能引出双指针最核心的两个分支快慢指针和交换技巧。我个人建议你写代码时先写“覆盖 补零”版本逻辑直白不容易错写熟了再改成“交换”版本体会一次遍历的简洁。两版都跑一遍然后在纸上画出slow和fast的轨迹你会发现对数组索引的理解会上一个台阶。最后再分享一个小技巧以后遇到任何“原地”二字开头的数组题先往双指针方向想大概率能找到一个干净利落的解法。这个经验我在很多难度更高的题上验证过希望对你也有用。