Pattern 03 · Sliding Window

03 滑动窗口 连续区间问题,右扩左缩维护一个「合法窗口」

面试频率 极高题目 5 道模板 2 个⭐认脸 1 道
§1 Mental Model 一句话心智模型

先记住这一句

窗口只做两件事:右边界无脑往外扩,扩到不合法就收左边界。所以整道题的全部难度,都在你怎么定义「不合法」。

先问自己三句话,答完模板就填完了:窗口里要维护什么状态(计数 / 和 / 去重集合)?什么时候算不合法(while 的条件)?答案在哪一刻统计(收缩之后,此刻窗口一定合法)?

复杂度是 O(n) 而不是 O(n²):左指针只会向右走,两个指针各自最多走 n 步。如果你写出的收缩循环里左指针可能回退,那说明它不是滑动窗口。

§2 Recognition Signals 识别信号

看到什么,想到本章

题面出现…想到
「最长 / 最短连续子串 / 子数组」+ 一个约束条件可变窗口(模板 A)
「至多 K 个不同字符 / 至多 K 次替换 / 至多 K 个 0」可变窗口,K 就是合法性阈值
「长度恰好为 k 的子数组的最大和 / 平均值」定长窗口(模板 B)
「s1 的排列是不是 s2 的子串」「找出所有 anagram 起点」定长窗口 + 计数相等(模板 B)
「包含 t 中所有字符的最小子串」可变窗口 + 缺口计数(76 题)
你的暴力解是「枚举所有子数组再逐个检查」滑窗优化信号
分流条件 · 决策树同源

都带「连续子数组」四个字,但分流很清楚:求最值 / 长度且约束单调 → 滑动窗口(本章);求「和恰好等于 K 的个数」→ 前缀和 + 哈希(第 13 章),因为窗口和不单调、收缩条件无从写起;窗口大小固定且要窗口内的最大 / 最小值 → 单调队列(第 04 章)。另外,数组里有负数时「和 ≥ target 的最短子数组」也不能用滑窗——单调性没了。还有一个同名陷阱:本章的「最短」永远修饰连续子数组;「最短路径 / 最少步数」说的是图上的 BFS(第 07 章的层序、第 10 章的网格),两者毫无关系。

§3 Template 模板代码

可默写的那几行

■ 【骨架】 不变,闭着眼默写 ■ 【插槽】 随题变化,写题前先问自己填什么

模板 A · 可变窗口覆盖本章 80% 的题
from collections import Counter

def longest_no_repeat(s):
    cnt = Counter()                         # 【插槽】窗口状态:计数 / 和 / 去重集合
    left = best = 0                         # 【骨架】
    for right, ch in enumerate(s):          # 【骨架】右边界无脑扩
        cnt[ch] += 1                        # 【插槽】加入 s[right] 后怎么更新状态
        while cnt[ch] > 1:                  # 【插槽】"不合法"的判定 —— 整道题的灵魂
            cnt[s[left]] -= 1               # 【插槽】移出 s[left] 后怎么更新状态
            left += 1                       # 【骨架】收缩到重新合法为止
        best = max(best, right - left + 1)  # 【插槽】答案在"已经合法"之后统计
    return best
默写口诀:右扩、不合法就左缩、合法后统计。三个插槽依次是:状态怎么加、怎么算不合法、状态怎么减。
模板 B · 定长窗口窗口大小 k 固定
def max_fixed_window(nums, k):
    total = sum(nums[:k])                   # 【骨架】先把第一个窗口填满
    best = total                            # 【插槽】要最大 / 最小 / 全部记录下来
    for i in range(k, len(nums)):           # 【骨架】
        total += nums[i] - nums[i - k]      # 【骨架】进一个,出一个,状态 O(1) 更新
        best = max(best, total)             # 【插槽】
    return best
定长窗口没有 while 收缩,只有「进一个、出一个」。注意 k > len(nums) 时首个窗口就不成立,调用前先挡一下。
§4 Problems 题目分层

从建直觉到扭模板

热身建直觉,easy 为主1 道
  • 严格说它是「窗口退化成一个变量」:左边界只在遇到更低价时跳过去。放在这里是为了先建立「左边界只前进不后退」的直觉,写法上记住best = max(best, price - min_so_far) 就够。
标准模板直接套3 道
  • 模板 A 原题。不合法 = 出现重复字符。进阶写法是 last[ch] 记录上次位置,left = max(left, last[ch] + 1) 一步跳到位——max 不能省,见翻车点③。
  • 不合法 = 窗口长度 - 出现最多的字符数 > k。most 只增不减看起来是 bug,实际不影响最终答案(窗口不会缩小,答案也就不会被高估),这一点值得在面试里主动讲清楚。
  • 定长窗口 + Counter 相等判断。习惯上要把减到 0 的键 del 掉:Python 3.10 起 Counter 的 == 已经会忽略零计数(所以这道题不删也能过),但 len() / in 仍然把它算进去,换成普通 dict 更是直接判不等。模板测试里三条断言把这个区别钉死了。
变体模板要扭,坑的来源1 道
  • ⭐认脸。可变窗口的完全体:用 missing 计数「还差几个字符」,为 0 才收缩。说清框架即可,不必默写。
§5 Pitfalls & Self-check 常见翻车点 + 自测

会写不等于不翻车

  1. 答案统计的时机——求最长时在收缩完(窗口已合法)之后统计;求最短时在窗口刚合法的那一刻、收缩循环内部统计。位置放错,最短的那类题会整体偏大。
  2. while 写成 if——一次收缩不一定能恢复合法(例如窗口里同一个字符出现了三次)。除非你能证明每步最多违规一次,否则一律用 while。
  3. 跳跃式左指针忘了 max——3 题的进阶写法里 left = max(left, last[ch] + 1);漏掉 max,遇到 "abba" 时左指针会倒退,答案偏大。
  4. 计数字典没删空键——Counter 减到 0 的键不会自动消失。== 从 Python 3.10 起会忽略零计数,但 len(cnt)、in、遍历都还算它——用「窗口内不同字符数」当合法性判据时必须 del。换成普通 dict 时连 == 都会判不等。
  5. 负数破坏单调性——滑窗成立的前提是「扩大窗口让指标单调变差、收缩让它单调变好」。数组含负数时「和 ≥ target」不再单调,得换前缀和 + 单调队列或别的思路。

自测 · 30 秒识别模式

30 秒识别Q1 · 1004 Max Consecutive Ones III:数组只含 0 和 1,最多可以把 k 个 0 翻成 1,求最长的全 1 连续子数组。你的第一反应是哪个模式?

看答案

滑动窗口。不合法 = 「窗口内 0 的个数 > k」,状态就一个计数器。「最多 K 次操作」是滑窗最标准的暗号。

30 秒识别Q2 · 438 Find All Anagrams in a String:找出字符串中所有「另一个串的字母异位词」的起始下标。你的第一反应是哪个模式?

看答案

定长窗口(模板 B)+ 计数相等,和 567 是同一道题换了个问法:567 问存不存在,它问全部位置。窗口长度固定为模式串长度。

30 秒识别Q3 · 239 Sliding Window Maximum:窗口大小固定为 k,返回每个窗口里的最大值。你的第一反应是哪个模式?

看答案

单调队列,不是本章——窗口是定长的,但要的是窗口内的最值,而滑窗模板只会维护「和 / 计数」这类能 O(1) 增删的状态,最大值删掉后无法 O(1) 恢复。见 §2 分流条件与第 04 章模板 B。

每章自测都会混入恰好一道非本章题,防止「读哪章选哪章」的条件反射;题面也一律取自本章未收录的题,避免凭记忆而非凭信号作答。