双指针算法第一弹(移动零 复写零 快乐数)

目录

前言

1. 移动零

(1)题目及示例

(2)一般思路

(3)双指针解法

2. 复写零

(1)题目及示例

(2)一般解法

(3)双指针解法

3. 快乐数

(1)题目及示例

  (2) 题目分析及思路

(3) 证明

总结


前言

本文是讲解三道双指针相关的OJ题目,我会慢慢深入,一般的题目从暴力解法讲起,再进行优化,使用双指针。本文附有详细的图文示例,干货多多。


1. 移动零

(1)题目及示例

题目:给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。请注意 ,必须在不复制数组的情况下原地对数组进行操作。

示例 1:

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

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

示例 2:

输入: nums = [0]

输出: [0]

(2)一般思路

这个题目的要求是把所有的零放在后面,并且非零元素的顺序保持不变。

正常来说,我们可以通过开辟一个新数组,将非零元素按照顺序拷贝下来,后面剩下的元素置为0,再拷贝回去。因为只需要遍历几遍数组,时间复杂度是O(N),需要新开辟一个空间大小相同的数组,空间复杂度也是O(N)。

下面是解题代码,其中需要注意的是,创建好vector类tmp对象,需要改变容器的包含元素的个数,即改变size的大小,可以减少频繁扩容带来的消耗。

void moveZeroes(vector<int>& nums)
{
    vector<int> tmp;
    int n = nums.size();
    tmp.resize(n);

    for (int i = 0, j = 0; j < n; )
    {
        if (i < n)
        {
            if (nums[i] != 0)
                tmp[j++] = nums[i++];
            else
                ++i;
        }
        else
            tmp[j++] = 0;
    }
    
    for (int i = 0; i < n; i++)
        nums[i] = tmp[i];
}

(3)双指针解法

如果想要在数组原地进行修改,就要使用双指针算法。那么何为双指针呢?

  • 双指针算法顾名思义是使用两个指针的算法,但是不仅于此。我们还可以使用下标来代替指针,其中的内核是类似两个指针一起移动来记录信息的思路。双指针算法可以用来解决划分区间的题目。
  • 观察示例中输出的数组,这些数组被划分为两个区域,非零和只有零的区域。我们创建两个整型变量,名为dest和cur,表示数组元素的下标,通过这两个变量来划分区域。
  • 我们要做的是,将cur向后移动,如果碰到0,继续移动,如果碰到非0的数字,dest向前移动一格,然后交换这两个变量所指向元素的值。不断重复这个过程,直到cur指向最后一个元素的下一个位置,就结束了。
  • 以数组{0,1,0,3,5}为例,一开始dest赋值为-1,cur赋值为0。dest一开始没有指向数组,cur指向第一个元素。

  • cur往后移动,遇到非零元素,dest先往后移动一步,然后交换元素内容。

  • cur继续往后走,遇到零,不停下来。再往后走遇到3,非零元素。dest往后走一步,并交换两个变量所指元素内容。

  • cur继续往后走,遇到非零元素。dest完后走一步,再次交换元素内容。最后,cur走到末尾元素的下一个位置时,就结束这个操作。此时你会发现,非0元素全部排在前面,0元素排在后面,而dest和cur就划分出了这两个区域。

代码如下,创建两个整型变量,使用for循环,循环继续条件是cur变量的值小于数组元素个数大小,即指向最后一个元素的下标。当遇到非零元素,dest先加加,然后再交换各自指向的元素。

void moveZeroes(vector<int>& nums) 
{
    int cur, dest;
    for (cur = 0, dest = -1; cur < nums.size(); cur++)
        if (nums[cur] != 0)
            swap(nums[++dest], nums[cur]);

}

2. 复写零

(1)题目及示例

题目:给你一个长度固定的整数数组 arr ,请你将该数组中出现的每个零都复写一遍,并将其余的元素向右平移。注意:请不要在超过该数组长度的位置写入元素。请对输入的数组 就地 进行上述修改,不要从函数返回任何东西。

示例 1:

输入:arr = [1,0,2,3,0,4,5,0]

输出:[1,0,0,2,3,0,0,4]

解释:调用函数后,输入的数组将被修改为:[1,0,0,2,3,0,0,4]

示例 2:

输入:arr = [1,2,3]

