LeetCode 27移除元素全解析:双指针技巧与面试官思维深度拆解
2026/9/11 7:35:30 网站建设 项目流程

前阵子,有个准备跳槽的学弟跑来问我:LeetCode 27这道移除元素的题,我闭着眼睛都能写出来,为什么面试官还能问半个小时?我当时就乐了,这道题恰恰是我当年面试时栽过跟头的地方。LeetCode 27确实属于新手友好型题目,代码量少,思路直观,但真正可怕的地方在于,面试官能从一个双指针原地移除元素的实现里,看出你对数组存储的理解、边界条件的敏感度、有没有写过工程代码的习惯,甚至是你遇到问题时会不会主动优化。这篇文章我就把这道经典题彻底拆开聊透,从题面到思路,从代码到面试官视角,再附上我刷题和面试多年总结下来的排查技巧,希望对正在备战算法面试的你有点帮助。

1. 先别急着写代码,把题意啃透

1.1 输入输出到底怎么算

很多人做LeetCode 27只记住了“删除元素”四个字,但题目真正要求的是两件事:第一,把数组里所有等于目标值 val 的元素移除;第二,返回移除后数组的新长度。这里有一个很关键的隐藏信息:题目允许你改变数组中元素的顺序,而且只关心前 k 个元素,k 就是函数返回的那个长度。也就是说,数组后半部分残留什么值,题目根本不检查。

举个实际例子,输入是 nums = [3,2,2,3],val = 3。合法的输出是返回 2,同时让 nums 的前两个元素变成 2 和 2。你不需要把后面的 nums[2] 和 nums[3] 真的擦掉,它们哪怕是原来的 3 也行。这一点很多第一次刷题的人会误解,还有人会傻乎乎地去pop()数组元素,结果时间复杂度和空间复杂度都变得很糟糕。原地操作的目的就是让你在同一个数组上做修改,不额外开一个大数组去拷贝,所以理解清楚“只看前 k 个元素”这一点是做题的第一步。

1.2 为什么“原地”这两个字这么关键

如果允许额外开数组,这道题就变成了简单遍历,写起来五分钟搞定:新建一个数组,把不等于 val 的值放进去,再拷回去。但面试官刻意强调“原地”,本质上是在考察你能不能控制内存开销,也考察你对数组这种连续内存结构的底层认知。

数组和链表不一样,数组是一片连续的内存空间,删除一个元素意味着要把后面的所有元素往前搬。如果每次都删一个就搬一次,碰到极端情况(比如整个数组全是 val)会出现 O(n²) 的复杂度。真正的原地做法,是让指针在移动过程中完成“覆盖”而不是“删除”,把不该留下来的值直接盖掉,这就是双指针技巧的用武之地。想明白这一步,你才能理解为什么快慢指针能够做到一次遍历就解决问题,因为你的操作从来都不是删除,而是选择性地覆盖。

1.3 它是整个“数组类双指针”题目的地基

LeetCode 27 和 26题(删除有序数组中的重复项)、80题(删除有序数组中的重复项 II)、283题(移动零)本质上是同一个家族。它们都是在一个数组上,通过两个指针分别承担“探测”和“写入”的职责,在不借助额外存储的情况下完成数据整理。这也是我把27题称为“地基”的原因,后面那些题目无非是在地基上加了排序性、允许重复次数、交换条件等约束。

如果你刷题比较有体系,会发现LeetCode官方把27题归类为“双指针”标签,但很多教程并没有点透:双指针不只是一种代码技巧,更是一种抽象思维模型。理解这个模型,比背下代码有用得多。我在下个章节会完整讲清楚这个模型怎么从暴力解法一步步演化出来。

2. 双指针思路从哪来:暴力解法到最优解

2.1 暴力解法为什么不行

先把最直观的解法写出来:遍历数组,一旦发现 nums[i] 等于 val,就把后面的所有元素往前移动一位,然后让数组长度减一。这里最容易写错的点是,移动完元素之后,当前下标 i 的位置上换成了一个新的元素,这个新元素还没有被检查过,所以 i 不能直接加一,否则可能跳过一个连续的 val。

