LeetCode(四):滑动窗口技巧

固定窗口与可变窗口的增量更新心法,覆盖最大子数组和、最小覆盖子串、滑动窗口最大值,外加单调队列进阶玩法。

如果你曾经写过双重 for 循环来遍历所有连续子数组,那么滑动窗口很可能就是你缺失的关键优化。它通过复用已计算的结果,将原本 $O(nk)$$O(n^2)$ 的暴力扫描压缩为一次线性遍历。本文将从基本原理出发,深入剖析这一技巧,并通过四道经典 LeetCode 题目加以演练,最后还会介绍其单调队列变体。

LeetCode(四):滑动窗口技巧 — 章节概览图


一图看懂滑动窗口#

滑动窗口本质上是在数组或字符串上维护一个连续区间 [left, right]。当窗口移动时,无需重新计算整个区间的值,而是加入右侧新进入的元素,移除左侧离开的元素。每个元素最多被访问两次(一次进、一次出),因此总时间复杂度为 $O(n)$

根据窗口大小是否固定,可分为两类:

  • 固定窗口:窗口宽度恒为 k,左右指针同步前进。
  • 可变窗口right 指针负责扩张窗口,left 指针在窗口违反约束时收缩以恢复合法性。

下图展示了固定窗口求最大子数组和的过程:绿色单元格代表当前窗口,橙色表示即将进入的元素,粉色表示即将离开的元素。右侧的更新公式清晰地表明——每一步仅需一次加法和一次减法,无需重新调用 sum()。这正是滑动窗口高效的核心所在。

固定窗口:求大小为 k 的最大子数组和

固定窗口:大小为 k 的最大子数组和#

问题描述:给定数组 arr 和整数 k,找出所有长度为 k 的连续子数组中和的最大值。

朴素解法:枚举每个长度为 k 的子数组并求和,时间复杂度为 $O(nk)$

滑动窗口优化:先计算第一个窗口的和,随后每次滑动时减去左侧离开的元素,加上右侧新进入的元素。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def max_sum_subarray(arr, k):
    """求长度为 k 的连续子数组的最大和。"""
    if len(arr) < k:
        return None

    window_sum = sum(arr[:k])     # 初始化第一个窗口的和
    best = window_sum

    for right in range(k, len(arr)):
        # 新进入窗口的元素是 arr[right],离开的是 arr[right - k]
        window_sum += arr[right] - arr[right - k]
        best = max(best, window_sum)

    return best

Java 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public int maxSumSubarray(int[] arr, int k) {
    if (arr.length < k) return Integer.MIN_VALUE;
    int windowSum = 0;
    for (int i = 0; i < k; i++) windowSum += arr[i];  // 初始化窗口和
    int best = windowSum;
    for (int right = k; right < arr.length; right++) {
        windowSum += arr[right] - arr[right - k];      // 滑动窗口更新
        best = Math.max(best, windowSum);
    }
    return best;
}

复杂度分析:时间 $O(n)$ —— 每次滑动仅需一次加法和一次减法;空间 $O(1)$

为何有效? 窗口的和由其内部元素集合决定。当窗口右移一位时,集合仅变化一个元素(移出一个、移入一个),因此只需应用这一增量变化。这种增量更新思想可推广至运行计数、频率哈希表,甚至单调队列(见第 6 节 )。

可变窗口的两种思路#

当窗口大小不固定时,我们使用双指针,并根据问题目标采用不同策略:

  • 寻找最长合法窗口:不断扩展 right;一旦窗口非法,就收缩 left 直到恢复合法,并在此过程中记录最大长度。
  • 寻找最短合法窗口:先扩展 right 直到窗口首次合法,然后尽可能收缩 left(同时保持合法),并在每次收缩时更新最短长度。

下图演示了“无重复字符的最长子串”算法在字符串 "abcabcbb" 上的执行过程:绿色表示合法窗口(正在扩张),粉色表示检测到重复字符,紫色表示正在收缩以修复窗口。

可变窗口:无重复字符的最长子串

下图则展示了窗口长度随时间的变化:绿色段为扩张阶段,紫色段为收缩阶段。

窗口长度随时间变化

