算法专题二:滑动窗口

目录

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

        1. 基本概念

        2. 直观比喻

        3. 适用场景

        4.滑动窗口的分类

                1. 固定窗口(窗口大小固定)

                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)。
  • 这一步是后续窗口匹配的「基准」。

2. 滑动窗口初始化

  • 定义 hash2:动态统计窗口内字符的频次(与 hash1 结构一致)。
  • 定义 count:记录窗口内「有效字符数」(即:窗口内该字符的频次 ≤ p 中该字符的频次的字符总数)。
  • 定义双指针 left/right:分别表示窗口的左、右边界,初始都为 0。

3. 滑动窗口的「扩张 + 收缩」逻辑

窗口的核心规则:窗口长度始终不超过 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++)。

4. 结果判断

  • 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 等

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值