目录
[1. 基本概念](#1. 基本概念)
[2. 直观比喻](#2. 直观比喻)
[3. 适用场景](#3. 适用场景)
[1. 固定窗口(窗口大小固定)](#1. 固定窗口(窗口大小固定))
[2. 可变窗口(窗口大小动态调整)](#2. 可变窗口(窗口大小动态调整))
滑动窗口是算法中解决数组 / 字符串子区间问题 的经典技巧,核心是通过「双指针维护一个动态的区间(窗口)」,将暴力枚举的 O(n2) 时间复杂度优化到 O(n),是面试和算法题中的高频考点。
一、滑动窗口算法的基本介绍
1. 基本概念
- 窗口 :数组 / 字符串中一个连续的子区间 (用两个指针
left和right界定); - 滑动 :通过移动
left或right指针,改变窗口的大小和位置; - 核心思想:避免重复计算子区间的元素,只对窗口「新增 / 移除」的部分做操作。
2. 直观比喻
可以把滑动窗口理解为「一列火车的车厢」:
right指针是「火车头」,向右移动表示「加一节车厢」;left指针是「火车尾」,向右移动表示「减一节车厢」;- 我们只需要关注「新增的车头」和「移除的车尾」,而不用重新统计整列火车的乘客。
3. 适用场景
滑动窗口主要解决以下类型的问题:
- 找满足条件的最长 / 最短子数组 / 子串;
- 找和为定值的子数组;
- 无重复字符的最长子串;
- 最小覆盖子串;
- 水果成篮、最大连续 1 的个数等。
4.滑动窗口的分类
滑动窗口分为「固定窗口」和「可变窗口」,其中可变窗口是考察重点。
1. 固定窗口(窗口大小固定)
- 适用场景:找长度固定的满足条件的子区间;
- 核心逻辑 :
- 先让
right指针移动,直到窗口大小达到目标; - 之后
left和right同步右移,保持窗口大小不变; - 每次移动后,检查窗口内的元素是否满足条件。
- 先让
2. 可变窗口(窗口大小动态调整)
- 适用场景:找满足条件的最长 / 最短子区间(窗口大小不固定);
- 核心逻辑 :
- 移动
right指针扩大窗口,直到窗口内元素满足条件; - 移动
left指针缩小窗口,直到窗口内元素不满足条件; - 过程中记录满足条件的窗口的最大 / 最小长度。
- 移动
二:例题解析
1.固定窗口例题
例题1:438. 找到字符串中所有字母异位词 - 力扣(LeetCode)
题目描述:
算法原理:
- 预处理:统计基准频次(hash1)
- 定义长度为 26 的数组
hash1(对应 26 个小写字母),统计字符串p中每个字符的出现次数(比如p="abc"→hash1[0]=1, hash1[1]=1, hash1[2]=1,其余为 0)。- 这一步是后续窗口匹配的「基准」。
- 滑动窗口初始化
- 定义
hash2:动态统计窗口内字符的频次(与hash1结构一致)。- 定义
count:记录窗口内「有效字符数」(即:窗口内该字符的频次 ≤ p 中该字符的频次的字符总数)。- 定义双指针
left/right:分别表示窗口的左、右边界,初始都为 0。
- 滑动窗口的「扩张 + 收缩」逻辑
窗口的核心规则:窗口长度始终不超过 p 的长度 ,通过「右边界扩张→左边界收缩」的循环,遍历所有长度等于
p的子串:(1)右边界扩张(入窗口)
- 每次将
s[right]加入窗口:hash2[s[right]-'a']++。- 若「加 1 后的频次」≤
hash1中对应频次 → 说明该字符是「有效贡献」,count++。- 右边界右移(
right++)。(2)左边界收缩(出窗口)
- 当窗口长度(
right-left+1)>p的长度时,需要收缩左边界:
- 取出窗口左侧字符
s[left]:若「减 1 前的频次」≤hash1中对应频次 → 说明该字符的「有效贡献」消失,count--。hash2[s[left]-'a']--,左边界右移(left++)。
- 结果判断
- 当
count == p.size()时:说明窗口内所有字符的频次都完全匹配p(有效字符数等于 p 的长度),此时窗口对应的子串是p的异位词。- 记录当前窗口的起始索引
left到结果数组中。完整代码:
class Solution {
public:
vector<int> findAnagrams(string s, string p)
{
vector<int> v;
int hash1[26]={0};//统计字符串s里面的字符的频次
for(auto m : p) hash1[m-'a']++;
int m =p.size();
int hash2[26]={0};//统计窗口里面的字符的频次
int count =0;//统计窗口内满足条件的字符个数
for(int left=0,right=0;right<s.size();right++)
{
char in = s[right];
if(++hash2[in-'a'] <= hash1[in-'a']) count++;//入窗口+维护count
if(right-left+1 > m)
{
char out = s[left++];
if(hash2[out-'a']-- <= hash1[out-'a']) count--;
}
if(count == m)
v.push_back(left);
}
return v;
}
};
例题2:30. 串联所有单词的子串 - 力扣(LeetCode)
题目描述:
算法原理:
算法原理与上个题438的大致相同,只是部分细节需要处理。
步骤 1:预处理 - 统计单词频次
首先用哈希表
hash1统计words数组中每个单词的出现次数(比如words = ["foo","bar"],则hash1["foo"]=1、hash1["bar"]=1)。这一步的目的是:后续滑动窗口时,能快速判断窗口内的单词是否属于words,以及出现次数是否超标。步骤 2:按单词长度分组(核心优化)
假设单个单词长度为
len,算法会遍历0 ~ len-1这len个起始位置(比如单词长度为 3,就从 0、1、2 分别开始)。
- 为什么要分组? :因为目标子串是由
len长度的单词串联而成,子串的起始位置只能是i, i+len, i+2*len...(i < len)。分组后每个窗口只处理同一起始偏移的子串,避免重复检查,时间复杂度从 O (n*m) 优化到 O (n)(n 是字符串s长度)。步骤 3:单组内的滑动窗口(核心逻辑)
对每一组(起始位置
i),初始化:
hash2:记录当前窗口内各单词的出现次数;ret:记录窗口内「有效匹配」的单词数量(即属于words且次数未超hash1的单词数);left/right:窗口的左右边界(均从i开始,步长为len,保证每次取的是完整单词)。窗口的滑动过程分 3 个子步骤:
右边界入窗 :截取
s中从right开始、长度为len的单词in,将其加入hash2计数。如果in存在于hash1中,且当前计数未超过hash1中的频次,说明这个单词是「有效匹配」,ret加 1。左边界出窗(窗口超限) :目标子串的总长度是
len * m(m是words单词总数),如果当前窗口的字符长度(right-left+len)超过这个总长度,需要将左边界的单词out移出窗口:
- 若
out之前是「有效匹配」的单词,移出后ret减 1;hash2中out的计数减 1,左边界left右移len个字符。判断有效结果 :当
ret == m时,说明窗口内恰好包含words中所有单词(次数也完全匹配),此时窗口左边界left就是目标子串的起始索引,加入结果数组。步骤 4:返回结果
遍历完所有分组后,结果数组中就存储了所有符合条件的起始索引。
逻辑可视化(以示例辅助理解)
假设
s = "barfoothefoobarman",words = ["foo","bar"](len=3,m=2):
- 分组 0(i=0):窗口从 0 开始,步长 3 → 截取 "bar"(0-2)、"foo"(3-5) →
ret=2,记录索引 0;- 分组 0 后续滑动:截取 "the"(6-8) 时,窗口长度超限,移出 "bar" →
ret=1;继续滑动到 "foo"(9-11)、"bar"(12-14) →ret=2,记录索引 9;- 分组 1、2(i=1、2):无有效匹配,最终结果为
[0,9]。完整代码:
class Solution {
public:
vector<int> findSubstring(string s, vector<string>& words)
{
vector<int> v;
unordered_map<string,int> hash1;//映射wors里面所有单词的频次
for(auto& s:words) hash1[s]++;
int len = words[0].size();//单个单词的长度
int m = words.size();//words所有单词的个数
for(int i =0;i<len;i++)//滑动窗口指向的次数,按单词长度分组进行划分
{
unordered_map<string,int> hash2;//维护窗口内words中单词的频次
int ret=0;//窗口内匹配words单词的数量
for(int left=i,right=i;right+len<=s.size();right+=len)
{
//右边界单词进入窗口
string in = s.substr(right,len);
hash2[in]++;//进窗口
//如果当前单词在words中,且计数未超,匹配数+1
if(hash1.count(in) && hash2[in] <= hash1[in]) ret++;
if(right-left+len > len*m)//出窗口条件
{
// 窗口长度超过目标长度,左边界单词移出窗口
string out = s.substr(left,len);
if(hash1.count(out) && hash2[out]<=hash1[out]) ret--;
hash2[out]--;
left +=len;// 左边界右移
}
//匹配数等于单词总数,记录起始索引
if(ret == m) v.push_back(left);
}
}
return v;
}
};
2.可变窗口例题
例题1:904. 水果成篮 - 力扣(LeetCode)
题目描述:
算法原理:
完整代码:
class Solution {
public:
int totalFruit(vector<int>& fruits)
{
int ret =0;//统计采摘水果的最大数
int hash[100001]={0};//利用数组来实现哈希表
int kinds=0;//表示篮子里水果的种类
for(int left=0,right=0;right<fruits.size();right++)
{
if(hash[fruits[right]]==0) kinds++;
hash[fruits[right]]++;//入窗口
while(kinds>2)
{
hash[fruits[left]]--;//出窗口
if(hash[fruits[left]]==0) kinds--;
left++;
}
ret = max(ret,right-left+1);
}
return ret;
}
};
例题2:76. 最小覆盖子串 - 力扣(LeetCode)
题目描述:
算法原理:
完整代码:
class Solution {
public:
string minWindow(string s, string t)
{
int hash1[128]={0};//统计t中字符串的频次
int kind = 0;//统计t中字符的种类
for(auto ch : t)
{
if(hash1[ch]++ == 0) kind++;
}
int hash2[128]={0};//统计窗口内字符的频次
int minlen = INT_MAX;//最短字符串的长度
int begin=-1;//存放匹配最短字符串的左边起始位置
int count = 0;//表示窗口内有效字符的种类
for(int left=0,right=0;right<s.size();right++)
{
char in = s[right];
if(++hash2[in] == hash1[in]) count++;//进窗口+维护count
while(kind==count)
{
if(right-left+1 < minlen)
{
minlen = right-left+1;//更新结果
begin = left;
}
char out = s[left++];
if(hash2[out]-- == hash1[out]) count--;//出窗口+维护count
}
}
if(begin == -1) return "";
else return s.substr(begin,minlen);
}
};
其他与滑动窗口相关的例题:
1658. 将 x 减到 0 的最小操作数 - 力扣(LeetCode)
class Solution {
public:
int minOperations(vector<int>& nums, int x)
{
//转化为找最长子数组和为 sum-x
int sum=0;//统计数组元素和
for(auto y:nums)
sum +=y;
int target = sum-x;//找最长数组和为target
if(target < 0) return -1;//表示总和小于x
int ret =-1,tmp=0;
for(int left=0,right=0;right<nums.size();right++)
{
tmp += nums[right];//进窗口
while(tmp>target)
{
tmp -= nums[left++];//出窗口
}
if(tmp == target)//正好等于
{
ret = max(ret,right-left+1);
}
}
if(ret == -1) return ret;
else return nums.size()-ret;
}
};
3. 无重复字符的最长子串 - 力扣(LeetCode)class Solution {
public:
int lengthOfLongestSubstring(string s)
{
int hash[128]={0};
int left=0,right=0,n=s.size(),num=0;
while(right < n)
{
hash[s[right]] ++;//进窗口
while(hash[s[right]] > 1)
{
hash[s[left++]]--;//出窗口
}
num = max(num,right-left+1);//更新结果
right++;
}
return num;
}
};
209. 长度最小的子数组 - 力扣(LeetCode)class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums)
{
int n=nums.size(),len=INT_MAX,sum=0;
for(int left=0,right=0;right<n;right++)
{
//right进窗口
sum += nums[right];
while(sum >= target)
{
len = min(len,right-left+1);//更新len长度
sum -= nums[left++];//left出窗口
}
}
return len == INT_MAX ? 0 : len;
}
};
1004. 最大连续1的个数 III - 力扣(LeetCode)class Solution {
public:
int longestOnes(vector<int>& nums, int k)
{
int n=nums.size(),ret=0,zero=0;
for(int left=0,right=0;right<n;right++)
{
if(nums[right]==0) zero++;//入窗口
while(zero>k)
{
if(nums[left++] == 0)
{
zero--;//出窗口
}
}
ret = max(ret,right-left+1);
}
return ret;
}
};
重点结论:
满足这 3 个条件,直接无脑用滑动窗口:
- 求连续子数组 / 连续子串(必须连续!)
- 求最优解:最长、最短、最大、最小
- 有明确的 "窗口条件":比如最多 2 种、包含全部字符、和 ≤ K 等
滑动窗口 = 右指针一直走 + 左指针按需缩 + 合法时更新答案















