双指针进阶——滑动窗口和双指针之KMP算法

双指针进阶——滑动窗口

本质上来说滑动窗口也是双指针的一种,它的好处是可以将一些需要用两层for的解法转换为只需要一层for的解法,如果说双指针是一个技巧,那滑动窗口就是双指针的一个思想。从下面几个题目中我们便能深刻体会这种思想。

问题一:长度最小的子数组

在这里插入图片描述

方法1:暴力

暴力解法不必多说,两层循环不断更新左右边界,第一层循环的变量代表左边界,第二层循环的变量代表右边界。时间复杂度n^2

class Solution {
public:
    int minSubArrayLen(int s, vector<int>& nums) {
        int result = INT_MAX; // 最终的结果
        int sum = 0; // 子序列的数值之和
        int subLength = 0; // 子序列的长度
        for (int i = 0; i < nums.size(); i++) { // 设置子序列起点为i
            sum = 0;
            for (int j = i; j < nums.size(); j++) { // 设置子序列终止位置为j
                sum += nums[j];
                if (sum >= s) { // 一旦发现子序列和超过了s,更新result
                    subLength = j - i + 1; // 取子序列的长度
                    result = result < subLength ? result : subLength;
                    break; // 因为我们是找符合条件最短的子序列,所以一旦符合条件就break
                }
            }
        }
        // 如果result没有被赋值的话,就返回0,说明没有符合条件的子序列
        return result == INT32_MAX ? 0 : result;
    }
};

方法2:前缀和+二分查找

这是官方题解给的一个思路,也挺有参考价值的,我们这里简要阐述,如果不知道前缀和的同学可以取了解一下,我们这里用到的是一维前缀和

方法一的时间复杂度是 O(n^2),因为在确定每个子数组的开始下标后,找到长度最小的子数组需要 O(n)O(n) 的时间。如果使用二分查找,则可以将时间优化到 O(logn),最后n^2的时间就变成了nlgn。

前缀和在这里的用法就是用来求子数组的和,假设第n项(包括第n项)的前缀和是Sn,那么左边界前缀和就是Sl,右边界的前缀和是Sr,最后子数组的和就是Sr-Sl+nums[l]。前缀和我们只需要遍历一遍数组再打个表就行了,所用的总时间是n+nlgn,时间复杂度nlgn。

注意一个很重要的问题,为什么能用二分?在这里是因为数组每个元素都是正数,前缀和递增。如果存在负数,二分就不能使用了

代码如下:

class Solution {
public:
    int minSubArrayLen(int s, vector<int>& nums) {
        int n = nums.size();
        if (n == 0) {
            return 0;
        }
        int ans = INT_MAX;
        vector<int> sums(n + 1, 0); 
        // 为了方便计算,令 size = n + 1 
        // sums[0] = 0 意味着前 0 个元素的前缀和为 0
        // sums[1] = A[0] 前 1 个元素的前缀和为 A[0]
        // 以此类推
        for (int i = 1; i <= n; i++) {
            sums[i] = sums[i - 1] + nums[i - 1];
        }
        for (int i = 1; i <= n; i++) {
            int target = s + sums[i - 1];
            auto bound = lower_bound(sums.begin(), sums.end(), target);
            if (bound != sums.end()) {
                ans = min(ans, static_cast<int>((bound - sums.begin()) - (i - 1)));
            }
        }
        return ans == INT_MAX ? 0 : ans;
    }
};

方法3:滑动窗口

在前面两个解法中,一个时循环为滑动窗口的起始位置,一个循环为滑动窗口的终止位置,用两个循环 完成了一个不断搜索区间的过程。

那么滑动窗口如何用一个for循环来完成这个操作呢。

思考两个问题:

  1. 如果用一个循环,那么应该表示 滑动窗口的起始位置,还是终止位置?
  2. 如果只用一个循环来表示 滑动窗口的起始位置,那么如何遍历剩下的终止位置?

此时难免再次陷入 两层循环思维。

注意只用一个循环,那么这个循环的索引,一定是表示 滑动窗口的终止位置,因为我们必须要终止循环。

那么问题又来了, 滑动窗口的起始位置如何移动呢?

