目录
零、题目描述
题目链接:移动零
题目描述:
示例 1:
输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]
示例 2:
输入: nums = [0]
输出: [0]
提示:
1 <= nums.length <= 10^4
-2^31 <= nums[i] <= 2^31 - 1
一、为什么这道题值得你花几分钟看懂?
如果你正在打磨自己的算法基础,那「移动零」这道题你一定不能错过——它是 LeetCode 第 283 题,更是双指针技巧的经典入门题,几乎所有算法入门教程都会拿它举例。
从算法能力提升来讲,这道题能帮你深刻理解双指针在原地修改数组中的核心作用,掌握「快慢指针配合」的精髓。这种技巧不仅能解决数组中的元素移动问题,还能延伸到链表操作、滑动窗口等多种场景,是很多高效算法的基础。
甚至在实际开发中,当需要对数据进行过滤、整理,又希望节省空间时,双指针的思路能帮你写出更高效、更优雅的代码。
二、题目拆解:提取其中的关键点
先看原题:
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。必须在不复制数组的情况下原地对数组进行操作。
再结合所给的代码框架和提示:
class Solution {
public:
void moveZeroes(vector<int>& nums) {
}
};
核心要素提炼:
- 输入是一个整数类型的向量
nums
,需要对其进行原地修改。 - 操作目标是将数组中的所有
0
移到末尾,同时保证非零元素的相对顺序不变。 - 不能使用额外的数组来辅助,必须在原数组上进行操作。
关键点:
- 双指针应用:用两个指针分别追踪非零元素的位置和当前遍历的位置。
- 元素交换:将非零元素移动到前面合适的位置。
- 保持顺序:确保非零元素的相对位置和原来一致。
- 原地操作:不使用额外的数组空间,只在原数组上进行修改。
三、明确思路:双指针的巧妙配合
1. 最直观的想法:先移非零,再补零
拿到题的第一反应:把所有非零元素按顺序移到数组前面,然后把剩下的位置都填上0。比如对于数组[0,1,0,3,12]
,先把非零元素1
、3
、12
依次移到前面,得到[1,3,12,...]
,再把后面的位置补成0,结果就是[1,3,12,0,0]
。
这种思路的关键是如何高效地找到非零元素应该放置的位置,这就需要用到双指针了。
2. 双指针的分工
在这里,我们可以用两个指针:
cur
指针:负责遍历整个数组,寻找非零元素,相当于“探索者”。dest
指针:负责记录下一个非零元素应该放置的位置,相当于“占位者”。
初始时,cur
从数组开头开始遍历,dest
指向-1(表示还没有确定非零元素的位置)。当cur
遇到非零元素时,就把dest
向前移动一位,然后交换cur
和dest
指向的元素。这样,dest
始终指向已经处理好的非零元素的最后一个位置。
举个例子(示例1):
初始数组:[0,1,0,3,12],cur=0,dest=-1
cur=0,元素是0,不做操作,cur++
cur=1,元素是1(非零),dest++变为0,交换nums[0]和nums[1],数组变为[1,0,0,3,12],cur++
cur=2,元素是0,不做操作,cur++
cur=3,元素是3(非零),dest++变为1,交换nums[1]和nums[3],数组变为[1,3,0,0,12],cur++
cur=4,元素是12(非零),dest++变为2,交换nums[2]和nums[4],数组变为[1,3,12,0,0],cur++
遍历结束,得到结果
3. 为什么这样可行?
因为cur
指针一直在dest
指针前面或者和它同步,dest
指针前面的元素都是已经处理好的非零元素,并且保持着原来的相对顺序。当cur
遇到非零元素时,把它交换到dest+1
的位置,就保证了非零元素按原来的顺序排列在前面,而后面剩下的位置自然都是0了。
这种方法只需要遍历一次数组,并且是原地操作,时间复杂度是O(n),空间复杂度是O(1),非常高效。
四、算法实现:双指针的代码演绎
这道题我用双指针的方法来实现,下面讲解具体的算法思路。
核心逻辑:
用cur
指针遍历数组,当遇到非零元素时,将dest
指针向前移动一位,然后交换cur
和dest
指向的元素,这样就能把非零元素逐步移到前面,零自然就到后面去了。
步骤拆解:
- 初始化
cur
为0,dest
为-1。 - 用
cur
遍历数组中的每个元素:- 若
nums[cur]
是非零元素:- 将
dest
向前移动一位(dest++
)。 - 交换
nums[cur]
和nums[dest]
。
- 将
cur
向前移动一位(cur++
)。
- 若
- 遍历结束后,数组中的所有零都被移到了末尾,非零元素保持相对顺序不变。
双指针细节:
cur
指针:从0开始,依次遍历数组的每个元素,直到数组末尾。它的作用是发现非零元素。dest
指针:初始值为-1,表示还没有非零元素被放置。每当cur
找到一个非零元素,dest
就向前移动一位,指向这个非零元素应该放置的位置。- 交换操作:当
cur
找到非零元素时,交换nums[cur]
和nums[dest]
,这样非零元素就被放到了前面合适的位置。 - 遍历顺序:
cur
始终从左到右遍历,保证了非零元素的相对顺序不变。
五、C++代码实现:一步步拆解
完整代码(附详细注释):
#include <vector>
using namespace std;
class Solution {
public:
void moveZeroes(vector<int>& nums) {
int n = nums.size();
// cur用于遍历数组,dest用于记录非零元素应放的位置
for (int cur = 0, dest = -1; cur < nums.size(); cur++) {
if (nums[cur] != 0) { // 遇到非零元素
dest++; // dest移动到下一个位置
swap(nums[dest], nums[cur]); // 交换元素,将非零元素放到前面
}
}
}
};
代码拆解
1. 变量初始化
int n = nums.size(); // 获取数组长度,虽然这里没有直接使用,但可以用于后续扩展
int cur = 0; // 遍历指针,从数组开头开始
int dest = -1; // 目标指针,初始为-1,表示还没有非零元素被放置
作用:cur
负责遍历整个数组,dest
负责跟踪非零元素应该放置的位置,初始状态下还没有非零元素被处理,所以dest
为-1。
2. 主循环逻辑
for (int cur = 0, dest = -1; cur < nums.size(); cur++) {
// 循环条件:cur遍历完数组的所有元素
if (nums[cur] != 0) { // 当遇到非零元素时
dest++; // 目标位置向前移动一位
swap(nums[dest], nums[cur]); // 交换元素,将非零元素放到目标位置
}
// 如果是零元素,则不做处理,cur继续向前移动
}
核心设计:
- 遍历机制:
cur
从0开始,每次循环后自增1,直到遍历完整个数组,确保每个元素都被检查。 - 非零元素处理:当
nums[cur]
不是0时,说明找到了一个需要放到前面的元素。此时dest
先自增1,指向当前应该放置非零元素的位置,然后交换nums[cur]
和nums[dest]
,这样非零元素就被放到了前面。 - 零元素处理:当
nums[cur]
是0时,不做任何操作,cur
继续向前移动,零元素就被留在了原地,最终会被后面的非零元素交换到后面去。
示例走读(示例1:nums = [0,1,0,3,12])
步骤 | cur | dest | nums[cur] | 操作 | 数组状态 |
---|---|---|---|---|---|
初始 | 0 | -1 | 0 | 不操作,cur++ | [0,1,0,3,12] |
1 | 1 | -1 | 1 | dest++变为0,交换nums[1]和nums[0],cur++ | [1,0,0,3,12] |
2 | 2 | 0 | 0 | 不操作,cur++ | [1,0,0,3,12] |
3 | 3 | 0 | 3 | dest++变为1,交换nums[3]和nums[1],cur++ | [1,3,0,0,12] |
4 | 4 | 1 | 12 | dest++变为2,交换nums[4]和nums[2],cur++ | [1,3,12,0,0] |
结束 | 5(退出循环) | 2 | - | - | [1,3,12,0,0] |
通过这个过程可以清晰地看到,非零元素1、3、12依次被放到了数组前面,零元素被移到了后面,并且非零元素的相对顺序保持不变。
时间复杂度和空间复杂度
复杂度类型 | 具体值 | 说明 |
---|---|---|
时间复杂度 | O(n) | n是数组的长度,cur 指针遍历整个数组一次,每个元素最多被交换一次 |
空间复杂度 | O(1) | 只使用了两个额外的指针变量,没有使用额外的数组或其他数据结构 |
这种算法在时间和空间上都非常高效,符合题目的要求。
六、实现过程中的坑点总结
dest
指针的初始值
错误写法:for (int cur = 0, dest = 0; cur < nums.size(); cur++) { // ... }
问题:如果
dest
初始化为0,当数组的第一个元素是非零元素时,会把它和自己交换,虽然结果正确,但做了无用功。更重要的是,如果数组全是零元素,可能会出现不必要的操作。
正确做法:初始化为-1,让第一个非零元素能正确地放到索引0的位置。忘记交换元素
错误写法:if (nums[cur] != 0) { dest++; nums[dest] = nums[cur]; // 直接赋值,没有处理原来的位置 }
问题:这样会导致原来
dest
位置的元素被覆盖,而cur
位置的非零元素没有被清除,可能会留下重复的值。
正确做法:使用swap
函数交换两个位置的元素,确保非零元素移动的同时,零元素被换到后面。遍历顺序错误
错误写法:for (int cur = nums.size() - 1; cur >= 0; cur--) { // ... }
问题:从后往前遍历会打乱非零元素的相对顺序,无法保证移动后非零元素的顺序和原来一致。
正确做法:cur
指针必须从左到右遍历数组,才能保证非零元素的相对顺序不变。处理空数组或只有一个元素的情况
虽然代码中没有专门处理,但当前的代码逻辑对这些情况是兼容的:- 如果数组为空,循环不会执行,直接返回。
- 如果数组只有一个元素,无论是0还是非零,循环执行一次后都会得到正确的结果。
不必要的交换
当cur
和dest
相等时(比如数组开头都是非零元素),交换操作是不必要的。可以添加一个判断来优化:if (nums[cur] != 0) { dest++; if (cur != dest) { // 只有当位置不同时才交换 swap(nums[dest], nums[cur]); } }
这样可以减少一些无用的交换操作,提高代码效率。
七、举一反三
学会这道题的双指针技巧,你能解决很多类似的数组操作问题:
- LeetCode 27. 移除元素:给定一个值,移除数组中所有等于这个值的元素,思路是用双指针将不等于该值的元素移到前面。
- LeetCode 26. 移除重复元素:删除有序数组中的重复项,让每个元素只出现一次,用双指针可以高效地实现。
- LeetCode 80. 删除有序数组中的重复项 II:允许元素最多出现两次,双指针的思路可以扩展应用。
明天我们一起讨论LeetCode 1089. 复写零这道题有兴趣的朋友可以提前思考下
双指针技巧在数组和链表操作中非常常见,掌握它能让你在处理元素移动、删除、查找等问题时更加得心应手。
最后,欢迎大家在评论区分享自己的代码和思路,一起交流学习~ 如果你有更巧妙的方法,也请不吝赐教,我会认真学习并回复的!
这是今天的封面原图: