
刷题练习移除元素——我愿称它为双指针的“第一颗扣子”如果你刚开始刷 LeetCode力扣多半会被各路大神按头安利一批“必刷基础算法题”而移除元素Remove Element几乎一定在名单里。我自己刷了五六百道题之后回头看这道题的确配得上“入门必修课”的名号题目短、约束简单、暴力解和三要素齐活但它背后那套快慢指针的逻辑几乎能串起后面几十道数组和链表题。这篇文章就把这题拆开揉碎从最朴素的思路讲到优雅的优化再配上实测踩坑记录希望能帮准备秋招、春招或者刚学数据结构的同学彻底拿捏住它。题目本身不复杂给定一个整数数组nums和一个目标值val需要你原地移除所有数值等于val的元素并返回移除后数组的新长度。不允许额外使用数组空间空间复杂度必须为 O(1)。也就是说你不能新建一个数组把不等于val的值挑出来存进去——必须原地震动把该删的删掉然后告诉判题系统新数组“有效区”有多长。这道题适合谁适合刚学完数组、准备接触“双指针”的新手也适合在面试前临时要捡起手感的社招选手。它能在半小时内让你体会到“暴力→优化→边界条件→复杂度分析”这一整套刷题流程这恰恰是决定你后续刷题效率的关键。1. 题目解析与核心思路1.1 题目到底在问什么先看原题的描述力扣第27题给你一个数组nums和一个值val你需要原地移除所有数值等于val的元素并返回移除后数组的新长度。不要使用额外的数组空间必须仅使用 O(1) 额外空间并原地修改输入数组。元素的顺序可以改变。不需要考虑数组中超出新长度后面的元素。“顺序可以改变”这句是题眼它意味着解法不止一种。我们后面会讲到如果顺序不允许改变你只能用快慢指针法如果顺序允许改变双端指针法能进一步减少赋值次数。来个具体的例子输入nums [3,2,2,3]val 3输出2且nums前两个元素应该是2,2。输入nums [0,1,2,2,3,0,4,2]val 2输出5且nums前五个元素可以是[0,1,3,0,4]顺序可变的体现。注意判题机制平台会比较“返回的长度”和“该长度范围内你数组里的元素”至于长度之后的位置上剩什么完全无所谓。这个细节刷题时容易被忽略但它恰恰是“双指针覆盖思想”能成立的前提。1.2 为什么它是“双指针”的第一课数组类的题最难的点往往不是“想出解法”而是“想出 O(1) 空间的原地解法”。正常人的第一反应是设个新数组ans遍历原数组遇到不等于val的就push进去最后把ans的值拷贝回nums。这种解法没错但它违背了“原地”要求一旦面试官追问空间复杂度就会露怯。双指针的出现就是为了解决“原地筛选”这类问题。核心思想非常朴素一个指针负责“往后看”快指针一个指针负责“往前写”慢指针。快指针遍历原数组里的每个元素判断它要不要慢指针指向“下一个可以写入的位置”。最终快指针走完整个数组慢指针的值正好就是“幸存元素的数量”。这其实可以类比成“流水线检视”一个工人站在传送带前检查传送带上的零件合格的放到手边的成品区不合格的推走成品区放满几个就是最终入库几个。你不需要额外准备一条新传送带只需要在原来那条传送带上动手。2. 解法拆解从暴力到双指针2.1 暴力解法先写对再写好我在刷题初期一直信奉一句话暴力解是思路的锚点。你连暴力都没想到说明对数据结构的操作还不熟直接上优化解容易“知其然不知其所以然”。暴力思路很简单遍历数组一旦发现nums[i] val就把i后面所有元素整体往前挪一位然后把数组“逻辑长度”减 1。因为元素往前挪了当前位置i上顶替过来的是一个“新元素”所以还需要i--否则会漏判这个位置。int removeElement_BruteForce(int* nums, int numsSize, int val) { int len numsSize; for (int i 0; i len; i) { if (nums[i] val) { for (int j i; j len - 1; j) { nums[j] nums[j 1]; } len--; i--; } } return len; }这个解法的最大问题在于每次删除元素都要把后续所有元素前移时间复杂度在最坏情况下是 O(n^2)。比如数组全是目标值val第一次删除要搬 n-1 个元素第二次搬 n-2 个……累加起来近似 n^2/2。刷题圈里有一句调侃“能过样例不代表能过性能测试”暴力解在力扣上虽然也能 AC因为数据量不大但面试时这样做等于主动送分。2.2 双指针解法快慢指针的标准写法这是本题最核心的解法也是面试官最希望你写出来的那版。算法流程初始化慢指针slow 0快指针fast 0。让fast从数组头走到尾如果nums[fast] ! val就把nums[fast]赋值给nums[slow]然后slow。如果nums[fast] val什么都不做继续前进。返回slow。代码看起来只有十行int removeElement(int* nums, int numsSize, int val) { int slow 0; for (int fast 0; fast numsSize; fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; } } return slow; }为什么slow恰好等于新长度因为每遇到一个“幸存元素”slow就自增一次而幸存元素的数量就是“不等于 val 的元素个数”所以它当然等于新长度。同时赋值操作保证了数组前slow位按顺序存着所有幸存元素。这里要注意元素之间的相对顺序并没有变化所以它适合“要求保持顺序”的场景。2.3 优化思路双端指针减少无谓赋值如果你眼尖会发现上面的快慢指针法存在一个“浪费”即使数组里一个val都没有它也会把每个元素原地赋值一遍也就是nums[slow] nums[fast]且slow fast这是没必要的。力扣题解里有一个优化版叫“双端指针法”思路是left从数组头往右走right从数组尾往左走。当nums[left] val时直接把nums[right]赋给nums[left]然后right--否则left。循环结束条件是left right返回left。int removeElement_TwoPointer(int* nums, int numsSize, int val) { int left 0; int right numsSize - 1; while (left right) { if (nums[left] val) { nums[left] nums[right]; right--; } else { left; } } return left; }这个版本最明显的优势不匹配的元素不会被“复制一遍”而是直接用右侧的元素覆盖掉赋值次数等于“需要被删除的元素个数”。最坏情况下数组全是valleft每次都用right覆盖自己然后 right 不断左移赋值次数是 O(n)但拷贝量比快慢指针更少。代价呢它改变了元素之间的相对顺序因为每删一个你就从尾部拿了个元素放到前面。题目已经说了顺序可以改变所以这是完全合规矩的操作。实际面试里你可以先把快慢指针背熟再补充一句“如果允许改变顺序我还可以用双端指针少几次赋值”这会让面试官觉得你对代码效率有敏感度。3. 代码实现与细节打磨3.1 多语言实现参考这个解法语言差异不大但手写的时候还是有各自的坑。我分别给一版常用语言的实现方便你对照。Pythondef removeElement(nums: list[int], val: int) - int: slow 0 for fast in range(len(nums)): if nums[fast] ! val: nums[slow] nums[fast] slow 1 return slowPython 的切片操作容易让人想走捷径比如nums[:] [x for x in nums if x ! val]。这在本地跑没问题也不违反“ O(1) 额外空间”因为切片赋值本质是原地替换但面试时写这种会被视为“没理解指针操作”建议老老实实写双指针。Javapublic int removeElement(int[] nums, int val) { int slow 0; for (int fast 0; fast nums.length; fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; } } return slow; }Java 没有for (int x : nums)边遍历边修改数组的问题但要注意在 for-each 里你拿不到下标所以必须用传统 for 循环。Cclass Solution { public: int removeElement(vectorint nums, int val) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; } } return slow; } };C 里如果不熟悉vector可以先用原生数组练但面试场景通常允许直接操作vector记住传引用即可否则修改不会被带回去。3.2 边界条件那些让人 WA 的细节刷题圈里流行一句话“边界条件才是算法的灵魂。”移除元素这道题虽然简单但边界条件稍微一马虎就容易翻车。我自己早年就在while (left right)和while (left right)之间吃过亏。先看双端指针的边界如果数组是[2]val 2初始left 0right 0。若循环条件是left right循环体根本不会执行直接返回0看起来好像没问题但如果数组是[2, 2]left 0right 1第一次循环nums[0] val执行nums[0] nums[1]数组变成[2, 2]right 0。此时若循环条件是left right即0 0不成立退出返回left 0——看上去也正确因为两个元素都被删了。但如果你处理的是[2, 1]且val 2呢初始left 0right 1第一次循环nums[0] 2赋值为nums[0] nums[1]数组变成[1, 1]right 0。然后因为left right不成立退出返回left 0。可是正确结果应该是1因为数组里有一个非 2 的元素 1问题出在被交换过来的nums[right]本身也可能是目标值val如果你在 right 减到等于 left 前就提前退出就可能漏掉检查。所以必须用left right保证当 left 指向新搬来的元素时仍有下一次循环检查它。这里我当年踩坑踩得很结实建议各位直接把刻进 DNA。快慢指针的边界则集中在“空数组”和“全目标值”两种极端情况数组为空时slow自然为 0返回 0循环都不必执行全为目标值时fast走完slow始终不递增也是返回 0逻辑依然成立。在写代码前先在脑子里跑一遍极端样例能省去很多次的“试错提交”。3.3 复杂度与数据规模分析这道题理论上没有显式给出数据规模但力扣默认编辑器的测试数据一般会把numsSize控制在 0 到 100 左右val的取值也是整型范围内。暴力解在最坏情况下 O(n^2)当n上万时就会明显变慢所以刷题平台虽然不会卡你但面试官一定会追问复杂度。双指针两个版本均为时间复杂度O(n)每个元素最多被访问一次快指针一次慢指针一次合起来是线性。空间复杂度O(1)只用了两个变量。这样看来这道题真正的训练重点并不在于“能不能 AC”而在于你能不能对着面试官讲清楚“为什么这样写是 O(n)”以及“为什么另一个解法更优”。刷题不是做题是练叙述逻辑。4. 常见问题与排查技巧实录4.1 为什么我的 length 明明对了数组却不对这是力扣上最常见的反馈之一。有些同学直接返回了新长度但数组里前面几个位置并没有正确排列因为他们在删除时只改了逻辑长度没有真的把元素搬过来。要知道在线判题系统会检查返回长度k之后验证nums[0..k-1]是否符合预期。你可以自己打印数组验证但平台不会给你看完整数组只告诉你“expected”和“output”的差别。排查口诀返回值决定边界数组内容决定对错。写完后先在本地做一轮“前后对照”打印原始数组、执行完函数后的数组、返回长度然后手动检查前k个元素是否都非val且没有漏掉该保留的元素。这一步能拦住八成低级错误。4.2 双指针时快指针要不要“回头”不需要。快指针始终在慢指针前面或者与慢指针重合它负责探索未知区域慢指针负责“已经确认安全”的区域。如果快指针还需要回头就说明你其实没有理解覆盖思想的本质——被覆盖掉的位置上的旧值已经没用了你根本不需要知道它是什么。这跟“移除”语义略有不同但算法题里用覆盖来代替删除是一种极其常见的 trick后面做“合并两个有序数组”时也一样适用。4.3 面试时这题的“台阶”从 AC 到讲清楚很多同学代码能跑通但一被追问就结巴。我建议按这个顺序来讲先说暴力解遍历、删除、搬移、O(n^2)。再讲双指针为什么用快慢指针、每个指针的意义、覆盖代替删除。补充优化如果顺序可乱双端指针能减少赋值次数。最后讲复杂度时间 O(n)空间 O(1)并解释为什么没法再优化了至少得访问一遍所有元素所以下界是 O(n)。面试官一般会打断你让你直接写快慢指针版。但前两步决定了你是“背题人”还是“懂题人”。我见过不少候选人能默写出代码但说不出slow为什么代表新长度这一类往往会被判定为“机械记忆”。5. 举一反三从移除元素到同族题型5.1 删除有序数组中的重复项力扣第 26 题“删除有序数组中的重复项”思路跟这题几乎一模一样快指针负责遍历慢指针负责记录“下一个插入位置”。区别在于比较对象不是val而是“前一个已保留的元素”。int removeDuplicates(int* nums, int numsSize) { if (numsSize 0) return 0; int slow 1; for (int fast 1; fast numsSize; fast) { if (nums[fast] ! nums[slow - 1]) { nums[slow] nums[fast]; } } return slow; }注意这里的slow初始值是 1因为数组的第一个元素必然被保留当前元素要与“已经保留的最后一个元素”比较才能判断是否有重复。做完“移除元素”再做这题你会觉得像呼吸一样自然。5.2 移动零力扣第 283 题“移动零”把所有 0 移到末尾非 0 元素保持相对顺序。“移除元素”是删掉目标值这道题是“把目标值移到后面”但本质也是双指针筛选。先用快慢指针把所有非 0 元素往前提记录非 0 个数k再把数组末尾从k到最后全部填 0。实际上你可以把它理解为“移除元素”之后再加一步“填充被移除区域”。def moveZeroes(nums: list[int]) - None: slow 0 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能自己写出这题你基本就把“覆盖思想”用熟了。5.3 移除链表元素力扣第 203 题“移除链表元素”则换了数据结构从数组搬到链表。链表没法随机访问所以双指针变成了“前驱指针 当前指针”核心痛点是“删除头节点”时要特殊处理。常规做法是加一个虚拟头节点dummy让删除逻辑统一def removeElements(head: ListNode, val: int) - ListNode: dummy ListNode(-1) dummy.next head cur dummy while cur.next: if cur.next.val val: cur.next cur.next.next else: cur cur.next return dummy.next这个dummy节点的思路在链表题里几乎是“万能药”漫山遍野都能用。刷完数组版“移除元素”再碰链表版你对“指针”这个概念的理解会上一个台阶。6. 刷题习惯与工具建议6.1 刷题平台与规划建议提到刷题平台最常被提及的是 LeetCode力扣中文站、洛谷、牛客网、AcWing 等。如果你是新手建议先从力扣的“LeetCode 热题 HOT 100”或者“剑指 Offer专项突击版”开始刷这两份题目清单是各路前辈反复验证过的“必刷清单”覆盖面广、经典度高。如果想用竞赛题练手洛谷的“入门与面试”题单也很舒服但它的题目风格更偏向算法竞赛对纯面试党来说稍偏。牛客网则适合大厂真题模拟比如它的“剑指 Offer”题单就经常出现在分享里。我自己的看法是平台不在多而在用透。选定一个平台按“数组 → 链表 → 哈希表 → 双指针 → 二分 → 栈与队列 → 二叉树”这个顺序刷每类至少 20 题量变产生质变。刷题频率上我比较建议每天固定 2 道题一道新题、一道复习旧题而不是某天心血来潮刷 10 道然后一周不碰。算法手感是需要持续保温的跟健身很像间断三天就会明显生疏。6.2 错题复盘与笔记方法很多人刷了几百题还是感觉没有体系问题不在“数量”在“复盘”。我自己的做法是建一个“错题与心得”表格字段包括题目核心思路卡壳点与同类题的关联下次复习日期移除元素快慢指针覆盖双端指针的left right边界删除重复项/移动零3 天后“移除元素”这道题本身很简单但你在卡壳点里记下的内容可能会救你于水火。比如我当年记的是“双端指针用left right否则会漏掉尾部的非目标值”后来刷“移动零”时就用上了。错题本的意义在于把你从“凭感觉写”变成“按模式写”。6.3 写在最后这道题带给我的启发很多新手刷题时喜欢追求“一次 AC”觉得提交通过了就万事大吉。但我更建议你做完之后走一遍“模拟执行”拿一支笔在纸上画一个数组手动模拟快慢指针的每一次移动。你会发现“覆盖”这个动作本身会抹掉后面的旧值但你并不关心它们因为新长度已经划定了安全区。这个直觉建立起来后后续很多数组题你都会条件反射地想到双指针。如果非要给一条个人经验那就是不要把“移除元素”当成一道题而是当成一个“模式”。刷完它立刻去刷“删除有序数组中的重复项”和“移动零”三题连做你才能真正把快慢指针内化成自己的肌肉记忆。这个“连坐式刷题法”后来帮我解决了很多原本看上去毫无头绪的题目——找到一道“母题”顺藤摸瓜解决一整串题。这大概就是刷题练习最大的魅力所在。个人体会是算法面试考的不只是你有没有背过题而是你有没有从一道简单的题目里提炼出可复用的思考框架。而“移除元素”就是你开始建立框架的第一块砖。