算法 --滑动窗口

滑动窗口(Sliding Window)是算法中解决数组/字符串连续子序列问题 的一种极其重要的优化技巧。它的核心思想可以用四个字概括:化繁为简

为什么需要滑动窗口?(从暴力到优化)

假设有一道题:在一个数组中,找到和为 target 的最短连续子数组。

暴力解法

用两层 for 循环,枚举所有的起点 i 和终点 j,计算 nums[i...j] 的和。每次计算都要重新累加,做了大量的重复工作。

滑动窗口解法

我们维护一个窗口 [left, right],用 sum 记录窗口内的元素和。

  • sum < target 时,右指针 right 向右移动,扩大窗口,纳入新元素(sum += nums[right])。

  • sum >= target 时,左指针 left 向右移动,缩小窗口,踢出旧元素(sum -= nums[left])。

为什么快?

因为 leftright只向右移动,永不后退 。每个元素最多被 right 访问一次(进窗口),最多被 left 访问一次(出窗口)。总操作次数是 2N,时间复杂度降为 O(N)。

滑动窗口的核心机制(三步走)

任何滑动窗口的代码,都可以拆解为以下三个标准动作:

1. 进窗口(右指针扩张)

right 指针向右移动,将新元素纳入窗口。此时需要更新窗口的状态(比如加上新元素的值、更新字符频次、更新0的个数等)。

  • 目的:寻找一个满足条件的合法解,或者让窗口变得更大。
2. 判断与出窗口(左指针收缩)

当窗口内的状态不再满足题目条件 (或者满足条件但要求找最短)时,left 指针开始向右移动,将元素踢出窗口。每次踢出,也要更新窗口的状态

  • 目的:剔除无效元素,让窗口重新变得合法,或者尝试寻找更优(更短)的解。

  • 注意 :这里通常用 while 而不是 if,因为可能需要连续收缩多次。

3. 更新答案(记录结果)

在窗口移动的过程中,实时更新我们需要的最优解(如最大长度、最小长度、符合条件的起始下标等)。

  • 关键:记录答案的时机取决于题目要求。求"最长"通常在扩张后记录;求"最短"通常在收缩前/收缩时记录。

三、 滑动窗口的底层哲学(为什么它能成立?)

滑动窗口之所以能成立,依赖于一个关键性质:单调性

  • 正向单调性:如果数组元素都是正数,那么窗口越长,窗口内的和一定越大。

    • 正因为有这个性质,当 sum > target 时,我们才敢放心地把 left 向右移(因为移走元素,和一定会变小)。如果数组里有负数,这个性质就被破坏了,滑动窗口也就失效了(此时需要改用前缀和 + 哈希表)。
  • 频次单调性:在字符串问题中,窗口越长,包含的字符种类/频次一定越多。

    • 正因为如此,当窗口内出现重复字符时,我们才敢收缩 left,直到重复字符被踢出去。

滑动窗口的本质,就是在利用这种单调性,避免回头路。 它像一个在数组上滑动的"贪吃蛇",吃掉右边的元素,吐出左边的元素,始终保持一个连续的状态。

四、滑动窗口的两种形态

  1. 固定大小窗口 :窗口大小恒定。right 进,left 立刻跟。像一列固定长度的火车在轨道上行驶。

  2. 可变大小窗口 :窗口大小动态变化。right 扩张找合法,left 收缩求最优。像一只变形虫,先伸展,再收缩。

第一类:固定大小的滑动窗口

代表题目: 串联所有单词的子串

cpp 复制代码
class Solution {
public:
    vector<int> findSubstring(string s, vector<string>& words) 
    {
        vector<int> ret;
        unordered_map<string, int> hash1;
        for(auto& s : words) hash1[s]++;

        int len = words[0].size(), m = words.size();  
        int n=s.size();
        
        for(int i = 0; i < len; i++)
        {
            unordered_map<string, int> hash2;
            for(int left=i,right=i,count=0;right+len<=s.size();right+=len)
            {
                string in = s.substr(right, len);
                hash2[in]++;
                if(hash1.count(in) && hash2[in]<=hash1[in]) count++;
                if(right - left + 1 > len * m)
                {
                    string out = s.substr(left, len);
                    if(hash1.count(out) && hash2[out]<=hash1[out]) count--;
                    hash2[out]--;
                    left+=len;
                }
                if(count == m) ret.push_back(left);
            }
        }
        return ret;
    }
};
  • 窗口大小特征恒定不变,严丝合缝。

    • 题目要求子串必须包含 words 中的所有单词,且顺序任意。因为所有单词长度相同(设为 len),单词个数为 m,所以合法子串的总字符长度必定是 len * m,多一个少一个都不行。
  • 指针移动逻辑

    • 因为处理的是单词 而不是字符,所以指针的步长变成了 len

    • right 每次跳跃 len 个字符(纳入一个完整的单词)。

    • 一旦窗口大小超过了 len * m(即 right - left + 1 > len * m),left 指针必须立刻 跳跃 len 个字符(踢出一个完整的单词)。

    • 左右指针像一对保持着固定距离的探照灯,在字符串 s 上整体平移。

  • 判断与更新答案的时机

    • 因为窗口大小是固定的,每次移动完 rightleft 后,窗口长度都是合法的 len * m

    • 我们只需要在每次移动后,检查当前窗口内的单词频次是否和 words 完全一致(代码里是用 count == m 来 O(1) 判断的)。如果是,就记录答案。

  • 特别注意 :因为 s 的起始位置可能在任何偏移量上(比如单词长度是 3,起始位置可能是 0, 1, 2),所以外层必须套一个 for(int i = 0; i < len; i++) 的循环,分别从每个可能的偏移量开始执行滑动窗口。

第二类:可变大小的滑动窗口(求最短)