考虑这个问题前,我们不妨考虑一个简单的问题,窗口内应该是什么?很明显,这个题中,窗口是>=s的子数组,但是这个窗口它不一定满足要求,所以我们需要不断更新窗口的大小和位置让它满足要求。这么想就简单了,如果窗口不满足要求,我们是不是要扩张窗口,这个时候固定左指针,移动右指针,让窗口位置变大,如果不满足继续扩大;如果窗口满足要求了,那么结束了吗?回顾下我们的目的是什么,我们的目的是求长度最小子数组,也就是满足要求的长度最小的窗口。那么该怎么做,我们只有两个选择,第一个继续移动右指针,第二个移动左指针,很明显移动右指针是不行的,只会造成窗口的扩大。那么我们只能移动左指针缩小窗口了,当然在此之前,我们要记录当前满足要求的窗口长度。将左指针移动了一步后,如果发现仍然满足要求,记录位置后继续移动,如果不满足就移动右指针。重复以上操作,并不断更新长度最小的窗口大小。

class Solution {
public:
    int minSubArrayLen(int s, vector<int>& nums) {
        int n = nums.size();
        if (n == 0) {
            return 0;
        }
        int ans = INT_MAX;
        int start = 0, end = 0;
        int sum = 0;
        while (end < n) {
            sum += nums[end];
            while (sum >= s) {
                ans = min(ans, end - start + 1);
                sum -= nums[start];
                start++;
            }
            end++;
        }
        return ans == INT_MAX ? 0 : ans;
    }
};

滑动窗口如果刚接触会感觉难以理解,但是花费时间体会之后会慢慢感受到它的奥妙。

当然有可能你会感觉这怎么这么像快慢指针呢?其实双指针类的技巧看起来可能差不多,但是思维本质有可能发生了改变,在快慢指针中我们在意的两个指针所指向的元素,而滑动窗口里面我们在意的两个指针围成的区域。

不急,我们还有两个题来理解这种思维,当然建议看明白上面这个后再琢磨下面的两个1题,它们的难度会更高。

问题二:水果成篮

在这里插入图片描述

首先我们来解析下题目:题目的大概意思就是,有一堆果树,果树是连续排列的,且每个果树上只长一种果实,第i棵树上的果实品种用fruits[i]表示,我们现在有两个篮子,每个篮子最多只能放一种果子,且我们采摘果实时必须从左到右采摘,不能跳过不能回头,最多我们可以采摘到多少棵树的果实?

暴力不必多说,当然暴力在中等题中应该是过不了的。

方法1:滑动窗口

我们直接使用滑动窗口来解决这个问题。滑动窗口我们无非就是思考三个问题:

  • 窗口内是什么?
  • 如何移动窗口的起始位置?
  • 如何移动窗口的结束位置?

先来看第一个问题,确定了它后面两个问题才能开始思考。

从简单入手,要是只有一个篮子怎么做,那不就是找最长连续相等子数组嘛?这个很简单吧。那两个篮子呢,不就是找到连续最大的子数组,让子数组中只能存在一种到两种元素。这就是我们窗口内的东西,接下来思考怎么维持。

相对于上面那个题而言,这里最大的难点就是如何确保窗口内只有一种或者两种元素。

当我们无从考虑的时候不妨把每种情况列出来再考虑1整合,这里的情况大致可以分成:

  1. 窗口类一种水果都没有,然后往里面加入水果,仍然满足要求
  2. 窗口内有一种水果,然后我们往窗口内加相同种类或者不同种类的水果,仍然满足要求
  3. 窗口内有两种水果,我们往窗口内加窗口内已经存在的两种水果中的一种
  4. 窗口内有两种水果,我们加入的水果不满足这两种中的任何一种

情况列出来后,我们会发现讨论的地方就是窗口内的状态和加入的水果,情况1,2和3,直接扩大窗口就行了,

而4就需要先记录当前的窗口状态,再不断先缩小窗口,直到满足前面三种情况后加入新水果。

具体细节在代码中都会体现出来

class Solution {
public:
struct help{
    int x=-1;
    int num=0;
};//我们使用这个结构体代表啦篮子,x代表水果的品种,num代表该品种的数量
    int totalFruit(vector<int>& fruits) {
        int l=0,r=0,len=fruits.size();
        help flag[2]; 
        int maxlen=INT_MIN;
        while(r<len)
        {
            if(flag[0].num==0||flag[0].x==fruits[r])//第一个篮子没有水果或者新加入的水果和第一个篮子中的水果种类相同,将水果加入到第一个篮子中
            {
                flag[0].x=fruits[r];
                flag[0].num++;
                r++;
            }
            else if(flag[1].num==0||flag[1].x==fruits[r])//第一个篮子中有水果且第二个篮子无水果,或者第二个篮子没有水果,将水果加入篮子中
            {
                flag[1].x=fruits[r];
                flag[1].num++;
                r++;
            }
            else if(flag[0].x==fruits[l])//窗口的左边第一个水果属于第一个篮子,将其丢弃,更新左边界,仍然满足窗口条件
            {
                flag[0].num--;
                l++;
            }
            else if(flag[1].x==fruits[l])//窗口的左边第一个水果属于第二个篮子,将其丢弃,更新左边界
            {
                flag[1].num--;
                l++;
            }
            maxlen=max(maxlen,r-l);//记录窗口状态
        }
        return maxlen;
    }
};