关键在于:left 指针只会向右移动,绝不会回退。尽管外层是 for 循环、内层是 while 循环,但 left 在整个算法中的总移动步数不超过 n。这正是滑动窗口能实现摊还 $O(n)$ 时间复杂度的根本原因。

LeetCode 3 —— 无重复字符的最长子串#

无重复字符的最长子串:窗口扩展与收缩

给定字符串 s,返回其中不含重复字符的最长子串的长度。

模式识别:可变窗口,目标为最长合法。约束条件是“窗口内所有字符互异”。当 s[right] 已存在于窗口中时,将 left 直接跳至该字符上次出现位置的下一位。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def length_of_longest_substring(s):
    """LeetCode 3:无重复字符的最长子串。"""
    last_seen = {}        # 记录每个字符最近一次出现的位置
    left = 0              # 窗口左边界
    best = 0              # 最长子串长度

    for right, ch in enumerate(s):
        if ch in last_seen and last_seen[ch] >= left:
            # 如果当前字符在窗口内已存在,直接移动左边界
            left = last_seen[ch] + 1
        last_seen[ch] = right
        best = max(best, right - left + 1)

    return best

Java 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public int lengthOfLongestSubstring(String s) {
    Map<Character, Integer> lastSeen = new HashMap<>();
    int left = 0, best = 0;
    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        if (last_seen.containsKey(c) && last_seen.get(c) >= left) {
            // 跳过重复字符的位置
            left = last_seen.get(c) + 1;
        }
        last_seen.put(c, right);
        best = Math.max(best, right - left + 1);
    }
    return best;
}

复杂度分析:时间 $O(n)$ —— 每个字符最多被插入和删除哈希表各一次;空间 $O(\min(n, |\Sigma|))$ ,其中 $|\Sigma|$ 为字符集大小。

两种实现风格

  • 跳跃式收缩(如上):left 直接跳过重复字符,代码更简洁。
  • 步进式收缩:维护频次哈希表,通过 while count[s[right]] > 1: drop(s[left]); left += 1 逐步收缩。

两者均为 $O(n)$ 。跳跃式更短,步进式则更容易扩展到“至多包含 k 种字符”等变体。

LeetCode 76 —— 最小覆盖子串#

给定字符串 st,返回 s 中能包含 t 所有字符(含重复)的最短子串;若不存在,返回 ""

模式识别:可变窗口,目标为最短合法。约束是“窗口中每种字符的出现次数不少于 t 中的要求”。先扩展 right 直到窗口合法,再尽可能收缩 left

为避免每次比对两个哈希表,可维护一个 valid 计数器,表示有多少种字符已满足数量要求。当 valid == len(need) 时,窗口即合法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
from collections import Counter

def min_window(s, t):
    """LeetCode 76:覆盖 t 所有字符的最短子串。"""
    if not s or not t or len(s) < len(t):
        return ""

    need = Counter(t)  # 目标字符及其数量
    have = {}          # 当前窗口中目标字符的数量
    valid = 0          # 已满足条件的字符种类数
    left = 0           # 窗口左边界
    best_len = float("inf")  # 最短窗口长度
    best_start = 0     # 最短窗口起始位置

    for right, ch in enumerate(s):
        # 如果当前字符是目标字符,更新其计数
        if ch in need:
            have[ch] = have.get(ch, 0) + 1
            # 如果该字符的数量刚好达标,增加 valid
            if have[ch] == need[ch]:
                valid += 1

        # 当窗口合法时,尝试收缩左边界
        while valid == len(need):
            # 更新最短窗口信息
            if right - left + 1 < best_len:
                best_len = right - left + 1
                best_start = left

            # 移除左边界字符
            drop = s[left]
            left += 1
            if drop in need:
                # 如果移除后即将失衡,提前减少 valid
                if have[drop] == need[drop]:
                    valid -= 1
                have[drop] -= 1

    # 返回结果,若未找到合法窗口则返回空字符串
    return "" if best_len == float("inf") else s[best_start:best_start + best_len]

