一、解题思维总结
1. 何时使用滑动窗口?
| 场景 | 解法 |
|---|---|
| 寻找长度固定的连续子数组的最大/最小值 | 固定窗口滑动 |
| 寻找不含重复字符的最长子串 | 可变窗口滑动(双指针) |
| 寻找可以通过k次替换得到的最长重复字符子串 | 可变窗口滑动(双指针+哈希表) |
| 判断一个字符串是否包含另一个字符串的排列 | 固定窗口滑动+哈希表 |
| 寻找包含所有目标字符的最小子串 | 可变窗口滑动(双指针+哈希表) |
| 寻找滑动窗口中的最大值 | 双端队列优化滑动窗口 |
2. 复杂度分析
- 固定窗口滑动:O(n) 时间复杂度,O(1) 空间复杂度(除存储结果外)
- 可变窗口滑动:O(n) 时间复杂度,O(k) 空间复杂度(k为字符集大小)
- 双端队列优化:O(n) 时间复杂度,O(k) 空间复杂度
3. 常用技巧
- 固定窗口初始化:
let sum = nums.slice(0, k).reduce((a, b) => a + b, 0) - 窗口滑动更新:
sum += nums[i] - nums[i - k] - 哈希表计数:
record[char] = (record[char] || 0) + 1 - 双指针调整窗口:
left = Math.max(left, record[char] + 1)
二、核心技术
技巧一:固定窗口滑动
适用场景:寻找长度固定的连续子数组的最大/最小值
核心要点:
- 先计算初始窗口的总和/最大值
- 每次滑动时仅更新变化的部分,避免重复计算
- 时间复杂度O(n),空间复杂度O(1)
典型例题:子数组最大平均数 I
var findMaxAverage = function(nums, k) {
let max = -Infinity;
let sum = 0;
for (let i = 0; i < k; ++i) {
sum += nums[i];
}
max = sum / k;
for (let i = k; i < nums.length; ++i) {
sum += nums[i] - nums[i - k];
max = Math.max(max, sum / k);
}
return max;
};
技巧二:可变窗口滑动(双指针)
适用场景:寻找满足特定条件的最长/最短子串
核心要点:
- 使用左右指针动态调整窗口大小
- 哈希表记录窗口内字符的最新位置
- 当遇到重复字符时,移动左指针到重复位置的下一位
典型例题:无重复字符的最长子串
var lengthOfLongestSubstring = function(s) {
let max = 0, left = 0;
const record = {};
for (let right = 0; right < s.length; ++right) {
const curChar = s[right];
if (curChar in record && record[curChar] >= left) {
left = record[curChar] + 1;
}
record[curChar] = right;
max = Math.max(max, right - left + 1);
}
return max;
};
技巧三:可变窗口滑动+哈希表计数
适用场景:寻找可以通过k次替换得到的最长重复字符子串
核心要点:
- 哈希表记录窗口内各字符的出现次数
- 维护窗口内出现次数最多的字符的频率
- 当窗口长度超过maxCount + k时,收缩左边界
典型例题:替换后的最长重复字符
var characterReplacement = function(s, k) {
let max = 0, maxCount = 0, left = 0, right = 0;
const record = {};
while (right < s.length) {
record[s[right]] = (record[s[right]] || 0) + 1;
maxCount = Math.max(maxCount, record[s[right]]);
if (maxCount + k < (right - left + 1)) {
record[s[left]] -= 1;
++left;
}
max = Math.max(max, right - left + 1);
++right;
}
return max;
};
技巧四:固定窗口滑动+哈希表计数
适用场景:判断一个字符串是否包含另一个字符串的排列
核心要点:
- 先统计目标字符串的字符频率
- 滑动窗口时动态更新字符频率
- 比较窗口内字符频率是否与目标字符串完全匹配
典型例题:字符串的排列
var checkInclusion = function(s1, s2) {
if (s1.length > s2.length) {
return false;
}
const charCount = new Map();
for (const c of s1) {
charCount.set(c, (charCount.get(c) || 0) + 1);
}
const windowWidth = s1.length;
for (let i = 0; i <= s2.length - windowWidth; ++i) {
const charCountCopy = new Map(charCount);
for (let j = 0; j < windowWidth; ++j) {
const curChar = s2[j + i];
if (charCountCopy.has(curChar)) {
charCountCopy.set(curChar, charCountCopy.get(curChar) - 1);
if (charCountCopy.get(curChar) === 0) {
charCountCopy.delete(curChar);
}
continue;
}
break;
}
if (charCountCopy.size === 0) {
return true;
}
}
return false;
};
技巧五:双端队列优化滑动窗口
适用场景:寻找滑动窗口中的最大值
核心要点:
- 双端队列存储元素索引,而非元素值
- 队列保持递减顺序,队首元素始终是当前窗口的最大值
- 移除队列中不再属于当前窗口的元素
典型例题:滑动窗口最大值
// 优化后的解法(原解法超时)
var maxSlidingWindow = function(nums, k) {
const q = [];
const result = [];
for (let i = 0; i < nums.length; i++) {
// 移除队列中比当前元素小的元素
while (q.length && nums[i] >= nums[q[q.length - 1]]) {
q.pop();
}
q.push(i);
// 移除队列中不在当前窗口的元素
while (q[0] <= i - k) {
q.shift();
}
// 当窗口形成后,记录最大值
if (i >= k - 1) {
result.push(nums[q[0]]);
}
}
return result;
};
三、易错点提醒
- 初始值设置错误:最大值初始值应设为-Infinity而非0
- 窗口边界计算错误:固定窗口滑动时,窗口起始位置应为i - k
- 哈希表更新不及时:可变窗口滑动时,忘记更新字符的最新位置
- 双指针调整错误:遇到重复字符时,左指针应移动到重复位置的下一位而非当前位置
- 超时问题:暴力解法时间复杂度较高,需使用滑动窗口优化
四、学习心得
滑动窗口算法的优势
- 时间效率高:将暴力解法的O(n^2)时间复杂度优化为O(n)
- 空间效率高:大部分情况下仅需O(1)或O(k)的额外空间
- 逻辑清晰:通过窗口的滑动直观地处理连续子数组/子串问题
解题思维模式
- 问题分析:确定是固定窗口还是可变窗口问题
- 数据结构选择:根据问题选择合适的数据结构(哈希表、双端队列等)
- 窗口初始化:设置初始窗口的状态和边界
- 窗口滑动:动态调整窗口大小并更新状态
- 结果记录:记录满足条件的结果