问题三:

在这里插入图片描述

情况讨论

  1. t串比s串长,直接返回空串;
  2. t串和s串一样长,判断两串是否相等
  3. s串长大于t串长

第三种情况的分析

首先分析我们需要的变量

  • 我们需要返回的是一个串,所以我们需要两个变量实时分别记录当前最小覆盖子串的长度,我们用一个数组ans[2]代替。
  • 滑动窗口所需要的两个指针l,r
  • 两个数组flag和nums,分别记录t串中各个字符的数量和s中当前子串中的字符数量,这两个数组是解题的关键,精妙之处稍后分析
  • cns和cnt分别记录s串当前子串字符中含有的t中字符的数量和t中t串中字符的数量,用于判断当cns==cnt时说明这时候的子串已经满足要求。

整体思路

因为t串是固定的,所以首先求出flag数组和cnt,回顾下滑动窗口是怎么工作的,我们就能大致知道该怎么做了。当flag不为0的项都小nums中对应的项时,我们就需要记录当前窗口并与之前窗口进行对比了,对比后就可以缩小窗口了,否者我们就继续扩大窗口。当然,其中还有几个很重要的细节,我们在代码中一一讲述。

class Solution {
public:
    string minWindow(string s, string t) {
        int flag[128]={0},nums[128]={0},l=0,r=0,ans[2]={0,INT_MAX};
        int len1=s.length(),len2=t.length(),cnt=0,cns=0;
        if(len1<len2)
        return "";
        for(int i=0;i<len2;i++)
        {
        flag[t[i]]++;
        cnt++;
        }//记录t串的状态
        while(r<len1)
        {
             nums[s[r]]++;//我们取循环不变量为左右都为闭,所以此时记录子串的状态
            if(flag[s[r]]!=0)
            {
                if(nums[s[r]]<=flag[s[r]])//这句话和下面的while循环构成了滑动窗口的关键
                cns++;//记录子串中包含t字符的数量
            }
            while(nums[s[l]]>flag[s[l]])//当子串新加入一个S[r]后,判断s[l]在子串中的数量是否会多余t串,若多于t串,那么这个s[l]是可以舍弃的,因为舍弃它并不会改变子串的需要的性质——对于 t 中字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。但是却可以让子串长度变得更小。上面的if语句嵌套的if语句条件nums[s[r]]<=flag[s[r]]和它对应维持着窗口的性质,这里需要仔细理解。
            {
                nums[s[l]]--;
                l++;
            }
            if(cns>=cnt&&r-l+1<ans[1]-ans[0])//cns有可能大于也有可能等于cnt这里需要注意,当它满足时,判断当前子串是否是当前最短的子串,若是,则更新数组
            {
                ans[0]=l;
                ans[1]=r+1;
            }
            r++;
        }
        string x;
        if(ans[1]==INT_MAX)
        return "";
        for(int i=ans[0];i<ans[1];i++)//把答案存储起来准备返回
        x+=s[i];
        return x;
    }
};

KMP算法

最后我们来讲一个非常经典的字符串匹配算法,KMP算法。它本身也是一个双指针算法,求next数组的时候用的是在一个序列上的双指针,在next数组进行匹配时它是在两个序列上的双指针。

因为kmp太过经典,网上的优秀文章也很多,此处主要是为自己复习巩固所用,原理不多做阐述,主要是讲解下代码。看过好几个版本,下标都是从1开始,有些语言的数组是从1开始,但有些是用数组的0项用来存储数组串长,我在这提供自己写的一个下标从0开始的版本

这里总结几点:

  1. 求next数组本质上就是求最长相等前后缀问题
  2. next数组还可以进行优化
  3. 使用双指针时,无论是求next数组还是KMP求解时有一个指针一直往前走,有一个在来回移动实现模式匹配。

具体细节在代码中用注释给出:

