Leetcode刷题笔记--滑动窗口


前言

滑动窗口也是常考知识点,他算是双指针的一个特殊形式,再复习一下之前做过的习题,顺便将之前刷过的题目分个类,不多废话了!

一、滑动窗口的基本习题

滑动窗口适合处理子串和子区间问题,是因为它能够有效地在一次遍历中解决这些问题。通过动态调整窗口的大小来维护一个连续的子区间,从而避免了对每个可能的子区间进行重复计算,大大降低了时间复杂度。

1.无重复字符的最长子串

先做个开胃菜!这道题目之前讲哈希表的时候写过,也是滑动窗口的典型题目,通过对右指针的一次遍历,获得每次区间最大长度。如果出现重复的字母,那么右指针就会停止运动,左指针不断运动到区间不出现重复的字母时候,右指针再继续运动。接下来我们思考一个问题:什么时候统计子串的长度? 我们可以发现,右指针移动时候会增加子串长度,左指针移动会减小字串长度。那么最长的子串一定出现在处理右指针之后,所以每次右指针增加时候判断即可!

int lengthOfLongestSubstring(string s) {
	unordered_map<char, int>map;
	int i =0, j = 0;
	int maxlen = 0;
	while (j<s.size())
	{
		if (map[s[j]] == 0) { map[s[j]]++; j++; maxlen = max(maxlen, j - i); }
		else { map[s[i]]--; i++; }
	}
	return maxlen;
}

2.水果成篮

这道题目转化过来,就是求最长的只有两个元素的连续子数组,哈希表加滑动窗口,轻松拿下!

    int totalFruit(vector<int>& fruits) {
	int n = fruits.size();
    unordered_map<int, int> map;

    int left = 0, right = 0;
    int max_len = 0;
    while (right<n)
    {
       map[fruits[right]]++;
         while (map.size()>2)
         {
              map[fruits[left]]--;
              if (map[fruits[left]]==0)
              {
                map.erase(fruits[left]);
              }
              left++;
         }
            max_len = max(max_len, right-left+1);   
            right++;
    }
    return max_len;
    }

这类题目太经典了,以至于很多滑窗的方法基于上面代码的变形,但是只要理解这个问题,就没什么难的。下面这几道题比较简单了,就不贴代码了。。
子数组最大平均数l
长度最小的子数组
爱生气的书店老板

二、不同类型的滑动窗口

2.1 定长的滑动窗口

1.串联所有单词的子串
这道题目确实是困难题目,我自己写了半天终于写出来垃圾代码,但是只通过了大部分案例,,太难受了!要是想解决这个问题,那就要增加时间复杂度,最后有一个例子超时!气死我了。不改通不过这个例子,改了就超时,左右为难了属于是。。
在这里插入图片描述

    int n = words.size(), m = words[0].size(), len = n * m;

我仔细分析了一下,原来我之前的做法并没有用到滑动窗口。这道题目是要有两个循环的,关键在于这两个循环分别要循环什么!先说最外层的循环,我们应该创建一个len长度的滑动窗口,如果s的长度大于Len,我们才能够进行判断,但是s的长度未必会被m整除,这样的话,我们第一个循环就可以这样定义,使得前面是余数,这样的话后面的都可以被m整除,最终可以遍历到字符串的尾部。

    for (int i = 0; i < m; i++)

之后在循环中,我们需要进行两件事:

1.遍历滑动窗口
2.比较滑动窗口中与words中每个单词出现的次数

在本题中,由于我们比较的不是每个单词,而是每个定长的字符串,由于words中每个元素的长度是一定的!这样的话,我们就可以通过遍历每个m长度的小窗口,也就是单词,从而对整个滑窗进行遍历。这也就是第二个循环,代码如下:

for (int j = i; j + m <= s.size(); j += m)

这样的话,代码的基本框架已经确定了,我们只需要解决最后一个问题:判断滑窗是否满足串联子串的要求!

这就有点像找出字符串中的所有字母异位词的问题了,我们可以创建两个哈希表,第一个哈希表记录words中每个单词出现的次数,这个哈希表里面的元素是固定的;第二个哈希表记录滑窗中单词出现的次数,用来与第一个进行比较,判断出结果。

