一、什么是滑动窗口问题

想象你在公交车上透过窗户看风景,窗户固定大小,随着车子移动,你会看到不同的景色。滑动窗口问题也是这样——在数组或字符串中维护一个固定或可变大小的"窗口",通过移动窗口来高效处理数据。这类问题通常要求我们找到满足特定条件的子数组或子串,比如最大和子数组、最小覆盖子串等。

滑动窗口的核心思想是避免重复计算。比如要计算长度为k的连续子数组最大和,暴力解法需要O(n*k)时间复杂度,而滑动窗口可以优化到O(n)。这就像你买菜时,老板不用每次都重新称重,只需要在前一次的基础上增减变化的部分。

二、双指针如何实现滑动窗口

双指针是滑动窗口的骨架。通常用left和right两个指针标记窗口边界,根据条件动态调整窗口大小。来看一个典型例子(使用Java语言示例):

// 寻找最长无重复字符的子串长度
public int lengthOfLongestSubstring(String s) {
    Set<Character> window = new HashSet<>();
    int left = 0, maxLen = 0;
    
    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        // 当遇到重复字符时,移动左指针缩小窗口
        while (window.contains(c)) {
            window.remove(s.charAt(left++));
        }
        window.add(c);
        maxLen = Math.max(maxLen, right - left + 1);
    }
    return maxLen;
}
/* 代码解析:
   1. HashSet记录当前窗口字符
   2. right指针主动向右扩展
   3. 发现重复时,left指针被动右移
   4. 实时更新最大窗口尺寸 */

再来看一个可变窗口的例子——求数组中和≥target的最短子数组:

public int minSubArrayLen(int target, int[] nums) {
    int left = 0, sum = 0;
    int minLen = Integer.MAX_VALUE;
    
    for (int right = 0; right < nums.length; right++) {
        sum += nums[right];  // 扩展右边界
        while (sum >= target) {
            minLen = Math.min(minLen, right - left + 1);
            sum -= nums[left++];  // 收缩左边界
        }
    }
    return minLen == Integer.MAX_VALUE ? 0 : minLen;
}
/* 关键点:
   1. 外层循环扩展窗口
   2. 内层while满足条件时收缩窗口
   3. 实时记录最小满足条件的窗口 */

三、滑动窗口的三大应用场景

1. 固定大小窗口

比如计算大小为k的窗口中的最大值。这时可以用单调队列优化:

public int[] maxSlidingWindow(int[] nums, int k) {
    Deque<Integer> deque = new LinkedList<>();
    int[] res = new int[nums.length - k + 1];
    
    for (int i = 0; i < nums.length; i++) {
        // 移除超出窗口范围的元素
        while (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
            deque.pollFirst();
        }
        // 维护单调递减队列
        while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) {
            deque.pollLast();
        }
        deque.offerLast(i);
        // 记录窗口最大值
        if (i >= k - 1) {
            res[i - k + 1] = nums[deque.peekFirst()];
        }
    }
    return res;
}
/* 技术要点:
   1. 队列存储的是索引而非值
   2. 严格保持队列头部是当前窗口最大值
   3. 双while结构保证队列单调性 */

2. 可变大小窗口

典型如字符串匹配问题。假设要在s中找到包含t所有字符的最小子串:

public String minWindow(String s, String t) {
    Map<Character, Integer> need = new HashMap<>();
    Map<Character, Integer> window = new HashMap<>();
    // 初始化need字典
    for (char c : t.toCharArray()) {
        need.put(c, need.getOrDefault(c, 0) + 1);
    }
    
    int left = 0, valid = 0;
    int start = 0, len = Integer.MAX_VALUE;
    
    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        if (need.containsKey(c)) {
            window.put(c, window.getOrDefault(c, 0) + 1);
            if (window.get(c).equals(need.get(c))) {
                valid++;
            }
        }
        // 当窗口满足条件时尝试收缩
        while (valid == need.size()) {
            if (right - left + 1 < len) {
                start = left;
                len = right - left + 1;
            }
            char d = s.charAt(left++);
            if (need.containsKey(d)) {
                if (window.get(d).equals(need.get(d))) {
                    valid--;
                }
                window.put(d, window.get(d) - 1);
            }
        }
    }
    return len == Integer.MAX_VALUE ? "" : s.substring(start, start + len);
}
/* 实现要点:
   1. 使用valid计数器避免频繁比较两个map
   2. 收缩窗口时先检查是否可能更新结果
   3. 注意Integer对象的比较要用equals */

3. 计数类问题

比如给定二进制数组,求包含相同数量0和1的最长子数组:

public int findMaxLength(int[] nums) {
    Map<Integer, Integer> map = new HashMap<>();
    map.put(0, -1);  // 初始前缀和为0的索引
    int count = 0, maxLen = 0;
    
    for (int i = 0; i < nums.length; i++) {
        count += (nums[i] == 1 ? 1 : -1);
        if (map.containsKey(count)) {
            maxLen = Math.max(maxLen, i - map.get(count));
        } else {
            map.put(count, i);
        }
    }
    return maxLen;
}
/* 技巧:
   1. 将0视为-1转化为求和为0的问题
   2. 使用哈希表记录前缀和首次出现位置
   3. 相同前缀和之间的子数组满足条件 */

四、技术优缺点与注意事项

优势分析

  1. 时间复杂度低:多数滑动窗口问题都能从O(n²)优化到O(n)
  2. 空间效率高:通常只需要常数或线性额外空间
  3. 代码简洁:模板化结构容易记忆和实现

局限性

  1. 适用场景有限:主要适用于连续子数组/子串问题
  2. 边界条件复杂:窗口移动时的边界处理容易出错
  3. 数据要求严格:通常需要数据具有单调性或特定规律

常见踩坑点

  1. 指针移动时机:该移动left时没移动会导致窗口过大,不该移动时移动会导致错过解
  2. 哈希表使用:当使用HashMap记录状态时,注意Integer对象的比较问题
  3. 初始值设置:如maxLen初始应为Integer.MIN_VALUE,minLen应为Integer.MAX_VALUE

调试技巧

  1. 打印窗口左右边界和关键变量
  2. 对特殊用例手动模拟运行过程
  3. 使用可视化工具观察窗口变化

五、总结与进阶思考

滑动窗口就像是一个智能的扫描仪,它不会重复检查已经确认过的区域,而是聪明地根据当前情况调整扫描范围。掌握这个技巧后,你会发现很多看似复杂的问题都能迎刃而解。

在实际面试中,滑动窗口类题目出现频率极高。建议重点练习:

  1. 无重复字符的最长子串(高频经典)
  2. 最小覆盖子串(综合难度较高)
  3. 字符串排列(固定窗口代表)
  4. 最大连续1的个数(简单但易错)
  5. 替换后的最长重复字符(需要技巧转换)

记住,所有滑动窗口问题都遵循相同模式:扩展右边界→满足条件时处理→收缩左边界→更新结果。把这个模式内化后,你就能快速识别并解决这类问题。