2月16号到25号,我给自己排了一个为期十天的滑动窗口专项刷题周期,平台就选在力扣。这十天我刷了差不多30道滑动窗口相关题目,从最开始看到“子数组”“子串”这类描述就下意识想暴力枚举,到后面能比较顺畅地套模板去解各种变体,算是把滑动窗口这个看似花样很多、实际上核心只有两三套思想的东西彻底捋清楚了。这篇就打算把这十天的规划、题型分类、每类题的核心套路,以及我在刷题过程中踩过且觉得特别值得记录的坑,一次性都写出来。
这篇内容适合正在准备技术面试、想系统攻一类算法的人,也适合那些已经刷过一些题、但感觉滑动窗口题目总是“换个马甲就不认识”的读者。我尽量不从纯理论讲起,而是用刷题过程中的真实例子来讲,这样大家看完可以直接照着自己的刷题计划去用。
1. 内容整体设计与思路拆解
1.1 滑动窗口到底解决了什么问题
我在刷题前先把滑动窗口的定位搞清楚了:它解决的是数组或字符串上的连续区间统计问题。比如“找出总和大于等于target的最短连续子数组”“找出不含重复字符的最长子串”,这些描述里都有一个共同特征——要求的是连续的一段,而不是任意组合。
暴力枚举思路很简单,两层循环穷举所有左右端点,然后计算区间内的统计信息。问题是复杂度动不动就是O(n^2)甚至更高,在力扣的中等难度题里基本过不了。滑动窗口的核心思想就是复用窗口内的信息,避免重复计算:窗口右边界向右扩展时,把新元素纳入统计;左边界向右收缩时,把移出的元素从统计里减掉。整个过程中,窗口里的信息是动态维护的,而不是每次重新计算。
我自己的理解是,滑动窗口本质上是一个“在线维护区间统计”的优化手段。它把“所有区间都算一遍”变成了“只维护一个当前区间,随左右指针移动不断更新”。这个思路从逻辑上并不复杂,但真正写代码时,收缩时机、统计更新顺序、边界条件,全都有讲究。
1.2 为什么选择十天集中刷一个专题
我之前刷题是分散着刷,今天做一道链表、明天碰一道动态规划,结果是每类题型都只混了个脸熟,面试的时候一问深一点就露怯。这次我选择了相反的策略——花一整段时间只攻一个算法专题。
滑动窗口非常适合这种集中突破的方式,原因有两个。第一,它题量足够大,力扣上打滑动窗口标签的题目有80多道,核心变体也够丰富,足够撑起一个十天的训练周期。第二,它的核心模板非常固定,一旦理解了“维护窗口 + 移动边界 + 更新答案”这个框架,几乎所有变体都能往里套,学习的性价比非常高。
十天的安排也不是随便定的。我给自己定了三个阶段性目标:前3天建立模板和手感,中间4天扩展题型和变式,最后3天做综合训练和面试模拟。这个节奏既能保证有足够的时间消化基础,又不会因为战线太长而失去紧迫感。
1.3 滑动窗口题型地图
刷题之前,我先把滑动窗口的题目做了一次分类梳理。这一步很重要,因为不同类型的题目虽然都叫“滑动窗口”,但实现细节和难点完全不一样。
| 题型类别 | 核心特征 | 典型题目 |
|---|---|---|
| 固定窗口 | 窗口大小不变,移动时计算统计信息 | 643. 子数组最大平均数 I、1456. 定长子串中元音的最大数目 |
| 可变窗口——求最长 | 窗口内满足某个条件,求满足条件的最大长度 | 3. 无重复字符的最长子串、424. 替换后的最长重复字符、1004. 最大连续1的个数 III |
| 可变窗口——求最短 | 窗口内满足某个条件,求满足条件的最小长度 | 209. 长度最小的子数组、76. 最小覆盖子串 |
| 窗口内统计 | 限制窗口内元素的种类或个数,统计满足条件的子数组数量 | 992. K个不同整数的子数组、1248. 统计优美子数组 |
| 进阶数据结构 | 需要在窗口内快速获取最大值、中位数等 | 239. 滑动窗口最大值、480. 滑动窗口中位数 |
这张分类表在我整个刷题周期里一直贴在桌面边上,每天刷题时先判断题目属于哪个类别,再想对应的模板,效率比盲目刷高很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 可变窗口的两个模板
可变窗口是滑动窗口里最核心、也最容易出错的一类。它的特点是窗口大小不固定,随着条件动态调整。
求最长窗口的模板可以这样写:
python复制def max_window(s):
n = len(s)
left = 0
window = {}
ans = 0
for right in range(n):
# 1. 扩展窗口,纳入 s[right]
# 2. 更新窗口内的统计数据
# 3. 当窗口不满足条件时,收缩左边界
while not valid(window):
# 移除 s[left],更新统计
left += 1
# 4. 此时窗口是满足条件的,更新答案
ans = max(ans, right - left + 1)
return ans
求最短窗口的模板则略有不同:
python复制def min_window(s, target):
n = len(s)
left = 0
window = {}
ans = float('inf')
for right in range(n):
# 扩展窗口
# 更新统计
# 当窗口已经满足条件时,尝试收缩左边界以找到更短的解
while valid(window):
ans = min(ans, right - left + 1)
# 移除 s[left],更新统计
left += 1
return ans if ans != float('inf') else 0
这两个模板的差异非常关键。求最长时,while 循环是“把不合法修到合法”,然后在合法状态下记录答案;求最短时,while 循环是“在已经合法的状态下尽量收缩”,在收缩过程中记录答案。我第一次刷题的时候把这两种逻辑混在一起,导致同一道题改来改去都不对,后来在纸上画了两次过程图才彻底厘清。
2.2 固定窗口的实现方式
固定窗口的代码要简单得多,因为窗口大小不变,逻辑上就是“完全相同的窗口,从一端滑到另一端”。核心优化点在于如何快速得到新窗口的统计信息。
以“子数组最大平均数”为例:
python复制def find_max_average(nums, k):
window_sum = sum(nums[:k])
ans = window_sum
for i in range(k, len(nums)):
window_sum += nums[i] - nums[i - k]
ans = max(ans, window_sum)
return ans / k
每次移动窗口时,不需要重新加总整个窗口,只需要“加一个、减一个”,这样整体复杂度是O(n)。这个技巧本质上和滑动窗口同源,都是利用连续性复用已有信息。有些进阶题会在固定窗口内维护更多结构,比如统计窗口内不同字符的个数、窗口内元素的和与积等,但核心思路都是“增量更新”。
2.3 哈希表辅助:窗口内的频率统计
当题目涉及到“字符种类”“元素个数”这类统计时,哈希表就成了滑动窗口的黄金搭档。其中最典型的例子就是无重复字符的最长子串。
这道题的思路是:用字典记录窗口内每个字符最后一次出现的位置。右边界向右移动时,如果当前字符已经在字典里且位置大于等于左边界,说明窗口内出现了重复,需要把左边界跳到重复位置的下一个。
python复制def length_of_longest_substring(s: str) -> int:
char_index = {}
left = 0
ans = 0
for right, c in enumerate(s):
if c in char_index and char_index[c] >= left:
left = char_index[c] + 1
char_index[c] = right
ans = max(ans, right - left + 1)
return ans
这里有一个特别容易踩的坑:判断重复时,不能只看字符是否在字典里,还必须判断上次出现的位置是否在当前窗口内。否则,左边界已经跳过该字符后,字符在字典里的记录仍然存在,会导致误判。我第一次写的时候漏掉了 char_index[c] >= left 这个条件,结果输入 "abba" 时就出错了——遍历到最后一个 'a' 时,字典里还记着它第一次出现的位置0,但左边界此时已经到2了,'a' 并不在窗口内,不应该触发跳转。
2.4 滑动窗口最大值:单调队列的引入
有些滑动窗口题光靠哈希表或者普通数组是不够的,比如滑动窗口最大值。这个需求不能简单地用窗口内统计解决,因为窗口滑动时,最大值可能会移出窗口,我们需要快速知道新的最大值是谁。
最自然的想法是用堆,但堆不能高效地删除“窗口左边界移出的元素”。这时候就需要用到单调队列:维护一个从队首到队尾单调递减的队列,队首就是窗口最大值。窗口滑动时:
- 新元素入队前,把队尾所有小于等于它的元素弹出
- 移出窗口的元素,如果它正好在队首,则弹出队首
- 队列里存的是元素下标,而不是值,这样才能判断队首元素是否已经不在窗口内
python复制from collections import deque
def max_sliding_window(nums, k):
dq = deque()
ans = []
for i, num in enumerate(nums):
# 队尾弹出较小的元素,保持单调递减
while dq and nums[dq[-1]] <= num:
dq.pop()
dq.append(i)
# 队首元素已经滑出窗口
if dq[0] <= i - k:
dq.popleft()
# 窗口已形成,记录答案
if i >= k - 1:
ans.append(nums[dq[0]])
return ans
单调队列的复杂度是O(n),因为每个元素最多入队一次、出队一次。这类题是滑动窗口里的进阶题,面试中常考,如果不提前练过,现场很难短时间内写对。
3. 实操过程与核心环节实现
3.1 刷题前的准备:基础自查与工具配置
我这次集中刷题不是从零开始的。我在1月底先做了一轮基础排查,确保自己已经具备“数组遍历”“字典基本操作”“复杂度分析”这几个前置能力。如果对哈希表的底层实现、Python中字典的更新时机的理解还比较模糊,建议先把这些基础补牢,否则刷题时会卡在非核心问题上。
工具方面我准备得很轻量:一个力扣账号、一个本地代码编辑器、一个用于记录刷题日志的表格。我的日志里记录了日期、题号、题目名称、题型分类、自己的解法思路、踩过的坑、以及官方题解或高赞解答里的优化点。这个日志在后期复盘时发挥了巨大作用,很多容易反复出错的地方,都能在日志里找到之前踩坑的记录。
3.2 十天计划表
这十天我实际执行的计划如下:
| 日期 | 重点内容 | 典型题 |
|---|---|---|
| Day 1 | 固定窗口入门 | 643 子数组最大平均数 I、1456 定长子串中元音的最大数目 |
| Day 2 | 可变窗口模板建立 | 209 长度最小的子数组、3 无重复字符的最长子串 |
| Day 3 | 窗口内字符统计 | 424 替换后的最长重复字符、1004 最大连续1的个数 III |
| Day 4 | 定长窗口与哈希表结合 | 567 字符串的排列、438 找到字符串中所有字母异位词 |
| Day 5 | 最短覆盖型窗口 | 76 最小覆盖子串、632 最小区间 |
| Day 6 | 窗口内元素种类限制 | 992 K个不同整数的子数组、1248 统计优美子数组 |
| Day 7 | 单调队列与堆 | 239 滑动窗口最大值、480 滑动窗口中位数 |
| Day 8 | 综合练习一 | 30 串联所有单词的子串、395 至少有K个重复字符的最长子串 |
| Day 9 | 错题复盘与套路总结 | 重刷前8天中做错的题 |
| Day 10 | 限时模拟与面试表达 | 随机抽取5道中等题,45分钟限时完成 |
这个计划表并不是随意排列的。我刻意把“固定窗口”(Day 1)放在最前面,因为它最简单,适合用来建立“窗口滑动的感觉”。然后是可变窗口(Day 2-3),这是核心中的核心。Day 4-6 覆盖各种变体,Day 7 引入更复杂的数据结构,Day 8 开始综合运用,最后两天用来巩固和检验。
3.3 典型题目拆解示例
光列计划不拆题,读者可能还是不知道实际怎么写。我挑三道最典型的题,把从读题到优化到写代码的完整思考过程写出来。
第一道:无重复字符的最长子串
- 读题后先判断:要求的是连续子串的最长长度,且有限制条件(无重复字符)。
- 暴力解:找到所有子串,逐一判断是否有重复字符,O(n^3)。
- 优化:用左右指针维护一个窗口,右指针不断拓展,遇到重复就移动左指针到不重复为止。
这里的关键点是“当右指针字符已经在窗口内时,左指针应该移动到什么位置”。如果用 char_index[c] + 1 作为新的左边界,需要保证该位置不往回退。实测下来,用字典存每个字符的最新下标,确实是最简洁的写法。
第二道:最小覆盖子串
- 题意:只在给定字符串中找包含 t 中所有字符的最短子串。
- 难点:如何判断窗口是否“覆盖”了 t 中的所有字符。
我用的方法是:先用 Counter(t) 记录 t 中每个字符的需求量,然后维护一个 required 变量表示还有多少个“种类”的字符需求量未被满足。当 required == 0 时,窗口已经覆盖了 t,尝试收缩左边界找更短解。
python复制from collections import Counter
def min_window(s: str, t: str) -> str:
need = Counter(t)
required = len(need)
left = 0
ans_start, ans_len = 0, float('inf')
for right, c in enumerate(s):
if c in need:
need[c] -= 1
if need[c] == 0:
required -= 1
while required == 0:
if right - left + 1 < ans_len:
ans_start, ans_len = left, right - left + 1
left_char = s[left]
if left_char in need:
if need[left_char] == 0:
required += 1
need[left_char] += 1
left += 1
return s[ans_start:ans_start + ans_len] if ans_len != float('inf') else ""
这道题非常推荐作为“最短覆盖型窗口”的代表题反复练习,它把哈希表、欠账计数、窗口收缩三个核心要素都揉在了一起。
第三道:滑动窗口最大值
- 固定窗口大小为 k。
- 暴力解:对每个窗口调用
max(),O(nk),数据量大时会超时。 - 优化:用单调队列,O(n)。
前面已经写過了单调队列的代码。我想补充一个我实测下来觉得最有用的理解方式:单调队列里的元素,是“在当前窗口内、且比右边元素都大的候选最大值”。队首永远指向当前窗口的最大值,当最大值滑出窗口时,队列里还有“备胎”顶上。这个思路可以迁移到“滑动窗口最小值”“滑动窗口的中位数”等变体中。
3.4 复盘方法:刷题日志怎么记
我一直觉得,刷题效率高的关键不是刷得多,而是复盘到位。我每天的复盘分三步:
先看今天的题有没有共性。比如 Day 5 做“最短覆盖型窗口”时,我发现 76. 最小覆盖子串、209. 长度最小的子数组、632. 最小区间 这三道题的核心逻辑都差不多,都是“先扩展窗口直到满足条件,再收缩窗口找最优解”。看穿这个共性之后,我就把这三道题归到同一个类别里,后续解题时直接套最短覆盖模板。
再看哪道题卡得最久。把卡住的原因具体到“是没想到用字典计数,还是没想到收缩时机,还是边界条件没处理好”。这一步能精准定位自己的薄弱环节。
最后,把官方题解中最优解与自己解法的差异写进日志。Day 7 做“滑动窗口最大值”时,我一开始用的是堆,时间复杂度是O(n log k),虽然也能过,但官方题解的单调队列明显更优。我把两个方案都记录在案,并写了“什么时候用堆、什么时候用单调队列”的对比说明,后续写题时遇到类似选择就能快速判断。
4. 常见问题与排查技巧实录
4.1 窗口收缩时机的判断
滑动窗口最常见的报错场景就是“窗口收缩时机不对”。我自己在刷题时至少犯过三次类错误:要么该收缩时没收缩,导致窗口里包含不合法元素,答案偏大;要么过早收缩,导致漏掉合法的最优解。
要判断收缩时机,核心是搞清楚“窗口合法性的定义”是什么。求最长类题目是“不合法时收缩”,求最短类题目是“合法时收缩”。这两个方向绝对不能搞反。我建议新手在写代码之前,先在注释里写清楚“什么时候窗口是合法的”,再决定 while 循环的条件。
4.2 数据同步更新的重要性
滑动窗口的题,左右指针每次移动时,相关的统计数据必须同步更新。漏掉任何一次更新,都会导致窗口状态与实际不符。
一个典型的错误案例是:在收缩窗口时只移动了 left,忘了从哈希表里把移出的字符频次减掉。表面上代码逻辑看起来完全没问题,但运行结果就是不对。这类错误最难排查,因为代码不报错、逻辑也写得像是对的,只有数据对不上。
我之后的习惯是:每一次 left += 1 和 right += 1 之后,都强制自己问一句“统计数据需要怎么变?”然后在代码里紧跟一行对应的更新代码,保证“指针移动”和“数据更新”永远成对出现。
4.3 性能问题:为什么O(n)写成了O(n^2)
有段时间我写出的滑动窗口代码,理论上是O(n),实际运行却很慢。后来分析发现,我在循环内部调用了 min() 或 max(),或者对子串做了切片操作,这些都是O(n)或者O(k)的操作,放在循环里就变成了O(n^2)。
正确的做法是:窗口内需要极值信息时,用单调队列或堆来维护,而不是每次现算;需要判断字符是否在窗口内时,用字典计数而不是用 s[left:right].count(c)。
4.4 调试技巧:打印窗口状态
写滑动窗口题时,我最推荐的调试方法就是打印每一步的窗口状态。具体来说,在循环的每个关键节点打印 left、right、当前统计信息、以及窗口中实际包含的元素。这样一眼就能看出窗口移动和统计更新是否一致。
我举一个例子。刚开始写“无重复字符的最长子串”时,我发现输入 "abcabcbb" 结果不对。打印了每次循环的状态后,很快发现问题出在 char_index[c] >= left 这个判断上——旧字符虽然出现在字典里,但已经不在当前窗口内,不应该触发左指针跳转。这个坑靠肉眼读代码很难发现,但打印状态后5秒钟就定位了。
4.5 常见错误速查表
我在十天的刷题过程中,把反复出现的问题整理成了下面这张速查表,供大家参考。
| 常见问题 | 错误示例 | 正确做法 |
|---|---|---|
| 字典判断漏了窗口范围 | if c in dict: 就移动左指针 |
必须同时判断 dict[c] >= left |
| 收缩窗口时漏更新统计 | 只移动 left,没从统计中减掉 s[left] |
指针移动与统计更新成对出现 |
| 求最短窗口时没在合法状态下收缩 | 只扩展、不收缩,结果总是整串长度 | 合法时 while 循环收缩左边界,记录最短长度 |
窗口内极值用 min()/max() 现算 |
循环里调用 max(window) |
用单调队列或堆维护极值 |
| 使用切片操作 | s[left:right] 作为窗口内容反复拼接 |
用下标区间表示窗口,避免复制 |
| 边界条件漏处理 | 空字符串、k=0、窗口小于目标长度 |
先写边界判断,再写主逻辑 |
这张表是我自己在复盘时提炼的,不一定覆盖所有情况,但基本能处理刷题中90%的常见问题。
5. 从滑动窗口到工程与网络:扩展思考
5.1 滑动窗口在TCP流量控制中的应用
刷题刷到一半,我突然想到,滑动窗口这个概念并不仅仅是算法题里的技巧,它在网络中也很常见——最典型的就是TCP协议中的流量控制和拥塞控制。
TCP的滑动窗口机制中,发送方和接收方各自维护一个窗口,用来控制可以发送但尚未确认的数据量。窗口大小会根据网络状况动态调整,这跟我们在算法题里维护一个“满足条件的窗口”并动态扩展和收缩,本质上是一个思路。刷题时研究“窗口什么时候该扩展、什么时候该收缩”,到了实际的网络传输中,研究的是“什么时候可以多发、什么时候必须少发”,逻辑上是相通的。
理解这个类比有一个好处:当面试官问到TCP流量控制时,你可以把“滑动窗口”的思想迁移过去。但注意,这里的“窗口”是数量概念(发了多少字节、确认了多少字节),不是位置概念,不要在面试时把这两个层面的概念混在一起说。
5.2 滑动窗口滤波模型
另一个工程中常见的滑动窗口应用是滤波器。滑动窗口滤波(也叫移动平均滤波)通过取窗口内数据的平均值来平滑信号,公式上就是让窗口在时间序列上滑动,每次取窗口内数据的均值。
这与算法题中的固定窗口模板几乎完全一致:窗口大小固定,窗口内计算统计量(这里是平均值),窗口滑动一步就算一次。如果你刷过643题“子数组最大平均数 I”,那么理解工程里的滑动窗口滤波就非常容易了——只要把题目里的“最大平均数”换成“实时平滑输出”就行。
我自己在刷到固定窗口时,就联想到平时做数据分析时用的 rolling().mean(),其实底层也都是滑动窗口的思想。算法题刷得好的人,去看这类工程实现时会特别快,因为底层逻辑你已经见过了。
5.3 面试中如何高效展示思路
最后聊一个很实际的问题:面试中遇到滑动窗口题,怎么回答才能拿高分。
我总结下来的顺序是:先讲暴力解,再讲优化思路,最后写代码。先说最直观的暴力解,让面试官知道“我理解问题”;然后说“暴力解是O(n^2),但是因为求的是连续区间,我们可以用滑动窗口来复用信息,把复杂度降到O(n)”;然后再开始写代码。写完代码后,主动补一句“这里要注意窗口收缩时统计数据的同步更新”。
不要一上来就写滑动窗口。面试官有时候会故意看你有没有“先想清楚、再动手”的意识。另外,如果写完代码还有时间,可以自己试着跑一个简单的用例,比如 s = "abcabcbb",在纸上推演一遍窗口的移动过程。这个过程就算代码有细节错误,也能展示你调试的思路。
刷完这十天,我的最终感受
这十天最直观的变化是,我现在看到“连续子数组”“最长子串”“最短覆盖”这类描述时,第一反应已经不再是无脑暴力枚举,而是先问自己三个问题:这个窗口是固定的还是可变的?窗口的合法性条件是什么?答案是在扩展时记录,还是收缩时记录?这三个问题想清楚,代码基本就能写出来。
刷题这件事,最忌讳的就是“刷完就忘”。我之所以把这十天的分类、模板、坑都记录下来,就是希望以后遇到变体题时,能快速定位“这属于哪一类、该套哪个模板”。滑动窗口这个专题,如果你想系统掌握,按我上面的计划去刷,十天足够从入门到不慌。但我也要说一句实话:模板能帮你写对题,但真正让思路融会贯通的关键,是每一天刷完后的复盘。别只埋头刷题,记得留出时间看自己的错误,那才是刷题真正的意义。
