深入浅出滑动窗口机制,让你不再滑不动
哈喽哈喽,大家好。我是你们的金樽清酒。最近不是马上到金三银四春招了嘛。我经历了两场面试。感觉这两场面试都学到了很多,后面再跟大家分享吧。今天的主角呢是滑动窗口,也是面试中没有做出来,分享给大家,难度也是中等难度,但是你接触过后它就是套公式的简单的题目。知一可求二。
首先我们来看一道算法题
给定一个字符串 s
,请你找出其中不含有重复字符的 最长 子串 ****的长度。
示例 1:
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
示例 2:
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列, 不是子串。
提示:
0 <= s.length <= 5 * 104
s
由英文字母、数字、符号和空格组成
让我们求一个字符的串的最长子串。这种求字符串的子串或者数组子串的问题,一般都可以用滑动窗口来做。什么是滑动窗口呢?就是设置两个指针。这两个指针的区域就像一个窗口一样,这个窗口内的区域满足一定的条件。像这道题,窗口符合的条件就是当前无重复的最长子串。但是这个窗口会滑动会变化,直到遍历完所有的条件找到最大值。
什么是滑动窗口
-
定义:滑动窗口(Sliding Window) 是一种用于解决数组或字符串中子串/子数组问题的经典算法技巧,它通过维护一个动态的窗口(由左右指针定义)来高效地遍历数据,避免暴力解法中的重复计算,时间复杂度通常为 O(n)。
-
核心思想
- 窗口定义:用两个指针
left
和right
表示窗口的左右边界。 - 窗口移动:根据条件动态调整
left
和right
,使得窗口在数据上“滑动”。 - 核心目标:在遍历过程中,找到满足条件的窗口,并记录最优解。
- 窗口定义:用两个指针
-
滑动窗口的使用场景
- 子串问题:“无重复子字符串的最大长度”
- 子数组问题:”和>=target的最小子数组“
- 统计类问题:“最大连续1的个数”
- 固定窗口问题:”长度为k的子数组的最大平均值“
-
滑动窗口的两种类型
- 可变窗口:根据条件动动态的调整左右指针。
- 固定窗口:窗口大小固定,直接滑动窗口,
简单的来说,滑动窗口就是用两个指针维护一个区间窗口,并且对区间进行移动,扩充或者收缩,使得窗口内的是符合条件的元素。然后取最值。
解题思路和代码
我看了什么是滑动窗口的定义之后,对于开头的题目,想必大家已有眉目。那必是滑动窗口来解决,并且是可变窗口,然后窗口内维护的就是子字符串,我们就是要通过滑动窗口来取最长子字符串。
那我们第一步就是要维护一个窗口,第二步就是要确定这个窗口里面是无重复子字符串。最后在众多的窗口之间选择最大值。
那么如何确定窗口里面是无重复子字符串呢,我们可以借助hash来进行统计。并记录下该元素的坐标,以便后续对窗口进行移动。
var lengthOfLongestSubstring = function (s) {
let substringMap = new Map()
let left = 0
let maxSize = 0
for (let right = 0; right < s.length; right++) {
let currentValue = s[right]
if (substringMap.has(currentValue) && substringMap.get(currentValue) >=left) {
left = substringMap.get(currentValue)+1
}
//更新hash表
substringMap.set(s[right],right)
//更新子串长度
maxSize = Math.max(maxSize,right-left+1)
};
return maxSize
}
这是可变窗口的滑动窗口机制。那不可变窗口呢?别急,现在展示
固定窗口大小的滑动窗口
关于固定窗口的滑动窗口一般都是求长度为多少的子数组的平均值等。也就是说子数组的长度是固定的,也就是说窗口是大小是固定的,我们只需要滑动窗口遍历找条件即可。
首先我们来做一道简单的题目,求有效的字符异位符吧。
给定两个字符串 s
和 t
,编写一个函数来判断 t
是否是 s
的 字母异位词。
示例 1:
输入: s = "anagram", t = "nagaram"
输出: true
示例 2:
输入: s = "rat", t = "car"
输出: false
提示:
1 <= s.length, t.length <= 5 * 104
s
和t
仅包含小写字母
分析一下题目,有效字母异位符。判断两个字符串是不是异位的,就是含字母的数量是一样的,但是可能位置不一样,那怎么做呢?
想了一下,转换成数组排序,用hash进行统计。但是呢最好的办法是维护一个数组,数组的值就是字母出现的次数。
var isAnagram = function(s, t) {
//首先考虑特殊条件
let slen = s.length
let tlen = t.length
if(slen!== tlen) return false
let sArray = new Array(26).fill(0)
let tArray = new Array(26).fill(0)
for(let i= 0;i<slen;i++){
sArray[s.charCodeAt(i)-'a'.charCodeAt(0)]++
tArray[t.charCodeAt(i) - 'a'.charCodeAt(0)]++
}
return sArray.toString()=== tArray.toString()
};
sArray和tArray就是我们维护的数组,chatCodeAt()是取字符的unicode编码,减去a的编码就是数组的下表。这样比如我们遍历到a字符,那么就会给数组的第一项的值➕1.最后转成字符串比较。这样可以高效的判断是否是有效的字母异位符。有小伙伴可能会说,不是滑动窗口嘛,跟有效字母异位符有什么关系。这是前置条件哦,因为接下来,难度升级。
接下来我们要找到字符串中所有的字母异位符。 438. 找到字符串中所有字母异位词
给定两个字符串 s
和 p
,找到 s
****中所有 p
****的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
示例 1:
输入: s = "cbaebabacd", p = "abc"
输出: [0,6]
解释:
起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。
起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。
示例 2:
输入: s = "abab", p = "ab"
输出: [0,1,2]
解释:
起始索引等于 0 的子串是 "ab", 它是 "ab" 的异位词。
起始索引等于 1 的子串是 "ba", 它是 "ab" 的异位词。
起始索引等于 2 的子串是 "ab", 它是 "ab" 的异位词。
提示:
1 <= s.length, p.length <= 3 * 104
s
和p
仅包含小写字母
这个时候,我们分析一下,是不是要用上滑动窗口了,每几个元素验证一下,验证完了再滑动一下,直到遍历完所有的 子字符串。并且还是固定长度的。
我们分析一下怎么做呢?首先要维护一个窗口,然后验证窗口内是不是有效字母异位符,然后再滑动窗口。右加左减。
var findAnagrams = function (s, p) {
//固定长度的窗口
let left = 0
let sArray = new Array(26).fill(0)
let pArray = new Array(26).fill(0)
let slen = s.length
let plen = p.length
let result = []
//如何判断是否是异位词呢?用sArray 和pArray来处理是否为异位词
for (let i = 0; i < plen; i++) {
pArray[p.charCodeAt(i) - 'a'.charCodeAt(0)]++
sArray[s.charCodeAt(i) - 'a'.charCodeAt(0)]++
//统计p中的字符
}
if (pArray.toString() === sArray.toString()) result.push(0)//符合条件
//滑动窗口
for (let right = plen; right < slen; right++) {
sArray[s.charCodeAt(right) - 'a'.charCodeAt(0)]++
sArray[s.charCodeAt(right - plen) - 'a'.charCodeAt(0)]--
if (pArray.toString() === sArray.toString()) result.push(right - plen+1)//符合条件
//然后窗口右移,右加左减
}
return result
};
这样通过滑动窗口机制和判断有效字母异位符就可以解决这道题目。很荣幸,这是对我这个算法小菜鸡来说,查找可一点点前置知识第一次做出这种难度的题,感觉还是很有成就感的。
我们再来做一道题来感受一下滑动窗口的魅力
给定一个含有 n
****个正整数的数组和一个正整数 target
。
找出该数组中满足其总和大于等于 ****target
****的长度最小的 子数组 [numsl, numsl+1, ..., numsr-1, numsr]
,并返回其长度 。 如果不存在符合条件的子数组,返回 0
。
示例 1:
输入: target = 7, nums = [2,3,1,2,4,3]
输出: 2
解释: 子数组 [4,3] 是该条件下的长度最小的子数组。
示例 2:
输入: target = 4, nums = [1,4,4]
输出: 1
示例 3:
输入: target = 11, nums = [1,1,1,1,1,1,1,1]
输出: 0
提示:
1 <= target <= 109
1 <= nums.length <= 105
1 <= nums[i] <= 104
找到值总和大于等于target的长度最小的子数组。触发关键词保底了。什么呀,子数组?大于等于target?长度最小?这些关键词一出来,你是不是立马就想维护一个窗口。窗口里面的条件就是大于等于target对吧。然后取其中最小的是不是?诶,这思路不就来了嘛,话说滑动窗口滑不动嘛。滑不动那就看看我的文章,滑一下就过去了。
代码:
var minSubArrayLen = function(target,nums){
//初始化左右指针维护一个窗口
let l = 0
let r = 0
let size = Infinity//size初始化为无穷大
curentSum = 0 //当前的数组总和
while (r<nums.length){
currentSum += nums[r]
while (curentSum >= target){
cureentSum = currentSum - nums[l]//减去前一个值
size = Math.min(size,r-l+1)//期间都是符合的子数组,要取最小
l++//移动左指针
}
r++
}
return size===Infinity? 0 : size
}
总结
好了大家,虽然前面废话很多。但是总结还是要好好的写一下子的。 什么是滑动窗口。就是用左右指针维护一个窗口,然后窗口内的内容是符合我们的需求的。我们可以通过滑动这个窗口来遍历字符串或者数组,找到一些符合要求的子串和子数组。滑动窗口分为两种一种是变长窗口另一种是固定窗口。变长窗口要找到扩张和收缩的条件,而固定窗口只需要向右滑动。