外观
滑动窗口 4 步框架实测:从固定模板到可变窗口一次打通
子串、子数组类题目是面试里出场率最高的一族:最长无重复子串、长度最小的子数组、所有字母异位词、覆盖最小子串……这类题的暴力解法出奇一致——枚举每个起点,向右扩展到不合法为止,整体 O(n²) 起步。滑动窗口之所以能把复杂度压到 O(n),靠的是一个朴素观察:当窗口从 left..right 扩张到 left..right+1 再收缩回 left+1..right 时,大量中间状态被重复计算了,而这些中间状态完全可以由一个「滚动维护」的统计结构增量更新。
但窗口题的写法坑很多:收缩条件往左还是往右搞反、答案在内层循环里更新还是循环外更新、状态计数器在扩张和收缩时各自维护什么——这些细节错了不报错,只是答案悄悄不对。这篇文章把所有窗口题收敛成一个四步框架,再用两道代表题演示框架如何伸缩变形,最后给一份踩坑清单。
思路推导:什么样的题才能滑起来
滑动窗口合法的前提条件只有一条:窗口的合法性指标随边界移动单调变化。具体说,右边界右移时指标单调变「差」(字符变多、和变大),左边界右移时指标单调变「好」——正是这种单调性保证了左指针全程不需要回退,两个指针各自最多走 n 步。如果数组里有负数,窗口和随扩张不再单调增大,「和大于等于 target 的最短子数组」这类题就必须换成前缀和加单调队列,这是窗口方法的第一个适用性判断。
第二个判断是窗口长度是否固定。固定窗口(比如「大小为 k 的子数组最大平均值」)不需要收缩判断,右指针走到 k 之后每一步同步弹出一个左侧元素即可;可变窗口才是框架的主战场,它又分两个方向:求最长合法窗口时,收缩到「恰好重新合法」就停,答案在收缩完成后更新;求最短合法窗口时,一旦窗口合法就持续收缩到底,答案在收缩过程中更新。这两个更新时机的差异是窗口题最大的坑,后面代码里会具体对比。
实操代码:固定模板与可变窗口两种形态
先用求最长合法窗口的代表题「无重复字符的最长子串」演示四步框架。窗口状态用一个计数数组维护每个字符的出现次数,不重复的判定就是「新字符进入时计数为零」:
python
# 无重复字符的最长子串:求最长合法窗口(Python)
def length_of_longest_substring(s: str) -> int:
count = [0] * 128 # ASCII 字符计数器,维护窗口状态
left = ans = 0
for right, ch in enumerate(s): # 第一步:右指针逐位扩张
count[ord(ch)] += 1 # 第二步:更新窗口状态
while count[ord(ch)] > 1: # 第三步:出现重复,收缩到重新合法
count[ord(s[left])] -= 1
left += 1
ans = max(ans, right - left + 1) # 第四步:窗口合法时更新答案
return ans注意求最长窗口的骨架:收缩条件写在外层的 while 里,收缩一旦结束窗口必然重新合法,所以答案更新放在收缩之后、每轮循环末尾。收缩条件「新进入字符的计数超过一」精确对应「当前窗口刚变得不合法」,多收缩一位都会漏掉更长答案。
再看求最短合法窗口的代表题「最小覆盖子串」:在字符串 s 中找出包含 t 全部字符(含重复次数)的最短子串。状态需要两个计数器:needs 记录目标字符需求,window 记录当前窗口实际计数,再用一个 valid 变量统计「已满足需求的字符种类数」,避免每次都全量比对两个计数器:
python
# 最小覆盖子串:求最短合法窗口(Python)
from collections import Counter
def min_window(s: str, t: str) -> str:
needs = Counter(t) # 目标字符需求表
window = {} # 当前窗口字符计数
valid = 0 # 已满足需求的字符种类数
left = start = 0
best = len(s) + 1 # 用不可能达到的长度做哨兵
for right, ch in enumerate(s): # 扩张:窗口纳入新字符
if ch in needs:
window[ch] = window.get(ch, 0) + 1
if window[ch] == needs[ch]: # 该字符种类刚凑够,valid 加一
valid += 1
while valid == len(needs): # 收缩:窗口已完全合法,压缩左边界
if right - left + 1 < best: # 收缩过程中持续更新最短答案
best = right - left + 1
start = left
left_ch = s[left]
if left_ch in needs:
if window[left_ch] == needs[left_ch]: # 移出的字符刚够用
valid -= 1 # 合法性被破坏,收缩停止
window[left_ch] -= 1
left += 1
return "" if best > len(s) else s[start:start + best] # 哨兵判断无解对比两段代码可以提炼出固定结论:求最长窗口时「先收缩再更新答案」,求最短窗口时「边收缩边更新答案」。原因在于两者的收缩目的不同——前者收缩是为了恢复合法性、保住最大长度,后者收缩本身就是在逼近最优解、每一步都可能刷新纪录。把这两句记住,窗口题的答案更新位置基本不会再错。
复杂度方面,两个指针都只单向前进:右指针走满 n 步,左指针的累计移动次数不超过右指针,每个字符最多经历「进窗口一次、出窗口一次」,加上计数器操作都是 O(1),总时间 O(n)、额外空间 O(|Σ|),Σ 是字符集大小。这也是为什么窗口题给的数据规模通常在 10⁵ 量级——出题人就是按线性解法设的限制。
易错点与避坑总结
- 收缩条件必须只由「刚变不合法的那个量」驱动。无重复子串里是重复字符计数,覆盖子串里是 valid 数,收缩条件写成「窗口长度超过 k」之类的静态条件是另一类题;条件写宽了会把合法窗口也收缩掉,答案系统性偏小。
- 不要在收缩循环里重扫窗口求状态。每次收缩都重新统计窗口内字符会让复杂度退化回 O(n²),正确做法是移出字符时同步把计数器减一、valid 相应调整——状态永远是增量维护的。
- 负数与单调性失效要提前排除。滑动窗口要求「扩张变差、收缩变好」的单调性,数组含负数时窗口和不再单调,「最短子数组之和至少为 target」必须换前缀和加单调队列,硬套窗口模板会漏解且难以察觉。
- 求最短窗口的答案更新放收缩内、求最长放收缩外。这是两族题目最容易互相污染的地方;写完模板后用一个两字符的极小用例手工验一遍更新时机,比读十遍代码更有效。
- 无解哨兵要有区分度。best 初始化成 len(s)+1 这类不可能值,返回前用哨兵判断是否更新过;直接返回空串或 0 会把「输入本身就是答案」的边界场景吞掉。
滑动窗口的通用性来自单调性,局限也来自单调性。拿到子串子数组题,先用三十秒判断指标是否随边界单调、窗口长度是否可变,再套四步框架——顺序对了,这类题的写法其实比递归类题目稳定得多。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。