输出:[1,2,3]

解释:调用函数后,输入的数组将被修改为:[1,2,3]

(2)一般解法

  • 如果使用额外的空间,再创建一个相同大小空间的临时数组,并将原数组元素全部拷贝过来。然后,使用两个整型变量表示两个数组的下标索引,临时数组遇到非零正常拷贝,遇到0,拷贝两个下来。
  • 代码如下,只需要遍历一遍数组,时间复杂度是O(N),但是消耗了原数组相同元素的空间,空间复杂度也是O(N)。
void duplicateZeros(vector<int>& arr)
{
    int n = arr.size();
    vector<int> tmp(arr);

    for (int i = 0, j = 0; j < n; i++)
    {
        if (tmp[i] != 0)
        {
            arr[j++] = tmp[i];
        }
        else
        {
            if (j + 1 < n)
                arr[j] = arr[j + 1] = 0;
            else
                arr[j] = 0;

            j += 2;
        }
    }
}

(3)双指针解法

这道题不允许开辟一个新的数组,必须就地修改。不过这道题也是关于处理区域划分的问题,可以使用双指针。

  • 按照第一道题的方法,创建两个表示下标的变量,往后移动划分区域,遇到0元素,往后再添加一个0,但是会覆盖后面的元素,需要临时变量存储,但是不直到需要几个临时变量,所以不能按照第一道题的方法。
  • 不过可以先使用类似第一题的双指针解法找到原数组填充完0后的最后一个数字,再从后往前使用双指针解法填充数组,这样就不会有被覆盖的风险。

  • 解法操作:先定义两个整型变量dest和cur,分别赋值为-1和0,代表首元素前一个位置的下标和首元素的下标。判断cur下标的元素是否为0,如果为不为0,dest变量就加一,如果为0,dest下标加二,表示填充两个零。不管dest加多少,cur只能加一,表示向后移动一个位置。
  • 在下面图示中,我们以数组[1,0,2,3,0,4,5,0]为例。一开始cur下标的元素不为0,dest加一,cur也加一。dest等于0,cur等于1。然后,此时cur下标的元素为0,dest加二,cur加一,都指向2这个元素。

  • 如上图,结束的条件是dest的值大于等于末尾元素下标值。此时,cur指向的数字就是扩充后数组的最后一个元素。我们仿照第一题的方法从后往前移动两个变量,这样就不会有前面元素覆盖后面元素的问题。
  • 解法操作:我们先判断cur下标的元素是否为0,如果不为0,dest指向的元素值赋值为cur指向的元素,dest减一;如果为0,dest指向的元素和前一个元素,都赋值为0,dest减二。不管dest如何变化,cur都要减一。重复这个操作,直到cur的值变为首元素下标的0时,才停止。
  • 根据解法操作,先判断cur指向元素,不是0,dest指向的元素赋值为4,dest减一,cur也减一

  • 下面是的图示过程,都是按照解法操作,进行赋值,对变量进行加加,在这里体现为向左移动。当

  • 不过需要注意的是,如果你按上面的解法操作写出代码并提交,会有上图堆栈溢出的报错,说明越界访问。这是为什么呢?
  • 我们将上面示例的数组中的4改成0,即是[1,0,2,3,0,0,5,0],然后进行第一次双指针算法,找到扩充后的最后一个数字。但是dest指向末尾元素的下一个位置,这是为何?
  • 0元素要乘于两倍,所以不过如何最后0出现偶数次。我们这里的总数是偶数,但是包含在内的非0元素有奇数个,奇数加上偶数还是奇数,并且刚好0元素出现在扩充数组后的最后一个位置上,所以dest指向的不是最后一个元素。同理,当总数是奇数个,0元素一定是偶数,如果非0元素也是偶数个时,dest也会指向末尾元素的下一个位置。

  • 如果不对这个情况进行处理,按照之前的从后往前移动进行操作,你会发现首元素放在越界的位置,此时下标为-1。

  • 如果要避免这个情况,需要在第一次双指针操作之后,就让dest减二,让它指向倒数第二个元素,并且最后一个元素位置赋值为0,cur也减一,指向前一个元素。

