滑动窗口模式
为什么滑动窗口很重要
滑动窗口优化涉及子数组或子串的问题——将 O(n²) 降低到 O(n):
- 子串搜索:查找具有特定属性的最长/最短子串
- 子数组问题:最大和子数组、满足约束的最长子数组
- 限流:在时间窗口内跟踪请求
- 流处理:在滑动时间窗口上计算聚合
实际影响:
- 查找不含重复字符的最长子串:
- 暴力法:O(n²) 检查所有子串
- 滑动窗口:O(n) 单次遍历——对 100K 字符的字符串快 100,000 倍
- 网络流量分析:在滚动窗口中监控带宽使用
核心概念
固定大小窗口
窗口大小恒定,在数组上滑动:
int windowSize = k;
for (int i = 0; i < arr.length - k + 1; i++) {
// 处理窗口 [i, i + k)
}
使用场景:
- k 个连续元素的最大和
- 大小为 k 的子数组的平均值
- 固定窗口内的计数
可变大小窗口
窗口根据条件扩大和缩小:
int left = 0;
for (int right = 0; right < arr.length; right++) {
// 将 arr[right] 加入窗口
// 当窗口无效时缩小
while (windowInvalid()) {
// 从窗口移除 arr[left]
left++;
}
// 窗口 [left, right] 有效
}
使用场景:
- 满足约束的最长子串
- 包含所有字符的最短子串
- 满足和约束的最大子数组
深入理解
固定窗口:最大平均子数组
查找长度为 k 的任意连续子数组的最大平均值:
public double findMaxAverage(int[] nums, int k) {
// 计算第一个窗口的和
int sum = 0;
for (int i = 0; i < k; i++) {
sum += nums[i];
}
int maxSum = sum;
// 滑动窗口
for (int i = k; i < nums.length; i++) {
sum += nums[i] - nums[i - k]; // 加入新元素,移除旧元素
maxSum = Math.max(maxSum, sum);
}
return (double) maxSum / k;
}
关键洞察:通过加入新元素和移除离开窗口的元素,以 O(1) 更新和
可变窗口:无重复字符的最长子串
public int lengthOfLongestSubstring(String s) {
Map<Character, Integer> lastIndex = new HashMap<>();
int left = 0, maxLength = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
// 如果字符出现过且在当前窗口内
if (lastIndex.containsKey(c) && lastIndex.get(c) >= left) {
// 将左指针移过上一次出现的位置
left = lastIndex.get(c) + 1;
}
// 更新最后出现的位置
lastIndex.put(c, right);
// 更新最大长度
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
最小覆盖子串
在 s 中找到包含 t 所有字符的最小窗口:
public String minWindow(String s, String t) {
if (s.length() < t.length()) return "";
// 统计 t 中的字符
Map<Character, Integer> need = new HashMap<>();
for (char c : t.toCharArray()) {
need.merge(c, 1, Integer::sum);
}
// 跟踪需要匹配的字符数
int required = need.size();
int formed = 0;
// 窗口字符计数
Map<Character, Integer> window = new HashMap<>();
int left = 0, right = 0;
int[] result = {-1, 0, 0}; // {length, left, right}
while (right < s.length()) {
// 从右侧添加字符
char c = s.charAt(right);
window.merge(c, 1, Integer::sum);
// 检查是否有足够的该字符
if (need.containsKey(c) && window.get(c).intValue() == need.get(c).intValue()) {
formed++;
}
// 尝试在窗口有效时缩小
while (left <= right && formed == required) {
c = s.charAt(left);
// 如果窗口更小则更新结果
if (result[0] == -1 || right - left + 1 < result[0]) {
result[0] = right - left + 1;
result[1] = left;
result[2] = right;
}
// 从左侧移除字符
window.merge(c, -1, Integer::sum);
if (need.containsKey(c) && window.get(c) < need.get(c)) {
formed--;
}
left++;
}
right++;
}
return result[0] == -1 ? "" : s.substring(result[1], result[2] + 1);
}
双指针扩展与收缩:
- 扩展:移动右指针,添加字符
- 检查:如果窗口有效(包含所有字符),尝试缩小
- 收缩:在窗口保持有效时移动左指针
- 记录:更新最小窗口
常见陷阱
❌ 窗口无效时不收缩
for (int right = 0; right < nums.length; right++) {
sum += nums[right];
if (sum >= target) { // BUG:只检查一次
minLength = Math.min(minLength, right - left + 1);
}
}
✅ 使用 while 循环收缩
for (int right = 0; right < nums.length; right++) {
sum += nums[right];
while (sum >= target) { // 收缩直到无效
minLength = Math.min(minLength, right - left + 1);
sum -= nums[left];
left++;
}
}