📌 先看摘要:一道"简单题",为什么能把人问哑火?
上周有个读者面试 LeetCode 27,被"原地移除数组元素"卡住了:两层 for 循环写完,面试官一句"数据量大了怎么办",当场哑火。
这道题看着人畜无害,实则藏着双指针的一整套核心思想。这篇不灌水,直接把它掰开揉碎:
- 从暴力 O(n²) 到快慢双指针 O(n) 原地压缩;
- 再到首尾交换填坑,把赋值次数从 n−k 砍到 k;
- 讲透"数组删除的本质是覆盖""读写分离""循环不变式";
- 整理 11 条避坑清单 + 7 个高频追问话术 + 4 道同模板变体。
适合谁看:正在准备面试、想把数组双指针彻底搞明白、不想在"简单题"上翻车的你。
读完你将收获:
- 快慢双指针标准写法:为什么返回
slow不用+1、自赋值为什么安全; - 首尾交换加分解法:覆盖后
left为什么不能++、开区间/闭区间怎么选; - 一张表选解法:要保序用快慢指针,不保序且
val稀疏用首尾交换; - 面试话术直接背:循环不变式、
ArrayList.removeIf源码联系、Stream 为什么不合格; - 一道题带飞一类题:26 / 283 / 80 / 844 的同一套骨架。
本文亮点 :不是"贴代码",而是把面试官真正想听的"选解法和边界意识"讲透。简单题才是最容易挖坑的地方------建议先收藏,面试前扫一遍避坑表。
一句话结论 :数组"删除"的本质不是删,是覆盖;双指针的关键不是背模板,是先想清楚------谁在读、谁在写。
评论区聊聊:你写双指针最容易栽在哪一步?忘了移动指针、循环条件写错,还是覆盖后忘了复检?我赌五毛钱,选"覆盖后忘了复检"的一定不少 😄
觉得有用点个赞/在看,关注我,下一期继续拆"移动零"和"删除重复项"。
LeetCode 27 移除元素:一道题吃透双指针原地删除(附面试加分点与避坑清单)
题型标签:数组 · 双指针 · 原地修改 难度:简单 | 考察频率:⭐⭐⭐⭐(数组操作必考入门题)
上周有个读者跟我吐槽,说他面试被一道"特别简单"的题卡住了:面试官让他原地移除数组里所有等于某个值的元素,还补了一句"不许用额外数组"。他脑子一热写了个两层 for 循环,当场被问"数据量大了怎么办",直接哑火。
这道题就是 LeetCode 27,看着人畜无害,实则藏着双指针的一整套核心思想。今天把它掰开揉碎讲透------这篇文章适合正在准备面试、想把数组双指针彻底搞明白的你。读完你会发现,这一道题能带飞一整类题。
一、先别急着写代码,把题意"聊透"
真正的高手,拿到题第一分钟不是在憋循环,而是在澄清边界。我们先把题目摆出来:
给你一个数组
nums和一个值val,原地 移除所有等于val的元素,返回剩余元素的个数k。要求不使用额外数组空间,元素顺序可以改变,不需要考虑超出新长度后面的元素。
这道题标着简单,但面试官其实就盯着三件事看:
- 能不能正确理解"原地修改" ------很多人上来就
new一个新数组,直接 0 分; - 有没有双指针优化意识------会不会写个暴力法就交差;
- 边界处理是否严谨 ------空数组、全是
val、没有val三种极端情况。
动手之前,先把下面三个确认点主动说出来,面试官对你的第一印象就不一样了:
| 确认点 | 要主动说出来的理解 |
|---|---|
| "原地"是什么意思? | 不能开新数组,必须在原 nums 上改,空间复杂度 O(1) |
| "移除"是真的删除吗? | 不是。Java 数组长度 new 出来就固定了,所谓移除 = 把保留元素挪到前面 ,返回个数 k,[0, k) 之外是"垃圾区",值无所谓 |
val 可能不存在 / 数组为空吗? |
会。分别返回原长度 / 0,必须覆盖 |
💡 破题一句话 :这题考的根本不是"删除",而是双指针下的原地数组压缩(in-place compaction)。
为什么这么说?因为数组在内存里是连续存储 的,不像链表能直接断开一个节点。数组长度又固定,没法真正"删掉"某个位置------所谓删除,本质是覆盖。想通这一点,后面的解法就顺理成章了。
二、思路演进:从暴力到双指针
我们先看最朴素的思路,再看它为什么不行,最后引出最优解------这个"演进过程"本身就是面试时展示思考层次的好素材。

