
最近我在整理自己的刷题清单算法系列的第二篇我选了数组原地操作这个方向。这个专题里最典型的组合就是我自己清单里编号238的移动零和编号27的移除元素。这两道题初看都很简单移动零是把数组里的0都挪到末尾移除元素是把等于某个值的元素都丢掉但真正上手之后你会发现它们其实是同一个双指针套路换了两副面孔。我第一次刷移动零的时候第一反应是新建一个数组把非零元素先放进去再补零结果一看题目要求原地修改不能复制数组。瞬间被打回原形。后来我把移除元素同时拿出来对比做才慢慢想明白这类题应该怎么下手。这篇就把我从暴力、到双指针、再扩散到其他变体的完整过程拆开讲讲适合刚开始接触双指针的读者也适合已经刷过这两题但想彻底吃透模板的人。1. 这两道题为什么值得放在一起刷1.1 一道题的两种马甲移动零的题面给定一个数组nums把所有的0移动到数组末尾同时保持非零元素的相对顺序原地修改。移除元素的题面给定一个数组nums和一个值val原地移除所有等于val的元素返回移除后数组的新长度。一眼看过去一个在挪0一个在删val好像没什么关系。但你把这两道题的要求拆开会发现骨骼几乎一样都要把不符合条件的元素处理掉都要让符合条件的元素保持原来的先后顺序都限制不能使用额外数组空间。移动零里的0就是移除元素里的val只不过移动零在删完0之后还要求在数组尾部用0把空缺补齐移除元素则不用管最后几位残留什么。这就像同一个人穿了两件马甲一件叫移动零一件叫移除元素。我做这类题的习惯是先从生活场景里找一个等价动作。想象你在整理书架要把所有漫画书抽出来放到最右边一排其他书顺序不能变。你不能把整排书全部推倒重来只能一本一本看过去看到的非漫画书就往左边挪挪出来的空位最后用来放漫画。这个过程几乎就是移动零的代码逻辑。有了画面感之后再看双指针的移动规则基本就不晕了。1.2 双指针到底在解决什么本质问题很多人刚开始会问为什么不能用一个指针搞定我们推演一下单指针的方案。用for循环从头到尾扫描遇到0就把后面的所有元素整体往前移动一格再在数组末尾补一个0。这个思路不能算错问题在于一次移动就是O(n)如果数组里有k个0总共要O(k*n)当n到10万甚至百万级别时基本跑不动。单指针的真正痛点在于它把扫描和写入这两个职责混在一起了。你一边要判断当前元素要不要保留一边还要决定把它放到哪个位置这些信息全压在同一个下标上代码很快就绕晕。双指针的核心解耦思路是这样慢指针slow专门记录下一个可以写入的位置快指针fast专门负责从前往后扫描每个元素。fast看到符合条件的元素就告诉slow这个要留下slow写入一个元素后自动后移一格。fast看到不符合条件的元素直接跳过slow原地不动。两个指针各管一件事整个数组只需要扫描一遍时间复杂度从O(n²)降到O(n)。空间上因为没有额外数组只是在原数组上做交换或覆盖所以空间复杂度是O(1)。写这类原地操作题有一个判断标准只要题面出现原地不使用额外数组保持相对顺序这组关键词直接往快慢指针上想大概率不会错。1.3 暴力解法的效率瓶颈在哪接下来把两种暴力方案拿出来晒一晒你就知道为什么非用双指针不可。移动零的暴力方案很多人的第一版代码是这样从前往后扫描碰到0就把它后面的所有元素往前覆盖一格然后数组末尾补一个0。这个方案有隐性问题。比如数组是[0,1,0,3,12]第一次碰到下标0的0需要把后面四个元素全往前挪变成[1,0,3,12,0]继续扫到下标1发现是0又把后面的三个元素挪一遍变成[1,3,12,0,0]。每个0都触发一次整段搬移零越多越慢。移除元素的暴力方案更隐蔽因为它容易让人依赖语言自带的删除接口。在C里用erase逐个删除等于val的元素每次删除都会把后面的元素全部往前移而且for循环里一边遍历一边erase很容易出现跳过相邻相同元素的情况。Python里用list.remove逐个删值同样逃不掉O(n²)的搬移成本。我也见过有人用筛选生成新数组再拷贝回去结果一提交就报没有原地修改。看完这几种写法你会意识到暴力解法的问题不仅是慢更危险的是错得悄无声息。逻辑看着对数据一大就超时或者边界一特殊就跳过元素。所以我建议这类题干脆别走暴力路线直接练双指针模板一步到位。2. 移动零的手把手拆解2.1 快慢双指针的移动规则与手推过程先把规则完整列出来后面所有代码都围绕这几句话转slow从0开始含义是当前已经整理出的非零序列的下一个写入位置。fast从0到n-1扫描整个数组。当nums[fast]不等于0时把它写入nums[slow]随后slow加1。当nums[fast]等于0时什么都不用做fast直接前进。fast扫描结束后从slow到n-1这些位置统一填0。这里有两点必须解释清楚。第一为什么非零元素的相对顺序不会乱因为fast严格从左往右扫描遇到非零就按顺序放到前面的位置所有非零元素被写入的顺序就是它们在原数组里出现的顺序。第二为什么最后还要补零因为在覆盖过程中只是把非零元素往前搬数组长度没变后面的位置还残留着之前的值必须显式置0才能得到正确结果。如果你用的是后面会讲的交换法零已经被交换到后面就不需要再补。拿[0,1,0,3,12]完整手推一遍覆盖法的过程初始slow0。fast0nums[0]0跳过。 fast1nums[1]1写入nums[0]数组变成[1,1,0,3,12]slow1。 fast2nums[2]0跳过。 fast3nums[3]3写入nums[1]数组变成[1,3,0,3,12]slow2。 fast4nums[4]12写入nums[2]数组变成[1,3,12,3,12]slow3。 最后补零nums[3]0nums[4]0得到[1,3,12,0,0]。注意中间过程里数组出现了重复元素这是覆盖法的正常现象别慌最后一步统一补零后就是正确答案。这是很多新手在纸上跑的时候会怀疑数组怎么变成这样了的地方。2.2 交换法代码更短的另一种写法交换法的思路是快指针遇到非零元素就与nums[slow]交换slow前进遇到0就跳过。实现起来比覆盖法更简洁因为它不需要最后再补一轮零。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 1C版本同样简洁void moveZeroes(vectorint nums) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! 0) { swap(nums[slow], nums[fast]); slow; } } }再看一个例子数组是[1,0,2,0,3]。fast0时nums[0]1与nums[0]自己交换slow1fast1看到0跳过fast2看到2与nums[1]交换数组变成[1,2,0,0,3]slow2fast3看到0跳过fast4看到3与nums[2]交换变成[1,2,3,0,0]。零在这个过程中被自动挤到了后面。两种写法的时间复杂度都是O(n)空间复杂度都是O(1)但底层操作次数不太一样。覆盖法每个非零元素写一次最后还要补k次零交换法每次交换在C里是三次移动。我的建议是面试手写优先用交换法代码最短、最不容易漏步骤。维度覆盖法交换法尾部处理最后需要统一补零零自动换到后面每个元素的写次数非零元素1次补零k次非零元素约3次移动代码长度稍长很短出错风险容易忘补零低我个人的选择场景需要精确控制写操作时面试手写、日常刷题2.3 边界条件与实测表现边界条件往往是这类题真正拉开差距的地方。我把常见的几类用例整理出来你可以直接当成自测清单空数组fast循环不进入slow0函数正常返回。全零数组[0,0,0]fast扫描完slow0交换法里所有元素都和自己交换结果不变正确。全非零数组[1,2,3]fast和slow同步走每次都是同一个下标交换结果不变正确但会产生n次无意义交换。零在开头[0,0,1,2]fast扫到1时slow0交换后1到最前零往后走正确。零在结尾[1,2,0,0]fast扫描完slow2后面本来就是0正确。全非零数组这个case可以做一个很小优化只有当fast和slow指向不同下标时才交换。写成if (nums[fast] ! 0 fast ! slow)能省掉一批自我交换。不过不加也不影响正确性面试时提到这个优化点反而是加分项。实测下来n10万的随机数组交换法和覆盖法耗时都在几毫秒级别差距很小。真正让我推荐交换法的原因是它不需要额外补零步骤逻辑上少了一个收尾动作提交到在线判题平台后不容易因为忘了补零而翻车。当然如果你在一个对写操作次数有严格要求的场景里覆盖法可能更优这个在第五部分展开说。3. 移除元素的完整拆解3.1 保留顺序的快慢指针版本移除元素这个题题目要求返回新数组长度同时原数组的前length个位置需要是保留元素。默认情况下要求保持相对顺序所以第一反应应该是快慢指针。思路和移动零几乎一样slow指向下一个保留位置的写入下标fast从头扫描。当nums[fast]不等于val时把这个元素复制到nums[slow]slow加1等于val时直接跳过。fast扫描结束后slow的值就是新数组长度。这里有一个和移动零不一样的点移动零用了交换移除元素用覆盖就够了。因为删除操作不要求保留被删元素的位置直接把后面的保留元素往前覆盖尾部残留的旧值没人关心反正返回的长度只是slow。def remove_element(nums, val): slow 0 for fast in range(len(nums)): if nums[fast] ! val: nums[slow] nums[fast] slow 1 return slowint removeElement(vectorint nums, int val) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; slow; } } return slow; }拿[0,1,2,2,3,0,4,2]val2走一遍。fast0时0不等于2写入nums[0]slow1fast1时1不等于2写入nums[1]slow2fast2和3都是2跳过fast4时3不等于2写入nums[2]slow3fast5时0不等于2写入nums[3]slow4fast6时4不等于2写入nums[4]slow5fast7是2跳过。最终slow5数组前5位是[0,1,3,0,4]返回5。注意这个版本的数组末尾会残留[4,2]这些旧值这完全没问题。判题系统只检查返回长度之前的部分是不是正确后面的残留不影响结果。3.2 允许乱序的相向指针版本有些变体题目会明确说元素顺序可以改变。一旦顺序不做要求就有更省写操作的方案左右指针相向夹逼。思路是这样left从0开始right从n-1开始。如果nums[left]等于val就把nums[right]的值搬过来覆盖掉这个位置然后right减1但left不动。为什么left不动因为从右边搬过来的元素有可能也是val需要留到下一轮再检查一次。如果nums[left]不等于valleft加1继续看下一个位置。循环条件是left right循环结束时left就是新数组长度。def remove_element(nums, val): left, right 0, len(nums) - 1 while left right: if nums[left] val: nums[left] nums[right] right - 1 else: left 1 return leftint removeElement(vectorint nums, int val) { int left 0, right nums.size() - 1; while (left right) { if (nums[left] val) { nums[left] nums[right]; right--; } else { left; } } return left; }举个例子[3,2,2,3]val3。初始left0right3。nums[0]等于3把nums[3]的值3搬过来数组还是[3,2,2,3]right2left仍为0。nums[0]还是3再把nums[2]的值2搬过来数组变成[2,2,2,3]right1。nums[0]2不等于3left1。nums[1]2不等于3left2。leftright循环结束返回2。数组前两位是[2,2]正确。这个版本的优势在于当val出现的频率很高时它能把尾部的好元素直接搬过来每个被删除位置只搬一次而快慢指针版本要把每一个保留元素都往前移动。两种版本怎么选核心就看题目允不允许改变顺序。维度快慢指针版本相向指针版本相对顺序保持不保持写操作次数每个保留元素写一次每个被删位置搬一次代码风险低中等注意换回来的元素适用场景题目要求保持顺序题目明确允许乱序3.3 统一模板把两道题塞进一个函数把移动零看作移除元素的一个特例先调用removeElement把0全部移除得到新长度length然后把length之后的位置全部补0。也就是移除0 尾部补零。def remove_element(nums, val): slow 0 for fast in range(len(nums)): if nums[fast] ! val: nums[slow] nums[fast] slow 1 return slow def move_zeroes(nums): length remove_element(nums, 0) for i in range(length, len(nums)): nums[i] 0这个视角非常重要。它意味着你不需要分别记两套解法只需要把一个模板吃透剩下都是微调。面试的时候如果你能讲出这一层关系面试官通常会认为你确实理解了本质而不是背了两道题。4. 实操中的常见问题与避坑指南4.1 指针边界越界与循环条件快慢指针最容易犯的错误是while循环里fast的推进写错。我曾经见过这样的代码while (fast n) { if (nums[fast] ! 0) { swap(nums[slow], nums[fast]); slow; } fast; // 这一步要是漏了就是死循环 }如果fast写到了if分支里面遇到0时fast不再前进程序卡死在循环里。我的建议是直接用for循环来遍历fast从语法层面杜绝这种问题。for循环天然保证每次迭代fast都加1你只需要关心slow怎么变。相向指针版本的边界更微妙。循环用left right左边等于右边时也要进循环处理一次这样才能覆盖数组只有一个元素且它恰好等于val的情况。如果写成left right这种方法在某些实现里会漏掉最后一个元素。我当时踩过这个坑以后凡是相向指针一律用left right作为循环条件不再纠结。4.2 原地修改的陷阱很多人在本地跑通一提交到在线判题平台就报错绝大多数情况是原地修改没做对。移除元素这个题要求的是在传入的数组上修改并返回新长度判题系统会用你返回的长度去检查原数组的前几位。如果你在函数内部新建一个数组来收集结果即使返回值正确原数组根本没变照样不通过。Python里还有一个非常隐蔽的坑nums result只是重新绑定局部变量不会影响外部传入的列表。要看懂按引用传递和按值传递的区别正确做法是nums[:] result用切片赋值去修改原列表的内容。C里vector作为引用传参时直接修改vector里的元素即可不需要去调用erase收缩size因为题目只看返回值指定的逻辑长度。下面这张表格是我整理的问题速查基本覆盖了新手会遇到的绝大多数情况现象原因解决方案程序卡住不结束fast没有在每轮循环推进用for fast in range(n)遍历数组尾部残留非零值移动零忘了补零覆盖后统一把slow之后置0返回长度偏大slow初始值设置成1slow初始化为0返回长度偏小用left right做相向循环改用left right本地输出正确但提交报错没有原地修改原数组Python用nums[:]赋值删除后跳过了相邻重复值for循环里直接erase改用双指针覆盖不要边遍历边删全零数组结果不对交换法里fast和slow同步导致自我交换加fast ! slow判断或者接受无意义交换4.3 快速自测的方法写完代码之后我会用一组固定的用例快速验证。这组用例是专门为数组原地操作题准备的空数组空数组不需要输出但函数不能崩。单元素数组分别测等于目标值和不等于目标值两种情况。全部元素都是目标值比如[0,0,0]或者[2,2,2]。完全没有目标值比如移除val5时传[1,2,3]。目标值在开头、在中间、在结尾分别测一轮。每轮测试的时候我还会在代码里临时加一行打印输出每轮处理后的数组和slow、fast的位置。肉眼观察指针的移动轨迹比只看最终结果更容易发现问题。比如移动零覆盖法如果你看到中间数组[1,1,0,3,12]时没有意识到这是正常的打印出来就能观察到非零正在被搬运尾部还没清零这个状态。4.4 写法取舍交换法还是覆盖法刷这两道题刷到后期我基本固定了选择标准默认写交换法面试时优先解释交换法的逻辑。原因是它不需要补零代码结构最利落。但如果题目对写操作的次数有明确要求或者数据量极大覆盖法加统一补零往往更优因为它对每个非零元素只写一次补零也是直接批量赋值。踩过几次坑之后的体会是不要为了优化那点性能去写让人看不懂的代码。两道题的核心考点是双指针思维不是n毫秒内的极致性能。你要是为了省一两次交换把代码写得连自己都解释不清面试官反而会因为沟通不清楚而不给分。先用最标准的写法通过再在口述中补充优化思路这才是稳妥路线。5. 跳出这两道题面试追问和变体地图5.1 原地数组压缩的通用模板把移动零和移除元素放在一起可以抽象出一个通用模板我管它叫原地数组压缩。slow 0 for fast in 遍历整个数组: if 当前元素满足保留条件: 把 nums[fast] 放到 nums[slow] slow 1 返回 slow或者继续做后续处理这个模板的适用条件是需要保留一部分元素保持相对顺序原地修改。只要满足这三个条件都可以直接套。面试时我会先在白纸上写出这个模板再根据具体题目填充保留条件和后续处理。有了这一步移动零和移除元素就不再是两道孤立的题而是同一条线上的两个点。5.2 删除排序数组中的重复项有一个经典变体是删除排序数组中的重复项给定一个有序数组原地删除重复出现的元素使每个元素只出现一次返回新长度。这个题仍然可以用快慢指针只不过保留条件变成了当前元素和前一个已保留元素不同。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 slowslow从1开始因为第一个元素一定保留。fast从1开始扫描每次和nums[slow - 1]比较如果不相等说明遇到了新元素写入slow位置。你会发现它和移除元素的代码结构几乎一致只是保留条件从不等于val变成了不等于前一个保留元素。5.3 荷兰国旗问题三指针的自然延伸移动零把数组分成两类0和非0。如果把数组分成三类0、1、2就变成了经典的荷兰国旗问题。这道题是三指针思路的自然延伸面试官如果顺着移动零追问很可能会问到这里。void sortColors(vectorint nums) { int left 0, mid 0, right nums.size() - 1; while (mid right) { if (nums[mid] 0) { swap(nums[left], nums[mid]); left; mid; } else if (nums[mid] 1) { mid; } else { swap(nums[mid], nums[right]); right--; } } }三个指针的分工很清晰left维护0区边界right维护2区边界mid负责扫描。遇到0就扔到左边遇到2就扔到右边遇到1就留在中间。关键点在于遇到2交换后mid不能前进因为换回来的可能是0需要再判断一次。这个细节和移除元素的相向指针版本如出一辙。5.4 面试官最常追问的三个问题第一为什么双指针的复杂度是O(n)因为每个元素最多被快指针处理一次慢指针最多移动n次两者加起来是2n次操作所以是O(n)时间、O(1)空间。回答时把每个元素只被访问常数次这个点讲清楚就行。第二能不能进一步减少写操作要分情况回答。如果非零元素很少、零很多交换法在数据不理想时会产生较多交换覆盖法可以做到每个非零元素只写一次最后补零批量处理。但如果题目只是要求通过O(n)的常规实现完全够用优化属于加分项。第三如果顺序要求变了算法要怎么换保持顺序用快慢指针允许乱序用相向指针。我见过不少人在笔试环境里没仔细读题题目明明写了元素的顺序可以改变还花大把时间写快慢指针的覆盖逻辑其实用相向指针更省事。先判断题目的顺序约束再决定用哪个模板这个判断顺序本身就是得分点。我个人刷完这两道题最大的体会是刷算法题别贪多把一组相似题吃透比乱刷五十题有用。移动零和移除元素就是最好的练手组合一次把快慢指针、原地修改、边界检查、模板抽象全都练到了。最后再分享一个小技巧我每刷一类新题会在笔记里记下这类题的模板伪代码和典型坑下次遇到新题先套模板再按题面差异做微调。如果你能把这道题的做法讲清楚说明白为什么slow最后能代表新长度双指针这一关就算真正过了。