如果看完上面的图示,充分理解之后,写出代码不是困难的事情。

    void duplicateZeros(vector<int>& arr) 
    {
        int cur1 = 0, dest1 = -1;
        
        //找到修改完后数组的最后一个元素的位置
        while (cur1 < arr.size())
        {
            if (arr[cur1] == 0)
                dest1 += 2;
            else
                ++dest1;

            //当dest1大于等于末尾元素下标,就终止循环
            if (dest1 >= arr.size() - 1 )
                break;

            ++cur1;
        }

        //处理dest指向末尾元素的后一个位置的情况
        if (dest1 == arr.size())
        {
            arr[arr.size() - 1] = 0;
            cur1--;
            dest1 -= 2;
        }
        
        //第二次双指针操作,从后往前修改数组
        while (cur1 >= 0)
        {
            if(arr[cur1] != 0)
            {
                arr[dest1--] = arr[cur1];
            }
            else
            {
                arr[dest1] = arr[dest1 - 1] = 0;
                dest1 -= 2;
            }
            --cur1;
        }
    }

3. 快乐数

(1)题目及示例

题目:编写一个算法来判断一个数 n 是不是快乐数。

「快乐数」 定义为:

  • 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
  • 然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
  • 如果这个过程 结果为 1,那么这个数就是快乐数。

如果 n快乐数 就返回 true ;不是,则返回 false

示例 1:

输入:n = 19

输出:true

解释:

1^{2}+9^{2}=82

8^{2}+2^{2}=68

6^{2}+8^{2}=100

1^{2}+0^{2}+0^{2}=1

示例 2:

输入:n = 2

输出:false

(2) 题目分析及思路

这道题本来有些难度,但是题目中有指出这个过程会成一个循环,所以就不用考虑不成环的情况,不然比较棘手。

  • 我们以11举例,两个1的平方相加等于2,以此类推到20,其中2的平方等于4,最后成为一个循环。用题目给的示例1数字19,最后到1。其实也是一个循环,1的平方等于1,是它本身。所以我们可以使用环形链表中的快慢双指针,先找到进循环的第一个数字,判断是否是1,如果是就是快乐数。
  • 其中使用快慢双指针,慢指针走一步,快指针走两步,就能寻找环形链表循环的结点入口的原因。我这篇《链表OJ题第二弹:环形链表和环形链表 II》中有详细讲解。http://t.csdnimg.cn/IsmYF

这是判断环形链表的代码。

bool hasCycle(struct ListNode *head) 
{
    struct ListNode* slow = head, *fast = head;
    while(fast && fast->next)
    {
        slow = slow->next;
        fast = fast->next->next;
        if (slow == fast)
        {
            return true;
        }  
    }
    return false;
}

我们只要再写一个函数用于拆分数字的每一位,并平方再相加。然后像环形链表的双指针方法一样,只不多每走一步,相当于调用一个函数。当这两个数字相等时,就是循环的第一个数字,再判断是否为0。

    int bitsum(int n)
    {
        int sum = 0, tmp;
        while(n > 0)
        {
            tmp = n % 10;
            sum += (tmp * tmp);
            n /= 10;
        }

        return sum;
    }


    bool isHappy(int n) 
    {
        int slow = n, fast = bitsum(n);
        while(slow != fast)
        {
            slow = bitsum(slow);
            fast = bitsum(bitsum(fast));
        }

        return slow == 1;
    }

(3) 证明

这道题的题目给出提示,按照寻找快乐数的方法持续下去,一定会成一个循环。我们可以证明这个过程。

  1. 数字n范围在1\leq n\leq2^{31} - 1之中。最大的数是2147483647,是个十位数字,我们取一个9999999999,把每一位的平方相加,等于810。
  2. 也就是说,在这个范围内随便给出一个数字,然后取每一位数,平方再相加,不会超过810。我们假设一个数字恰巧经过810次操作,都没有成一个循环,那么在第811次操作下,必然出现一个数和前面的数重复,组成循环。况且2^{31} - 1没有比9999999999小,进行操作后都达不到810这个数字,必然在810次操作内形成一个循环。


总结

如果认真做这三道题,并且通过画图来熟悉整个流程,对与双指针算法的理解更深入,不知可以使用指针,还可以使用变量函数来记录信息,从而提高效率。

创作不易,希望这篇文章能给你带来启发和帮助,如果喜欢这篇文章,请留下你的三连,你的支持的我最大的动力!!!

  • 17
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值