滑动窗口(Sliding Window)是算法中解决数组/字符串连续子序列问题 的一种极其重要的优化技巧。它的核心思想可以用四个字概括:化繁为简。
为什么需要滑动窗口?(从暴力到优化)
假设有一道题:在一个数组中,找到和为 target 的最短连续子数组。
暴力解法 :
用两层 for 循环,枚举所有的起点 i 和终点 j,计算 nums[i...j] 的和。每次计算都要重新累加,做了大量的重复工作。
滑动窗口解法 :
我们维护一个窗口 [left, right],用 sum 记录窗口内的元素和。
-
当
sum < target时,右指针right向右移动,扩大窗口,纳入新元素(sum += nums[right])。 -
当
sum >= target时,左指针left向右移动,缩小窗口,踢出旧元素(sum -= nums[left])。
为什么快?
因为 left 和 right 都只向右移动,永不后退 。每个元素最多被 right 访问一次(进窗口),最多被 left 访问一次(出窗口)。总操作次数是 2N,时间复杂度降为 O(N)。
滑动窗口的核心机制(三步走)
任何滑动窗口的代码,都可以拆解为以下三个标准动作:
1. 进窗口(右指针扩张)
right 指针向右移动,将新元素纳入窗口。此时需要更新窗口的状态(比如加上新元素的值、更新字符频次、更新0的个数等)。
- 目的:寻找一个满足条件的合法解,或者让窗口变得更大。
2. 判断与出窗口(左指针收缩)
当窗口内的状态不再满足题目条件 (或者满足条件但要求找最短)时,left 指针开始向右移动,将元素踢出窗口。每次踢出,也要更新窗口的状态。
-
目的:剔除无效元素,让窗口重新变得合法,或者尝试寻找更优(更短)的解。
-
注意 :这里通常用
while而不是if,因为可能需要连续收缩多次。
3. 更新答案(记录结果)
在窗口移动的过程中,实时更新我们需要的最优解(如最大长度、最小长度、符合条件的起始下标等)。
- 关键:记录答案的时机取决于题目要求。求"最长"通常在扩张后记录;求"最短"通常在收缩前/收缩时记录。
三、 滑动窗口的底层哲学(为什么它能成立?)
滑动窗口之所以能成立,依赖于一个关键性质:单调性。
-
正向单调性:如果数组元素都是正数,那么窗口越长,窗口内的和一定越大。
- 正因为有这个性质,当
sum > target时,我们才敢放心地把left向右移(因为移走元素,和一定会变小)。如果数组里有负数,这个性质就被破坏了,滑动窗口也就失效了(此时需要改用前缀和 + 哈希表)。
- 正因为有这个性质,当
-
频次单调性:在字符串问题中,窗口越长,包含的字符种类/频次一定越多。
- 正因为如此,当窗口内出现重复字符时,我们才敢收缩
left,直到重复字符被踢出去。
- 正因为如此,当窗口内出现重复字符时,我们才敢收缩
滑动窗口的本质,就是在利用这种单调性,避免回头路。 它像一个在数组上滑动的"贪吃蛇",吃掉右边的元素,吐出左边的元素,始终保持一个连续的状态。
四、滑动窗口的两种形态
-
固定大小窗口 :窗口大小恒定。
right进,left立刻跟。像一列固定长度的火车在轨道上行驶。 -
可变大小窗口 :窗口大小动态变化。
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上整体平移。
-
-
判断与更新答案的时机:
-
因为窗口大小是固定的,每次移动完
right和left后,窗口长度都是合法的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 == kinds(kinds是t中不同字符的种类数)。 -
进窗口 :
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 达到了某个阈值,就说明窗口合法。
五、 什么时候该用滑动窗口?(识别信号)
看到以下关键词,优先考虑滑动窗口:
-
连续的子数组 / 子串。
-
求满足条件的最长 / 最短 / 是否存在 / 所有子序列。
-
数组元素通常是正数(或者具有某种单调性质)。
-
需要维护窗口内的频次 、种类数 、和等状态。