文章目录
- 前言:滑动窗口解题思路小结
- 一、长度最小的子数组
- 二、无重复字符的最长子串
- [三、 最大连续1的个数 III](#三、 最大连续1的个数 III)
- [四、 将 x 减到 0 的最小操作次数](#四、 将 x 减到 0 的最小操作次数)
- 五、串联所有单词的子串
- 六、最小覆盖子串
前言:滑动窗口解题思路小结
-
滑动窗口是一种优化暴力解法的双指针技巧。
-
左指针和右指针构成一个窗口,通过移动左右指针来调整窗口大小或位置。
什么时候使用滑动窗口?
最适合的场景:
- 连续子数组/子串问题(`必须连续!``)
- 求满足条件的"最长"/"最短"子数组
- 涉及"和"、"乘积"、"不同字符数"等约束条件,比如:"为x的子数组"这样的问题
而像"找到[任意位置]的和为k的子序列"这样的问题就不能使用滑动窗口算法思想来解题,因为数据不连续,不适合用。
- 具体还要在题目中感知滑动窗口的算法思想
移动策略:
-
右指针:每次循环固定右移,探索新元素
-
左指针:只在窗口不满足条件时移动(while循环)
-
用变量维护窗口的当前状态(由窗口得来的信息),比如:和、计数、字符频率等。随着窗口的大小/位置发生变化,我们所维护的窗口状态信息一定也要及时更新!
一、长度最小的子数组
给定一个含有 n 个正整数的数组和一个正整数 target 。
找出该数组中满足其总和大于等于 target 的长度最小的 子数组 [numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。
解题思路
- 利用正整数的数量与加和结果成正比的特性结合滑动窗口思想解题
- 依题意来定义滑动窗口边界移动的规则
- 利用sum+=以及-=的操作可以避免每次对新的子数组进行从头到尾的遍历计算其元素加和的值,在滑动窗口移动的过程中可以直接拿累加的结果sum加上进入窗口的值或减去出窗口的值。
代码实现及解析
java
class Solution {
public int minSubArrayLen(int target, int[] nums) {
int left=0,right=0;//定义滑动窗口边界
int sum=0;//记录子数组元素之和
int minLen=Integer.MAX_VALUE;//记录筛选的子数组中长度的最小值
while(right<nums.length){
sum+=nums[right];//每次直接让sum+=右边界的值,不用再每次从头开始遍历子数组计算总和
while(sum>=target){//发现目标子数组
int len=right-left+1;
minLen=Math.min(minLen,len);//记录数组长度较小值
sum-=nums[left++];//left++直接进入下一趟,因为后面的值都是正整数,加上一定满足>=target,
//但是数组长度却在增加。
//让sum减去去掉的left位置的值,这样sum可以直接用来进行下一趟的计算
}
right++;//right不用从头开始遍历,因为加和结果一定是<target的
}
return minLen==Integer.MAX_VALUE?0:minLen;
}
}
总结
当解题过程中出现两个指针共同维护指针中间的区域(常见于"连续子数组问题"、"连续子字符串问题",不是连续的数据就无法构成一个完整的窗口了),且两个指针的移动方向为同向时,就可以使用滑动窗口的算法思想来解决问题依题意来定义滑动窗口边界移动的规则
二、无重复字符的最长子串
给定一个字符串 s ,请你找出其中不含有重复字符的 最长 子串 的长度。
示例 :
输入: s = "abcabcbb"
输出: 3
解题思路
- 使用滑动窗口算法思想,right位置字符入窗口,并记录每个字符出现次数以及子串长度,一旦出现重复记录的字符就要移动窗口的左边界到合适位置。
代码实现及解析
java
class Solution {
public int lengthOfLongestSubstring(String s) {
char[] arr=s.toCharArray();
int[] hash=new int[128];//模拟哈希表
int ret=0;
int left=0;
int right=0;
while(right<arr.length){
hash[arr[right]]++;//入窗口
while(hash[arr[right]]>1){//一旦发现此次right入窗口后被记录了两次,此次的子串的长度就不能被计算了
hash[arr[left++]]--;//将left移到重复字符的后面,并同时删掉已被筛选的记录
}
ret = Math.max(ret, right - left + 1); // 更新结果(两者较大的长度)
right++; // 让下⼀个字符进⼊窗⼝
}
return ret;
}
}
总结
这种题不能遇到了重复字符才去尝试比较并更新子串最大长度,完全有可能会出现无重复字符的情况字符的ASC||码值范围是0~127,大小为128的数组就完全可以表示所有字符滑动窗口的实现常常与"因对暴力算法的优化而砍掉了很多遍历"这样的操作联系在一起,比如,窗口边界的移动或者滞留(因为有些可能是指针本来需从头开始遍历,而因优化直接原地开始就行)该题为经典题目,复习时建议再着重看一下代码实现
三、 最大连续1的个数 III
给定一个二进制数组 nums 和一个整数 k,假设最多可以翻转 k 个 0 ,则返回执行操作后 数组中连续 1 的最大个数 。
示例 :
输入:nums = [1,1,1,0,0,0,1,1,1,1,0], K = 2
输出:6
解释:[1,1,1,0,0,1,1,1,1,1,1]
粗体数字从 0 翻转到 1,最长的子数组长度为 6。
解题思路
- 最多可以将k个0翻转为1,这些被翻转后得到的1将于其相邻的1组成最长的连续的数字1,我们不妨直接找出数组的特殊的一个子数组,其满足:含有的数字0不超过k个,将这些0翻转为1就达到了 题目要求。
- 这样就将问题转化为了"连续的子数组问题"
代码实现及解析
java
class Solution {
public int longestOnes(int[] nums, int k) {
int ret=0;
int left=0,right=0;//窗口边界
int zeroNum=0;//维护窗口中0的个数
while(right<nums.length){
if(nums[right]==0){
zeroNum++;
while(zeroNum>k){//若窗口中0的个数>k,则及时调整窗口状态
if(nums[left++]==0){//左边界收缩,遇0,则zeroNum--
zeroNum--;
}
}
}
ret=Math.max(ret,right-left+1);//右边界本次变化都要更新ret状态,这样记录就不会遗漏
//上面的while(zeroNum>k)则会保证不会错误记录
right++;//右边界遍历数组
}
return ret;
}
}
总结
将特定问题转化为了"子数组问题",便于解题
滑动窗口重要解题框架:
以上几个题目都可以看得出来滑动窗口的特点:右边界来遍历数组,左边界只有在窗口在特定状态时才会手动更改,比如出现异常时(窗口状态已不符合题意)。这种做法通常每次在右边界更新之前都要同时更新所维护的窗口的信息(不然会在因窗口异常而更改窗口状态时失去异常前正常的临界窗口状态的信息),这是滑动窗口常用的策略,务必熟悉!另外也要注意针对与每道题的细节处理
就像:
java
while(){//第一层循环
//右边界开始遍历数组,入窗口
.......
//入窗口之后立即检查窗口状态是否异常
while(检查窗口){
//左边界不断调整,直到窗口进入正常状态
}
//在right边界改变前更新所维护的窗口信息
right++;
}
四、 将 x 减到 0 的最小操作次数
给你一个整数数组 nums 和一个整数 x 。每一次操作时,你应当移除数组 nums 最左边或最右边的元素,然后从 x 中减去该元素的值。请注意,需要 修改 数组以供接下来的操作使用。
如果可以将 x 恰好 减到 0 ,返回 最小操作数 ;否则,返回 -1 。
示例 :
输入:nums = [1,1,4,2,3], x = 5
输出:2
解释:最佳解决方案是移除后两个元素,将 x 减到 0 。
解题思路
- x 减去数组 nums 最左边或最右边的元素,直到x减为0,找到操作次数最小的那一个方法,也就是要去两边被减去的元素长度最小
- 我们发现减去数据之后留下的是中间的数据,其数据和恒为原数组总和减去x,所以我们能不能找一个"中间子数组",其和为原数组总和减去x,且满足长度为最大。这样就把题目转换为了"特定条件的子数组"问题,以便求解
代码实现及解析
java
class Solution {
public int minOperations(int[] nums, int x) {
int ret=-1;//记录合法子数组的最大长度
int sum=0;//算一下数组的和
for(int a:nums){
sum+=a;
}
int target=sum-x;//得出子数组之和的的目标值
if(target<0) return -1;//细节:target可能<0(也就是x太大),导致后续逻辑混乱
int left=0,right=0;
sum=0;//sum重新来记录子数组的和
while(right<nums.length){
sum+=nums[right];
while(sum>target){//不合法的子数组,需调整窗口状态
sum-=nums[left++];//移动左边界
}
//注意这里:可能为出现不合法数组直接来到这个if语句,也可能在while循环调之后又刚好出现target值
if(sum==target){
ret=Math.max(ret,right-left+1);
}
right++;
}
if(ret==-1) return ret;
else return nums.length-ret;
}
}
总结
我们发现,有很多题目都可以转化为"子数组/子串问题"。本题也是将特定问题转化为了"满足特定条件的子数组问题",便于解题。且本题的转化方法值得学习本题将原问题转化为了其"对立问题",也就是把"找两边长度最小"问题转化为了"找中间长度最大问题",顺利地将复杂问题简单化,这种解题思路是解算法题时重要的思想!
五、串联所有单词的子串
给定一个字符串 s 和一个字符串数组 words。 words 中所有字符串 长度相同。
s 中的 串联子串 是指一个包含 words 中所有字符串以任意顺序排列连接起来的子串。
例如,如果 words = ["ab","cd","ef"], 那么 "abcdef", "abefcd","cdabef", "cdefab","efabcd", 和 "efcdab" 都是串联子串。 "acdbef" 不是串联子串,因为他不是任何 words 排列的连接。
返回所有串联子串在 s 中的开始索引。你可以以 任意顺序 返回答案。
示例 :
输入:s = "barfoothefoobarman", words = ["foo","bar"]
输出:[0,9]
解题思路
- 将words数组中的单词全部任意组合起来,得到一个新的字符串,在s中找到该字符串。
我们可以将这个问题转化为一个常见的问题:words中的单词看为组件,这些组件任意组合成一个新字符串,那么我们可以直接在s中找到与words含有一模一样组件的子串,则该子串一定可以由words中单词组合而成,具体组合顺序也无需给出。这样就将复杂的多元素任意组合问题转化为了在字符串中找到含固定元素的子串问题,那就可以使用滑动窗口算法思想来解决。
代码实现及解析
java
class Solution {
public List<Integer> findSubstring(String s, String[] words) {
List<Integer> ret=new ArrayList<Integer>();
int wordsNum=words.length;
int len=words[0].length();//每个单词长度
Map<String,Integer> wordsMap=new HashMap<>();
for(String str:words) wordsMap.put(str,wordsMap.getOrDefault(str,0)+1);//将words里面所有的单词及其频次记录
for(int i=0;i<len;i++){//按照不同起点对字符串进行划分为若干个单词,每一趟都是一次滑动窗口算法操作
Map<String,Integer> windowMap=new HashMap<>();
int left=i,right=i;//以每次的起点为窗口边界起始位置
int count=0;//记录当前窗口中有效子串的个数
while(right<=s.length()-len){//防止越界访问,此次一定是<=
//1.入窗口
String in=s.substring(right,right+len);
windowMap.put(in,windowMap.getOrDefault(in,0)+1);//记录每次进入窗的子串的类型与频次
//2.维护窗口信息
if(windowMap.get(in)<=wordsMap.getOrDefault(in,0)){//为有效子串
count++;
}
//3.检查窗口是否异常
if(right-left+1>len*wordsNum){//窗口异常
//(维护窗口信息)处理异常窗口之前,要先判断位于左边界的这个子串是否为有效子串,若是,则count--
String out=s.substring(left,left+len);
if(windowMap.get(out)<=wordsMap.getOrDefault(out,0)){//为有效子串
count--;
}
//4.处理异常的窗口,移动左边界
windowMap.put(out,windowMap.get(out)-1);
left+=len;
}
//4.记录正确的子串的开始索引
if(count==wordsNum){
ret.add(left);
}
right+=len;//右边界按单词长度跨越式遍历s字符串
}
}
return ret;
}
}
总结
见解题思路及代码实现。本题比较两个哈希表中储存的单词类型及出现频次的方法不是一一比较哈希表中数据,而是采用了count计数器来记录窗口中的"有效单词"的个数,最后再与words数组的长度对比就可以知道此次窗口信息是否符合题意
六、最小覆盖子串
给定两个字符串 s 和 t,长度分别是 m 和 n,返回 s 中的 最短窗口 子串,使得该子串包含 t 中的每一个字符(包括重复字符)。如果没有这样的子串,返回空字符串 ""。
测试用例保证答案唯一。
示例 :
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。
解题思路
- 具有综合性的一道滑动窗口题目,思路变化不大,但要注意题中细节处理
代码实现及解析
java
class Solution {
public String minWindow(String ss, String tt) {
char[] s=ss.toCharArray();
char[] t=tt.toCharArray();
int[] hashT=new int[128];//记录t数组中字符的类型和出现频次
int kinds=0;//记录t数组中的字符类型总数目
for(char ch:t){
if(hashT[ch]==0){
kinds++;
}
hashT[ch]++;
}
int[] hashWindow=new int[128];//记录窗口中字符的类型和出现频次
int count=0;//记录窗口中有效字符的种类个数
//滑动窗口的框架:
int left=0,right=0;
int minLen=Integer.MAX_VALUE;//记录所筛选的子串中的最小长度
int headIndex=-1;//记录子串的首位元素的索引,以返回该子串
while(right<s.length){
//1.入窗口
char in=s[right];
hashWindow[in]++;
//2.检查是否需要更新count
if(hashWindow[in]==hashT[in]){
count++;
}
//3.检查窗口状态是否已达到要求
while(count==kinds){
//更新窗口信息
if(right-left+1<minLen){
minLen=right-left+1;
headIndex=left;
}
//4.更改左边界来调整窗口,并且更改完之后要继续循环判断新的窗口是否达到要求
int out=s[left++];
if(hashWindow[out]--==hashT[out]){//更改左边界之前就要检查left出窗口后是否需要更新count
count--;
}
}
right++;
}
if(headIndex==-1){
return new String("");
}
return ss.substring(headIndex,headIndex+minLen);
}
}
总结
该题综合性较强,可以借助代码对滑动窗口的框架加深记忆
该题中使用到了一种对数据记录的一种优化方式,在前几题中也有用到,值得总结其方法:
我们使用到了两个用数组模拟的哈希表hash1、hash2来记录记录一组数据的字符的类型和出现频次,hash1对应目标数据,hash2对应我们正在处理的窗口中的数据。解题过程中我们会不断对hash1进行判断,看其是否已经达到或者已经不满足题目要求(也就是判断窗口异常),比如看hash1与目标数据组hash2是否已经具备了某种关系。这个时候一般做法是遍历两个哈希表,进行一一对比,但这种情况使用的哈希表一般是右很多空位的,那么就会出现很多不必要的遍历,我们可以对这种情况进行算法的优化
优化方法:
我们可以另外再定义一个变量 count,在窗口状态变化的过程中时刻记录下"有效的数据参数",让我们不必再对哈希表进行遍历就可以知晓hash1的状态或其与hash2的关系,这个"有效的数据参数"可以是窗口中的"不同的字母的数目"、"与hash2数据具有对应关系的数据的数目"等等