Leetcode 第四题:用C++解决移动零问题

0 阅读5分钟

1. 问题描述

给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序

要求必须在原数组上操作,不能拷贝额外数组,同时要尽量减少操作次数。

示例:

输入: [0, 1, 0, 3, 12]

输出: [1, 3, 12, 0, 0]


2. 解题思路

这道题中,我们要解决的问题核心在于:如何在不破坏非零元素顺序的前提下,把零全部移到数组后面。

这里,我们采用快慢指针法。

我们定义两个指针:

快指针:负责快速向后扫描,遍历数组,寻找数组中的非零元素。

慢指针:指向待填充非零元素的位置,其左侧的所有元素都是已经排好序的非零数。

刚开始我们将两个指针都初始化为零,让他们都指向数组中第一个元素。

按照我们的快慢指针逻辑,每一轮循环快指针都会加一,也就是指向下一个元素,当快指针指向的元素为 0 时,我们不做任何处理,而只有当快指针指向非零元素时,我们将快慢指针指向的元素进行交换,然后让慢指针加一,指向下一个元素。

这样一来,慢指针的左边就是已经排好序的非零元素,直到快指针便利到数组末尾,慢指针左边就是全部的排好序的非零元素,慢指针指向的以及它右边的全部都是零元素。

为了更形象地描述这个流程,我下面画个示意图:

如下图,这是数组和快慢指针的最初状态,快慢指针都指向数组第一个元素 0。

1. 流程图(1).png

根据我们的解题思路,第一轮循环由于快指针指向零元素,快指针将会直接跳过这个元素,指向下一个元素,而慢指针不动。如下:

2. 流程图(2).png

第二轮循环,此时快指针指向数组第二个元素,这是非零元素 1,按照我们的思路,快慢指针的元素应该交换,并且慢指针指向数组下一个元素,然后快指针再指向下一个元素。如下:

3. 流程图(3).png

第三轮循环,此时快指针指向 0,直接跳过,慢指针不动。如下:

4. 流程图(4).png

第四轮循环,快指针指向非零元素,快慢指针指向的元素进行交换,然后快慢指针都指向下一个元素。如下:

5. 流程图(5).png

第五轮循环,快指针指向非零元素,两指针指向的元素交换,慢指针指向下一个元素,此时,快指针不满足继续循环的条件,循环结束。如下:

6. 流程图(6).png

如此,问题便解决了。下面我们编写代码。


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 接收到的变长数据帧时,我们经常需要去除无效的填充字节,将有效字节前移。

最后,希望这篇文章对大家有所帮助。


本文结束