暴力解法:能写,但别在面试里写
最朴素的想法:遍历数组,遇到等于 val 的元素,就把后面所有元素整体往前挪一位。
java
public int removeElement(int[] nums, int val) {
int length = nums.length;
for (int i = 0; i < length; i++) {
if (nums[i] == val) {
// 把后面所有元素前移一位
for (int j = i + 1; j < length; j++) {
nums[j - 1] = nums[j];
}
i--; // 关键:移完后 i 要回退,否则会跳过新顶上来的元素
length--; // 逻辑长度减一
}
}
return length;
}
时间复杂度 O(n²):每删一个元素都要搬移整个后缀,数据量一大就超时。
⚠️ 过来人提醒 :面试时这个思路嘴上说一句就行 ,千万别真上手写,面试官会觉得你算法基础薄弱。不过里面
i--这个细节值得记住------删完回退一步,不然会漏掉刚顶上来的元素,不少人在这个细节上翻车。
那正确的打开方式是什么?一快一慢两个指针,一遍过。
三、解法一:快慢双指针(标准答案,保序)
这是面试必须先写出来的标准解法,稳、好懂、没有边界坑。
核心思想:读写分离
定义两个指针,分工明确:
fast(快指针) :负责读 ,扫描整个数组,筛查出不等于val的元素;slow(慢指针) :负责写,指向"下一个该放合法元素"的位置,也就是新数组的写入下标。
一句话记住它:fast 是"眼睛",slow 是"笔"------眼睛把数组全扫一遍,笔只写值得留下的。

图解:一次完整的旅程
拿 nums = [3, 2, 2, 3]、val = 3 手动走一遍:
| 轮次 | fast | numsfast | 动作 | slow | 有效前缀 |
|---|---|---|---|---|---|
| 1 | 0 | 3 == val ❌ | 跳过,fast 独跑 | 0 | [] |
| 2 | 1 | 2 != val ✅ | 抄送到 slow 位,slow++ | 1 | [2] |
| 3 | 2 | 2 != val ✅ | 抄送到 slow 位,slow++ | 2 | [2,2] |
| 4 | 3 | 3 == val ❌ | 跳过,fast 独跑 | 2 | [2,2] |
遍历结束,返回 slow = 2,数组前两位 [2, 2] 就是答案,后面的 [2, 3] 是垃圾区,不用管。
核心代码(Java)
java
class Solution {
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]; // 合格元素抄送上前,写 + 后移一步到位
}
// 等于 val 就原地跳过,让 fast 自己往前走
}
return slow; // slow 天然就是新长度
}
}
🔥 技术亮点:
nums[slow++] = nums[fast]一行把"赋值 + 指针前移"压缩掉,返回值直接复用slow,零额外变量;- 单次遍历 O(n) 时间、原地覆盖 O(1) 空间,相比暴力的 O(n²) 是质的提升;
fast走完全程但只在!= val时才写数组,天然保留元素相对顺序。
为什么这样写一定是对的?(循环不变式)
这一段是面试加分的重头戏,主动说出来,面试官会知道你懂的不只是代码:
循环不变式 :任意时刻,
[0, slow)区间内始终是不含 val 的有效前缀 ;并且写指针slow永远不超前于读指针fast,所以不会踩坏还没读过的数据。
再补两个新手常问的点:
- 自赋值安全吗? 当
slow == fast时,nums[slow] = nums[fast]就是自己赋值给自己,完全无害。全都不等于val时,全程自赋值,数组原样保留,返回n,正确。 - 为什么覆盖不会丢数据? 因为
fast永远 ≥slow,nums[slow]位置上的旧值要么已经被fast读过,要么本身就是val,覆盖它不会丢失任何合法数据。
为什么返回 slow,而不是 slow + 1?
这是新手最容易踩的坑。slow 是"下一个待写入的下标",从 0 开始,每次赋值完才自增,所以遍历结束时它正好等于合法元素的个数 ,指向第一个无效位置。空数组时 slow = 0,直接返回就对了------不用 +1。
四、解法二:首尾交换填坑(加分项,写次数更少)
写到这儿,面试官很可能追问一句:"还能再优化吗?" 加分的机会来了。
优化动机:快慢指针的"白干活"问题
快慢指针有个隐患:即使 nums[fast] != val,它也会老老实实赋值一次。考虑 nums = [1, 2, 3, 4, 5]、val = 5 这种 val 极度稀疏的场景------快慢指针会把前 4 个元素原地赋值给自己 ,白干 4 次。换句话说,快慢指针的写入次数 = 保留元素个数(n − k),当要删的元素很少时,接近 99% 的写入都是无意义的自我赋值。
而题目明确说了元素顺序可以改变------这个条件不用白不用。我们可以把右边不删的元素直接搬过来填左边的坑。
核心思想:末尾顶坑
left从左往右走,遇到val就停下来;- 用末尾还没判定的元素直接覆盖这个坑,逻辑长度收缩;
- 每命中一次
val只写一次,写入次数 = 待删除元素个数 k。