具体的策略就很容易理解了:如果滑窗的长度小于len,那就一直扩充,并且增加哈希表的对应关系,直到等于len,就开始进行窗口移动的过程,添加新的单词,移除最初的单词,这样的话,即可达到想要的要求。具体代码如下:

vector<int> findSubstring(string s, vector<string> &words)
{

    int n = words.size(), m = words[0].size(), len = n * m;
    vector<int> res;
    //统计words中每个单词出现的次数,是固定的
    unordered_map<string, int> map;
    for (auto word : words)
    {
        map[word]++;
    }
    
    for (int i = 0; i < m; i++)
    {
        unordered_map<string, int> window;
        int cnt = 0;
        for (int j = i; j + m <= s.size(); j += m)
        {
            if (j - i >= len)
            {
                string temp = s.substr(j - len, m);
                window[temp]--;
                if (window[temp] < map[temp])
                {
                    cnt--;
                }
            }
            string word = s.substr(j, m);
            window[word]++;
            if (window[word] <= map[word])
                cnt++;
            if (cnt == n)
            {
                res.push_back(j - len + m);
            }
        }
    }
    return res;
}

2.2 不定长的滑动窗口

2.最小覆盖子串
这道题目是一个不定长的滑动窗口,不定滑窗的关键在于左边和右边的双指针分别在什么条件下运行。只要把这个判断准了,就可以轻松拿下题目了!
一句话可以总结:先滑动右指针到满足区间覆盖字符串t,然后再移动左指针缩减最小区间范围,记录区间,之后继续移动左指针使区间刚好不能覆盖字符串t,继续移动右指针。
明白了上面的话,代码就比较简单了,我这里使用了两个哈希表来计数对比,其实一个哈希表增删就能实现功能,但总体差不多。

string minWindow(string s, string t)
{
    unordered_map<char, int> map;
    unordered_map<char, int> window;
    int cnt = 0;
    for (auto c : t)
    {
        map[c]++;
        cnt++;
    }

    int left = 0, right = 0, num = 0;
    int min_len = 0;
    int min_left = 0;
    while (right < s.size())
    {
        window[s[right]]++;

        if (window[s[right]] == map[s[right]])
            num += map[s[right]];
        while (num == cnt)
        {
            if (min_len == 0 || min_len > right - left + 1)
            {
                min_len = right - left + 1;
                min_left = left;
            }
            if (window[s[left]] == map[s[left]])
                num -= map[s[left]];
            window[s[left]]--;
            left++;
        }
        right++;
    }
    return s.substr(min_left, min_len);
}

3.最小区间

这道题目表面看起来和上一道题目没有毛线关系,但是仔细分析一下,还真有关系,能把这道题转化为上面这道题,简直是有点牛B,接下来咱们看看,怎么转化的。
看了这道题的题目,有些无从下手的感觉,关键原因在于,这道哈希表结合滑动窗口的题目,变了一个东西,哈希表的数据结构变了,而且比较难以理解。我们要创建一个哈希表,其中,哈希表的键是所有数组的所有元素,而值是这些元素在哪个数组中出现的索引!借用一下官方的图,以便更好理解。
在这里插入图片描述
这样的话,我们需要创建的哈希表应该长这样。

unordered_map<int, vector<int>>

之后问题就进行了转化,对于一个滑动窗口,如果他包含了所有区间的元素,并且这个区间尽可能小,那么这个区间就是我们要求的。这个时候,就彻底转化为了上面的一道题。妙哉!接下来看一下具体的代码。

