算法专题二:滑动窗口

目录

一、滑动窗口算法的基本介绍

[1. 基本概念](#1. 基本概念)

[2. 直观比喻](#2. 直观比喻)

[3. 适用场景](#3. 适用场景)

4.滑动窗口的分类

[1. 固定窗口(窗口大小固定)](#1. 固定窗口(窗口大小固定))

[2. 可变窗口(窗口大小动态调整)](#2. 可变窗口(窗口大小动态调整))

二:例题解析

固定窗口例题

例题1:

例题2:

可变窗口例题

例题1:

例题2:


滑动窗口是算法中解决数组 / 字符串子区间问题 的经典技巧,核心是通过「双指针维护一个动态的区间(窗口)」,将暴力枚举的 O(n2) 时间复杂度优化到 O(n),是面试和算法题中的高频考点。

一、滑动窗口算法的基本介绍

1. 基本概念
  • 窗口 :数组 / 字符串中一个连续的子区间 (用两个指针 leftright 界定);
  • 滑动 :通过移动 leftright 指针,改变窗口的大小和位置;
  • 核心思想:避免重复计算子区间的元素,只对窗口「新增 / 移除」的部分做操作。
2. 直观比喻

可以把滑动窗口理解为「一列火车的车厢」:

  • right 指针是「火车头」,向右移动表示「加一节车厢」;
  • left 指针是「火车尾」,向右移动表示「减一节车厢」;
  • 我们只需要关注「新增的车头」和「移除的车尾」,而不用重新统计整列火车的乘客。
3. 适用场景

滑动窗口主要解决以下类型的问题:

  • 找满足条件的最长 / 最短子数组 / 子串
  • 找和为定值的子数组;
  • 无重复字符的最长子串;
  • 最小覆盖子串;
  • 水果成篮、最大连续 1 的个数等。
4.滑动窗口的分类

滑动窗口分为「固定窗口」和「可变窗口」,其中可变窗口是考察重点。

1. 固定窗口(窗口大小固定)
  • 适用场景:找长度固定的满足条件的子区间;
  • 核心逻辑
    1. 先让 right 指针移动,直到窗口大小达到目标;
    2. 之后 leftright 同步右移,保持窗口大小不变;
    3. 每次移动后,检查窗口内的元素是否满足条件。
2. 可变窗口(窗口大小动态调整)
  • 适用场景:找满足条件的最长 / 最短子区间(窗口大小不固定);
  • 核心逻辑
    1. 移动 right 指针扩大窗口,直到窗口内元素满足条件;
    2. 移动 left 指针缩小窗口,直到窗口内元素不满足条件;
    3. 过程中记录满足条件的窗口的最大 / 最小长度。

二:例题解析

1.固定窗口例题
例题1:438. 找到字符串中所有字母异位词 - 力扣(LeetCode)

题目描述:

算法原理:

  1. 预处理:统计基准频次(hash1)
  • 定义长度为 26 的数组 hash1(对应 26 个小写字母),统计字符串 p 中每个字符的出现次数(比如 p="abc"hash1[0]=1, hash1[1]=1, hash1[2]=1,其余为 0)。
  • 这一步是后续窗口匹配的「基准」。
  1. 滑动窗口初始化
  • 定义 hash2:动态统计窗口内字符的频次(与 hash1 结构一致)。
  • 定义 count:记录窗口内「有效字符数」(即:窗口内该字符的频次 ≤ p 中该字符的频次的字符总数)。
  • 定义双指针 left/right:分别表示窗口的左、右边界,初始都为 0。
  1. 滑动窗口的「扩张 + 收缩」逻辑

窗口的核心规则:窗口长度始终不超过 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++)。
  1. 结果判断
  • 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"]=1hash1["bar"]=1)。这一步的目的是:后续滑动窗口时,能快速判断窗口内的单词是否属于 words,以及出现次数是否超标。

步骤 2:按单词长度分组(核心优化)

假设单个单词长度为 len,算法会遍历 0 ~ len-1len 个起始位置(比如单词长度为 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 个子步骤:

  1. 右边界入窗 :截取 s 中从 right 开始、长度为 len 的单词 in,将其加入 hash2 计数。如果 in 存在于 hash1 中,且当前计数未超过 hash1 中的频次,说明这个单词是「有效匹配」,ret 加 1。

  2. 左边界出窗(窗口超限) :目标子串的总长度是 len * mmwords 单词总数),如果当前窗口的字符长度(right-left+len)超过这个总长度,需要将左边界的单词 out 移出窗口:

    • out 之前是「有效匹配」的单词,移出后 ret 减 1;
    • hash2out 的计数减 1,左边界 left 右移 len 个字符。
  3. 判断有效结果 :当 ret == m 时,说明窗口内恰好包含 words 中所有单词(次数也完全匹配),此时窗口左边界 left 就是目标子串的起始索引,加入结果数组。

步骤 4:返回结果

遍历完所有分组后,结果数组中就存储了所有符合条件的起始索引。

逻辑可视化(以示例辅助理解)

假设 s = "barfoothefoobarman"words = ["foo","bar"]len=3m=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 个条件,直接无脑用滑动窗口:

  1. 求连续子数组 / 连续子串(必须连续!)
  2. 求最优解:最长、最短、最大、最小
  3. 有明确的 "窗口条件":比如最多 2 种、包含全部字符、和 ≤ K 等

滑动窗口 = 右指针一直走 + 左指针按需缩 + 合法时更新答案

相关推荐
ccLianLian2 小时前
数论·约数
数据结构·算法
会编程的土豆2 小时前
【数据结构与算法】最短路径---Dijkstra 算法
数据结构·c++·算法
2401_879693872 小时前
C++中的观察者模式实战
开发语言·c++·算法
炽烈小老头2 小时前
【 每天学习一点算法 2026/03/24】寻找峰值
学习·算法
fff9811182 小时前
C++与Qt图形开发
开发语言·c++·算法
计算机安禾2 小时前
【数据结构与算法】第3篇:C语言核心机制回顾(二):动态内存管理与typedef
c语言·开发语言·数据结构·c++·算法·链表·visual studio
njidf3 小时前
C++中的访问者模式
开发语言·c++·算法
C_Si沉思3 小时前
C++中的工厂模式变体
开发语言·c++·算法
C羊驼3 小时前
C语言学习笔记(十五):预处理
c语言·经验分享·笔记·学习·算法