滑动窗口算法终极指南:O(n²)到O(n)的性能飞跃

116 阅读8分钟

前言

滑动窗口(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")

解题思路

  1. 使用滑动窗口,左右指针维护窗口边界
  2. 用哈希表记录字符最后出现的位置
  3. 当遇到重复字符时,移动左指针到重复字符的下一位
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"

解题思路

  1. 用哈希表记录目标字符串 t 中每个字符的需求数量
  2. 使用滑动窗口,右指针扩展窗口,左指针收缩窗口
  3. 当窗口包含所有目标字符时,尝试收缩左指针找到最小窗口
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. 边界处理要点

  • 空数组/字符串的处理
  • 窗口大小超过数组长度的情况
  • 单个元素的特殊情况

性能优化建议

  1. 合理选择数据结构: 根据操作需求选择最合适的数据结构
  2. 避免重复计算: 利用窗口的连续性,增量更新结果
  3. 空间换时间: 适当使用额外空间来降低时间复杂度
  4. 边界优化: 提前处理边界情况,避免不必要的计算

总结

滑动窗口算法是一种优雅而高效的技巧,通过维护一个动态窗口来避免重复计算,将原本 O(n²) 的暴力解法优化到 O(n)。掌握滑动窗口的关键在于:

  1. 理解核心思想: 动态维护窗口,增量更新结果
  2. 熟练掌握模板: 区分固定窗口和可变窗口的使用场景
  3. 灵活运用数据结构: 根据题目需求选择合适的辅助数据结构
  4. 注重边界处理: 考虑各种特殊情况,确保算法的健壮性

通过大量练习和总结,相信你能够熟练运用滑动窗口算法。


本文涵盖了滑动窗口算法的核心概念、解题模板和 6 道经典题目的详细解析。所有代码均经过完整测试验证,可直接运行。希望这篇文章能够帮助你全面掌握滑动窗口这一重要的算法技巧。