算法解析——双指针算法(1):数据划分

欢迎来到博主的专栏:算法解析
博主ID:代码小豪


什么是数据划分?

在一个无序数组中,让数组中的数据以某些性质分布在数组当中,这种操作称为数据划分。(当然,这是博主自己起的名字哈哈--!)

使用双指针进行数组划分的最典型的例子,就是快速排序了。在快速排序中,需要在数组内部找到一个key值,然后让key值的左边,全都小于等于key值,让key值的右边,全都大于等于key值。

这意味着,这个数组的数据被划分成了两部分,一部分是小于等于key值,而另一部分大于key值,假设key值的下标为k,那么数组当中[0,k)的元素小于key,[k+1,n)的元素大于key。这就是所谓的数据划分。

那么快速排序又是如何完成数据划分的呢?
(1)令第一个元素为key值
(2)定义两个指针,一个left,指向数组的起始位置,一个是right,指向数组的末尾。
(3)让left向右移动,找到比key大的值,让right向左移动,找到比key小的值,然后交换两者的元素
(4)如果left和right相遇,说明数据已经划分完了,让key值与left和right相交的位置交换数据。

在这里插入图片描述
通过left,right双指针来维护数组的区域,使得key的左边保持值小于key,key的右边保持值大于key。

当然,快速排序的具体实现和上图有所偏差,具体细节博主不多赘述,毕竟当前讲的是双指针算法而非快速排序。

在left和right移动的过程中,我们可以将区域分为三种。
在这里插入图片描述

[0,left):由于left遇到大于key的值就会停止,因此[0,left)的数据均小于key
[right,end):由于right遇到小于key的值就会停止,因此从right到数组结束的末尾,数据均大于key
而[left,right]区域中的数据分布无法分析

如上上图:当数据交换完成后,left会继续向右移动,right也会继续向左移动,那么[left,right]这片未处理的区间就会越来越小,在经历多次交换的操作后,未处理区域消失。当这片区域消失后,也就完成了数据划分,即key的左边小于key,key的右边大于key。

leetcode283——移动0

实践是检验真理的唯一标准,我们来尝试通过这道OJ题来提升我们对双指针的理解。
在这里插入图片描述
这道题关于数据划分的区域很明显,其实就是将原数组的数据分为两部分。前边存储非0的元素,后边存储0。对于数据区域划分,我们通常使用双指针,参考快速排序,我们在数组的最左边设置一个left,在数组的右边设置一个right(注意left和right是广义上指针,即数组下标,指针,以及迭代器)。

(1)让left向左遍历,找到0元素停止。
(2)让right向右遍历,找到非0元素
(3)交换数据,并且重复(1)(2)操作,直到left和right相遇

class Solution {
    public:
        void moveZeroes(vector<int>& nums) {
            int left = 0;//左指针
            int right = nums.size() - 1;//右指针

            while (left < right)//当左右指针未相遇
            {
                while (left<right&&nums[left] != 0)
                    left++;//left向右找为0的数据
                while (left<right&&nums[right] == 0)
                    right--;//right向左找为0的数据

                //当循环停止时会出现以下两种情况
                //1.left指针,找到了0数据,right指针,找到了非0的数据
                //2.left和right相遇
                swap(nums[left], nums[right]);//此时交换两者数据,不断循环上述操作,完成数据划分
            }
        }
    };

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
通过左右指针来划分数据,成功的让数组的左边均非0元素,让数组的右边均0元素。
在这里插入图片描述
如果我们将这段代码提交,leetcode会狠狠的给我们挂红,因为题目里要求了,非0元素必须保持有序。即结果应该是
在这里插入图片描述
这是因为,如果用左右指针来进行数据划分,left找到的是靠前位置的数据,而right找到的是靠后位置的数据,如此交换,那么靠后的数据就会移动到靠前的位置,因此顺序就被颠倒了。

难道这道题就不能使用双指针了吗?别急,我们只是左右指针的算法不能行,双指针还有另外一种形式,即前后指针算法。

