前言
滑动窗口(Sliding Window)是解决数组和字符串问题的重要技巧。它通过维护一个动态的窗口来减少重复计算,将时间复杂度从 O(n²) 优化到 O(n),是一种典型的"空间换时间"的优化思路。
本文将通过 6 道经典 LeetCode 题目,带你全面掌握滑动窗口算法的精髓。
算法核心思想
滑动窗口的核心思想是:维护一个窗口,根据题目要求动态调整窗口的大小和位置。
基本模板
根据窗口大小是否固定,滑动窗口可以分为两类:
1. 可变窗口模板
function variableWindow(s: string): number {
let left = 0;
let result = 0;
for (let right = 0; right < s.length; right++) {
// 扩大窗口,处理 s[right]
while (/* 窗口需要收缩的条件 */) {
// 缩小窗口,移除 s[left]
left++;
}
// 更新结果
result = Math.max(result, right - left + 1);
}
return result;
}
2. 固定窗口模板
function fixedWindow(nums: number[], k: number): number[] {
const result: number[] = [];
for (let i = 0; i <= nums.length - k; i++) {
// 处理窗口 [i, i+k-1]
let windowResult = processWindow(nums, i, i + k - 1);
result.push(windowResult);
}
return result;
}
经典题目实战
1. 无重复字符的最长子串 (LeetCode 3)
题目描述:给定一个字符串 s,请找出其中不含有重复字符的最长子串的长度。
示例:
- 输入:
s = "abcabcbb" - 输出:
3(因为无重复字符的最长子串是 "abc")
解题思路:
- 使用滑动窗口,左右指针维护窗口边界
- 用哈希表记录字符最后出现的位置
- 当遇到重复字符时,移动左指针到重复字符的下一位
function lengthOfLongestSubstring(s: string): number {
if (s.length === 0) return 0;
const charMap = new Map<string, number>();
let left = 0;
let maxLength = 0;
for (let right = 0; right < s.length; right++) {
const char = s[right];
// 如果字符已存在且在当前窗口内,移动左指针
if (charMap.has(char) && charMap.get(char)! >= left) {
left = charMap.get(char)! + 1;
}
charMap.set(char, right);
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
时间复杂度: O(n)
空间复杂度: O(min(m,n)),m 是字符集大小
2. 最小覆盖子串 (LeetCode 76)
题目描述:给你一个字符串 s 和一个字符串 t,返回 s 中涵盖 t 所有字符的最小子串。
示例:
- 输入:
s = "ADOBECODEBANC",t = "ABC" - 输出:
"BANC"
解题思路:
- 用哈希表记录目标字符串 t 中每个字符的需求数量
- 使用滑动窗口,右指针扩展窗口,左指针收缩窗口
- 当窗口包含所有目标字符时,尝试收缩左指针找到最小窗口
function minWindow(s: string, t: string): string {
if (s.length === 0 || t.length === 0 || s.length < t.length) {
return "";
}
// 统计目标字符串中每个字符的需求数量
const need = new Map<string, number>();
for (const char of t) {
need.set(char, (need.get(char) || 0) + 1);
}
const window = new Map<string, number>();
let left = 0;
let right = 0;
let valid = 0; // 窗口中满足需求的字符种类数
// 记录最小覆盖子串的起始索引及长度
let start = 0;
let minLen = Infinity;
while (right < s.length) {
// 扩大窗口
const char = s[right];
right++;
// 进行窗口内数据的一系列更新
if (need.has(char)) {
window.set(char, (window.get(char) || 0) + 1);
if (window.get(char) === need.get(char)) {
valid++;
}
}
// 判断左侧窗口是否要收缩
while (valid === need.size) {
// 更新最小覆盖子串
if (right - left < minLen) {
start = left;
minLen = right - left;
}
// 缩小窗口
const leftChar = s[left];
left++;
// 进行窗口内数据的一系列更新
if (need.has(leftChar)) {
if (window.get(leftChar) === need.get(leftChar)) {
valid--;
}
window.set(leftChar, window.get(leftChar)! - 1);
}
}
}
return minLen === Infinity ? "" : s.substr(start, minLen);
}
时间复杂度: O(|s| + |t|)
空间复杂度: O(|s| + |t|)
3. 滑动窗口最大值 (LeetCode 239)
题目描述:给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。返回滑动窗口中的最大值。
示例:
- 输入:
nums = [1,3,-1,-3,5,3,6,7],k = 3 - 输出:
[3,3,5,5,6,7]
解题思路:使用单调递减双端队列来维护滑动窗口内的最大值。
function maxSlidingWindow(nums: number[], k: number): number[] {
if (nums.length === 0 || k === 0) return [];
if (k === 1) return nums;
const result: number[] = [];
const deque: number[] = []; // 存储索引的双端队列
for (let i = 0; i < nums.length; i++) {
// 移除超出窗口范围的元素
while (deque.length > 0 && deque[0] <= i - k) {
deque.shift();
}
// 维护队列的单调递减性
while (deque.length > 0 && nums[deque[deque.length - 1]] <= nums[i]) {
deque.pop();
}
// 当前元素入队
deque.push(i);
// 当窗口大小达到k时,开始记录结果
if (i >= k - 1) {
result.push(nums[deque[0]]);
}
}
return result;
}
时间复杂度: O(n) - 每个元素最多入队出队一次
空间复杂度: O(k)
4. 找到字符串中所有字母异位词 (LeetCode 438)
题目描述:给定两个字符串 s 和 p,找到 s 中所有 p 的异位词的子串,返回这些子串的起始索引。
示例:
- 输入:
s = "abab",p = "ab" - 输出:
[0,1,2] - 解释:
- 起始索引等于 0 的子串是 "ab",它是 "ab" 的异位词
- 起始索引等于 1 的子串是 "ba",它是 "ab" 的异位词
- 起始索引等于 2 的子串是 "ab",它是 "ab" 的异位词
解题思路:固定长度的滑动窗口,比较窗口内字符频次是否与 p 相同。
function findAnagrams(s: string, p: string): number[] {
const result: number[] = [];
if (s.length < p.length || p.length === 0) return result;
// 统计p中每个字符的出现次数
const need = new Map<string, number>();
for (const char of p) {
need.set(char, (need.get(char) || 0) + 1);
}
const window = new Map<string, number>();
let left = 0;
let right = 0;
let valid = 0;
while (right < s.length) {
// 扩大窗口
const char = s[right];
right++;
if (need.has(char)) {
window.set(char, (window.get(char) || 0) + 1);
if (window.get(char) === need.get(char)) {
valid++;
}
}
// 当窗口大小大于p的长度时,需要收缩窗口
while (right - left > p.length) {
const leftChar = s[left];
left++;
if (need.has(leftChar)) {
if (window.get(leftChar) === need.get(leftChar)) {
valid--;
}
window.set(leftChar, window.get(leftChar)! - 1);
}
}
// 当窗口大小等于p的长度且所有字符都匹配时,找到一个异位词
if (right - left === p.length && valid === need.size) {
result.push(left);
}
}
return result;
}
时间复杂度: O(|s| + |p|)
空间复杂度: O(|p|)
5. 长度最小的子数组 (LeetCode 209)
题目描述:给定一个含有 n 个正整数的数组和一个正整数 target,找出该数组中满足其和 ≥ target 的长度最小的连续子数组。
示例:
- 输入:
target = 7,nums = [2,3,1,2,4,3] - 输出:
2(子数组 [4,3] 是该条件下的长度最小的子数组)
function minSubArrayLen(target: number, nums: number[]): number {
let left = 0;
let sum = 0;
let minLen = Infinity;
for (let right = 0; right < nums.length; right++) {
sum += nums[right];
// 当和>=target时,尝试收缩窗口
while (sum >= target) {
minLen = Math.min(minLen, right - left + 1);
sum -= nums[left];
left++;
}
}
return minLen === Infinity ? 0 : minLen;
}
6. 替换后的最长重复字符 (LeetCode 424)
题目描述:给你一个字符串 s 和一个整数 k,你可以选择字符串中的任一字符,并将其更改为任何其他大写英文字符。该操作最多可执行 k 次。返回包含相同字母的最长子字符串的长度。
function characterReplacement(s: string, k: number): number {
const count = new Map<string, number>();
let left = 0;
let maxCount = 0;
let maxLength = 0;
for (let right = 0; right < s.length; right++) {
const char = s[right];
count.set(char, (count.get(char) || 0) + 1);
maxCount = Math.max(maxCount, count.get(char)!);
// 如果需要替换的字符数量超过k,收缩窗口
if (right - left + 1 - maxCount > k) {
const leftChar = s[left];
count.set(leftChar, count.get(leftChar)! - 1);
left++;
}
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
算法对比与选择
| 题目类型 | 窗口类型 | 核心数据结构 | 难点 | 时间复杂度 |
|---|---|---|---|---|
| 最长子串 | 可变 | 哈希表 | 处理重复字符 | O(n) |
| 最小覆盖 | 可变 | 双哈希表 | 判断覆盖条件 | O(n) |
| 窗口最大值 | 固定 | 单调队列 | 维护单调性 | O(n) |
| 字母异位词 | 固定 | 哈希表 | 频次比较 | O(n) |
| 最小子数组 | 可变 | 无 | 贪心收缩 | O(n) |
| 重复字符 | 可变 | 哈希表 | 替换次数控制 | O(n) |
解题技巧总结
1. 识别滑动窗口问题
- 涉及连续子数组/子字符串
- 需要找最长/最短/数量
- 有明确的窗口约束条件
2. 选择合适的窗口类型
- 固定窗口: 题目明确给出窗口大小
- 可变窗口: 需要找最长/最短满足条件的子数组
3. 数据结构选择策略
- 哈希表: 记录字符/元素的出现次数或位置
- 双端队列: 需要维护窗口内的最值
- 双指针: 基本的窗口边界控制
4. 窗口收缩时机
- 立即收缩: 一旦不满足条件就收缩(如重复字符)
- 延迟收缩: 先扩大到满足条件,再尝试收缩优化
5. 边界处理要点
- 空数组/字符串的处理
- 窗口大小超过数组长度的情况
- 单个元素的特殊情况
性能优化建议
- 合理选择数据结构: 根据操作需求选择最合适的数据结构
- 避免重复计算: 利用窗口的连续性,增量更新结果
- 空间换时间: 适当使用额外空间来降低时间复杂度
- 边界优化: 提前处理边界情况,避免不必要的计算
总结
滑动窗口算法是一种优雅而高效的技巧,通过维护一个动态窗口来避免重复计算,将原本 O(n²) 的暴力解法优化到 O(n)。掌握滑动窗口的关键在于:
- 理解核心思想: 动态维护窗口,增量更新结果
- 熟练掌握模板: 区分固定窗口和可变窗口的使用场景
- 灵活运用数据结构: 根据题目需求选择合适的辅助数据结构
- 注重边界处理: 考虑各种特殊情况,确保算法的健壮性
通过大量练习和总结,相信你能够熟练运用滑动窗口算法。
本文涵盖了滑动窗口算法的核心概念、解题模板和 6 道经典题目的详细解析。所有代码均经过完整测试验证,可直接运行。希望这篇文章能够帮助你全面掌握滑动窗口这一重要的算法技巧。