
跟着代码随想录的数组章节往下刷很多人是被第二道题“移除元素”绊了一下的。不是它难而是它和第一题二分查找的画风完全不同二分查找只需要你在一段静态的排序数组里找下标“移除元素”却要求你原地删掉一个数组里的指定值还不能用额外数组。我第一次做这题的时候下意识写了remove、pop之类的现成接口跑完样例还挺高兴回头一想完全不对——这题根本不是考你会不会调API。移除元素对应的是LeetCode 27题题面很短给你一个数组nums和一个值val原地移除所有等于val的元素返回移除后数组的新长度。它看起来是数组专题里最“小而美”的一道题但地位很特别它是数组双指针的入门题是后续删除有序数组重复项、移动零、有序数组平方等一系列题目的共同出发点。这篇文章不打算只贴一个标准答案我会把暴力解为什么不行、双指针怎么一步步推出来、边界条件怎么自测、以及面试官在这道题背后真正想问什么完整拆一遍。无论你是校招刷题、社招突击还是考研机试这套思维模型都值得吃透。1. 为什么移除元素是数组专题的分水岭1.1 数组删除的本质其实是“覆盖”很多初学者第一次做这道题时会陷入一个很自然的困惑数组里有一个元素等于val我把它“删掉”不就行了吗问题在于数组在内存里是一段连续空间长度在创建时就固定了。语言层面并没有“删除一个格子”这种操作你能做的只有把后面的元素一个个往前挪用后面的值盖住要删的位置。你说的“删除”本质上是“覆盖”。这一点想通了整道题的走向就清楚了移除元素就是在数组上做一次筛选让不等于val的元素按原顺序排列在数组头部最后返回一个“逻辑长度”数组本身在内存里的长度不需要变超出逻辑长度的部分也不用管。我见过有人用Python的del nums[i]或nums.remove(val)来做这题跑几个样例确实能过。但remove的时间复杂度是O(n)它背后的动作就是把目标元素之后的所有元素整体前移而且每删一个都要重新扫描几个删除操作叠加起来就是O(n²)。更麻烦的是力扣这道题要求你返回“新长度”而remove会直接改变数组长度你的返回值跟判定逻辑都对不上。面试官让你写这道题想看的恰恰是你自己动手实现那套“覆盖前移”的逻辑而不是把底层操作交给解释器。1.2 暴力解法能跑通但离面试要求很远顺着“覆盖”的思路最容易想到的解法是两层循环外层遍历数组遇到等于val的元素就用内层循环把它后面的所有元素整体往前挪一位然后数组的逻辑长度减一。下面是Python版本的暴力解法def removeElement(nums, val): i 0 n len(nums) while i n: if nums[i] val: # 把 i 之后的元素全部往前挪一位 for j in range(i 1, n): nums[j - 1] nums[j] n - 1 # 逻辑长度减一 # 注意这里 i 不递增 else: i 1 return n这段代码有个非常容易踩的坑当我们删掉nums[i]后原本在i1位置的元素已经挪到了i位置这个新元素可能也等于val所以i不能马上加一否则会漏掉连续出现的val。很多第一次写暴力解的人都在这里翻过车漏删导致返回长度偏大。暴力解在数据量小时没有任何问题力扣给的测试样例也不算大跑起来感受不到性能差异。但它的问题客观存在每一轮删除都要搬移后面所有元素最坏情况下数组里全是val每删一个都要搬移几乎整个数组总操作次数是O(n²)。而且这种搬移经常是重复劳动——某个元素刚往前挪了一步下一轮又可能被整体前移一次。面试官看到O(n²)的解法通常会追问一句“能不能优化到O(n)”目的就是把你引向双指针。2. 快慢指针把“删掉不要的”翻转为“留下要的”2.1 一个整理书架的场景把双指针想通暴力解法之所以慢是因为它总盯着“要删掉谁”每删一个就要搬动一堆元素。如果我们换个角度不说“删掉等于val的元素”而是说“把不等于val的元素按原顺序留下来”整个问题瞬间简单了。打个比方你在整理书架。一种做法是看到一本不要的书就抽出来然后把后面所有的书一本本往前推补上这个空位。这种做法跟你抽掉的书数量成正比书越多越累。另一种做法是你从左边开始一本本看过去遇到要留下的书就放到书架左边第一个空位上遇到不要的书就跳过完全不碰它。一圈走完书架左边已经整齐地排好了所有想留下的书中间连一次大规模搬动都没有。第二种做法就是快慢指针也叫双指针、读写指针。在移除元素这题里fast指针负责遍历原数组相当于检查员逐个看每个位置上的值。slow指针指向“下一个保留位置”也就是已经整理好的区域的末尾。当fast位置的值不等于val时说明这个元素要保留就把它写到slow指向的位置然后slow加一。当fast位置的值等于val时什么都不做fast继续往前走。你可能会担心slow指向的位置会不会还没被fast扫描过不会。因为整个过程中fast始终走在slow前面或至少相等slow位置上的旧数据一定是“已经处理过、不再需要”的数据覆盖它完全安全。这也是这个算法敢原地操作的根本原因。2.2 代码落地谁在读谁在写什么时候写快慢指针的代码只有几行但每一行都有明确职责。我习惯用Python这样写def removeElement(nums, val): slow 0 # 慢指针下一个保留元素写入的位置 for fast in range(len(nums)): # 快指针遍历整个数组 if nums[fast] ! val: # 当前元素需要保留 nums[slow] nums[fast] # 覆盖到前面 slow 1 # 保留区尾指针后移 return slow # 新长度 保留元素的个数这里有一个很多初学者纠结的点nums[slow] nums[fast]这一步到底是先赋值还是先移动slow顺序错了会怎样答案是必须先赋值再移动slow。因为slow代表“保留区末尾的空位”第一个保留元素应该写到nums[0]写完后空位变成nums[1]。如果先slow 1再赋值第一个元素就会被写到nums[1]nums[0]反而空着了。还有一点值得说明当slow和fast相等时nums[slow] nums[fast]是一次自赋值赋值给自己无害。这种情况发生在数组前部没有任何val的时候比如nums [1, 2, 3]且val 4你会发现每个元素都被“自己写自己”了一遍。无需担心性能编译器对这个模式也很友好而且这是保持代码简洁的必要代价。为什么这个写法能保持元素的相对顺序因为fast是从左往右按原顺序扫描的slow写入的顺序和扫描顺序完全一致。被跳过的val只是在逻辑上不存在了不等于val的元素之间的先后关系没被破坏。这个性质在后续做“删除有序数组中的重复项”时同样关键。2.3 手动推演[3,2,2,3] 的全部过程光看代码不够我们手动推演一遍。假设nums [3, 2, 2, 3]val 3目标是移除所有3。初始状态slow 0fast 0。步骤fastnums[fast]动作slow数组状态103等于val跳过0[3,2,2,3]212不等于val赋值1[2,2,2,3]322不等于val赋值2[2,2,2,3]433等于val跳过2[2,2,2,3]最终返回slow 2实际有效部分nums[:2] [2, 2]后面的[2, 3]属于“超出新长度的部分”题目明确说不需要清理。注意第2步把nums[0]从3改成2时nums[1]原本就是2看起来数组变化不大但逻辑长度已经悄悄更新了。遇到nums [0,1,2,2,3,0,4,2]、val 2这种更长的例子你可以自己在纸上跑一遍你会发现所有不等于2的元素都会按原顺序落位到数组头部。复杂度也非常清楚fast走完整个数组每个元素最多被检查一次、最多被赋值一次时间O(n)全程只用了一个额外变量空间O(1)。3. 边界条件、常见低级错误与面试追问3.1 五个必须跑一遍的边界样例算法题提交后报错八成是边界没处理好。移除元素这题虽然简单但下面的样例我一个都建议你亲手跑一遍直接在编辑器里写完代码后逐条验证输入val预期新长度关键验证点[]10空数组循环根本不执行[1,1,1]10所有元素都要删除[1,2,3]43没有任何元素要删除[4]40单元素且恰好等于val[1,2,2,1]22连续重复段验证不能漏删跑这些样例时重点观察两件事一是返回长度是否正确二是前slow位元素是否符合预期。对于“全删”的情况slow最后是0返回0逻辑上数组为空对于“全不删”的情况数组每个位置被自赋值一次返回原长度。这两种看似对立的场景快慢指针都能用一个统一的循环处理正是这个解法优雅的地方。还有一个值得说的细节如果面试官问“删掉了多少个元素”答案不是返回的slow而是len(nums) - slow。题目要的是新长度但很多人被追问时就懵了。这一点要提前想清楚。3.2 面试官在这题背后真正想问什么移除元素在面试里出现频率很高但它太简单简单到不太适合直接当难题考。面试官出这题通常是在考察几个更底层的能力。第一能不能看出来数组“删除”的本质是覆盖。如果你上来就写remove、pop对方大概会微笑着让你再想想如果你能说出“数组连续存储长度固定删除只能靠覆盖”第一关就过了。第二能不能从O(n²)优化到O(n)。暴力解法是合格的“第一反应”但如果停留在那里说明你还不习惯用双指针压缩重复搬移。面试官会追问“能不能用一次遍历完成”这时候快慢指针就该上场了。第三能不能主动确认需求的约束条件。比如“返回的是新长度还是删掉的个数”“顺序必须保持吗”“允许改变数组原有顺序吗”。很多题目描述里写“元素的顺序可以改变”这句话不是随便写的它意味着还有另一类更省搬移的解法。如果你能自己发现这一点并主动和面试官确认比闷头写一个答案要加分得多。第四能不能画清楚指针变化的每一步。面试官经常让你拿一个具体例子口头跑一遍代码。前面我推演[3,2,2,3]的过程就是标准答案的框架先定义slow和fast各自的语义再按流程逐轮说明“谁移动了、谁覆盖了、返回值是什么”。能把这个过程讲清楚说明你是真懂不是背代码。我自己带过一些实习生发现一个规律能快速讲清楚“为什么覆盖时不需要考虑slow位置的旧值”的人后面写滑动窗口、原地哈希一般也不会差背出一个标准答案却说不清理由的人换个输入很容易写崩。移除元素就是一面很好的镜子。4. 另一种解法顺序允许改变时的两端交换法4.1 题目末尾那句说明是官方给的暗示力扣27题原题末尾有一句话“元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。”很多人扫一眼就过去了但在面试题里这种看似无关紧要的“宽限条件”往往是考官故意留给你的优化空间。快慢指针保持了数组元素的相对顺序这个性质很好但代价是几乎所有不等于val的元素都可能被复制一遍。假如数组有一千万个元素里面只有三个位置上出现了val快慢指针依然要把一千万个元素全部搬到前面不对其实是遍历一遍赋值也只发生在不等于val的元素位置但整体扫描是O(n)。这还不算最高效的写法。既然题目允许顺序改变我们其实可以换一种思路既然不要求保持相对顺序那我从左往右遇到一个等于val的元素时不一定要把它后面所有的元素都往前挪。我只需要从数组尾部拿一个元素来填这个坑就行了。尾部那些元素本来也没处理过拿过来再用同样的规则检查一遍即可。这就是两端交换法也叫双指针相向法。4.2 两端交换的代码与“先别移动left”的细节两端交换法的代码长这样def removeElement(nums, val): left 0 right len(nums) while left right: if nums[left] val: # 用尾部的元素覆盖掉要删除的位置 nums[left] nums[right - 1] right - 1 # 逻辑长度减一 # 注意这里 left 不增加 else: left 1 return right这里我把right初始化为len(nums)让它指向“逻辑区间的尾后位置”。每次发现nums[left] val就把right - 1位置的元素拿过来覆盖left位置然后right - 1。为什么覆盖之后不能立刻left 1因为从尾部换过来的这个元素可能也等于val需要站在当前left位置再检查一次。这个细节是整段代码最容易错的地方仅次于把right初始化为len(nums) - 1然后返回时各种差一。整个算法的区间不变量是这样的[0, left)是已经检查过的“保留区”[left, right)是等待处理的“未知区”[right, len(nums))是“已删除区”。算法每次从left位置开始如果它等于val就把它踢进删除区同时从未知区尾部捞一个元素补进来如果不等于valleft前进把它并入保留区。当left和right相遇时所有元素检查完毕right就是新长度。这个不变量理解了代码就不需要背了。4.3 快慢指针与两端交换到底怎么选很多刷题教程只讲快慢指针但两端交换法也是标准解法之一面试时能主动对比两者是很加分的表现。我把它们的差异整理成一张表维度快慢指针两端交换法是否保持原顺序保持不保证每个元素的访问次数一次扫描一次扫描但可能反复检查尾部换来的元素删除元素少时的搬移量仍可能复制大量元素只复制被删除位置对应的尾部元素代码简洁度很简洁适合优先写也很简洁但有覆盖后不移动left的细节后续可扩展性可平移到26题、283题等适用面较窄但适合特定场景我的建议是面试一开始默认写快慢指针因为它不破坏顺序能覆盖更多后续题目代码也更好讲清楚。如果面试官追问“能不能减少元素搬移次数”你再切换到两端交换法说明你理解两种解法的取舍。如果题目明确要求“顺序无所谓”两端交换法其实是更优解——在删除元素很少的情况下它几乎不用搬动什么数据只在碰到val时才动一次手。5. 从移除元素延伸出去一条完整的双指针迁移线5.1 代码随想录路线里的同门题目代码随想录的数组章节是按难度递进的二分查找解决“找”的问题移除元素解决“改”的问题后面的题目一个比一个有挑战。移除元素之所以排在这个位置是因为它植入的“快慢指针”模型后面几道题全在用。最有代表性的是第26题“删除有序数组中的重复项”。它几乎是移除元素的同胞兄弟同样是快慢指针不同点在于比较条件从nums[fast] ! val变成了nums[fast] ! nums[fast - 1]或者nums[fast] ! nums[slow - 1]。你如果理解了移除元素里“slow指向保留区末尾”的写法做26题就是改一个判断条件的事。另一道经典题是第283题“移动零”。它的要求是把数组里所有的0移到末尾同时保持非零元素的相对顺序。用移除元素的思路可以先通过快慢指针把非零元素全部写回数组头部再把slow之后的位置全部置0。这等于在移除元素的基础上多做了一次“尾部填充”核心思想完全一致。还有一道比较有意思的题是844题“比较含退格的字符串”它可以用双指针倒序遍历遇到#就跳过下一个有效字符。虽然场景变了但“一个指针负责读、一个指针负责记录”的底层思维是一样的。刷到这里你会慢慢发现算法题里的“套路”大多是同一个思维模型在不同场景下的换皮。把移除元素练扎实相当于给这一整段刷题路线打了地基。5.2 读指针/写指针思想在工程代码里的应用这套“读指针写指针”的模式不只是面试题工程代码里也随处可见。我举几个我实际遇到过的例子。第一个是日志清洗。假设一个请求日志数组里有大量记录你想保留所有status ! 500的记录同时不新建数组、不改变其他字段的顺序快慢指针的写法就是一行循环的事扫一遍把所有有效记录前移最后截断长度。在内存里处理这种过滤比创建新数组再拷贝要省得多。第二个是缓冲区或帧数据处理。在写C语言相关的通信协议处理时经常需要从接收缓冲区里剔除填充字节。这时候“读指针”和“写指针”分别指向源缓冲区和目标缓冲区一边读一边按条件写完全就是移除元素的工程版。很多高性能代码里甚至直接把这些指针命名为rptr和wptr语义和fast、slow一一对应。第三个是数据结构内部的原地整理。比如一个自定义队列在做批量出队后需要把剩余元素集体前移重新维护头尾标志。这类操作如果借助双指针提前规划写入位置可以避免无意义的反复memmove。虽然大多数语言提供了现成接口但理解底层原理遇到性能瓶颈时才知道怎么优化。所以这道题的价值不只在LeetCode排名里那一行绿它让你掌握的是一个可迁移的底层操作模式。5.3 刷这题的正确姿势从口述到自测最后聊点方法论。代码随想录反复强调“五步刷题法”读题想清楚、先写思路、再写代码、手动举例、复查边界。移除元素恰好是练习这套流程的完美题目因为它的代码太短短到如果你只是“看懂了”第二天很可能就会忘。真正有效的练习方式是这样的先合上所有题解用口头语言描述一次思路“用fast遍历每个元素遇到不等于val的就写到slow位置slow后移最后返回slow。”如果你能说出“slow指向的是下一个保留位置”这句话说明核心模型已经建立了。然后从零写代码写完不用急着提交先跑一遍我上面列的五个边界样例再随机造一个自己的数组手动推演一遍。我实习带人的时候会要求他们把这个推演过程写在纸上尤其是[3,2,2,3]这种样例把每一轮fast、slow的值和数组变化画出来。看起来像是在做小学数学题但四五道题练下来对指针类题目的直觉会明显变好。这个方法也适用于后面所有双指针题提前养成就很划算。移除元素这道题难的从来不是那十行代码而是你脑子里什么时候能把“删掉我不要的”换成“留下我要的”。这个思维反转一旦完成后面26题、283题、844题基本都不需要再看题解了。把这篇文章里的模拟过程亲手做一遍比把答案抄十遍都有用。