Java 版本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public String minWindow(String s, String t) {
    if (s.length() < t.length()) return "";
    Map<Character, Integer> need = new HashMap<>();
    for (char c : t.toCharArray()) need.merge(c, 1, Integer::sum);

    Map<Character, Integer> have = new HashMap<>();
    int valid = 0, left = 0;
    int bestLen = Integer.MAX_VALUE, bestStart = 0;

    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        if (need.containsKey(c)) {
            have.merge(c, 1, Integer::sum);
            if (have.get(c).intValue() == need.get(c).intValue()) valid++;
        }
        // 窗口合法时尝试收缩
        while (valid == need.size()) {
            if (right - left + 1 < bestLen) {
                bestLen = right - left + 1;
                bestStart = left;
            }
            char d = s.charAt(left++);
            if (need.containsKey(d)) {
                if (have.get(d).intValue() == need.get(d).intValue()) valid--;
                have.merge(d, -1, Integer::sum);
            }
        }
    }
    return bestLen == Integer.MAX_VALUE ? "" : s.substring(bestStart, bestStart + bestLen);
}

复杂度分析:时间 $O(|s| + |t|)$ —— 双指针均单调前进;空间 $O(|\Sigma|)$

易错细节if have[drop] == need[drop]: valid -= 1 这一行必须在减少 have[drop] 之前执行。因为我们要在计数跌破阈值就减少 valid,否则计数器会与实际状态脱节。

LeetCode 567 —— 字符串的排列#

判断 s2 中是否存在某个子串是 s1 的排列。

模式识别固定窗口,宽度为 len(s1)。两字符串互为排列当且仅当字符频次完全相同。因此,我们维护 s2 当前窗口的频次向量,并与 s1 的频次向量比较。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def check_inclusion(s1, s2):
    """LeetCode 567:s2 是否包含 s1 的某个排列?"""
    n, m = len(s1), len(s2)
    if n > m:
        return False

    need = [0] * 26
    have = [0] * 26
    for c in s1:
        need[ord(c) - ord('a')] += 1
    for c in s2[:n]:
        have[ord(c) - ord('a')] += 1

    # 统计当前有多少个字符的频率匹配上了
    matches = sum(1 for i in range(26) if need[i] == have[i])

    for right in range(n, m):
        if matches == 26:
            return True

        r = ord(s2[right]) - ord('a')
        l = ord(s2[right - n]) - ord('a')

        # 新元素从右边进入窗口
        have[r] += 1
        if have[r] == need[r]:
            matches += 1
        elif have[r] == need[r] + 1:
            matches -= 1

        # 旧元素从左边移出窗口
        have[l] -= 1
        if have[l] == need[l]:
            matches += 1
        elif have[l] == need[l] - 1:
            matches -= 1

    return matches == 26

Java 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public boolean checkInclusion(String s1, String s2) {
    int n = s1.length(), m = s2.length();
    if (n > m) return false;
    int[] need = new int[26], have = new int[26];
    for (int i = 0; i < n; i++) {
        need[s1.charAt(i) - 'a']++;
        have[s2.charAt(i) - 'a']++;
    }
    int matches = 0;
    for (int i = 0; i < 26; i++) if (need[i] == have[i]) matches++;
    for (int right = n; right < m; right++) {
        if (matches == 26) return true;
        int r = s2.charAt(right) - 'a';
        int l = s2.charAt(right - n) - 'a';
        have[r]++;
        if (have[r] == need[r]) matches++;
        else if (have[r] == need[r] + 1) matches--;
        have[l]--;
        if (have[l] == need[l]) matches++;
        else if (have[l] == need[l] - 1) matches--;
    }
    return matches == 26;
}

复杂度分析:时间 $O(m)$ —— 每次滑动仅需常数时间更新 matches,无需扫描全部 26 个桶;空间 $O(1)$

同一套框架也适用于 LC 438(查找所有字母异位词),只需将“提前返回”改为“收集起始索引”即可。

进阶变体:滑动窗口最大值(单调队列)#

当窗口需要查询最大值或最小值时,简单的“加/减”操作不再足够——移除当前最大值后,我们需要立刻知道下一个最大值是谁。此时标准工具是单调双端队列:队列中存储的值(或索引)严格递减,队首始终是当前窗口的最大值。

单调队列求滑动窗口最大值

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from collections import deque