这个解法在面试时用来铺垫思路是可以的,但它的问题非常明显:最坏情况下,数组里全是 val,每个元素都会被移动 n 次,总复杂度 O(n²)。这在工程里是不能接受的。面试官问这道题,一大半原因就是想看看,你能不能从 O(n²) 的直觉解法,进化到 O(n) 的双指针解法。如果你只停留在暴力层面,那说明对算法复杂度优化还没有形成本能。

2.2 快慢指针的写法,以及它为什么正确

快慢指针的思想非常朴素:设置两个指针,一个叫 slow(慢指针),一个叫 fast(快指针)。fast 负责在前面探路,逐个检查数组里的每个元素;slow 则指向“下一个可以被覆盖写入的位置”。

一开始 slow = 0,fast = 0。fast 每走到一个元素,就判断这个元素是否不等于 val。如果不等于,说明这个元素要保留下来,那就把它写到 nums[slow] 的位置,然后 slow 加一。如果等于 val,说明要丢弃,fast 继续往前走,slow 原地不动。遍历结束后,slow 的值就是新数组的长度。

这个写法最精彩的地方是,它维护了一个不变量:区间 [0, slow) 里的所有元素,全都是不等于 val 的。fast 每走一步,这个性质都不会被破坏。正因为有这个不变量,你根本不需要犹豫当前元素是不是需要“删除”,只需要判断“是否保留”。这种用覆盖代替删除的思路,恰恰就是工程里“原地整理数组”的经典套路。

2.3 对向指针的另一种思路,什么时候用

快慢指针虽然好理解,但并不是唯一解法。还有一类解法是两个指针分别从数组两端向中间移动:左指针从左往右找等于 val 的元素,右指针从右往左找不等于 val 的元素,然后把右指针指向的元素复制到左指针位置。每覆盖一次,右指针就往左移一位,直到两个指针相遇。

对向指针的好处是,它能尽量减少元素的移动次数。因为它是拿后面的元素去填前面的坑,只要后面的元素自己不需要保留,移动就是“顺便”的。所以如果你的目标是“最小化移动次数”,对向指针更优。但它也有代价:它会改变数组中元素的相对顺序。题目本身不要求保持相对顺序,所以两种写法都合法,不过如果面试题后续要求顺序稳定,你就不能这种写法了。

从更广义的角度看,这两种思路正好对应双指针的两种常见形态:同向指针和相向指针。同向指针适合“保留部分元素并保持顺序”的场景,相向指针适合“快速整理、不关心顺序”的场景。LeetCode 27 一题两解,恰好可以当作双指针入门的第一课。

2.4 复杂度速查表

解法时间复杂度额外空间是否保持原顺序适用场景
暴力覆盖O(n²)O(1)仅用于教学铺垫
快慢指针O(n)O(1)主流写法,通用性强
对向指针O(n)O(1)追求最少移动次数

面试时如果能把这三种解法按复杂度排出来,并说清楚各自取舍,基本就已经向面试官证明你具备工程上的复杂度意识了。

3. 面试官到底在考察什么:我拆成四个维度讲

3.1 对数组存储结构的底层理解

面试官问LeetCode 27,首先想知道你对数组的理解到底停在哪一层。如果说“数组就是可以按下标访问的一排数据”,这只能说及格;但如果你能说出“数组是一片连续内存,删除某元素必须移动后续元素,因此删除操作本身是O(n)的”,那就完全不一样了。

这就是为什么很多面试官看完你写代码,还会追问一句:“你的解法里到底有没有真正删除元素?”如果你回答“没有,只是覆盖”,他会觉得你对内存和指针是有感知的。如果你支支吾吾说“应该算删除吧”,那印象分会大打折扣。LeetCode 27这道题表面考逻辑,实际考的是你在系统层面有没有建立起“数组删除并不便宜”的直觉。