void get_next(int *next,int len,string str)//求next数组问题本质就是求最长前后缀问题
{
    int i=0,j=-1;
    next[0]=-1;
    while(i<len-1)//避免溢出
    if(j==-1||str[j]==str[i])
    {
        ++j;
        ++i;
        /*
        next数组优化
        if(str[i]!=str[j])
        next[i]=j;//若当前字符和前缀字符不同,不需要进行改变,和原来方案一致
        else
        next[i]=next[j];//若相同,就把next[i]的值就等于j之前长度为j的字符串的最长前后缀长度
        为什么要这么优化,举个例子:
        aaaabc
        按原来方案:next={-1,0,1,2,3,0},如果进行模式匹配,那么在第四个a元素不匹配的时候,我们寻找最长前后缀,也就是2,我们从下标为1的元素开始匹配,但是它也是a,以次类推,下标为0的元素也是a,不匹配,j就变成了-1,我们完全可以一开始就让next[i]等于字符全部相同的子串的第一个元素的next值的值,这样就避免了多余的匹配
        */
        next[i]=j;//j的值就是当第i个元素前的字符串的最长相等前后缀长度,注意长度不能达到i,这段话的含义就是,
        //如果匹配成功,next[i+1]的值就是j+1,j+1代表长度,若j=-1,则说明无最长前后缀,即最长前后缀0,此时从next[i+1]的值就是0,也就是j+1;
    }
    else//若字符不匹配,回溯寻找长度为j-1的子串的最长前后缀
    j=next[j];
}
    int KMP(string haystack, string needle) {
        int len=needle.length();
        int *next=new int[len];
        get_next(next,len,needle);
        int n=haystack.length(),i=0,j=0;
        while(i<n&&j<len)
        {
            if(j==-1||haystack[i]==needle[j])
            {
                ++i;
                ++j;
            }
            else
            j=next[j];//j退回到合适位置,i不变
        }
        if(j==len)
        return i-len;
        else
        return -1;
    }

一个KMP的用例

在这里插入图片描述

思路

这是杭电oj的一个题,就是KMP的一点扩展,我们需要做的不再是找到第一次出现的子串,而是此处存在的所有子串。

这题思维的难点在另外一个地发,如何保证最多的子串?

我们先假设从左到右依次取子串,每次取了之后,子串及前面所有的字符舍弃,以子串后面一个字符未开始到最后一个字符为新的主串,这样求出来的就是最多子串?为什么呢?

在这里插入图片描述

如图,绿色部分是子串,1,2,3部分相等,1+2+中间的蓝色部分与子串匹配,那么2+3+中间蓝色部分和子串也匹配,我们如果选择后面一部分,前面一部分相当于已经浪费了

假设1前面部分已经匹配完了,从1开始进行新的匹配,那么设选择1部分开始匹配得到的子串数n,选择2开始匹配的子串数为m。

我们注意,这种要进行选择的情况只会在存在多个红色和蓝色部分连续出现的时候存在,假设红色部分是数量x,蓝色部分夹在红色部分中间,蓝色部分数量就是x-1,选择后面一个进行匹配,会让实际红色部分只有x-1,蓝色部分x-2,若x是奇数,不会造成影响,但若是偶数,会让匹配出来的子串个数比最大子串个数少一,也就是会造成一定存在n>=m。

那么我们就能发现这实际是一个不断更新主串进行模式匹配的题目,我们在匹配成功后更新主串,并记录当前已经匹配的数量

在这里插入图片描述

代码:
#include<iostream>
#include<cstring>
#include<string>
#include<vector>
using namespace std;
void get_next(string x, vector<int> &next)
{
	int i=0, j=-1;
	next[0] = -1;
	while (i < x.size()-1)
	{
		if (j == -1 || x[i] == x[j])
		{
			i++;
			j++;
			if (x[i] != x[j])
				next[i] = j;//若当前字符和前缀字符不同,不需要进行改变,和原来方案一致
			else
				next[i] = next[j];
		}
		else
		{
			j = next[j];
		}
	}
}
int KMP(string str, string x)
{
	int len1 = str.size(), len2 = x.size(),i=0,j=0,num=0;
	vector<int>next(len2);
	get_next(x, next);
	while (i < len1 && j < len2)
	{
		if (j == -1 || str[i] == x[j])
		{
			i++;
			j++;
		}
		else
			j = next[j];
		if (j == len2)
		{
			num++;
			j=0;
		}
	}
	return num;
}
int main()
{
	string str,x;
	while (cin >> str >> x&&str!="#")
	{
		cout << KMP(str, x)<<endl;
	}
	return 0;
}
  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值