1. 问题描述
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
要求必须在原数组上操作,不能拷贝额外数组,同时要尽量减少操作次数。
示例:
输入:
[0, 1, 0, 3, 12]输出:
[1, 3, 12, 0, 0]
2. 解题思路
这道题中,我们要解决的问题核心在于:如何在不破坏非零元素顺序的前提下,把零全部移到数组后面。
这里,我们采用快慢指针法。
我们定义两个指针:
快指针:负责快速向后扫描,遍历数组,寻找数组中的非零元素。
慢指针:指向待填充非零元素的位置,其左侧的所有元素都是已经排好序的非零数。
刚开始我们将两个指针都初始化为零,让他们都指向数组中第一个元素。
按照我们的快慢指针逻辑,每一轮循环快指针都会加一,也就是指向下一个元素,当快指针指向的元素为 0 时,我们不做任何处理,而只有当快指针指向非零元素时,我们将快慢指针指向的元素进行交换,然后让慢指针加一,指向下一个元素。
这样一来,慢指针的左边就是已经排好序的非零元素,直到快指针便利到数组末尾,慢指针左边就是全部的排好序的非零元素,慢指针指向的以及它右边的全部都是零元素。
为了更形象地描述这个流程,我下面画个示意图:
如下图,这是数组和快慢指针的最初状态,快慢指针都指向数组第一个元素 0。
根据我们的解题思路,第一轮循环由于快指针指向零元素,快指针将会直接跳过这个元素,指向下一个元素,而慢指针不动。如下:
第二轮循环,此时快指针指向数组第二个元素,这是非零元素 1,按照我们的思路,快慢指针的元素应该交换,并且慢指针指向数组下一个元素,然后快指针再指向下一个元素。如下:
第三轮循环,此时快指针指向 0,直接跳过,慢指针不动。如下:
第四轮循环,快指针指向非零元素,快慢指针指向的元素进行交换,然后快慢指针都指向下一个元素。如下:
第五轮循环,快指针指向非零元素,两指针指向的元素交换,慢指针指向下一个元素,此时,快指针不满足继续循环的条件,循环结束。如下:
如此,问题便解决了。下面我们编写代码。
3. 完整代码实现
class Solution {
public:
void moveZeroes(vector<int>& nums) {
for(int fast=0,slow=0;fast<nums.size();fast++)
{
if(nums[fast] != 0)
{
swap(nums[fast],nums[slow]);//交换两个元素
slow++;
}
}
}
};
我们其实还可以就这道题拓展一下,如果我们要操作的是一个很长的数组,并且这个数组的前面若干个元素均为非零元素,那么我们上面的代码其实做了很多无用功,因为它们本来就是非零元素,直接放那不动就可以的,我们却进行了交换,这样就会降低程序运行效率。
我们先来观察一下解题思路中的示意图,不难发现:当快指针指向非零元素的那一轮循环中,快指针和慢指针都会指向下一个元素。
那么解决方案就来了,既然这个长数组的前面若干个都是非零元素,我们直接加个判断,当快指针和慢指针指向同一个位置时,不进行交换,这样就避免了很多无用操作:
if (fast != slow)
{
std::swap(nums[slow], nums[fast]);
}
slow++;
4. C++相关知识点
4.1 swap原理
代码中使用了内置函数 std::swap,在 C++11 之后,std::swap 对于支持移动语义的对象非常高效。
对于 int 类型,它相当于通过一个临时变量交换两个值,如下:
tmp = a;
a = b;
b = tmp;
但对于一个支持移动语义的对象,通过 std::move,交换的是资源的所有权而非内容本身,
4.2 vector的内存连续性与性能
std::vector 的底层是连续的内存空间,也就是线性表。
由于内存连续,CPU 在读取第一个数组元素时,会将数组其他元素也加载到 L1 或 L2 Cache 中去,从而增加 Cache 命中率,进而提升元素的访问效率。
如果是 std::list链表,元素会散落在内存各处,频繁交换指针会多次导致 Cache Miss,性能远不如 vector。
从这一点看来,算法的效率不仅取决于时间复杂度,更取决于对 CPU 缓存行的利用率。
5. 总结
再回看这道题,虽然这道题很简单,但其涉及到的原理确实很常见的:
比如操作系统为了减少碎片,需要将散落在内存中的已用页框推到一侧,留出连续的大块空间。
比如在处理串口或 DMA 接收到的变长数据帧时,我们经常需要去除无效的填充字节,将有效字节前移。
最后,希望这篇文章对大家有所帮助。
本文结束