3.2 对双指针思维模型的建立

双指针不是死记硬背的模板,而是一种用“相对位置”解决问题的思路。在LeetCode 27里,快慢指针分别代表了探测和写入两种职责。能把一个数组问题拆成两个角色的协作,这是很多中级算法题的通法。

面试官考察这个点的方式通常是追问:“如果数组不是无序的,而是有序的,你会不会想到别的双指针用法?”或者是“这个思路还能解决什么其他问题?”一旦你把双指针理解成“用两个游标维护一段区域的语义”,你就能举一反三。比如三数之和、接雨水、最长无重复子串等等,背后都有双指针的影子。26、80、283这些题也都可以用同一套思维模型快速解决,面试官看到你能主动建立知识网络,通常会非常欣赏。

3.3 对边界条件的敏感度

LeetCode 27 的小陷阱非常多。空数组怎么办?整个数组全部等于 val 怎么办?一个元素都没有被删掉怎么办?val 不存在于数组中怎么办?这些都考察你对极端情况的敏感度。

写出正确代码不难,难的是在写代码的同时就把这些边界情况全部考虑到。我面试别人的时候,常常看到一个候选人写完代码就停在那里等我来验证。我更喜欢看到的是,候选人主动在代码注释里写清楚“这里假设 nums 长度可以为0”,或者在小黑板上画几个例子自测一下。这体现出的是工程师对自己代码负责的态度,而不是“能跑就是赢”的学生思维。

3.4 代码规范与口头沟通

算法题不是只考算法,它同时考你能不能把思路清晰地传递给别人。面试官会让你先讲思路再写代码,这道题因为实现太短,甚至可能出现“你还没讲完,面试官就已经知道你要写什么”的情况。这时候,你的口头表达就显得更重要。

我见过很多候选人代码写得很对,但全程沉默,写完就交卷。也有候选人表达能力极好,每一步都说清楚:先定义两个指针的语义,再说循环结束条件,最后说明返回值。后者往往在面试评价里会获得更高分,因为工程开发中代码评审、团队协作都需要沟通能力。简而言之,你把LeetCode 27当做一道“表达题”来准备,收获会比单纯刷题大得多。

4. 手写实现:从伪代码到可运行代码

4.1 三种主流语言的参考实现

先放一段最经典的快慢指针实现,我用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

用C++写的话,逻辑完全一样:

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; }

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; }

看到没有,三种语言的差异只在语法层,核心逻辑都是从0开始的快慢指针,扫描一遍,在 fast 找到合法元素时写入 slow 的位置。这段代码甚至不需要额外处理空数组,因为空数组的循环根本不会进入,slow 自然返回0。

4.2 这段代码为什么能处理所有边界条件

我们来逐条验证。空数组场景:循环体不执行,slow 返回0,正确。整个数组全是 val:比如 [3,3,3,3],val = 3,fast 每次找到的元素都等于 val,所以永远不会执行写入,slow 保持0,返回0,正确。整个数组全是合法元素:比如 [1,2,3,4],val = 5,fast 每走一步都把元素写到 nums[slow],slow 逐渐追上 fast,最终返回4,数组内容原封不动,正确。混合场景 [0,1,2,2,3,0,4,2],val = 2,执行完slow等于5,前5个元素是0,1,3,0,4,后面的元素虽然还是残留的2,但题目不关心,正确。

这段代码简洁到让人怀疑它是不是太简单了,但它的正确性正是由之前说的“不变量”保证的:在任何时刻,[0, slow) 区间内的所有元素都已经通过检查,且都不等于 val。只要你不破坏这个不变量,代码就是安全的。

4.3 面试中常见的三个追问变体

面试官不会只让你把代码写完就放你走,LeetCode 27经常搭配下面几个变体。第一个变体是“如果要求必须保持元素的原始相对顺序,你能不能用快慢指针?”这个追问基本是送分题,因为快慢指针本身就是保持相对顺序的,你把数组里保留的元素按扫描顺序写入,顺序天然不变。

