面试被LeetCode 27(移除元素)问懵?别再用两层 for 循环了:一道“简单题”吃透双指针原地删除,附面试加分话术 + 11 个避坑清单

文章来源声明: 原文作者:Rain的Java大神之路; 来源站点:掘金; 原文链接:https://juejin.cn/post/7702238052617469971; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

这篇适合面试前快速复盘数组双指针:不只给模板,更讲清选解法依据和易错边界,能帮读者在“简单题”上少翻车。

### 📌 先看摘要:一道“简单题”,为什么能把人问哑火?

上周有个读者面试 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。要求不使用额外数组空间,元素顺序可以改变,不需要考虑超出新长度后面的元素。

这道题标着简单,但面试官其实就盯着三件事看:

  1. 能不能正确理解"原地修改"——很多人上来就 new 一个新数组,直接 0 分;
  2. 有没有双指针优化意识——会不会写个暴力法就交差;
  3. 边界处理是否严谨——空数组、全是 val、没有 val 三种极端情况。

动手之前,先把下面三个确认点主动说出来,面试官对你的第一印象就不一样了:

确认点要主动说出来的理解
"原地"是什么意思?不能开新数组,必须在原 `nums` 上改,空间复杂度 O(1)
"移除"是真的删除吗?不是。Java 数组长度 `new` 出来就固定了,所谓移除 = **把保留元素挪到前面**,返回个数 `k`,`[0, k)` 之外是"垃圾区",值无所谓
`val` 可能不存在 / 数组为空吗?会。分别返回原长度 / 0,必须覆盖

💡 破题一句话:这题考的根本不是"删除",而是双指针下的原地数组压缩(in-place compaction)。

为什么这么说?因为数组在内存里是连续存储的,不像链表能直接断开一个节点。数组长度又固定,没法真正"删掉"某个位置——所谓删除,本质是覆盖。想通这一点,后面的解法就顺理成章了。


二、思路演进:从暴力到双指针

我们先看最朴素的思路,再看它为什么不行,最后引出最优解——这个"演进过程"本身就是面试时展示思考层次的好素材。

image.png

暴力解法:能写,但别在面试里写

最朴素的想法:遍历数组,遇到等于 val 的元素,就把后面所有元素整体往前挪一位。

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 是"笔"——眼睛把数组全扫一遍,笔只写值得留下的。

image.png

图解:一次完整的旅程

拿 nums = [3, 2, 2, 3]、val = 3 手动走一遍:

轮次fastnums\[fast\]动作slow有效前缀
103 == val ❌跳过,fast 独跑0`[]`
212 != val ✅抄送到 slow 位,slow++1`[2]`
322 != val ✅抄送到 slow 位,slow++2`[2,2]`
433 == val ❌跳过,fast 独跑2`[2,2]`

遍历结束,返回 slow = 2,数组前两位 [2, 2] 就是答案,后面的 [2, 3] 是垃圾区,不用管。

核心代码(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。

image.png

核心代码(开区间写法,推荐)

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 步:

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 感受下"尾部顶坑"的节奏:

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,逻辑完全等价:

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++(因为换上来的已经确认是好的):

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]`**
7fast 不该在分支里 ++写成 `nums[slow++] = nums[fast++]` 导致跳元素`fast` 只在 for 循环里统一自增,**职责分离,各司其职**
8纠结返回值要不要 +1返回 `slow + 1` 导致多算一个`slow` 是**个数**不是末位下标,直接返回
9纠结后面脏数据要不要清零画蛇添足去置 0题目明确只关心前 `k` 个,评测代码根本不检查,**不用管**
10物理长度 vs 逻辑长度混淆以为数组真的变短了返回的是**逻辑长度**,`[k, len)` 是垃圾区,契约上调用方不得访问
11边界 case 没验证就说"正确"空数组 / 全 val / 全非 val 翻车三种 case 脑内跑一遍再下结论(见下节)

三种边界,两种解法都天然兼容,无需任何特判:

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 里的那一个判断条件:

image.png

题目保留条件核心代码差异
**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。同时我主动覆盖了空数组、全匹配、无匹配三个边界。"

最后再送一个实战建议:写完代码别急着交,拿示例手动走一遍流程。面试官最怕"写完就交"的候选人,最爱"写完还能验算"的候选人。


写在最后

这道题是送分题,但也是拉分题:

  • 写对快慢双指针 → 及格;
  • 讲清楚边界和循环不变量 → 良好;
  • 能主动给出首尾交换优化、说清两种解法的适用场景 → 优秀。

三句话带走今天的核心:

  1. 数组"删除"的本质,是用覆盖代替删除,用双指针控制写入位置;
  2. 先想清楚"谁在读、谁在写",读写分离了,代码自然就出来了;
  3. 顺序可变是个信号——它在暗示你可以用首尾交换省写入。

别觉得这只是玩具题。这道题是理解原地算法的基石,实际开发里处理大数据流清洗——从一堆数据里"只保留有效数据、就地压缩"——用的就是同一套思路。把地基打牢,后面的题和真实业务都是它的变体。

这种"一道题带飞一类题"的拆解,我后面还会持续更新(移动零、删除重复项都在路上了),感兴趣的话点个关注,别错过下一期。


来评论区聊聊:你写双指针的时候,最容易栽在哪一步?是忘了移动指针、循环条件写错,还是那个该死的 off-by-one?我赌五毛钱,选"覆盖后忘了复检"的一定不少 😄