详解双指针算法(三)之滑动窗口
前言
- 上一期我主要讲了一般类型的双指针,对于解决数组划分或按需求遍历数组的问题,该算法能极大程度地提高效率
- 这一期我主要讲解滑动窗口算法,该算法常用于解决找子数组或找子串的问题,同一般类型的双指针,滑动窗口也能极大程度降低时间复杂度
- 滑动窗口算法都可以通过暴力解法优化而来
一、滑动窗口简介
- 概念
滑动窗口通常定义 left 和 right 两个指针,两指针同向移动,指针需要通过判断区间 [ left , right ] 是否合法来决定指针的走向;因为在指针移动过程中,区间 [ left , right ] 很像一个滑动的窗口,故称为滑动窗口算法
- 使用场景
一般类型的双指针可以用来解决如下问题:
找数组的子数组或找字符串的子串
- 分类
按照问题的不同,滑动窗口一般分为如下两类:
长度不断发生变化的窗口
长度固定的窗口
- 解题步骤
滑动窗口类型的题目有很强的套路性,解题类型比较固定,八股文如下:
1.维护窗口(不同问题维护窗口的方法不同)
2.判断窗口的合法性(到底是合法出窗口还是非法出窗口视情况而定)
判断为真,执行出窗口操作(left++)
长度不固定窗口此处一般为循环出窗口操作,长度固定只需进行一次出窗口操作
3.执行进窗口操作(right++)
更新结果(此步骤穿插在如上步骤的中,具体问题具体分析)
以上解题步骤并不是核心,滑动窗口算法的正确性、为什么能使用滑动窗口算法、以及如何通过暴力解法优化到滑动窗口算法,才是这节内容的主题!!!
二、长度不断发生变化的窗口
- 经典例题
长度最小的子数组
解析:该题目是经典的找子数组问题,使用暴力解法穷举能轻松地解决该问题
信心慢慢,点击提交
超时了???很正常,因为暴力解法的时间复杂度为 O(N^2) 但是没有关系,我会带大家从暴力解法优化成 O(N) 的滑动窗口算法
优化思路:
- 第一个满足题目要求的合法窗口如下:
- 暴力解法的思路:先记录长度,再让 left 向后移一位,right 回到 left 的位置向后穷举
- 首先,left 后移的思路没有问题,但是再思考一下:right 有必要回退吗???
- left 向后移一位,窗口内 sum 的变化情况如下:
- a. sum 减小并小于 target:此时窗口不再合法,right 移动情况如下:
- 暴力解法让 right 回退到 left 的位置,但往后遍历的过程中,right 依然会回到原来的位置;因为 right 在原来位置处,窗口已然不合法,right 在之前的位置 sum 只会比原来小,窗口必然是不合法的(这里判断的依据为:数组内数据都为正数,否则不成立,也无法使用滑动窗口)
- 故 right 不需要回到 left 位置,只需让 right 右移找到下一个合法窗口
- b. sum 减小但还是大于 target:此时窗口依然合法,需记录新的最小长度,指针又该如何移动???:
- 窗口依然合法,即又找到了固定 left 的最小合法子数组,right 无需向后移动,此时又回到一开始的问题:窗口合法后,应该如何做?
- 所以判断窗口是否合法应为循坏操作,窗口合法进循环,记录新长度、left++ 出窗口,再进入循环入口判断
- 结论:
- 窗口不合法,即 sum 小于 target ,right 需右移进窗口
- 窗口合法,进循环出窗口,循环结束后窗口不合法,right 需右移进窗口
- 所以无论如何,right 无需回退,只需右移执行进窗口操作
过程演示:
代码实现:
class Solution { public: int minSubArrayLen(int target, vector<int>& nums) { size_t left = 0,right = 0,sz = nums.size(); size_t ret = -1,sum = 0; // 用 sum 维护窗口 while(right < sz) { // 更新 sum 维护窗口 sum += nums[right]; // 判断窗口的合法性 while(sum >= target) { // 窗口合法,更新结果 ret = min(ret,right - left + 1); // 出窗口 sum -= nums[left++]; } // 窗口不合法,进窗口 right++; } // 特殊情况的处理,未找到结果 if(ret == -1) return 0; else return ret; } };
无重复字符的最长子串
解析: 该题目是经典的找子串问题,故可以用滑动窗口解决
优化思路:
- 第一个不合法窗口如下:
- 暴力解法的思路:让 left 向后移一位,right 回到 left 的位置向后穷举
- 同上题,left 后移的思路没有问题,但是再思考一下:right 有必要回退吗???
- 首先, right 指向的位置刚好导致窗口非法,即说明 right 指向窗口内重复出现的字符
- left 向后移一位,窗口内子串的变化情况如下:
- a. left 出窗口的字符为 right 指向的字符:此时窗口恢复合法,right 移动情况如下:
- 暴力解法让 right 回退到 left 的位置,但是 left 出窗口后,窗口已经合法,right 回退还是会回到原来的位置
- 故 right 不需要回到 left 位置,只需让 right 右移进窗口来最长的合法窗口
- b. left 出窗口的字符不为 right 指向的字符:此时窗口依然不合法,指针又该如何移动???:
- 窗口依然不合法,right 无需向后移动,只需让 left++ 继续出窗口直至出掉重复的字符使窗口合法
- 所以判断窗口是否合法应为循坏操作,窗口非法进循环,left++ 出窗口,再进入循环入口判断
- 结论:
- 窗口不合法,子串出现重复字符 ,进循环让 left++ 出掉重复的字符使窗口合法
- 窗口合法,right++ 进窗口,使窗口变长,找最长的窗口
- 所以无论如何,right 无需回退,只需右移执行进窗口操作
- 和上题不一样,这里是窗口不合法进循环,所以执行到循环后的代码,窗口是合法的,在此处更新结果
过程演示:
代码实现:
class Solution { public: int lengthOfLongestSubstring(string s) { int hash[128] = {0}; // 哈希表统计窗口内每个字符出现的频次,用来维护窗口 size_t left = 0, right = 0, sz = s.size(); size_t ret = 0; while (right < sz) { hash[s[right]]++; // 把 right 指向字符丢进哈希表 while (hash[s[right]] > 1) // 若丢进哈希表的字符频次大于 1 ,子串出现重复字符,窗口不合法 hash[s[left++]]--; // left++ 出窗口,并把出窗口的字符在哈希表中的频次减一 ret = max(ret, right - left + 1); // 更新结果 right++; // right++进窗口 } return ret; } };
- 相关例题(难度递增)
- 最大连续 1 的个数 III
提示:转化为找到最长子数组,使子数组中 0 的个数不超过 k
- 水果成篮
提示:转化为找最长子数组,使数组内部不同数的个数不超过 2
- 将 x 减到 0 的最小操作数
提示:转化为找到最长子数组,使子数组中元素的和等于整个数组元素的和减去 x(如何转化是该题最为困难的地方)
- 最大连续 1 的个数 III
三、长度固定不变的窗口
- 经典例题
找到字符串中所有字母异位词
题目来源
解析: 该题目依旧是找子串问题,不同于之前的题目,该题目的窗口是固定的,整个过程演示大致如下图所示:
窗口长度固定不变的题目,大家很容易想到这种思路,但细节实现的不同也会大大影响算法的时间复杂度!!!
优化:
- 如果你代码的实现是:先固定 left ,再让 right 向后移 len 步,即先固定窗口的大小,那么恭喜你,你掉入了本题的陷阱;该代码为本题的暴力解法,时间复杂度为 O(N^2),因为你还需遍历一次窗口来判断窗口内字符串是否为 p 的异位词
- 尽管从上述代码可以看到滑动窗口的身影,但却看不到滑动窗口的精髓,因为滑动窗口的灵魂是:每次进出窗口的同时来维护窗口,而非固定完窗口再判断(省去遍历窗口的时间)!!!
- 如何判断异位词???
- 绝大部分同学的思路是:将 p 字符串和窗口内字符串的每个字符出现的频次分别用两个哈希表记录,如果哈希表完全相同,则为异位词
- 然而这种判断方法需要遍历两遍哈希表,造成效率的下降;此处我们用 count 来记录窗口内有效字符的个数
- 字符进窗口时,字符在 hash2 中频次加一,此时字符在 hash2 中频次如果小于等于字符在 hash1 中的频次,进窗口字符即为有效字符,count++
- 字符出窗口时,如果字符在 hash2 中频次小于等于字符在 hash1 中的频次,出窗口字符即为有效字符,count–,字符在 hash1 中频次减一
- 故当窗口内有效字符的个数 count 等于 p 字符串长度时,窗口内字符串即为 p 的异位词
代码实现:
class Solution { public: vector<int> findAnagrams(string s, string p) { int hash1[26] = { 0 }; // hash1 记录 p 字符串每个字符出现的频次 int hash2[26] = { 0 }; // hash2 记录窗口内字符串每个字符出现的频次 for(auto ch : p) hash1[ch - 'a']++; vector<int> ret; // 记录符合题目要求字符串的索引 int len = p.size(); int left = 0,right = 0,sz = s.size(),count = 0; while(right < sz) { char in = s[right]; if(++hash2[in - 'a'] <= hash1[in - 'a']) // 先将 right 处字符丢进 hash2 再记更新有效字符的个数 count++; if(right - left + 1 > len) { char out = s[left++]; // 出窗口 if(hash2[out - 'a']-- <= hash1[out - 'a']) // 先更新有效字符的个数再从 hash2 中丢出 left 处字符 count--; } if(count == len) // 有效字符等于 p 字符串长度时,向 vector 中插入索引 ret.push_back(left); right++; } return ret; } };
-
串联所有单词的子串
解析:
- 此题的是 LeetCode 困难级别的题目,困难点在于,如何转化为滑动窗口问题;此题和上一题异位词几乎一模一样,区别在于:异位词是对字符的重排列,而串联字符串是对字符串的重排列;如果将此题 words 数组中元素看作字符,就和上一题一模一样
- 然而,按上题划分只进行一次滑动窗口可能会遗漏答案,还需要再进行 2 次划分,并执行滑动窗口
- 本题之所以能如此划分,是因为 words 数组中所有字符串的长度相同;转化为上一题后,代码实现就很简单了
- 优化: 本题在更新 count 时需要用到 [ ] 操作符,如果 [ ] 中 key 值不存在,则会进行一次插入,所以在比较 hash1 和 hash2 中 key 的频次时,先判断 key 在不在 hash1 中,避免插入时创建字符串的消耗
代码实现:
class Solution { public: vector<int> findSubstring(string s, vector<string>& words) { int len = words[0].size(); unordered_map<string,int> countmap1; //第一个哈希表 countmap1 记录 words 中字符串出现的频次 for(auto& str : words) countmap1[str]++; vector<int> ret; for(int i = 0 ; i < len ; i++) // 执行滑动窗口的趟数 { int left = i , right = i , count = 0 , sz = s.size(); unordered_map<string,int> countmap2; //第二个哈希表 countmap2 记录窗口内字符串出现的频次 while(right + len <= sz) // right 指向位置如果组不成长度为 len 的字符串时,表示单趟滑动窗口结束 { string in = s.substr(right,len); ++countmap2[in]; // right 处字符串进第二个哈希表 countmap2 if(countmap1.count(in) && countmap2[in] <= countmap1[in]) // 更新窗口内有效字符串的个数 count++; if(((right - left)/len + 1) > words.size()) { string out = s.substr(left,len); if(countmap1.count(out) && countmap2[out] <= countmap1[out]) // 更新窗口内有效字符串的个数 count--; --countmap2[out]; // 将出窗口字符串从哈希表中丢出 left += len; // 出窗口 } if(count == words.size()) // 有效字符串数等于 words 中字符串个数时,向 vector 中插入串联子串的索引 ret.push_back(left); right += len; // 进窗口 } } return ret; } };
- 相关例题
- 最小覆盖子串
提示:长度变化的窗口 + 记录有效字符数
- 最小覆盖子串
四、总结
- 滑动窗口的适用场景
- 找子数组及子串问题
- 细节操作
- 维护窗口方法
- 使用变量维护,如:长度最小的子数组
- 使用哈希表维护,记录 key 值出现频次,如:无重复字符的最长子串
- 使用哈希表 + 变量,记录频次并更新变量,如:找到字符串中所有字母异位词、串联所有单词的子串
- 进出窗口
- 进窗口,right++
- 出窗口,left++
- 判断窗口
- 窗口长度变化,则需 while 循环判断出窗口
- 窗口长度不变,则需 if 条件判断出窗口
- 更新结果
- 具体问题具体分析
- 维护窗口方法
- 重点
- 滑动窗口的正确性
- 如何从暴力解法优化到滑动窗口