第二个变体是“如果要移除的元素不止一个,而是一个数组 removeSet,你怎么办?”这时候你可以在循环里先用一个哈希集合缓存需要删除的目标值,再用同样的快慢指针逻辑判断if (!removeSet.contains(nums[fast]))。时间复杂度仍是 O(n),但空间复杂度会上升为 O(m),其中 m 是需要删除的目标种数。

第三个变体是“能不能把空间复杂度降到 O(1),并且只扫描一次?”这个问题实际上已经把答案送到你嘴边了,就是要求你用双指针原地做。你甚至可以反问面试官:“您是希望我保持顺序,还是允许交换?”这样做瞬间显得你很专业,因为两种需求对应不同写法。

5. 从27延伸出去:一套“双指针”打法

5.1 LeetCode 26、80、283 如何套用

LeetCode 26题要求删除有序数组中的重复项,其实就是LeetCode 27的变体,val 不再是一个固定值,而是前一个元素。用快慢指针写时,判断条件从nums[fast] != val变成了nums[fast] != nums[slow - 1]。注意这里要小心越界,所以通常让 slow 从1开始,fast 从1开始,第0个元素天然保留。

LeetCode 80是26的加强版,要求每个元素最多出现两次。这时你只需要把判断条件改成nums[fast] != nums[slow - 2],原理是:只要 fast 指向的元素不等于 slow 前面第二个元素,就说明它和当前已保留区域的最多重复两个元素不冲突,可以写入。这个技巧非常经典,面试时如果你能顺手写出来,会很有加分效果。

LeetCode 283移动零,本质上也是27题的变形。它要求把数组里的0全部移动到末尾,并保持非零元素的相对顺序。做法是用快慢指针把非零元素按顺序写入前部,然后剩余位置全部补0。核心思想是一样的:用慢指针标记“下一个非零元素该放的位置”,快指针负责找一个非零元素。所以说,学会27题,相当于学了五道题,这是我对它评价最高的原因。

5.2 快慢指针还可以解决哪些经典题

快慢指针的适用范围远不止移除元素。链表里判定是否有环、寻找链表中点,都会用到快慢指针,只不过那边快指针每次走两步,慢指针每次走一步。数组里求“最长无重复子串”也要用到滑动窗口,本质上是两个指针维护一个窗口的合法状态。

还有一道典型题是“最短无序连续子数组”,需要利用双指针分别从左往右和从右往左找边界。另一道是“盛最多水的容器”,用相向指针不断收缩短边。这些题目看起来各不相同,但当你做多了会发现,只要你明确两个指针各自的含义,以及移动指针的条件,剩下的就是细心维护边界。

我建议你刷题时做一个“双指针归纳表”,记录每道题里 slow 和 fast 分别代表什么,移动条件是什么,终止条件是什么。LeetCode 27作为第一行放进表里,后面每遇到一类新题就新增一行。整理一段时间后,你会发现这类题的套路非常有限,真正需要死记硬背的几乎没有。

5.3 面试官如果继续深挖怎么办

有的面试官对LeetCode 27会深挖到“你能不能用类似的思想处理字符串?”字符串本质上可以看作字符数组,很多数组题都能平移到字符串场景。比如移除字符串里的空格、去除指定字符,都是同一个套路。你只需要把字符数组用快慢指针整理,最后返回新长度或者原地修改字符串。

如果面试官继续问“如果数组特别大,大到内存装不下怎么办?”这个问题就超出算法题本身了,可能会往分治、外部排序方向走。你不需要给出完美答案,但至少应该能说出:双指针算法的优势是单次扫描、内存友好,但面对超大数据时,瓶颈在磁盘IO,可能需要分批加载数据,每一批内做双指针整理,再思考如何跨批次归并。这其实已经到了系统设计层面的讨论,能聊到这里,已经说明你具备一定的工程全局观。

6. 常见错误与排查技巧实录