// 最小区间
vector<int> smallestRange(vector<vector<int>> &nums)
{

    int n = nums.size();
    int m = nums[0].size();
    vector<int> sorted;
    // 创建哈希映射,并且找到最大值和最小值
    unordered_map<int, vector<int>> map;
    int max_num = INT_MIN;
    int min_num = INT_MAX;
    for (int i = 0; i < n; i++)
    {
        for (auto num : nums[i])
        {
            map[num].push_back(i);
            max_num = max(max_num, num);
            min_num = min(min_num, num);
            sorted.push_back(num);
        }
    }
    // cout << max_num << "   " << min_num << endl;
    //  获取所有元素的去重排序数组
    sort(sorted.begin(), sorted.end());
    auto unique1 = unique(sorted.begin(), sorted.end());
    sorted.erase(unique1, sorted.end());
    // for (auto s : sorted)
    //     cout << s << " ";
    // cout << endl;

    //滑动窗口遍历去重数组,找到所有可能的区间
    int left = 0;
    //创建哈希表计数
    unordered_map<int, int> set;
    vector<vector<int>> ans;
    for (int right = 0; right < sorted.size(); right++)
    {
        for (auto i : map[sorted[right]])
            set[i]++;
        while (set.size() == n)
        {
            // bestleft = sorted[left];
            // bestright = sorted[right];
            ans.push_back({sorted[left], sorted[right]});
            for (auto i : map[sorted[left]])
            {
                if (set[i] <= 1)
                    set.erase(i);
                else
                    set[i]--;
            }

            left++;
        }
    }

    // for (int i = 0; i < ans.size(); i++)
    // {
    //     cout << "[" << ans[i][0] << " " << ans[i][1] << "]" << endl;
    // }

    //比较区间,找到最小区间
    int bestleft = min_num;
    int bestright = max_num;
    for (int i = 0; i < ans.size(); i++)
    {
        if (ans[i][1] - ans[i][0] < bestright - bestleft)
        {
            bestleft = ans[i][0];
            bestright = ans[i][1];
        }
        else if (ans[i][1] - ans[i][0] == bestright - bestleft)
        {
            if (ans[i][0] < bestleft)
            {
                bestleft = ans[i][0];
                bestright = ans[i][1];
            }
        }
    }
    //返回最小区间
    return {bestleft, bestright};
}

代码复杂的一批,不过过了就行,不枉我调试了半天。。

滑动窗口的本质就是剪枝?

我们知道,滑动窗口在字符串的应用是处理连续的子串,其中这分为固定区间和不固定区间两种情况。现在不考虑题目,我们假设一个字符串为s,我们想求得他的子区间【i,j】的子字符串。以这个问题,我们展开思考。
1.固定长度的区间
这个很简单,对于定长区间,滑窗会比暴力要快。暴力的时间复杂度是O(n*k),如果k比较大,逼近n的话,就要到O(n2)复杂度了。滑动窗口遍历了n-k次,时间复杂度是O(n),更快一些,而且可以遍历所有情况。
2.不固定长度的区间
但是如果是不定长情况的话,就会有些复杂了,最关键的因素就是,不定长滑动窗口不能包含子字符串的所有情况!因为滑动窗口本身就是剪枝操作,我们通过移动左指针或者右指针来对区间进行改变。注意,这里是或!只移动一个指针,那么必定会剪枝一部分子串,而如果我们想要的结果中,包含滑窗之外的情况,那么这道题目就没法使用滑动窗口进行解答了,就需要想其他办法进行解决了。所以说滑窗是好用,但是也要分清场合。
下面举一个简单的例子:
在这里插入图片描述
假设左右指针都进行增加操作,此时我移动左指针到2,那么在之后,我就无法访问到[1,4]这个区间了。这个区间已经被剪枝了!下面咱们就看两道题,感受一下剪枝之外的题目如何巧妙解答。

2.3.区间简化的滑窗

1.区间子数组的个数
这个题目可以有很多种解法,这里我使用滑动窗口的方法解答,以此来循序渐进地引入下一个题目。
首先分析一下,要满足最大元素在一个区间内,先看看正常滑窗能否解答。我们可以将所有的元素分为三类

小于 left,用 0表示;
大于等于 left 且小于等于 right,用 1表示;
大于 right,用 2 表示。

这样的话,我们就将题目转化为:找到所有至少包含1,且不包含2的子数组。之后需要讨论多种情况,这里官方的代码不太容易理解。
我这里的做法是,简化问题,题目是求最大数的一个区间,这里把题目转化为:找出 nums 中最大元素小于等于k的子数组。
这一步实在是巧妙,大大的简化了问题,很好写出这个代码:

int lessEqualsThan(vector<int> &nums, int k)
{
    int left = 0;
    int ans = 0;
    for (int right = 0; right < nums.size(); right++)
    {
        if (nums[right] > k)
            left = right + 1;
        ans += right - left + 1;
    }
    return ans;
}