我们设一个指针为后指针next,指向数组的起点,那么既然被称为前指针,那么指针必然要在next指针之前,因此将前指针放在数组起点前一个位置,命名为prev。
在这里插入图片描述
其实用双指针进行数据划分时,我们最需要考虑的问题就是,数组中的区域的性质是什么,在左右指针来划分数据时这一点就很清楚,即[0,left)数据非0,[left,right],数据未处理,(right,end)数据为0,那么前后指针的数据划分应该是怎样的?

根据题目的要求,前部分的数据非0,而后部分的数据为0.因此前后指针应该将数据划分成如下区域在这里插入图片描述

我们根据结果来推过程,由于前后指针只能向后运动,那么在运动的过程中,数据区域应该被划分成如下情况:

既然我们已经清楚了划分区域的性质,那么接下来要做的,就是划分区域的算法,首先,从图中可以看出一点,如果数组中的0越多,(prev,next)之间的距离就越大。因此我们可以让
(1)如果next遇到0元素,next++,prev保持不变。
(2)如果next遇到非0元素,prev++。
在这里插入图片描述

由于(prev,next)之间的元素均为0,因此prev的下一个元素必定为0,除非prev+1==next。所以如果prev向后移动了一步,此时[0,prev]区域就出现了一个0,因为prev位置上就是一个0数据。而next脚下的数据不为0,因为next脚下的数据为0的话,那么next将会继续向后移动,prev保持不变,因此,如果prev移动了,那么一定是next遇到了非0元素,此时我们就让prev与next的数据交换
(3)如果next遇到了非0元素,prev++,与next交换数据,next++。

void moveZeroes(vector<int>& nums) {
    int prev = -1;//前指针
    int next = 0;//后指针
    while (next < nums.size())
    {
        if (nums[next] != 0) {//如果next遇到了非0元素
            prev++;//prev++
            swap(nums[prev], nums[next]);//与next交换元素
        }
        next++;//无论next有没有遇到非0元素,next都要++
    }
}

在这里插入图片描述
我们可以发现,前后指针能够保持非0部分的数据是顺序的。即
在这里插入图片描述
左右指针不能保持顺序的原因很简单,因为右指针查找数据是从后往前,而左指针是从前往后,因此,出现数据交换时,会让靠后的数据来到前端,破坏顺序

而前后指针无论是前指针,还是后指针,都是从前往后遍历,如果发生交换,那便是将后指针的非0数据,与前指针的0数据发生交换,双指针中间都是0数据,所以不会导致越位。因而可以保持前端的数据保持原有的数据。

leetcode2089——复写0

在这里插入图片描述
解法1,暴力解法
注意题干中的一个叙述,将数组中出现的每个0都复写一遍,并将其余元素向右平移。
那么我们遍历整个数组,如果出现0,就执行以下操作
(1)将后面的数据全都往后移动一位,并删除最后一个元素’
(2)将下一个数替换成0.
在这里插入图片描述

class Solution {//leetcode1089——复写0
public:
    void duplicateZeros(vector<int>& arr) {
        for (int i = 0; i < arr.size(); i++)
        {
            if (arr[i] == 0) {//遇到0了,就要复写
                arr.push_back(0);//因为挪动数据需要扩容,因此先往容器中尾插一个值
                int n = arr.size() - 1;//挪动数据
                while (n - 1 >= i)
                {
                    arr[n] = arr[n - 1];
                    n--;
                }
                arr.pop_back();//删除最后一位多于的数据
                i++;
            }
        }
    }
};

如果你对c++的STL特别了解,可以使用这种方式
(1)使用find()函数找到0,并返回该位置的迭代器
(2)在该迭代器位置之后,使用insert()函数插入一个0
(3)push_back()删除末尾元素

class Solution {//leetcode1089——复写0
public:
    void duplicateZeros(vector<int>& arr) {
        vector<int>::iterator vit = find(arr.begin(), arr.end(), 0);//查找0在那个位置
        while (vit != arr.end())
        {
            vit = arr.insert(vit + 1, 0);//复写一个0
            arr.pop_back();
            vit = find(vit + 1, arr.end(), 0);//在剩余区间中找到0
        }
    }
};