6.1 我都见过哪些翻车现场

先说一个新手高频错误:用 for 循环遍历数组时,在循环体里对 nums 做pop()操作。你这样一改,数组长度变了,遍历范围也乱了,轻则跳过元素,重则下标越界。在Python里,for fast in range(len(nums))里的 len(nums) 只计算一次,后续 pop 会导致 fast 指向位置错位。如果你真要在Python里边删边遍历,只能用 while 循环手动控制下标,但那样写很容易出bug。

再说第二个常见错误:快慢指针初始值写错。有人把 slow 初始化成1,认为第一个元素肯定保留。这在不清楚 val 到底是什么的时候显然不成立,万一 val 恰好等于第一个元素呢?用1当初始值就直接跳过了第一位的检查。所以最稳妥的写法就是把 slow 和 fast 都从0开始,让代码对任何输入都保持一致。

第三个错误是试图在循环结束后把后续元素置为0或者清空。你这么做虽然不影响评测,但额外增加了时间复杂度,还可能因为修改了数组元素导致调试时出错。原地移除元素的题意是“只关心前 k 个元素”,所以完全没有必要做这一步“打扫卫生”的工作。

6.2 遇到了隐藏的坑,怎么调试

LeetCode 27 属于思路简单但实现容易出小错的题,调试时最好的工具不是 print 到处打,而是在心里维护一张表:每一步 slow 和 fast 分别指向哪里,当前 [0, slow) 的内容是什么。真要打日志,就打印 slow、fast、当前要写入的值三个变量。

举个例子,nums = [1, 1, 2, 1, 3], val = 1。刚开始 slow = 0,fast = 0,nums[0] = 1 等于 val,不写入,slow 保持0。fast = 1,nums[1] = 1,仍然不写入。fast = 2,nums[2] = 2 不等于 val,写入 nums[0],数组变成 [2,1,2,1,3],slow = 1。fast = 3,nums[3] = 1,不写入。fast = 4,nums[4] = 3,不等于 val,写入 nums[1],数组变成 [2,3,2,1,3],slow = 2。最终返回2,前两个元素是2和3。整个过程非常清晰,一旦你在纸上画过一遍,你基本不会写错。

6.3 面试时的表达节奏怎么控制

面试时,建议你按照“场景确认 -> 思路铺垫 -> 代码实现 -> 复杂度分析 -> 主动测试”的顺序走。先确认题目细节:是否需要保持原始顺序?数组长度可以为0吗?val 一定出现在数组里吗?这些问题哪怕你已经知道答案,也可以快速问一句,目的是展示你做题前有澄清需求的习惯。

然后跟面试官简单聊一下暴力解法:“最直观的做法是每次删除都移动元素,复杂度很高。所以我会用双指针,一个负责找需要保留的元素,一个负责记录写入位置。”这样就把思考过程展示清楚了。写代码时保持安静也可以,但最好穿插一句“我让 slow 从0开始,因为它代表写入位置的初始值”,让面试官跟上你的思路。

写完代码后主动做复杂度分析,时间复杂度 O(n),空间复杂度 O(1)。最后自己挑一两个边界例子自测。如果你能把这个流程走完,面试官对你的评价通常不会差。说到底,LeetCode 27拼的不是你会不会做,而是你会不会像一个成熟的工程师那样思考和表达。

我个人在刷题和真实面试里的体会是,越简单的题越能暴露一个人的真实水平。LeetCode 27就像一面镜子,能照出你对数组、指针、边界条件和工程习惯的理解程度。建议你不仅会写,还要能讲明白,最好再把26、80、283这几道变体串起来练一遍,形成一个完整的双指针知识小单元。最后分享一个小技巧:刷这类题时,先别急着打开题解,试着把快慢指针画在一张白纸上,模拟一遍运行过程,比看十遍答案都管用。很多年后你可能会忘记题号,但这个“用慢指针维护一段合法区域”的思路,会在你处理工程里的数组整理、数据清洗问题时反复出现。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询