🪟 滑动窗口 — 子串子数组的秘密武器

用窗口在序列上滑动,O(n)时间解决子串子数组问题

📖 滑动窗口的核心概念

滑动窗口(Sliding Window)是双指针的特殊形式,维护一个"窗口"在序列上滑动。窗口的左右边界可以分别扩展和收缩,从而在 O(n) 时间内解决许多看似需要 O(n²) 或 O(n³) 的子串/子数组问题。核心思想是:右指针扩展窗口探索新元素,左指针收缩窗口满足约束条件。

滑动窗口的基本模型: 数组: [a, b, c, d, e, f, g, h, i] ←——窗口——→ ↑ ↑ left right 步骤: 1. right 右移 → 窗口扩大 → 加入新元素 2. 检查窗口是否满足约束 3. 不满足 → left 右移 → 窗口收缩 → 移除左侧元素 4. 满足 → 更新答案 5. 重复直到 right 到达末尾 两种类型: ┌───────────────┬──────────────────────────────┐ │ 固定大小窗口 │ 窗口大小固定,右移一步左移一步 │ │ 可变大小窗口 │ 窗口大小可变,维护某种最优解 │ └───────────────┴──────────────────────────────┘

1. 滑动窗口的通用模板

# 可变大小窗口模板
def sliding_window(s):
    from collections import defaultdict
    window = defaultdict(int)
    left = 0
    result = 0  # 根据题意初始化
    
    for right, ch in enumerate(s):
        # 1. 右指针扩展:加入新元素
        window[ch] += 1
        
        # 2. 判断窗口是否需要收缩
        while 窗口不满足条件:
            # 左指针收缩:移除左侧元素
            window[s[left]] -= 1
            left += 1
        
        # 3. 更新答案
        result = max(result, right - left + 1)
    
    return result

2. 窗口内维护什么?

题目类型窗口维护的内容数据结构
无重复字符的最长子串字符出现次数哈希字典 / 数组
最小覆盖子串目标字符的满足数量哈希字典 + 计数器
长度最小的子数组子数组和变量累加
滑动窗口最大值窗口内最大值单调队列
滑动窗口的关键问题:窗口何时扩张?何时收缩?答案由题目的约束条件决定。先想清楚"合法窗口"的定义,再编码。

🎯 题目一:无重复字符的最长子串 (LC 3)

题目描述:给定一个字符串 s,请你找出其中不含有重复字符的最长子串的长度。

示例: s = "abcabcbb" right=0: 窗口 [a] → 长度1, left=0 right=1: 窗口 [a,b] → 长度2, left=0 right=2: 窗口 [a,b,c] → 长度3, left=0 right=3: 遇到a重复! → left跳到1, 窗口 [b,c,a] → 长度3 right=4: 遇到b重复! → left跳到2, 窗口 [c,a,b] → 长度3 right=5: 遇到c重复! → left跳到3, 窗口 [a,b,c] → 长度3 right=6: 遇到b重复! → left跳到5, 窗口 [c,b] → 长度2 right=7: 遇到b重复! → left跳到7, 窗口 [b] → 长度1 最长无重复子串长度 = 3

思路分析

滑动窗口 + 哈希表:
用哈希表记录每个字符最近出现的位置。
右指针遍历字符串,遇到重复字符时,将左指针跳到重复字符上次出现位置的下一个。
注意:left 只能前进不能后退,所以 left = max(left, last_pos + 1)
时间复杂度:O(n),空间复杂度:O(min(m, n)),m 为字符集大小

代码实现

class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        last_pos = {}  # 字符 → 最近出现的位置
        left = 0
        max_len = 0
        for right, ch in enumerate(s):
            if ch in last_pos:
                left = max(left, last_pos[ch] + 1)
            last_pos[ch] = right
            max_len = max(max_len, right - left + 1)
        return max_len

复杂度分析

时间复杂度:O(n) — 每个字符最多被访问两次

空间复杂度:O(min(m, n)) — m 为字符集大小

变体练习

🎯 题目二:最小覆盖子串 (LC 76)

题目描述:给你一个字符串 s 和一个字符串 t。返回 s 中涵盖 t 所有字符(包括重复字符)的最小子串。如果不存在,返回空字符串。

示例: s = "ADOBECODEBANC", t = "ABC" 需要覆盖: A×1, B×1, C×1 窗口扩展过程: [A] → 缺B,C [A,D,O,B] → 缺C [A,D,O,B,E,C] → 满足! 长度6 → 尝试收缩 [D,O,B,E,C] → 缺A → 继续扩展 ... [E,B,A,N,C] → 满足! 长度5 → 尝试收缩 [B,A,N,C] → 满足! 长度4 → 继续收缩 [A,N,C] → 缺B → 继续 最小覆盖子串 = "BANC" (长度4)

思路分析

