一、滑动窗口概念
滑动窗口(Sliding Window)是一种常用于处理子数组或子序列问题的高效算法技巧。滑动窗口的核心思想是通过维护一个窗口(即数组或序列的连续子集)在数据结构上滑动,以动态调整子集的范围,从而解决各种问题,如寻找最大/最小子数组、满足某些条件的子数组、最长无重复字符子串等。也就是不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果。
在暴力解法中,是一个for循环滑动窗口的起始位置,一个for循环为滑动窗口的终止位置,用两个for循环 完成了一个不断搜索区间的过程。
滑动窗口的工作原理
- 定义窗口:
- 通常使用两个指针
left
和right
来表示窗口的左右边界。初始时,这两个指针通常都指向数组的起始位置。
- 通常使用两个指针
- 扩展窗口:
- 移动
right
指针扩展窗口,将新元素纳入窗口内。 - 根据问题的具体要求(如窗口内元素的和、乘积、或者某些条件是否满足),判断当前窗口是否有效。
- 移动
- 收缩窗口:
- 当窗口不再满足条件时,移动
left
指针收缩窗口,直到窗口重新满足条件或者彻底无效。 - 在收缩窗口的过程中,通常会更新一些状态(如窗口的最大/最小值、窗口内元素的频率等)。
- 当窗口不再满足条件时,移动
- 记录结果:
- 在整个过程中,实时更新和记录满足要求的窗口状态,如最大值、最小值、窗口长度等。
我们可以发现,滑动窗口的精妙之处在于根据当前子序列和大小的情况,不断调节子序列的起始位置。从而将O(n2)暴力解法降为O(n)。
二、滑动窗口应用场景以及题目解析
- 字符串问题:
- 最长无重复字符子串:例如,在一个字符串中寻找最长的无重复字符的子串。
- 包含所有字符的最小子串:例如,在一个字符串中寻找包含另一个字符串所有字符的最小子串。
76.最小覆盖子串 (opens new window):这段代码实现了一个滑动窗口算法,用于解决经典的“最小覆盖子串”问题。该问题要求在字符串 s
中找到一个最小的子串,包含字符串 t
中的所有字符(包括频率相同的字符)。如果不存在这样的子串,则返回空字符串。
class Solution {
// 判断当前窗口的字符计数是否覆盖了目标字符串的字符计数
bool is_covered(const vector<int>& sourceCounts, const vector<int>& targetCounts) {
// 检查大写字母是否满足条件
for (int i = 'A'; i <= 'Z'; ++i) {
if (sourceCounts[i] < targetCounts[i]) {
return false; // 如果当前窗口内某个字符的数量少于目标字符串所需数量,则不满足条件
}
}
// 检查小写字母是否满足条件
for (int i = 'a'; i <= 'z'; ++i) {
if (sourceCounts[i] < targetCounts[i]) {
return false; // 如果当前窗口内某个字符的数量少于目标字符串所需数量,则不满足条件
}
}
return true; // 如果所有字符都满足条件,则返回 true
}
public:
string minWindow(string s, string t) {
int sourceLength = s.size();
int targetLength = t.size();
// 如果源字符串的长度小于目标字符串,无法找到满足条件的子串,直接返回空字符串
if (sourceLength < targetLength) return "";
vector<int> targetCounts(128, 0); // 存储目标字符串 t 中各字符的频率
vector<int> sourceCounts(128, 0); // 存储当前窗口中各字符的频率
// 初始化目标字符串 t 中各字符的频率
for (char c : t) {
targetCounts[c]++;
}
int minWindowStart = 0; // 最小覆盖子串的起始索引
int minWindowEnd = sourceLength; // 最小覆盖子串的结束索引
int minWindowSize = INT_MAX; // 最小覆盖子串的长度,初始化为最大整数值
// 使用滑动窗口算法寻找最小覆盖子串
for (int left = 0, right = 0; right < sourceLength; ++right) {
// 将当前右指针指向的字符加入窗口
sourceCounts[s[right]]++;
// 当窗口内字符满足覆盖目标字符串 t 时,开始尝试收缩窗口
while (is_covered(sourceCounts, targetCounts)) {
// 如果当前窗口长度小于之前找到的最小窗口长度,则更新最小窗口的位置和大小
if (right - left < minWindowEnd - minWindowStart) {
minWindowStart = left;
minWindowEnd = right;
minWindowSize = min(right - left + 1, minWindowSize); // 更新最小窗口长度
}
// 将左指针指向的字符移出窗口,缩小窗口
sourceCounts[s[left]]--;
left++; // 左指针右移
}
}
// 如果没有找到满足条件的子串,返回空字符串;否则返回找到的最小覆盖子串
return minWindowSize == INT_MAX ? "" : s.substr(minWindowStart, minWindowSize);
}
};
-
数组问题:
-
固定大小的子数组的最大/最小和:在一个数组中查找固定大小的子数组,使其和最大或最小。
-
和等于某个目标值的子数组:在数组中找到和为某个目标值的最长或最短子数组。
-
713. 乘积小于 K 的子数组: 用于计算数组中乘积小于给定值 k
的连续子数组的数量。
class Solution {
public:
int numSubarrayProductLessThanK(vector<int>& nums, int k) {
int n = nums.size(); // 数组的长度
// 如果 k <= 1,那么所有子数组的乘积都无法小于 k,直接返回 0
if (k <= 1) {
return 0;
}
int num = 1; // 当前窗口内所有元素的乘积
int ans = 0; // 记录满足条件的子数组的数量
// 使用滑动窗口的左右边界 left 和 right
for (int left = 0, right = 0; right < n; right++) {
num *= nums[right]; // 将当前元素 nums[right] 乘入窗口的乘积
// 如果当前乘积大于或等于 k,移动左边界 left 缩小窗口
while (num >= k) {
num /= nums[left++]; // 将左边界元素除出窗口,并右移左指针
}
// 每次右指针移动时,计算满足条件的子数组数量
// 当前窗口长度为 (right - left + 1),这个长度范围内的所有子数组都是有效的
ans += right - left + 1;
}
// 返回满足条件的子数组的总数
return ans;
}
};
滑动窗口的注意事项
- 窗口的状态维护:在滑动窗口过程中,维护好窗口内的状态(如总和、频率等)是关键,以便快速做出判断。
- 边界条件的处理:确保指针的移动和窗口的更新不会导致越界或遗漏情况,特别是在收缩窗口时。
三、进阶题目
2302. 统计得分小于 K 的子数组数目:给定一个数组 nums
和一个整数 k
,要求统计所有满足条件的子数组数目。条件是子数组的元素之和乘以子数组长度的得分小于 k
。
class Solution {
public:
long long countSubarrays(vector<int>& nums, long long k) {
long long ans= 0l,sum= 0l;
int left = 0,right = 0;
for(;right<nums.size() ;right++)
{
sum+=nums[right];
while(sum*(right-left+1)>=k)
{
sum=(sum - nums[left++]);
}
ans+=right-left+1;
}
return ans;
}
};
我们使用双指针(left
和 right
)表示滑动窗口的左右边界。右指针 right
逐步向右扩展窗口,左指针 left
根据需要收缩窗口。sum
用于记录当前窗口的元素和,right-left+1
用于记录当前窗口的长度。当 sum * (right-left+1) >= k
时,收缩窗口,直到条件满足。
为什么使用
ans += right - left + 1
?
考虑滑动窗口的概念:
- 窗口的范围:假设当前窗口由
left
和right
这两个指针界定,窗口内的子数组就是从left
到right
的所有元素。 - 子数组的数量:
- 对于固定的右边界
right
,从left
到right
的每个位置都可以形成一个子数组,这些子数组的起点可以是left
到right
中的任意一个位置。 - 因此,以
right
作为子数组的结束位置时,所有以left
到right
之间的任意位置为起点的子数组都是有效的。子数组的数量正好等于right - left + 1
。
- 对于固定的右边界
当我们找到一个满足 sum * (right - left + 1)< k
的窗口时,所有从 left
到 right
的子数组(包括 left
和 right
)都是满足条件的。所以 right - left + 1
是以 right
结尾的、满足条件的子数组的数量。
2962. 统计最大元素出现至少 K 次的子数组:给定一个数组 nums
和一个整数 k
,要求统计所有满足条件的子数组数目。条件是子数组中最大元素出现至少 k
次。
class Solution {
public:
long long countSubarrays(vector<int>& nums, int k) {
auto mx = *max_element(nums.begin(), nums.end());
long long ans = 0;
int cnt_mx=0,left = 0,right = 0;
for(;right<nums.size() ; right++)
{
if(nums[right]==mx)
cnt_mx++;
while(cnt_mx == k)
{
if(nums[left]==mx)
cnt_mx--;
left++;
}
ans += left;
}
return ans;
}
};
使用双指针(left
和 right
)表示滑动窗口的左右边界。右指针 right
逐步向右扩展窗口,左指针 left
根据需要收缩窗口。cnt_mx
用于记录窗口内最大值 mx
出现的次数。当 cnt_mx == k
时,收缩窗口,直到 cnt_mx < k
。每次窗口缩小时,统计 left
的值,因为此时窗口内所有以 left
为起点的子数组都不满足条件。
当 cntMx
达到 k
时,表示当前窗口 [left, right]
中,最大元素 mx
的出现次数至少为 k
。需要调整左端点 left
,不断右移 left
,直到窗口内最大元素 mx
的出现次数小于 k
为止。这个过程确保在窗口内的最大元素出现次数为 k
。
为什么使用
ans += left
?
一旦窗口内最大元素的出现次数小于 k
,意味着在此时,所有以 right
为右端点、左端点小于 left
的子数组,都满足最大元素出现至少 k
次的条件。
因此,当前窗口的有效子数组数目等于 left
,因为从 left
到 right
的所有子数组都满足条件。
将 left
加到结果 ans
中,以统计所有符合条件的子数组数量。