滑动窗口入门篇
本篇提供:Java语言与Go语言版本
里面的代码均可以根据标题链接到对应题目提交通过。
前置知识和滑动窗口介绍
本篇题目示例的都是简单题或者中等题。
题目:
练习:
前置知识
前置知识: 数组,字符串, 部分题需要用到哈希表处理。
滑动窗口是一种数组操作技巧。
滑动窗口介绍
利用(类似贪心)分析单调性, 找到范围和答案之间的单调性关系。
滑动窗口是一个子数组或者子串区间[left,right]
, 左右两个都是闭区间 , 借助这个区间+变量/某种结构(比如哈希表)可以做到维护区间信息。
滑动窗口实际上是left,right两个同向指针维护的区间, 左右指针都不回退。=>滑动窗口: 同向双指针+单调性+变量or结构维护区间信息。
滑动窗口的结构与数据结构-队列非常相似, 窗口中进窗口出窗口, 队列中入队与出队。只不过我们可以用双指针直接维护窗口, 不用真的申请一个队列容器维护(浪费空间)。
有关单调队列的部分以后会放在栈与队列模块, 它是维护滑动窗口的最大值和最小值的一种更新结构
有关滑动窗口是双指针的补充
双指针只是一个外壳, 本质是单调性的处理和实际题意处理。 双指针这个皮并不重要。
双指针是根据实际容器使用, 然后发现可以用少数指针替代容器, 进而优化空间的复杂度为 O ( 1 ) O(1) O(1)。
具体题目中的贪心分析,单调性分析, 数学分析, 实际的细节处理 是真正意义上将暴力枚举的时间优化成线性时间的。
滑动窗口实际时间复杂度 O ( n ) O(n) O(n), 它是一种线性时间完成题目的任务。
题目篇
题目1:Minimum Size Subarray Sum
分析题意
给定一个含有 n 个正整数的数组和一个正整数 target 。
找出该数组中满足其总和大于等于 target 的长度最小的
子数组
[numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。
示例1
输入:target = 7, nums = [2,3,1,2,4,3]
输出:2
解释:子数组 [4,3] 是该条件下的长度最小的子数组。
数据范围限制:
- 1 < = t a r g e t < = 1 0 9 1 <= target <= 10^9 1<=target<=109
- 1 < = n u m s . l e n g t h < = 1 0 5 1 <= nums.length <= 10^5 1<=nums.length<=105
- 1 < = n u m s [ i ] < = 1 0 4 1 <= nums[i] <= 10^4 1<=nums[i]<=104
怎么思考呢?
从每个位置开始或者每个位置结尾开始枚举。
比如,依次枚举从0下标开头, 1下标开头,2下标开头...的符合要求的最短长度子数组。
或者,从n-1下标结尾,n-2下标结尾,...向前枚举, 依次找到最短长度子数组。
这里以每个位置为头开始向后枚举为例。
解法一:暴力枚举(TLE)
以示例一举例,
以0位置开头为例,[2,3,1,2]
1位置, [3,1,2,4]
2位置, [1,2,4]
3位置, [2,4,3]
4位置, [4,3]
ans初始为系统最大值Integer.MAX_VALUE
设置一个ans变量记录最小长度, 每次找到一个符合要求的子数组,更新ans。
java
class Solution {
public int minSubArrayLen(int target, int[] nums) {
int n = nums.length;
//由于本题求最小值, 初始化ans为系统最大值
int ans = Integer.MAX_VALUE;
//外循环枚举每一个位置开头
for(int i=0;i<n;i++){
int sum = 0;
//从开头位置向下延申
for(int j=i;j<n;j++){
sum+=nums[j];
if(sum>=target){
ans = Math.min(ans, j-i+1);
break;//如果发现一个解,那么一定i位置打头的最短子数组,小优化。
}
}
}
//若ans还是为最初的值,那么不存在满足要求的一个子数组。
return ans==Integer.MAX_VALUE?0:ans;
}
}
为什么会超时?(时间复杂度分析)
确定每个子数组的开始下标后,找到长度最小的子数组最坏下需要 O ( n ) O(n) O(n)的时间。
由于 1 < = n u m s . l e n g t h < = 1 0 5 1 <= nums.length <= 10^5 1<=nums.length<=105
时间复杂度是 O ( n 2 ) O(n^2) O(n2), Java1~2秒处理10^8的指令, 那么10^5的平方时间复杂度必然会超时...
解法二:滑动窗口(最优解)
你需要看看解法一, 然后看这个。
思考一下,我们需要每次找到一个子数组结果, 需要让它以下一个位置为头重新开始吗?
没必要!当扩展到一种结果时, 由于数组元素都是正整数, 那么这个子数组的和一定是单调递增的。如果当前子数组和大于等于target, 应该挪动左边界减小区间的子数组和。 然后根据子数组和选择是否往后扩(挪动右边界)或者挪动左边界。
0位置开头,[2,3,1,2]
可以挪动左边界吗, 发现如果挪动左边界[3,1,2]
,发现子数组和小于target。好的,那么不能挪动左边界,选择右边界扩展。
[2,3,1,2,4]
, 这个结果一定是满足条件的,但不一定是最短的。再次判断,是否挪动左边界, 发现挪动一个左边界后[3,1,2,4]
, 发现子数组和仍然大于等于target, 那么继续挪动左[1,2,4]
。发现子数组和恰好等于target,不能继续挪动左边界了。 继续挪动右边界[1,2,4,3]
,重复上述过程。[4,3]
,此时左边界不能挪动, 右边界也不能挪动(达到数组边界了), 那么结束。
期间, 用一个变量ans
记录满足条件的最小子数组长度。实时更新。
最后返回结果。
Java版本
java
class Solution {
public int minSubArrayLen(int target, int[] nums) {
//初始化系统最大值
int ans = Integer.MAX_VALUE;
for(int l = 0, r = 0 , sum = 0;r < nums.length ; r++){
//进窗口
sum += nums[r];
//判断条件出窗口
while(sum - nums[l] >= target){
//sum : nums[l...r]
//如果nums[l]出窗口仍然满足条件, 那么出窗口
sum -= nums[l++];
}
if(sum >= target){
ans = Math.min(ans , r - l + 1);
}
}
//ans若仍然是初始值, 那么根据题意返回0
return ans == Integer.MAX_VALUE ? 0 : ans;
}
}
Go版本:
go
import "math"
func minSubArrayLen(target int, nums []int) int {
l,r:=0,0
//初始为int最大值
ans := math.MaxInt
sum := 0
for r<len(nums){
sum += nums[r]
for sum-nums[l]>=target{
//sum : nums[l...r]
//如果nums[l]出窗口仍然满足条件, 那么出窗口
sum -= nums[l]
l++
}
if(sum >= target){
ans = min(ans, r-l+1)
}
r++
}
if(ans == math.MaxInt){
return 0
}
return ans
}
//设计min函数, Go语言没有像Java那样内置Math.min(int,int)...
func min(x,y int) int{
if x>y{
return y
}
return x
}
解法三: 前缀和&二分查找
以下解法三不属于本篇内容滑动窗口的范畴, 感兴趣或者前缀和二分过来的朋友可以参考这种写法
分析:
本题分析求和大于等于target的最小长度子数组。
子数组和不禁让人联想到前缀和
, 子数组的区间和开头表示边界前缀和的差值。
构建的前缀和数组还是严格递增的(因为原数组元素是正数), 差值也是严格递增的。还可以使用二分查找。
因此,这题坚定了我们构建前缀和数组的想法。
基本流程就是先构建前缀和数组, 枚举每个位置为头的子数组区域和,然后二分查找。
O ( n ) O(n) O(n)构建前缀和数组
外层循环线性时间枚举每个位置为子数组的开头 外层循环线性时间枚举每个位置为子数组的开头 外层循环线性时间枚举每个位置为子数组的开头
内层循环 O ( l o g n ) :利用前缀和的数组的有序性快速定位到满足的最小右边界 内层循环O(logn): 利用前缀和的数组的有序性快速定位到满足的最小右边界 内层循环O(logn):利用前缀和的数组的有序性快速定位到满足的最小右边界二分查找
时间复杂度是 O ( n + n l o g n ) = > O ( n l o g n ) O(n+nlogn)=>O(nlogn) O(n+nlogn)=>O(nlogn)
空间复杂度: O ( n ) O(n) O(n), 申请前缀和数组空间提供有序性和%O(1)$的子数组区间和查询。
Java版本
java
class Solution {
public int minSubArrayLen(int target, int[] nums) {
int n = nums.length;
//下标: 第i个元素, 值:[0...i-1]的前缀和
int[] prefix = new int[n+1];
for(int i=1;i<=n;i++){
prefix[i]=prefix[i-1]+nums[i-1];
}
//range(L,R) = prefix(R+1)-prefix()
int ans = Integer.MAX_VALUE;
for(int i=0;i<n;i++){
int l=i+1,r=n;
while(l<=r){
int m = l+((r-l)>>1);
//prefix[m]-prefix[i]是原数组中[i,...,m-1]子数组区间和
if(prefix[m]-prefix[i]>=target){
r = m - 1;
//区间长度[i...m-1]=> m-1-i+1 == m-i
ans = Math.min(ans, m-i);
}
else{
l = m + 1;
}
}
}
return ans == Integer.MAX_VALUE?0:ans;
}
}
Go版本
go
func minSubArrayLen(target int, nums []int) int {
n := len(nums)
prefix := make([]int, n+1)
for i:=1;i<=n;i++{
prefix[i]=prefix[i-1]+nums[i-1]
}
ans := n+1//初始化为一个不可能的值, 比如总长度+1
//穷举[0...n-1]开头的局部子数组和
for i:=0;i<n;i++{
l,r:=i+1,n
for l<=r{
m := l+((r-l)>>1)
if prefix[m]-prefix[i]>=target{
r = m-1
ans = min(ans,m-i)
}else{
l = m+1
}
}
}
// 如果没有找到符合条件的子数组,返回 0
if ans == n+1{
return 0
}
return ans
}
func min(x,y int) int{
if x>y{
return y
}
return x
}
滑动窗口模板
滑动窗口是一种高效解决子数组或子字符串问题的技巧。
比如某些比较阴的字符串问题就可以套用模板解决。
子数组或者子串必须具有某种"单调性"。 可以用同向双指针来维护窗口的信息
-
初始化左右指针:
l
和r
初始化为0
,分别表示窗口的左边界和右边界。索引闭区间 [left, right] , 有些题是[L,r),取决于题目r,l扩展的位置。(比如for循环风格和while循环风格, 扩展后立即自增r还是执行循环完毕自增, 具体分析!)
-
扩展右边界:
r++
表示右指针扩展窗口。- 根据需求,更新窗口内统计状态
condition
(如窗口内的和、频率等)。
-
收缩左边界:
- 当窗口内的状态不符合要求时,通过移动左指针
l++
缩小窗口,直到窗口满足条件。 - 同时更新窗口状态
condition
,保持与当前窗口内容一致。
- 当窗口内的状态不符合要求时,通过移动左指针
-
更新结果:
- 在窗口满足条件时,计算并更新答案(如窗口长度、窗口内容的某种统计结果信息收集)。
- 更新结果的位置根据题意分析。
-
结束条件:
- 当右指针遍历完整个数组(或字符串)时结束。
练习一:Maximum Average Subarray I
给定 n 个整数,找出平均数最大且长度为 k 的连续子数组,并输出该最大平均数。
输入:[1,12,-5,-6,50,3], k = 4
输出:12.75
解释:最大平均数 (12-5-6+50)/4 = 51/4 = 12.75
- n == nums.length
- 1 <= k <= n <= 1 0 5 10^5 105
- − 1 0 4 -10^4 −104 <= nums[i] <= 1 0 4 10^4 104
套用模板即可。 很明显的滑动窗口。
[1,12,-5,-6]
=>[12,-5,-6,50]
=>[-5,-6,50,3]
.
设计一个全局变量ans记录子数组的最大和。
java代码
java
class Solution {
// 1 <= k <= n <= 10^5
public double findMaxAverage(int[] nums, int k) {
int ans = Integer.MIN_VALUE;
for(int l=0,r=0,sum=0;r<nums.length;r++){
sum += nums[r];
if(r-l+1==k){
ans = Math.max(ans,sum);
sum -= nums[l++];
}
}
return ans*1.0/k;
}
}
Go代码
go
import "math"
func findMaxAverage(nums []int, k int) float64 {
ans := math.MinInt
for l,r,sum:=0,0,0;r<len(nums);r++{
sum += nums[r]
if(r-l+1==k){
ans = max(ans, sum)
sum -= nums[l]
l++
}
}
return float64(ans)/float64(k)
}
func max(x,y int) int{
if x>=y{
return x
}
return y
}
题目2:Longest Substring Without Repeating Characters.
给定一个字符串 s ,请你找出其中不含有重复字符的 最长
子串的长度。
示例一
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
题目范围
- 0 < = s . l e n g t h < = 5 ∗ 1 0 4 0 <= s.length <= 5 * 10^4 0<=s.length<=5∗104
- s 由英文字母、数字、符号和空格组成 s 由英文字母、数字、符号和空格组成 s由英文字母、数字、符号和空格组成 ->ASCII码
你已经具备相关经验了, 那么分析这道题为什么是滑动窗口。
暴力解法的细节不再说了, 必然超时。
首先从左到右可以得出第一个无重复字符的子串abc
, 长度为3。
当它再一次将a纳入时, 发现a已经出现过, 于是从子串a的下一个位置开始, 继续向右扩展。
a再一次出现时,说明前面的子串以a结尾的部分已经不满足, 直接选择abca
,上次出现a的下一个字符作为出发点,bca
是新的子串。
需要回退吗? 不需要! l直接跳到子串中出现重复字符的下一个位置 。
怎么知道字符上次出现的位置? 哈希表或者数组, 本题涉及ASCII码,显然用数组(常数时间比语言内置哈希表好)
java版本
:
java
//leetcode测试链接:https://leetcode.cn/problems/longest-substring-without-repeating-characters/
class Solution {
public static int lengthOfLongestSubstring(String str) {
if(str==null||str.equals("")){
return 0;
}
char[] s = str.toCharArray();
//i下标:字符的ASCII码-> val:该字符最近出现在字符串中的位置
int[] last = new int[128];
//初始化无效值-1
Arrays.fill(last, -1);
int ans = 0;//最长子串的长度
for(int l=0,r=0;r<s.length;r++){
//更新左边界
//1. 更新左边界, 保持不变 or s[r]字符重复出现, 哈希表快速索引到重复字符的下一个位置。
l = Math.max(l, last[s[r]]+1);
//更新ans
ans = Math.max(ans, r-l+1);
last[s[r]] = r;
}
return ans;
}
}
Go版本
:
go
func lengthOfLongestSubstring(str string) int {
if str==""{
return 0
}
//i下标:字符的ASCII码-> val:该字符最近出现在字符串中的位置
var last [128]int;
//初始化无效值-1
for i:=0;i<128;i++{
last[i]=-1
}
ans := 0
for l,r:=0,0;r<len(str);r++{
//更新左边界
//1. 更新左边界, 保持不变 or s[r]字符重复出现, 哈希表快速索引到重复字符的下一个位置
l = max(l,last[str[r]]+1)
//更新ans
ans = max(ans,r-l+1)
last[str[r]] = r
}
return ans
}
func max(x,y int) int{
if x>y{
return x
}
return y
}
时间复杂度分析:
O ( n ) O(n) O(n)
空间复杂度分析:
O ( 1 ) O(1) O(1)
结语
考虑篇幅有限, 只介绍了两道题目, 其余滑动窗口的题目后续更新。
切忌盲目套用模板, 一定要分析题目, 思考是否符合滑动窗口的特性。
结束...