那么最大元素在范围 [left, right] 内的子数组不就等于最大元素小于等于right的子数组的数量减去最大元素小于等于left-1的子数组的数量吗! 这就是解决这个问题的关键!而且是一个重要的思想。需要注意的是,我们求的是闭区间 [left, right],而上面代码求得也是区间,所以减去的应该是left-1,可以画个图更清晰。之后代码就很简单了。

int numSubarrayBoundedMax(vector<int> &nums, int left, int right)
{

    return lessEqualsThan(nums, right) - lessEqualsThan(nums, left - 1);
}

2.K个不同整数的子数组

刚开始看这道题,需要找到某个子数组中不同整数的个数恰好为k 。我一看,这不是水果成篮一模一样吗?把2改为k就完事了,说时迟那时快,一下就秒了,结果很快啊,一下就报错了。。。于是我分析了原因。有些子数组我是没得到的,举个简单的例子。
nums = [1,2,1,2,3], k = 2。
我根本没法得到[1,2,1]这个子数组,原因在于,我的左右指针都在往右跑,我只能得到[2,1],得不到[1,2,1],这在上文中也分析过了,那么如何解决这个问题呢?

还是进行转化。找某个子数组中不同整数的个数恰好为k 的子数组太难了,因为滑窗遍历过程中会漏掉情况。那么我就稍微一变,找小于等于k的子数组个数,因为之前遗漏的情况都是在等于k之前出现的,所以都是小于等于k,这样就可以用之前的方法安心找到小于等于k的子数组的个数了。
注意,这里是开区间,那么我们回过头来,再找恰好等于k的情况,只需要求小于等于k的子数组数量减去小于等于k-1的子数组数量即可。代码如下。

int lessEqualsThan2(vector<int>& nums, int k)
{
	int left = 0;
	int count = 0;
	int ans = 0;
	unordered_map<int, int>map;
	for (int right = 0; right < nums.size(); right++)
	{
		map[nums[right]]++;
		while (map.size() > k)
		{
			count++;
			map[nums[left]]--;
			if (map[nums[left]] == 0) { map.erase(nums[left]); }
			left++;

		}
		ans += right - left;
	}
	return ans;
}
int subarraysWithKDistinct(vector<int>& nums, int k) {
	return lessEqualsThan2(nums, k) - lessEqualsThan2(nums, k - 1);
}

总结

做了上面的题目,对于滑动窗口我们也算是有了一定的认识。可以发现,滑动窗口通常和哈希表进行结合,从而达到题目的多样性。
1.对于定长滑动窗口来讲需要注意的就是,在每次移动窗口的过程中,左右指针会同时移动。通常为了方便对比,会创建两个哈希表,一个用于最初遍历数组或者字符串,另一个用于滑窗过程中进行变化。在每次滑动窗口中,对比两个哈希表的差异解决问题。
2.对于不定长的滑动窗口,最典型的情况就是,先移动右指针,当右指针满足条件时候,移动左指针直到刚好不满足条件,继续移动右指针。这类题目基本上有一个固定的模板了,下面是一个模板的例子。需要注意的是:

1.先移动右指针,再进行while循环的判定
2.我们想要的结果在代码的哪些位置,可能会出现在while的前,中,后三个部位。他们的区别在于,不同临界情况的左右指针的差别。while循环中,是刚好满足条件的滑窗,while的其他位置是刚好不满足条件的滑窗。具体问题具体分析。

	//创建左指针
   	int left = 0;
   	//创建哈希表
    unordered_map<int, int> set;
    //遍历右指针
    for (int right = 0; right < sorted.size(); right++)
    {
    	//操作右指针
        for (auto i : map[sorted[right]])
            set[i]++;
        //判断满足的条件,进行左指针操作
        while (set.size() == n)
        {
            ans.push_back({sorted[left], sorted[right]});
            for (auto i : map[sorted[left]])
            {
                if (set[i] <= 1)
                    set.erase(i);
                else
                    set[i]--;
            }
			//移动左指针
            left++;
        }
    }

3.最后就是对于一些由于不定滑窗剪枝的遗漏情况,我们可以进行问题的转化,其中2.3第一道题很容易简化为左边和右边两个范围。但是对于第二道题,恰好为k可以转化为小于等于k减去小于等于k-1,这是巧妙且较难想的,以后做题的时候,希望遇到类似的情况,可以想起这种方法。

  • 22
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值