这段代码显然比上一段简短多了,但是对于这道题,这两段代码都不是最优代码。为什么?
因为他们的时间复杂度高达O(N^2)。如果我们使用双指针算法。可以将时间复杂度打到O(N)。

我们先来思考双指针该怎么用。又是如何划分数据的呢?

我们创建一个和arr一致的数组,设一个指针为src,指向原数组,一个指针为dest,指向拷贝的数组。
(1)让src++,如果遇到非0元素,就让dest复制这个元素,dest++
(2)如果src遇到0,就让dest复制0,然后dest++,再复制0,再dest++。
(3)当dest来到末尾时,停止复制操作

在这里插入图片描述
但是这和数据划分有什么关系呢?这明明只是拷贝数据罢了,别急,大家注意一下题干,里面写着:对输入的数组就地进行修改。而这个方法是异地修改,显然不合题意。难道这道题不能用双指针吗?大家别急,如果不能用,博主也不会写进博客了。

我们还是按照上面的方法,但是这次不一样了,我们不异地复写0.我们就地修改数组。步骤还是一样。
(1)让src++,如果遇到非0元素,就让dest复制这个元素,dest++
(2)如果src遇到0,就让dest复制0,然后dest++,再复制0,再dest++。
(3)当dest来到末尾时,停止复制操作
在这里插入图片描述
嘶,这么一看,就地修改用双指针也不行啊,别急,从前往后修改不对,那么我们可以试试用双指针,从后向前修改。

这次,我们依旧让src和dest指向开头的元素,只是这次,双指针只移动,不修改。
(1)让src++,如果遇到非0元素,dest++
(2)如果src遇到0,dest+=。
(3)如果dest超出边界,停止复制操作
在这里插入图片描述
但是这和数据划分有什么关系吗?我们直接来看最后一帧的画面
在这里插入图片描述
将上图与下图,异地修改数据的图像进行对比
在这里插入图片描述
可以发现,我们这次是将数据划分成了两部分,一部分是[0,src],另一部分是[0,dest]。那么这两部分数据是什么关系呢?
[0,src]是异地修改数据的原数组中被拷贝的部分。而[0,dest]是异地修改数据拷贝到达目标位置。

那么找到这两个关系到底有什么用呢?大家想想,就地修改数据为什么不能从前往后拷贝呢?是不是会因为0会覆盖后续数据,导致拷贝出错,因此,我们应该换一种思路,那就是从后向前拷贝,即
在这里插入图片描述
可以发现,如果从后往前修改数据,是不会造成覆盖原有数据的问题的。因此这道OJ使用双指针的真正解法是:
找到最后一个待拷贝的数,然后从后往前拷贝,即可完成就地修改。

但是难题也就是在此,即如何找到最后一个拷贝的数据呢?此时,这里就体现了双指针进行数据划分了。

我们让src和dest从头开始,只移动,不修改,于是原数组被划分成了两部分,一部分是[0,src]这里代表被拷贝的数据区间,另一部分就是[0,dest]这里代表的是复写0之后的数组。换句话说,我们只是先模拟异地拷贝是怎么样的,来推断最后一个拷贝的数据在哪,然后再从后向前就地拷贝。即可完成复写0的操作
在这里插入图片描述
其实还会出现一种越界的情况,那就是
在这里插入图片描述

在这里插入图片描述

如果出现这种情况,那么就让src往后退一步,dest-1=0,并且dest-=2。然后继续从右往左拷贝即可。
在这里插入图片描述

代码如下:

class Solution {
public:
    void duplicateZeros(vector<int>& arr) {//leetcode1089——复写0
        int src = -1;
        int dest = -1;
        while (dest < int(arr.size() - 1)) {//模拟异地修改数据
            src++;
            if (arr[src] != 0) {
                dest++;
            }
            else {
                dest += 2;
            }
        }
        if (dest >= arr.size()) {//出现越界的情况
            arr[dest - 1] = 0;
            dest -= 2;
            src--;
        }

        while (src < dest)//从后,向前拷贝
        {
            if (arr[src] != 0)
                arr[dest--] = arr[src--];
            else {
                arr[dest--] = 0;
                arr[dest--] = 0;
                src--;
            }
        }
    }
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

代码小豪

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值