滑动窗口 + 双哈希表:
1. 用 need 字典记录 t 中每个字符的需求量
2. 用 window 字典记录当前窗口中各字符的数量
3. 用 valid 计数器记录"已满足需求的字符种类数"
4. 右指针扩展:加入字符,如果该字符数量达到需求则 valid++
5. 当 valid == len(need) 时,窗口满足条件,尝试收缩左指针
6. 左指针收缩:移除字符,如果该字符数量低于需求则 valid--
时间复杂度:O(n),空间复杂度:O(k),k 为字符集大小

代码实现

class Solution:
    def minWindow(self, s: str, t: str) -> str:
        from collections import defaultdict
        need = defaultdict(int)
        for ch in t:
            need[ch] += 1
        
        window = defaultdict(int)
        valid = 0
        left = 0
        start, min_len = 0, float('inf')
        
        for right, ch in enumerate(s):
            if ch in need:
                window[ch] += 1
                if window[ch] == need[ch]:
                    valid += 1
            
            while valid == len(need):
                if right - left + 1 < min_len:
                    start = left
                    min_len = right - left + 1
                
                left_ch = s[left]
                if left_ch in need:
                    if window[left_ch] == need[left_ch]:
                        valid -= 1
                    window[left_ch] -= 1
                left += 1
        
        return s[start:start + min_len] if min_len != float('inf') else ""

复杂度分析

时间复杂度:O(n) — 每个字符最多进出窗口各一次

空间复杂度:O(k) — k 为 t 中不同字符数

valid 计数器是关键优化!如果没有它,每次检查窗口是否满足条件需要 O(k) 遍历 need 字典,总复杂度变成 O(nk)。用 valid 常数时间判断,才是真正的 O(n)。

变体练习

🎯 题目三:长度最小的子数组 (LC 209)

题目描述:给定一个含有 n 个正整数的数组和一个正整数 target,找出该数组中满足其和 ≥ target 的长度最小的连续子数组,并返回其长度。如果不存在,返回 0。

示例: nums = [2, 3, 1, 2, 4, 3], target = 7 滑动窗口过程: [2] → sum=2 < 7 → 扩展 [2,3] → sum=5 < 7 → 扩展 [2,3,1] → sum=6 < 7 → 扩展 [2,3,1,2] → sum=8 ≥ 7 → 长度4,收缩 [3,1,2] → sum=6 < 7 → 扩展 [3,1,2,4] → sum=10 ≥ 7 → 长度4,收缩 [1,2,4] → sum=7 ≥ 7 → 长度3,收缩 [2,4] → sum=6 < 7 → 扩展 [2,4,3] → sum=9 ≥ 7 → 长度3,收缩 [4,3] → sum=7 ≥ 7 → 长度2 ← 最优! 最小长度 = 2

思路分析

滑动窗口(正整数专属):
因为所有元素为正整数,窗口和随右指针扩张而增大,随左指针收缩而减小,具有单调性。
1. 右指针扩展,累加窗口和
2. 当窗口和 ≥ target 时,记录答案并收缩左指针
3. 窗口和 < target 时,继续扩展右指针
时间复杂度:O(n),空间复杂度:O(1)

代码实现

class Solution:
    def minSubArrayLen(self, target: int, nums: List[int]) -> int:
        left = 0
        window_sum = 0
        min_len = float('inf')
        
        for right in range(len(nums)):
            window_sum += nums[right]
            while window_sum >= target:
                min_len = min(min_len, right - left + 1)
                window_sum -= nums[left]
                left += 1
        
        return min_len if min_len != float('inf') else 0

复杂度分析

时间复杂度:O(n) — 每个元素最多进出窗口各一次

空间复杂度:O(1) — 只用几个变量

如果数组中有负数,窗口和不再具有单调性,滑动窗口失效!此时需用前缀和 + 二分查找 O(n log n),或前缀和 + 单调队列 O(n)。

变体练习

成就解锁:窗口猎手 — 掌握可变/固定窗口技巧,能快速判断何时扩张何时收缩
LeetCode AC验证:LC 3 Longest Substring Without Repeating Characters ✅ | LC 76 Minimum Window Substring ✅ | LC 209 Minimum Size Subarray Sum ✅

📝 课后练习

  1. LC 438 找到字符串中所有字母异位词(固定窗口大小)
  2. LC 567 字符串的排列(判断是否包含排列)
  3. LC 904 水果成篮(最多两种类型的最长子数组)
  4. LC 1004 最大连续1的个数 III(最多翻转 k 个 0)
  5. LC 1438 绝对差不超过限制的最长连续子数组(有序集合)

🔑 本课要点回顾

📚 扩展阅读

思考题:如果 LC 209 的数组中包含负数,滑动窗口还能用吗?为什么?应该用什么方法替代?提示:前缀和 + 单调队列。