def max_sliding_window(nums, k):
    """LeetCode 239:每个长度为 k 的窗口的最大值。"""
    dq = deque()      # 存储下标;对应的值在 nums 中严格递减
    out = []

    for i, x in enumerate(nums):
        # 1. 踢掉队尾所有比 x 小的元素——它们不可能再成为最大值
        while dq and nums[dq[-1]] < x:
            dq.pop()
        dq.append(i)

        # 2. 如果队首已经滑出窗口,直接移除
        if dq[0] <= i - k:
            dq.popleft()

        # 3. 窗口形成后,队首就是当前窗口的最大值
        if i >= k - 1:
            out.append(nums[dq[0]])

    return out

每个索引最多入队和出队一次,总时间复杂度仍为 $O(n)$ ,与基础滑动窗口一致。

滑动窗口的适用场景#

下图决策树可帮助你快速判断问题是否适合滑动窗口及其具体形式:

滑动窗口适用场景决策树

当你遇到以下全部条件时,应考虑滑动窗口:

  1. 答案对应一个连续区间(子数组 / 子串);
  2. 区间的属性可增量维护(如累加和、频次哈希表、单调队列等);
  3. 目标是找固定大小k)或满足某种边界条件的最长/最短合法窗口

若任一条件不满足(如需非连续选择、全局视角或任意撤销操作),则应尝试前缀和、排序后的双指针、动态规划或堆等其他方法。

三套值得背下来的模板#

固定窗口

1
2
3
4
5
6
7
8
def fixed_window(arr, k):
    state = init_state(arr[:k])           # 例如和、频次表
    best = report(state)
    for right in range(k, len(arr)):
        state = add(state, arr[right])
        state = remove(state, arr[right - k])
        best = better(best, report(state))
    return best

可变窗口——最长合法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def longest_valid(arr):
    left = 0
    state = empty_state()
    best = 0
    for right, x in enumerate(arr):
        state = add(state, x)
        while not is_valid(state):
            state = remove(state, arr[left])
            left += 1
        best = max(best, right - left + 1)
    return best

可变窗口——最短合法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def shortest_valid(arr):
    left = 0
    state = empty_state()
    best = float("inf")
    for right, x in enumerate(arr):
        state = add(state, x)
        while is_valid(state):
            best = min(best, right - left + 1)
            state = remove(state, arr[left])
            left += 1
    return best if best != float("inf") else 0

常见陷阱#

  • 窗口长度计算错误:正确公式是 right - left + 1,而非 right - left
  • valid 更新顺序错误:应在 have[c] 首次等于 need[c] 时增加 valid;在 have[c] 即将低于 need[c] 前减少 valid
  • list.pop(0) 维护固定窗口:这是 $O(k)$ 操作。应改用 collections.deque 或直接通过下标计算,确保 $O(1)$
  • 重新计算整个窗口:滑动窗口的核心是增量更新。若内层循环仍遍历整个窗口,复杂度将退化为暴力解法。
  • “和 ≥ target” 遇到负数:收缩窗口时和可能不减反增,导致“最短合法”模板失效。此时应改用前缀和 + 单调队列或其他思路。

练习题#

难度题号与名称适用模板
简单LC 643 子数组最大平均数 I固定窗口
简单LC 1456 定长子串中元音的最大数目固定窗口
中等LC 3 无重复字符的最长子串求最长
中等LC 159 / 340 至多包含 K 个不同字符的最长子串求最长
中等LC 209 长度最小的子数组求最短
中等LC 424 替换后的最长重复字符求最长
中等LC 567 字符串的排列固定窗口
中等LC 438 找到字符串中所有字母异位词固定窗口
中等LC 713 乘积小于 K 的子数组求最长
中等LC 1004 最大连续 1 的个数 III求最长
困难LC 76 最小覆盖子串求最短
困难LC 239 滑动窗口最大值单调队列
困难LC 30 串联所有单词的子串固定窗口 + 哈希

滑动窗口在真实场景中的应用#

