在做LeetCode的「移除元素」这道题时,我的第一反应就是用左右指针来解决——毕竟题目要求在原地修改数组,还得返回移除指定元素后的新长度,双指针是原地操作的常用思路。但在实现和调试过程中,我发现左右指针的边界处理特别繁琐,踩了好几个坑,最后才发现快慢指针的解法更简洁、更不容易出错。今天就把这个思考和踩坑的过程分享给大家,希望能帮到有同样困惑的同学。
一、题目回顾:移除元素
先简单回顾下题目要求,避免大家对题目理解有偏差:
给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素,元素的顺序可能发生改变。然后返回
nums中与val不同的元素的数量。不要使用额外的数组空间,你必须仅使用 O(1) 额外空间并 原地 修改输入数组。
元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。
举个例子:
输入:nums = [3,2,2,3], val = 3 → 输出:2,且 nums 前 2 个元素为 [2,2]
输入:nums = [1], val = 1 → 输出:0,且 nums 前 0 个元素为空
二、我的第一思路:左右指针解法
拿到题目的时候,我首先想到的是把数组分成两个部分:
-
左半部分 [0, k-1]:存储不等于 val 的有效元素(k 就是最终要返回的新长度)
-
右半部分 [k, nums.length-1]:存储等于 val 的无效元素
基于这个思路,我用两个指针来分别维护这两个部分:
-
p1(左指针):维护有效区域的边界,负责遍历左半部分,寻找需要移除的 val
-
p2(右指针):维护无效区域的边界,负责从数组末尾寻找可以替换 val 的有效元素
1. 左右指针的实现代码
结合这个思路,我写出了第一版代码:
function removeElement_1(nums: number[], val: number): number {
// 思路:把nums分为 两个部分 [0,k-1] 和 [k,nums.length-1] 用两个指针维护这两个部分
// p1 用来维护 第一部分 [0,k)
// p2 用来维护 第二部分 (k-1,num.length-1]
let p1 = 0, p2 = nums.length - 1;
while (p1 <= p2) {
if (nums[p1] === val) {
// 找到p1位置的val,从p2开始向左找第一个非val元素
while (p1 <= p2 && nums[p2] === val) {
p2--;
}
// 若p1 > p2,说明所有剩余元素都是val,直接退出
if (p1 > p2) break;
// 交换p1和p2位置的元素,把有效元素放到p1
[nums[p1], nums[p2]] = [nums[p2], nums[p1]];
p2--; // 交换后p2左移,维护无效区域边界
}
p1++; // 无论是否交换,p1右移继续遍历
}
// 最终p1的值就是有效元素的个数k
return p1;
};
2. 左右指针的核心逻辑拆解
我们以「nums = [3,2,2,3], val = 3」为例,一步步看代码是怎么执行的:
-
初始状态:p1=0,p2=3,nums=[3,2,2,3]
-
p1=0 时,nums[p1]=3 === val,进入逻辑:
-
内层循环:nums[p2]=3 === val,p2-- 变成 2
-
此时 p1=0 ≤ p2=2,交换 nums[0] 和 nums[2],nums 变成 [2,2,3,3]
-
p2-- 变成 1
-
-
p1++ 变成 1,进入下一轮循环:p1=1 ≤ p2=1
-
nums[p1]=2 !== val,p1++ 变成 2
-
循环结束(p1=2 > p2=1),返回 p1=2,正好是有效元素的个数
3. 左右指针的踩坑点(重点!)
最开始写的时候,我踩了好几个边界问题的坑,导致代码在某些测试用例下失效:
-
坑1:循环条件用了 p1 < p2 → 漏检了 p1=p2 的情况(比如 nums=[1], val=1)。后来改成 p1 ≤ p2,确保最后一个元素也能被检查。
-
坑2:内层循环没加 p1 ≤ p2 防护 → 当数组全是 val 时(比如 nums=[3,3], val=3),p2 会一直减到负数,导致数组越界。后来在内层循环加上 p1 ≤ p2,防止越界。
这些坑都是左右指针解法的核心难点——边界条件太多,很容易考虑不全。调试了好几次,才把这些问题都修复。
三、简化思路:快慢指针解法
虽然左右指针最终能解决问题,但我在调试过程中明显感觉到:左右指针的维护太麻烦了,边界条件太多,很容易出错。于是我开始思考:有没有更简洁的双指针思路?
后来想到了快慢指针——同样是双指针,但逻辑更简单,不需要考虑交换和复杂的边界,只需要“找有效元素、放有效元素”即可。
1. 快慢指针的核心思路
快慢指针的思路更直接,不用分左右两个区域,而是用两个指针配合遍历:
-
slow(慢指针):维护有效元素的边界,指向有效区域的下一个空位(也就是最终的 k 值)
-
fast(快指针):遍历整个数组,负责寻找不等于 val 的有效元素
核心逻辑:快指针遍历数组,只要找到不等于 val 的元素,就把它放到慢指针的位置,然后慢指针右移。这样遍历结束后,慢指针的位置就是有效元素的个数。
2. 快慢指针的实现代码
结合这个思路,写出的代码特别简洁:
function removeElement(nums: number[], val: number): number {
let slow = 0; // 慢指针:有效元素的末尾下标+1
// 快指针遍历数组,找非val元素
for (let fast = 0; fast < nums.length; fast++) {
// 非val元素 → 放到慢指针位置,慢指针右移
if (nums[fast] !== val) nums[slow++] = nums[fast];
}
return slow;
};
3. 快慢指针的逻辑拆解
还是以「nums = [3,2,2,3], val = 3」为例,看看快慢指针是怎么执行的:
-
初始状态:slow=0,fast=0,nums=[3,2,2,3]
-
fast=0:nums[0]=3 === val → 不操作,fast++ 变成 1
-
fast=1:nums[1]=2 !== val → 把 nums[1] 放到 slow=0 的位置,nums 变成 [2,2,2,3],slow++ 变成 1,fast++ 变成 2
-
fast=2:nums[2]=2 !== val → 把 nums[2] 放到 slow=1 的位置,nums 变成 [2,2,2,3],slow++ 变成 2,fast++ 变成 3
-
fast=3:nums[3]=3 === val → 不操作,fast++ 变成 4,循环结束
-
返回 slow=2,和左右指针的结果一致
4. 快慢指针的优势
对比左右指针,快慢指针的优势太明显了:
-
逻辑简单:不用考虑交换,也不用处理复杂的边界条件(比如 p1 > p2、p1=p2 等),只需要“找有效元素、放有效元素”
-
代码简洁:从左右指针的十几行代码,简化到只有 5 行核心逻辑,不容易出错
-
效率相同:时间复杂度都是 O(n)(每个元素最多被遍历一次),空间复杂度都是 O(1),完全符合题目要求
四、两种解法对比总结
最后做个总结,帮大家理清两种解法的适用场景和差异:
| 解法 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 左右指针 | 分区域维护,交换有效元素和无效元素 | 元素移动次数少(适合元素体积大的场景) | 边界条件复杂,易出错 | 元素体积大,希望减少移动次数 |
| 快慢指针 | 遍历找有效元素,直接放到有效区域 | 逻辑简单,代码简洁,不易出错 | 元素移动次数可能更多(但时间复杂度不变) | 大多数场景,尤其是面试时快速解题 |
五、最后想说的话
做这道题的过程,让我深刻体会到:解决问题的思路没有绝对的对错,只有“更适合”。左右指针是我最初的直观思路,虽然能解决问题,但调试过程很繁琐;而快慢指针虽然不是第一反应,但更简洁、更不容易出错,是这道题的最优解。
如果大家和我一样,一开始想到的是左右指针,不用觉得自己的思路不好——这是正常的思考过程。重要的是在调试和优化中,发现更优的解法,并理解两种解法的差异和适用场景。
最后,希望这篇博客能帮到正在学双指针的同学,祝大家刷题顺利!