【算法】用“龟兔赛跑”的思想原地移除元素
文章目录
- 一、问题
- 二、题目分析
- 三、算法实现
- 3.1、双指针(快慢指针)
- 3.2、双指针(首尾双指针)
- 3.3、无耻做法
- 四、总结
一、问题
给定一个整数数组 nums
和一个整数值 val
,你的目标是原地(in-place)移除 nums
中所有数值等于 val
的元素,可以改变元素的顺序。你需要返回移除后数组中与 val
不同的元素的数量,我们称之为 k
。
强调 “原地” 操作,即不允许使用额外的空间。
约束条件:
- 原地操作: 必须在原始数组
nums
上进行修改,不能使用额外的数组空间(即空间复杂度为 O(1))。 - 顺序改变: 元素的顺序可以改变。
- 返回值
k
: 返回值为k
,表示nums
中不等于val
的元素数量。 - 数组内容要求: 更改
nums
数组,使nums
的前k
个元素包含不等于val
的元素。nums
的其余元素和nums
的大小并不重要(可以随意修改)。
示例 1:
输入:nums = [3,2,2,3], val = 3
输出:2, nums = [2,2,,]
解释:函数应该返回 k = 2, 并且 nums 中的前两个元素均为 2。在返回的 k 个元素之外留下了什么并不重要。
示例 2:
输入:nums = [0,1,2,2,3,0,4,2], val = 2
输出:5, nums = [0,1,4,0,3,,,_]
解释:函数应该返回 k = 5,并且 nums 中的前五个元素为 0,0,1,3,4。
注意这五个元素可以任意顺序返回。在返回的 k 个元素之外留下了什么并不重要。
二、题目分析
这个问题考验对原地操作、数组元素移动以及双指针法的理解。关键在于正确地运用双指针法,并充分利用题目中 “元素顺序可以改变” 和 “nums
的其余元素和 nums
的大小并不重要” 这两个条件,来选择最合适的算法,并优化算法的效率。
输入和输出:
- 输入: 一个整数数组
nums
和一个整数val
。 - 输出: 一个整数
k
,表示nums
中不等于val
的元素数量。 数组nums
也会被修改。
核心问题:
- 如何在 O(1) 的空间复杂度下,高效地移动数组元素,使得不等于
val
的元素集中在数组的前面? - 如何确保返回的
k
值是正确的?
思路:
- 双指针法: 利用两个指针,一个快指针用于遍历数组,一个慢指针用于指向下一个需要被赋值的位置。
- 快指针 (fast pointer): 扫描整个数组,检查每个元素是否等于
val
。 - 慢指针 (slow pointer): 指向下一个要放置 非
val
元素的位置。只有当快指针遇到 非val
元素时,才将该元素移动到慢指针的位置,并更新慢指针。
- 快指针 (fast pointer): 扫描整个数组,检查每个元素是否等于
- 另一种双指针法 (如果允许改变相对顺序): 首尾双指针,可以提高在特定情况下的效率。
left
: 指向数组的起始位置。right
: 指向数组的末尾位置。- 如果
nums[left] == val
,则将nums[left]
和nums[right]
交换,并且right--
。 否则,left++
。 - 当
left > right
时,停止交换。 最终,left
的值就是k
。
约束解读:
- “元素的顺序可以改变” 允许我们使用一些技巧,例如首尾双指针法,这样可以更快速地进行元素的交换。如果不允许改变顺序,则需要使用另一种双指针方法,保证元素的相对顺序。
- “
nums
的其余元素和nums
的大小并不重要” 这意味着我们不需要关心nums
的后半部分是什么,只要保证nums
的前k
个元素是正确的即可。这简化了问题,因为我们不必真正地删除数组元素,只需要覆盖它们即可。
复杂度分析 (初步):
- 时间复杂度: O(n),因为需要遍历整个数组。
- 空间复杂度: O(1),因为是原地操作。
特殊情况考虑:
- 如果
nums
为空数组,则返回 0。 - 如果
nums
中没有元素等于val
,则返回nums
的长度。 - 如果
nums
中所有元素都等于val
,则返回 0。
三、算法实现
3.1、双指针(快慢指针)
利用两个指针,一个快指针用于遍历数组,一个慢指针用于指向下一个需要被赋值的位置。
- 快指针 (fast pointer): 扫描整个数组,检查每个元素是否等于
val
。 - 慢指针 (slow pointer): 指向下一个要放置 非
val
元素的位置。只有当快指针遇到 非val
元素时,才将该元素移动到慢指针的位置,并更新慢指针。
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int left = 0, right = 0;
while (right < nums.size()) {
if (nums[right] != val) {
nums[left] = nums[right];
++left;
}
++right;
}
return left;
}
};
这样的算法在最坏情况下(输入数组中没有元素等于 val),左右指针各遍历了数组一次。
快慢指针原理可以理解成一段爱情故事:
有一对夫妇, slow和fast,他们的人生原本都是零起点,对未来充满着迷茫。但生活总要继续,他们开始了各自的旅程。
Fast天生就是一个闲不住的人,他跑得飞快,探索着生活的无限可能。Slow则显得有些慢热,他需要时间来沉淀和思考。
Fast先出发了,他肩负着为两人寻找共同目标的重任。一路上,他寻找、尝试,经历着各种各样的事情 (
nums[fast] != val
) 。当他遇到一个真正有价值的目标时 (nums[fast] != val
),他会小心翼翼地将它交给Slow (nums[slow] = nums[fast]
) 。Slow接收到Fast传递过来的目标,将它视为珍宝,也向前移动了一步,向着目标更近了一步 (
slow++
)。不管Fast是否找到了目标,他都从未停下脚步,一直奔跑到生命的尽头 (
fast++
)。最终,完成了筛选、并且不断前进的,只剩下了Slow。他继承了Fast的努力和付出,成为了他们共同旅程的见证者 (
return slow
)。
3.2、双指针(首尾双指针)
首尾双指针,可以提高在特定情况下的效率:
left
: 指向数组的起始位置。right
: 指向数组的末尾位置。- 如果
nums[left] == val
,则将nums[left]
和nums[right]
交换,并且right--
。 否则,left++
。 - 当
left > right
时,停止交换。 最终,left
的值就是k
。
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int left = 0, right = nums.size();
while (left < right) {
if (nums[left] == val) {
--right;
std::swap(nums[left], nums[right]);
} else {
++left;
}
}
return left;
}
};
这样的方法两个指针在最坏的情况下合起来只遍历了数组一次。与前面的快慢指针方法不同的是,它避免了需要保留的元素的重复赋值操作。
3.3、无耻做法
有题我不读,哎~就是玩。用一个临时数组保存不等于 val 的元素,最后再和 nums 交换。
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
vector<int> tmp;
tmp.reserve(nums.size());
int count = 0;
for (auto& num : nums) {
if (num != val) {
tmp.emplace_back(num);
++count;
}
}
tmp.swap(nums);
return count;
}
};
四、总结
本题的核心思想是利用双指针法在原地移除数组中的特定元素。通过维护两个指针,fast
和 slow
,来高效地遍历数组并实现元素的筛选和移动。
该算法的核心运作机制在于:当fast
指针找到一个不等于目标值 val
的元素时,便将其赋值给 slow
指针指向的位置,然后 slow
指针向前移动一步。这样,slow
指针之前的元素始终是不等于 val
的元素,从而保证了原地移除元素的效果。
通过这种巧妙的双指针协同工作,算法在 O(n) 的时间复杂度和 O(1) 的空间复杂度内解决了问题,实现了高效且节省空间的元素移除操作。 关键在于理解 fast
指针用来遍历所有元素,而slow
指针只在发现有效的、需要保留的元素时才移动。