移动零(Move Zeroes)
题目描述
给定一个数组 nums,编写一个函数将所有 0移动到数组的末尾,同时保持非零元素的相对顺序。
要求:
- 必须在原数组上操作,不能拷贝额外的数组
- 尽量减少操作次数
示例:
输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]
解题思路分析
首先考虑暴力解法,很容易想到
方法一:暴力法
遍历数组,遇到0时将其与后面的非零元素交换,直到所有0都移到末尾。
时间复杂度:O(n²)
空间复杂度:O(1)
#include<iostream>
#include<vector>
using namespace std;
int main(){
int nums[5] = {0,1,0,3,12};
//暴力解法
for(int i = 0; i < 5; i++){
if(nums[i] == 0){
for(int j = i+1; j < 5; j++){
if(nums[j] != 0){
swap(nums[i],nums[j]);
break;
}
}
}
}
//输出
for(int i=0;i<nums.size();i++){
cout<<nums[i]<<" ";
}
}
发现暴力解法是符合双指针优化条件的,使用双指针模板即可解决问题
方法二:双指针法
使用快慢指针,快指针遍历所有元素,慢指针记录非零元素应该放置的位置。
时间复杂度:O(n)
空间复杂度:O(1)
#include<iostream>
#include<vector>
using namespace std;
int main()
{
vector<int> nums = {0,1,0,3,12};
//双指针做法
int slow=0;//慢指针,指向第一个零的位置(如果有)
int fast=0;//快指针,指向当前位置
while(fast<nums.size()){//遍历数组
if(nums[fast]!=0){//快指针的元素不为零
swap(nums[slow],nums[fast]);//快指针的元素和慢指针的元素交换位置
slow++;//慢指针后移
}
fast++;//快指针后移
}
//输出
for(int i=0;i<nums.size();i++){
cout<<nums[i]<<" ";
}
}
方法三:其实也可以理解成这道题双指针的另一种写法,非适用性模板思路
用k来记录0的个数
时间复杂度:O(n)
空间复杂度:O(1)
#include<iostream>
#include<vector>
using namespace std;
int main(){
vector<int> nums = {0,1,0,3,12};
int k=0;//有几个不是0的数
for(int i=0;i<nums.size();i++){
if(nums[i]!=0){
nums[k]=nums[i];
k++;
}
}
for(int j=k;j<nums.size();j++){
nums[j]=0;
}
//输出
for(int i=0;i<nums.size();i++){
cout<<nums[i]<<" ";
}
}
双指针算法通用模板
1. 双指针优化条件
当遇到暴力解法形式为:
for(int i = 0; i < n; i++){
for(int j = i+1; j < n; j++){
// ...
}
}
且内层循环存在重复计算时,大多可以通过双指针优化,将时间复杂度从 O(n²) 降低到 O(n)。
2. 通用双指针模板
for (int i = 0, j = 0; i < n; i ++ )
{
while (j < i && check(i, j)) j ++ ;
// 具体问题的逻辑
}
模板说明:
- i 指针:通常作为快指针,遍历整个序列
- j 指针:通常作为慢指针,维护满足条件的区间左边界
- check(i, j) :判断当前区间 [j, i] 是否满足特定条件
- while 循环:调整 j 指针的位置,直到区间满足条件
3. 常见问题分类
(1) 对于一个序列,用两个指针维护一段区间
- 典型问题:最长连续不重复子序列、最小覆盖子串、滑动窗口最大值
- 应用:本题"移动零"属于此类,用快慢指针维护非零元素区间
(2) 对于两个序列,维护某种次序
- 典型问题:归并排序中合并两个有序序列、判断子序列
- 应用:两个指针分别遍历两个序列,按特定顺序处理
4. 移动零问题的模板应用
在移动零问题中,我们可以将双指针模板具体化为:
for (int fast = 0, slow = 0; fast < n; fast ++ )
{
// 相当于 check 条件:如果当前元素非零,需要交换
if (nums[fast] != 0) {
// 调整 slow 指针:先交换,后移动
swap(nums[slow], nums[fast]);
slow++;
}
// 不需要 while 循环调整,因为 slow 指针的移动是条件性的
}
核心思想与关键问题解答
1. 为什么慢指针能指向第一个零的位置(方法二)?
关键原因:
- 指针移动规则:快指针每次循环都移动,慢指针只在交换非零元素后移动
- 遇到零时:慢指针不动,零自然留在慢指针位置
- 遇到非零时:交换后慢指针右移,非零元素被"搬运"到前面
- 数学归纳:循环过程中,慢指针左边都是已处理的非零元素,慢指针位置指向第一个零(如果有)
直观理解:
数组分区:[非零区域] | [零区域] | [未处理区域]
slow fast
- slow左边:已处理完成的非零元素
- slow位置:指向第一个零(或未处理区域起点)
- fast右边:还未处理的元素
2. 双指针优化的本质
结合双指针优化的通用思想,我们可以从更抽象的角度理解本题的双指针设计:
双指针优化的本质:
- 消除重复计算:窗口滑动时,大部分计算可以复用上次的结果。在本题中,我们通过一次遍历完成非零元素的搬运和零的定位,避免了暴力法中重复交换的过程。
- 利用单调性:指针只向右移动,不会向左回退。本题中,快指针
fast始终向前扫描,慢指针slow也只会在遇到非零元素时向前推进,保证了整体时间复杂度控制在 O(n)。 - 空间换时间:虽然我们使用了两个指针,但只用了常数级额外空间,通过巧妙的指针移动策略,将原本 O(n²) 的时间复杂度优化至 O(n)。
适用双指针优化的判断标准:
- 问题具有“窗口”特性:本题可以看作是在数组中维护一个“非零区域”的窗口,快指针不断扩展窗口,慢指针收缩窗口。
- 指针移动具有单调性:两个指针都只向右滑动,不会向左回退,这保证了算法的效率。
- 存在重叠子区间:在处理数组时,前后区间存在大量重复元素,双指针通过跳过这些重复部分来缩短查询时间。
3. 双指针优化的使用情形
在求解某段时间区间内的数量时,前一区间和后一区间存在大量重复部分。我们要做的就是利用这部分来缩短查询时间。
判断方法(针对本题):
- 窗口特性:本题可以视为一个“滑动窗口”,窗口内维护非零元素,窗口外为零元素。
- 单调性:指针只向右移动,不会回退,符合双指针优化的基本条件。
- 重复计算:通过慢指针记录已处理非零元素的位置,避免了对已处理区域的重复扫描。
典型应用场景:
- 滑动窗口最大值:在数组中寻找满足某种条件的连续子区间。
- 求子数组的和、最大、最小值:如和为连续正整数序列、最长不重复子串等。
- 处理数组中的连续子序列:如本题,将所有0移动到末尾,保持非零元素相对顺序。
算法流程详解
模拟执行过程(nums = [0,1,0,3,12])
| 步骤 | fast | nums[fast] | slow | 操作 | 数组状态 | 说明 |
|---|---|---|---|---|---|---|
| 初始 | 0 | - | 0 | - | [0,1,0,3,12] | 初始化 |
| 1 | 0 | 0 | 0 | 跳过 | [0,1,0,3,12] | 遇到0,只移动fast |
| 2 | 1 | 1 | 0 | 交换 | [1,0,0,3,12] | 非0,交换后slow++ |
| 3 | 2 | 0 | 1 | 跳过 | [1,0,0,3,12] | 遇到0,只移动fast |
| 4 | 3 | 3 | 1 | 交换 | [1,3,0,0,12] | 非0,交换后slow++ |
| 5 | 4 | 12 | 2 | 交换 | [1,3,12,0,0] | 非0,交换后slow++ |
最终输出:[1,3,12,0,0]