Series · LeetCode Patterns · Chapter 4

LeetCode(四)—— 滑动窗口技巧

如果你写过两层 for 循环去枚举所有连续子数组,那么滑动窗口多半就是你缺的那一步优化。它把 $O(nk)$ 或 $O(n^2)$ 的暴力枚举压成线性的一遍扫描,关键就在于"复用上一步算出来的东西"。本文从最朴素的直觉出发,先讲清楚思路,再用四道高频 LeetCode 题目把套路彻底落地,最后再补一个单调队列的进阶用法。

1. 一图看懂滑动窗口

滑动窗口本质上是数组或字符串上的一段连续区间 [left, right]。窗口移动的时候,我们不重新计算整个区间,而是把右边新进来的元素加进去、把左边离开的元素拿出来,每个元素最多被处理两次,整体是 $O(n)$。

按窗口大小是否变化,可以分成两类:

  • 固定窗口——宽度固定为 k,左右指针同步前进。
  • 可变窗口——right 主动扩张,left 在窗口"违反约束"时被动收缩。

下面这张图展示了固定窗口求最大子数组和:绿色是当前窗口,橙色是即将进入的元素,粉色是即将离开的元素。右侧的算式说明每次只做一次加法和一次减法,这就是滑动窗口的全部秘密。

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

2. 固定窗口:大小为 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 节)。

3. 可变窗口的两种姿势

如果窗口大小不固定,就要靠两个指针配合:

  • 求最长合法窗口right 一路向右扩张;一旦窗口违反约束,就把 left 往右挪到重新合法为止,期间记录最大长度。
  • 求最短合法窗口right 扩到窗口刚好满足条件后,left 尽量往右挤,看看能不能更短,每次收缩时更新答案。

下图追踪了"无重复字符的最长子串"在 "abcabcbb" 上的运行过程:绿色表示窗口合法、橙色括号是当前 [L, R],粉色表示检测到重复,紫色表示正在收缩。

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

下面这张图把窗口长度随迭代步数的变化画了出来,绿色背景是扩张阶段,紫色是收缩阶段。

窗口长度随时间变化

最关键的不变量是:left 永远只往右走,绝不回头。所以即使外层 for 套着内层 while,整个内层在全程里跑过的总步数也最多 $n$,这就是滑动窗口"摊还 $O(n)$“的根据。

4. 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:
            # 跳过上一次出现位置,O(1) 完成收缩
            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
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 (lastSeen.containsKey(c) && lastSeen.get(c) >= left) {
            left = lastSeen.get(c) + 1;
        }
        lastSeen.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 种字符"那类变体。

5. LeetCode 76:最小覆盖子串

题目:给定字符串 st,返回 s 中能覆盖 t 所有字符(含重数)的最短子串;不存在则返回 ""

套路:可变窗口、求最短。约束是"窗口内每个目标字符的出现次数都达标"。先扩到合法,再尽力收缩。

判断窗口是否合法时,最干净的办法是维护一个 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
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
            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:
                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
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 真的减下去之前判断"现在恰好达标,移走一个就要失衡了",所以判断必须在自减之前。顺序写反,valid 就会跟实际窗口对不上。

6. LeetCode 567:字符串的排列

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

套路:长度为 len(s1) 的固定窗口。两个字符串互为排列当且仅当字符频率向量相同,所以只要在 s2 上维护一个长度 26 的频率数组,跟 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

    # 当前 26 个桶里有几个对得上
    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 计数器把"比较两个频率数组"的 $O(26)$ 也省掉了;空间 $O(1)$。

**LeetCode 438(找到所有字母异位词)**完全可以套这个模板,只是把"提前返回"换成"收集起始下标"。

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

当窗口要查询的不是和、不是计数,而是窗口内的最大值或最小值时,简单的"加一个、减一个"就不够用了——因为移除当前最大值后,下一个最大值在哪里我们并不知道。这时的标准做法是单调队列:队列里的元素按值严格递减,队首始终是当前窗口的最大值。

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

 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)$。

8. 什么时候用滑动窗口

下面这棵决策树总结了"什么形状的问题该用滑动窗口的哪种姿势":

滑动窗口适用场景决策树

满足下列三条时,优先考虑滑动窗口:

  1. 答案是某段连续区间(子数组、子串)。
  2. 区间属性可以增量维护——窗口移动一格只需做常数次操作(如累加和、计数表、单调队列)。
  3. 目标要么是固定大小k),要么是最长 / 最短的合法区间

如果不连续、需要全局信息、或者要任意撤销操作,那就别强行套滑动窗口,换前缀和、双指针、动态规划或堆才是正解。

9. 三套值得背下来的模板

固定窗口

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

10. 常见陷阱

  • 窗口长度差一:长度是 right - left + 1,不是 right - left
  • valid 更新时机搞反have[c] 首次等于 need[c]valid += 1即将跌破时 valid -= 1。先减计数还是先判断,差一步计数器就乱了。
  • list.pop(0) 维护固定窗口:那是 $O(k)$ 的操作。请用 collections.deque 或者直接靠下标,保持 $O(1)$。
  • 每次都重新计算窗口:滑动窗口的全部价值就在于增量更新;只要内层还在扫整个窗口,渐进复杂度就退回去了。
  • “和 ≥ target” 遇到负数:收缩时和不一定下降,“求最短"模板会失效。这时考虑前缀和 + 单调队列,或者换思路。

11. 推荐练习题清单

难度题号与名称适用模板
简单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 串联所有单词的子串固定窗口 + 哈希

12. 总结

滑动窗口的本质是一个摊还分析:与其每个窗口都从头算一遍,不如维护一小块状态,让它随着边界推进做增量更新。两个指针都单调右移,于是把暴力的 $O(n^2)$ 压到 $O(n)$。

碰到连续区间题时,给自己问四个问题:

  1. 窗口大小是固定还是可变
  2. 如果可变,是求最长合法窗口,还是最短合法窗口?
  3. 我要维护什么状态——和、频次表,还是单调队列?
  4. 什么是窗口的不变量,谁的移动会破坏它?

这四个问题想清楚了,绝大多数滑动窗口题就只剩下"往模板里填空"的工作。

Liked this piece?

Follow on GitHub for the next one — usually one a week.

GitHub