代表题目:最小覆盖字串

cpp 复制代码
class Solution {
public:
    string minWindow(string s, string t) {
        int hash1[128] = {0}; 
        int kinds = 0;        
        for (auto ch : t)
            if (hash1[ch]++ == 0)
                kinds++;
        int hash2[128] = {0}; 
        int minlen = INT_MAX, begin = -1;
        for (int left = 0, right = 0, count = 0; right < s.size(); right++) {
            char in = s[right];
            if (++hash2[in] == hash1[in])
                count++;           
            while (count == kinds) 
            {
                if (right - left + 1 < minlen) 
                {
                    minlen = right - left + 1;
                    begin = left;
                }
                char out = s[left++];
                if (hash2[out]-- == hash1[out])
                    count--; 
            }
        }
        if (begin == -1)
            return "";
        else
            return s.substr(begin, minlen);
    }
};
  • 窗口大小特征动态变化,先膨胀后收缩。

    • 为了覆盖 t 中的所有字符,窗口必须先不断扩大(right++),直到满足条件。

    • 一旦满足条件,为了求"最小",窗口必须立刻尝试缩小(left++),看看能不能在保持合法的情况下变得更短。

  • 指针移动逻辑

    • right 每次只移动 1 个字符(步长为1)。

    • left 每次也只移动 1 个字符

    • 左右指针像拉锯战一样:右指针拉长,左指针缩短,来回交替。

  • 判断与更新答案的时机

    • 收缩阶段 记录答案。每当 left 准备移动时,只要当前窗口依然满足覆盖条件(count == kinds),就记录一次当前窗口的长度,并尝试更新最小值。

两种场景下的 count 维护逻辑

场景 1:最小覆盖子串
  • 目标 :窗口内必须包含 t 中的所有字符(包括重复字符)。

  • count 的含义窗口内已经"达标"的字符种类数

  • 阈值count == kindskindst 中不同字符的种类数)。

  • 进窗口if (++hash2[in] == hash1[in]) count++;

    • 注意这里是 ==。只有当窗口内某个字符的数量刚好达到 t 中的要求时,这个字符种类才算"达标",count++。超过不算,因为已经达标过了。
  • 出窗口if (hash2[out]-- == hash1[out]) count--;

    • 如果踢出前,这个字符在窗口里的数量刚好等于 t 中的要求,说明踢走它之后,这个字符种类就不达标了,count--
场景 2:串联所有单词的子串
  • 目标 :窗口内必须包含 words 中的所有单词(包括重复单词)。

  • count 的含义窗口内已匹配的单词总个数

  • 阈值count == words.size()(即 m)。

  • 进窗口if (hash1.count(in) && ++hash2[in] <= hash1[in]) count++;

    • 先判断这个单词是否在 words 中。如果在,并且窗口里的数量还没超标,说明这个单词是有效的,count++
  • 出窗口if (hash1.count(out) && hash2[out] <= hash1[out]) count--;

    • 如果踢出前,这个单词在窗口里的数量 <= words 里的数量,说明它之前是有效的,现在被踢走了,count--

count 维护逻辑的微妙差异

  • 最小覆盖子串count 记录的是**"已经达标(数量足够)的字符种类数"** 。目标是 count == kinds

    • 进窗口:if(++hash2[in] == hash1[in]) count++;
  • 串联所有单词的子串count 记录的是**"已经匹配的单词总数"** 。目标是 count == m

    • 进窗口:if(hash1.count(in) && ++hash2[in] <= hash1[in]) count++;

    • 不难发现,串联所有单词的子串的条件是 <=,而不是 ==。因为要找的是恰好包含所有单词 的窗口,多一个不行,少一个也不行。当 hash2[in] 小于等于 hash1[in] 时,说明这个单词的出现是"有效的",计入总数。

count 的核心思想:只记"有效"的

其实无论是哪道题,count 维护的核心思想都是一致的:只统计那些对达成目标有"正向贡献"的元素。

  • 如果新加入的字符/单词是"有用"的(没超标),count 就加 1。

  • 如果新加入的字符/单词是"多余"的(超标了),count 就不加。

  • 如果踢出的字符/单词是"有用"的,count 就减 1。

  • 如果踢出的字符/单词是"多余"的,count 就不减。

这样,count 的数值就实时反映了当前窗口的"匹配进度" 。只要 count 达到了某个阈值,就说明窗口合法。

五、 什么时候该用滑动窗口?(识别信号)

看到以下关键词,优先考虑滑动窗口:

  • 连续的子数组 / 子串。

  • 求满足条件的最长 / 最短 / 是否存在 / 所有子序列。

  • 数组元素通常是正数(或者具有某种单调性质)。

  • 需要维护窗口内的频次种类数等状态。

相关推荐
stolentime1 小时前
(有原题)CSP-S2026第一轮试题(附答案解析、markdown源码)
c++·csp
m0_734571761 小时前
深入理解C++ RAII
开发语言·c++
reikocao2 小时前
大场景下 Hessian 特征值放大与退化检测
算法
hetao17338372 小时前
2026-09-18 hetao1733837 的刷题记录
c++·算法
wuyk5552 小时前
21.计数排序:不用比较的“空间换时间”排序算法
python·算法·排序算法
stolentime2 小时前
CSP-J2026第一轮试题(附答案解析、markdown源码)
c++·csp
Rabitebla2 小时前
【Linux系统编程】 指令(二):一条路径是怎么定位到文件的
linux·c++·笔记·学习·算法
库玛西3 小时前
数据结构与算法之美:树、森林与二叉树全解析
c语言·数据结构·笔记·考研·算法·学习方法
木井巳3 小时前
【记忆化搜索】最长递增子序列
java·算法·leetcode·深度优先·剪枝·推荐算法