滑动窗口远不止用于面试刷题,它在工程实践中也有广泛应用。以下是几个我亲身经历的案例:

  • 限流算法:令牌桶和滑动窗口计数器是两大主流方案。“每分钟最多 1000 次请求”的实现通常就是一个时间戳双端队列,从左侧逐出超时记录。redis-cell 和多数 API 网关均采用此模式。
  • 实时数据聚合:监控系统中“过去一小时每分钟错误数”的图表,本质就是事件流上的滑动窗口。Kafka Streams 的 TimeWindow 和 Flink 的滑动窗口,都是对此模式的框架级封装。
  • 网络拥塞控制:TCP 的拥塞窗口(cwnd)是对未确认字节的滑动窗口,而接收方通告窗口则限制了发送方左边界可推进的位置。

面试题只是简化版,生产环境中的实现通常以时间戳替代数组下标,以 TTL 过期替代指针递增,但核心思想完全一致——动态维护窗口并高效更新状态。

更地道的 Python 写法#

C 风格的 while right < n 循环虽可行,但使用 for right, x in enumerate(arr) 更简洁,还能避免常见的差一错误。对比:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# C 风格
right = 0
while right < len(arr):
    add(arr[right])
    while bad(): remove(arr[left]); left += 1
    best = max(best, right - left + 1)
    right += 1

# Pythonic
left = 0
for right, x in enumerate(arr):
    add(x)
    while bad():
        remove(arr[left]); left += 1
    best = max(best, right - left + 1)

后者省去了 right += 1(面试中最易遗漏的语句),也避免了循环体内重复索引 arr[right]算法逻辑不变,但出错概率显著降低

若需维护频次窗口,优先使用 collections.Counter 而非手写字典——其 +=-= 语义经过充分测试,可靠且简洁:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from collections import Counter

def length_of_longest_substring_k_distinct(s: str, k: int) -> int:
    cnt = Counter()
    left = best = 0
    for right, c in enumerate(s):
        cnt[c] += 1
        while len(cnt) > k:
            cnt[s[left]] -= 1
            if cnt[s[left]] == 0:
                del cnt[s[left]]
            left += 1
        best = max(best, right - left + 1)
    return best

注意减计数后的 del 操作:这是确保 len(cnt) 准确的关键。若不删除零计数键,“不同字符数”会悄悄包含无效项。

边界用例自查表#

提交前,请在脑中快速过一遍以下情况。滑动窗口题目的“答案错误”大多源于这些边界:

情况为什么会挂
空字符串 / 空数组初始化 best 时选 0 还是 float('inf') 至关重要
“至多 K 个不同”中 k = 0合法窗口应为空,但若代码先扩展 right,可能误报长度为 1
单个元素>>=best 的判断在此处极易暴露问题
元素全相同“最长”模板通常正常;“最短”模板可能立即塌缩至长度 1 且无法扩展
target 大于 sum(arr)“和 ≥ target 的最短子数组”应返回 0,而非 float('inf')
包含负数滑动窗口通常假设“添加元素使值单调增长”,负数会破坏此前提,需切换至前缀和 + 单调队列
窗口比数组还大固定窗口模板必须显式检查 len(arr) < k,否则会越界

花两分钟脑内干跑这些用例,能帮你避开 80% 的坑,省下对着“测试用例 87 错误”发呆的十五分钟。

总结#

滑动窗口本质上是一种摊还分析技巧:通过维护少量状态并在窗口移动时增量更新,避免重复计算。两个单调递增的指针将暴力解法的 $O(n^2)$ 优化至 $O(n)$

面对连续区间问题时,牢记以下四个问题:

  1. 窗口大小是固定还是可变
  2. 若可变,目标是最长还是最短合法窗口?
  3. 需要维护什么状态——和、频次表,还是单调队列?
  4. 窗口的约束条件是什么?哪个指针的移动会破坏它?

厘清这四点后,大多数滑动窗口问题都能归约为模板填充。

本系列

LeetCode 算法模式 10 篇

  1. 01 LeetCode(一):哈希表
  2. 02 LeetCode(二):双指针技巧
  3. 03 LeetCode(三):链表操作
  4. 04 LeetCode(四):滑动窗口技巧 当前
  5. 05 LeetCode(五):二分查找
  6. 06 LeetCode(六):二叉树遍历与构造
  7. 07 LeetCode(七):动态规划入门
  8. 08 LeetCode(八):回溯算法
  9. 09 LeetCode(九):贪心算法
  10. 10 LeetCode(十):栈与队列

读有所得?

GitHub 关注我 → 新文周更

GitHub