news 2026/9/26 5:49:19

移除元素:双指针算法的第一课,从暴力到快慢指针全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
移除元素:双指针算法的第一课,从暴力到快慢指针全解析

刷题练习:移除元素——我愿称它为双指针的“第一颗扣子”

如果你刚开始刷 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 双指针解法:快慢指针的标准写法

这是本题最核心的解法,也是面试官最希望你写出来的那版。

算法流程:

  1. 初始化慢指针slow = 0,快指针fast = 0。
  2. 让fast从数组头走到尾:
    • 如果nums[fast] != val,就把nums[fast]赋值给nums[slow],然后slow++。
    • 如果nums[fast] == val,什么都不做,继续前进。
  3. 返回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; }

这个版本最明显的优势:不匹配的元素不会被“复制一遍”,而是直接用右侧的元素覆盖掉,赋值次数等于“需要被删除的元素个数”。最坏情况下数组全是val,left每次都用right覆盖自己,然后 right 不断左移,赋值次数是 O(n),但拷贝量比快慢指针更少。

代价呢?它改变了元素之间的相对顺序,因为每删一个,你就从尾部拿了个元素放到前面。题目已经说了顺序可以改变,所以这是完全合规矩的操作。实际面试里,你可以先把快慢指针背熟,再补充一句“如果允许改变顺序,我还可以用双端指针少几次赋值”,这会让面试官觉得你对代码效率有敏感度。

3. 代码实现与细节打磨

3.1 多语言实现参考

这个解法语言差异不大,但手写的时候还是有各自的坑。我分别给一版常用语言的实现,方便你对照。

Python

def 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 slow

Python 的切片操作容易让人想走捷径,比如nums[:] = [x for x in nums if x != val]。这在本地跑没问题,也不违反“ O(1) 额外空间”,因为切片赋值本质是原地替换,但面试时写这种会被视为“没理解指针操作”,建议老老实实写双指针。

Java

public 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 循环。

C++

class Solution { public: int removeElement(vector<int>& 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 = 0,right = 0。若循环条件是left < right,循环体根本不会执行,直接返回0,看起来好像没问题;但如果数组是[2, 2],left = 0,right = 1,第一次循环nums[0] == val,执行nums[0] = nums[1],数组变成[2, 2],right = 0。此时若循环条件是left < right,即0 < 0不成立,退出,返回left = 0——看上去也正确,因为两个元素都被删了。但如果你处理的是[2, 1]且val = 2呢?初始left = 0,right = 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 到讲清楚

很多同学代码能跑通,但一被追问就结巴。我建议按这个顺序来讲:

  1. 先说暴力解:遍历、删除、搬移、O(n^2)。
  2. 再讲双指针:为什么用快慢指针、每个指针的意义、覆盖代替删除。
  3. 补充优化:如果顺序可乱,双端指针能减少赋值次数。
  4. 最后讲复杂度:时间 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”,觉得提交通过了就万事大吉。但我更建议你做完之后走一遍“模拟执行”:拿一支笔,在纸上画一个数组,手动模拟快慢指针的每一次移动。你会发现,“覆盖”这个动作本身会抹掉后面的旧值,但你并不关心它们,因为新长度已经划定了安全区。这个直觉建立起来后,后续很多数组题你都会条件反射地想到双指针。

如果非要给一条个人经验,那就是:不要把“移除元素”当成一道题,而是当成一个“模式”。刷完它,立刻去刷“删除有序数组中的重复项”和“移动零”,三题连做,你才能真正把快慢指针内化成自己的肌肉记忆。这个“连坐式刷题法”后来帮我解决了很多原本看上去毫无头绪的题目——找到一道“母题”,顺藤摸瓜解决一整串题。这大概就是刷题练习最大的魅力所在。个人体会是,算法面试考的不只是你有没有背过题,而是你有没有从一道简单的题目里提炼出可复用的思考框架。而“移除元素”就是你开始建立框架的第一块砖。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 5:49:18

Jev 决策模型接入实战:TypeSafe 与置信度路由

1. 为什么我会盯上 Jev 这个决策模型第一次看到 Jev 这个名字&#xff0c;是在一个做智能体编排的群里。有人丢了一句“置信度路由终于有人做成 TypeSafe 的了”&#xff0c;底下立刻炸出一堆人问怎么接入、API Key 去哪申请。我当时的第一反应是&#xff1a;又一个套壳&#x…

作者头像 李华
网站建设 2026/9/26 5:48:57

PP-OCR 五条推理路线实战:从 OpenCV 到纯 C 与 Java 引擎

1. 为什么我要把 PP-OCR 反复“折腾”五遍PP-OCR 这套东西&#xff0c;但凡做过文字识别落地的同学都不陌生。百度飞桨开源出来的这套轻量级 OCR 系统&#xff0c;检测加识别两个模型加起来模型体积能压到几兆&#xff0c;中文识别准确率在通用场景下能到 95% 以上&#xff0c;…

作者头像 李华
网站建设 2026/9/26 5:48:53

Claude Code 模板化实战:从上下文约束到可复用资产搭建

1. 我为什么如此看重 Claude Code 的模板化1.1 先说一个真实的翻车场景上个月我临时接手一个内部工具项目&#xff0c;代码量不大&#xff0c;但结构很乱。我打开 Claude Code 想让它帮我梳理一下模块依赖&#xff0c;顺手敲了一句“帮我看看这个项目的架构”&#xff0c;结果它…

作者头像 李华
网站建设 2026/9/26 5:48:23

BrowserSkill:用AI和CDP协议接管你已登录的浏览器

1. 项目全景解读&#xff1a;BrowserSkill到底是什么先直接说结论&#xff1a;BrowserSkill是腾讯开源的一个浏览器AI操控工具&#xff0c;核心能力是让AI直接接管你本地已经登录的浏览器实例&#xff0c;基于现成的登录态去执行网页自动化操作。这个项目在技术圈里火起来&…

作者头像 李华
网站建设 2026/9/26 5:48:11

MySQL CASE WHEN实战指南:从语法到行转列、批量更新的完整用法

MySQL的CASE WHEN是我见过的被低估得最惨的SQL功能&#xff1a;很多人只在刷面试题的时候看到过它&#xff0c;真到自己写业务代码&#xff0c;却总是想不起来用。实际上它就是SQL世界里的if-else&#xff0c;却比if-else更值钱&#xff0c;因为判断是在数据库内部完成的&#…

作者头像 李华
网站建设 2026/9/26 5:47:50

高铁5G低速迁出:破解进站减速区切换失败的关键参数调优策略

简介&#xff1a;这份5G网络优化案例资料面向通信工程师、网优人员及5G技术学习者&#xff0c;聚焦高铁场景下低速用户迁出策略的完整应用过程。内容从功能原理入手&#xff0c;说明如何通过UE移动速度识别将沿线低速公网用户切换回公网&#xff0c;避免其占用高铁专网资源&…

作者头像 李华