核心代码(开区间写法,推荐)
java
class Solution {
public int removeElement(int[] nums, int val) {
int left = 0, right = nums.length; // right 取 length(开区间),天然防空数组
while (left < right) {
if (nums[left] == val) {
nums[left] = nums[--right]; // 拿末尾未被判定的元素来顶替
// 注意:left 不前进!换过来的元素还没检查,要再审一次
} else {
left++;
}
}
return left; // 此时 left == right
}
}
🔥 技术亮点:
right初值取length(开区间)+ 前置自减--right,规避了nums.length - 1在空数组上的越界;- 覆盖后
left故意不 ++ :从尾巴换过来的元素身份未知,必须重新过一遍筛子------这是最容易写错的一个坑;- 赋值次数 = 待删除元素个数 k ,
val稀疏时性能远优于快慢指针的n − k次。
图解:顶上来的元素可能再次命中
拿 nums = [3, 2, 2, 3]、val = 3 走一遍,注意第 2 步:
text
i=0 命中 3 → 尾元素 3 顶坑 → [3, 2, 2 ¦ 3] n=3,i 不动
i=0 复检,又命中 3 → 尾元素 2 顶坑 → [2, 2 ¦ 2, 3] n=2,i 还不动
i=0 复检 = 2 ✅ → i=1
i=1 = 2 ✅ → i=2 == n,结束,返回 2 ✅
(¦ 后面是已经"失效"的垃圾区)看到没?顶上来的元素还可能再命中一次 ,所以命中分支绝不能推进 left,必须原地复检。
再拿一个长一点的例子 nums = [0, 1, 2, 2, 3, 0, 4, 2]、val = 2 感受下"尾部顶坑"的节奏:
text
left=0: 0≠2 → left=1
left=1: 1≠2 → left=2
left=2: 2==2 → 尾元素 2 顶坑,right=7,left 不动(顶上来的还是 2!)
left=2: 2==2 → 尾元素 4 顶坑,right=6,left 不动 → 数组 [0,1,4,2,3,0,4,2]
left=2: 4≠2 → left=3
left=3: 2==2 → 尾元素 0 顶坑,right=5,left 不动 → 数组 [0,1,4,0,3,0,4,2]
left=3: 0≠2 → left=4
left=4: 3≠2 → left=5 == right,结束,返回 5 ✅
前 5 个元素 [0, 1, 4, 0, 3] 都不等于 2,顺序确实被打乱了------这正是题目允许的。
另外两种常见写法(认得就行)
闭区间写法 :right 取 length - 1,循环条件相应改成 left <= right,逻辑完全等价:
java
public int removeElement(int[] nums, int val) {
int left = 0, right = nums.length - 1;
while (left <= right) {
if (nums[left] == val) {
nums[left] = nums[right]; // 尾部元素覆盖,右指针左移
right--;
} else {
left++;
}
}
return left;
}
双 while 写法 :右边先主动找到一个"确定不等于 val"的元素再覆盖,这样覆盖后可以直接 left++(因为换上来的已经确认是好的):
java
public int removeElement(int[] nums, int val) {
int left = 0, right = nums.length - 1;
while (left <= right) {
while (left <= right && nums[left] != val) left++; // 左边找要移除的
while (left <= right && nums[right] == val) right--; // 右边找要保留的
if (left < right) {
nums[left] = nums[right]; // 右边的有效元素覆盖左边的 val
left++;
right--;
}
}
return left;
}
💡 三种写法的本质区别就两点 :区间是开是闭(决定
right初值和循环条件取<还是<=);覆盖后left要不要前进(取决于换上来的元素是否已经确认不是 val)。把这两点想清楚,怎么写都不会错。
五、两种解法怎么选?这张表直接背下来
| 维度 | 快慢指针 | 首尾交换 |
|---|---|---|
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(1) | O(1) |
| 是否保持相对顺序 | ✅ 保序、稳定 | ❌ 会打乱 |
| 赋值次数 | n − k(保留元素个数) | k(删除元素个数) |
| 最佳场景 | 通用、要求保序、val 密集 | 不关心顺序、val 稀疏 |
| 面试推荐度 | ⭐⭐⭐⭐⭐ 必须先写 | ⭐⭐⭐⭐ 主动提出当加分项 |
一句话决策:要保序 → 快慢指针;不保序且 val 稀疏 → 首尾交换把写入次数砍到 k。
🎤 面试话术 (建议原话背诵): "如果业务要求保持原有顺序,我用快慢指针;如果只求数量、不关心顺序,且
val出现频率低,我用首尾交换把赋值次数从 n−k 降到 k。"
面试官真正想听的,从来不是代码本身,而是你"根据约束选解法"的思路。 题目给了"顺序可变"这个条件,你主动用上了,说明你审题到位。
六、技术难点 & 避坑速查表
这一节是我压箱底的东西,把 8 份面试笔记里所有踩坑点合并去重,整理成一张表。面试前扫一遍,能少丢很多冤枉分:
| # | 技术难点 | 常见踩坑现象 | 解决方案 |
|---|---|---|---|
| 1 | 数组定长,无法物理删除 | 想用 Arrays.copyOf 或新数组,空间超限 |
引入逻辑长度 :返回 k,[0, k) 为有效区,用覆盖代替删除 |
| 2 | 暴力搬运导致 O(n²) | 每次删除全体前移,大数据超时 | 读写分离 :fast 扫、slow 写,一趟搞定 |
| 3 | 大量"自己赋值给自己" | 快慢指针在 val 极小时白写 n−1 次 | 改首尾交换:从尾部取元素覆盖,赋值次数降为 k |
| 4 | 首尾交换覆盖后漏检 | 直接 left++,换过来的 val 被漏掉 |
覆盖后不移动 left,下一轮重新判定 |
| 5 | 空数组 / 右边界越界 | right = length - 1 在空数组上出问题 |
right 取 length(开区间)+ 前置自减 |
| 6 | 循环条件 < 还是 <= |
首尾指针写错条件导致越界或漏判 | 开区间用 left < right;闭区间用 left <= right,先判条件,再访问 nums[left] |
| 7 | fast 不该在分支里 ++ | 写成 nums[slow++] = nums[fast++] 导致跳元素 |
fast 只在 for 循环里统一自增,职责分离,各司其职 |
| 8 | 纠结返回值要不要 +1 | 返回 slow + 1 导致多算一个 |
slow 是个数不是末位下标,直接返回 |
| 9 | 纠结后面脏数据要不要清零 | 画蛇添足去置 0 | 题目明确只关心前 k 个,评测代码根本不检查,不用管 |
| 10 | 物理长度 vs 逻辑长度混淆 | 以为数组真的变短了 | 返回的是逻辑长度 ,[k, len) 是垃圾区,契约上调用方不得访问 |
| 11 | 边界 case 没验证就说"正确" | 空数组 / 全 val / 全非 val 翻车 | 三种 case 脑内跑一遍再下结论(见下节) |
三种边界,两种解法都天然兼容,无需任何特判:
java
nums = [], val = 1 → 0 ✅ 循环根本不进
nums = [3, 3], val = 3 → 0 ✅ 全跳过 / 全被顶掉
nums = [1, 2], val = 3 → 2 ✅ 全保留,原样返回
七、面试官高频追问,提前准备好就是稳
这部分是拉开差距的地方。我把高频追问和标准答案都列出来,建议对着练一遍:
Q1:为什么返回长度就行,不用真的删掉元素?
Java 数组长度在
new的那一刻就固定了,物理删除不存在。连ArrayList.remove()看似在"删",底层也是System.arraycopy做原地压缩 + 把末位置null帮 GC 回收。所以"逻辑长度"是数组删除类问题的通用解法。
Q2:这和 JDK 源码有什么联系?
ArrayList.removeIf(Predicate)就是这个思想的工程版:先跳过前段不需要删的元素,再批量标记,最后一次性System.arraycopy压缩------核心都是"减少无效搬运"。能在面试里主动扯到这一层,面试官会眼前一亮。
Q3:能不能用 Stream 或者开个新数组?
能写,但不合格 。
filter().toArray()是 O(n) 额外空间,违背"原地"约束。这题考的就是空间意识,这么写等于告诉面试官你没读懂题。
Q4:如果要求保留元素相对顺序,还能用首尾交换吗?
不能。首尾交换会把后面的元素换到前面,打乱顺序。要求保序时必须用快慢指针------这句主动说出来,面试官会点头。
Q5:为什么这道题能做到 O(n)?
只遍历数组常数次,没有嵌套循环,每个元素最多被访问两次(读一次、被覆盖一次),所以是线性时间。
Q6:怎么证明你的解法是对的?
循环不变式:
[0, slow)始终是不含val的有效前缀,且写指针不超前于读指针。遍历结束时该区间正好就是全部有效元素,边界情况也都被它兜住。
Q7(延伸):ArrayList 循环删除元素要注意什么?
ArrayList.remove(int index)就是把index后面的元素整体前移一位,O(n)。如果在 for 循环里边遍历边删,下标会错位漏元素------要么倒序遍历,要么用迭代器的remove()。这是实际开发里真会踩的坑。
八、一道题打通一类题:这是模板题
讲真,这道题最大的价值不是它自己,而是它背后的**"原地压缩"模板**。骨架完全一样,差异只在 if 里的那一个判断条件:

| 题目 | 保留条件 | 核心代码差异 |
|---|---|---|
| 27. 移除元素 | nums[fast] != val |
不等于目标值就保留 |
| 26. 删除有序数组重复项 | nums[fast] != nums[slow-1] |
和上一个保留的元素比 |
| 283. 移动零 | nums[fast] != 0 |
保留非零,最后统一补零 |
| 80. 删除重复项 II | nums[fast] != nums[slow-2] |
允许保留 2 个,和倒数第二个比 |
面试时主动提一嘴这些变体,面试官会觉得你对双指针这个模式是真理解了,而不是只背了这一道题。
💡 收尾自评话术(面试最后 30 秒这么收,很加分): "这题我优先写快慢指针版本,因为它保序、可读、最稳,复杂度 O(n)/O(1);如果您关注极端性能、且明确不要求顺序,我再给首尾交换版本,把赋值次数从 n−k 降到 k。同时我主动覆盖了空数组、全匹配、无匹配三个边界。"
最后再送一个实战建议:写完代码别急着交,拿示例手动走一遍流程。面试官最怕"写完就交"的候选人,最爱"写完还能验算"的候选人。
写在最后
这道题是送分题,但也是拉分题:
- 写对快慢双指针 → 及格;
- 讲清楚边界和循环不变量 → 良好;
- 能主动给出首尾交换优化、说清两种解法的适用场景 → 优秀。
三句话带走今天的核心:
- 数组"删除"的本质,是用覆盖代替删除,用双指针控制写入位置;
- 先想清楚"谁在读、谁在写",读写分离了,代码自然就出来了;
- 顺序可变是个信号------它在暗示你可以用首尾交换省写入。
别觉得这只是玩具题。这道题是理解原地算法 的基石,实际开发里处理大数据流清洗------从一堆数据里"只保留有效数据、就地压缩"------用的就是同一套思路。把地基打牢,后面的题和真实业务都是它的变体。
这种"一道题带飞一类题"的拆解,我后面还会持续更新(移动零、删除重复项都在路上了),感兴趣的话点个关注,别错过下一期。
来评论区聊聊:你写双指针的时候,最容易栽在哪一步?是忘了移动指针、循环条件写错,还是那个该死的 off-by-one?我赌五毛钱,选"覆盖后忘了复检"的一定不少 😄