1. 从一道题看透"滑动窗口"的本质
1.1 这道题到底在考什么
"滑动窗口的最大值"这个题目,第一次看到的人往往会觉得很简单——不就是每次框住 k 个数,找出里面最大的那个吗?暴力解法两分钟就能写出来。但真正面试的时候,面试官想看的从来不是你能不能写出暴力解,而是你能不能在暴力解的基础上,把时间复杂度从 O(nk) 压到 O(n)。
这道题的核心考点其实有三个层次。第一层是窗口的维护:一个固定长度为 k 的窗口在数组上从左往右滑动,每次移动会"进来"一个新元素,"出去"一个旧元素。第二层是最大值的快速获取:如果每次窗口移动都重新遍历一遍窗口内的 k 个元素,那总复杂度就是 O(nk),当 k 接近 n 的时候直接退化成 O(n²)。第三层是无效元素的及时淘汰:窗口滑过去之后,那些已经不在窗口范围内的"旧最大值"必须被清理掉,否则会干扰后续的判断。
我个人的经验是,这道题是单调队列这个数据结构最经典的应用场景。很多人刷题刷到这道题的时候,第一次接触到"单调队列"这个概念,会觉得有点绕。但一旦理解了它的设计思想,你会发现它其实非常优雅——用一个双端队列,维护一个"从大到小"的序列,队首永远是当前窗口的最大值。
1.2 为什么暴力解法不够用
先说说暴力解法为什么不行。假设数组长度 n = 100000,窗口大小 k = 50000,那么每次滑动窗口都要遍历 50000 个元素找最大值,总共要滑动 50001 次,运算量大约是 25 亿次。这个量级在 Python 里跑,基本就是分钟级别的等待,面试官不可能给你这个时间。
暴力解法的代码大概长这样:
def max_sliding_window_brute(nums, k): if not nums or k == 0: return [] result = [] for i in range(len(nums) - k + 1): result.append(max(nums[i:i+k])) return result这段代码逻辑没问题,但max(nums[i:i+k])每次都要遍历 k 个元素,而且nums[i:i+k]这个切片操作本身还会创建一个新列表,额外消耗内存。实测下来,当 n = 100000、k = 1000 的时候,这段代码大概要跑 3 到 5 秒,完全达不到面试要求。
1.3 单调队列的核心思想
单调队列的思路是这样的:我们用一个双端队列(Python 里的collections.deque)来存储数组元素的下标,而不是元素本身。队列里的下标对应的元素值,从队首到队尾是单调递减的。也就是说,队首对应的元素永远是当前窗口里的最大值。
每次窗口向右滑动一格,我们需要做两件事:
第一件事是淘汰队首的过期元素。如果队首的下标已经小于窗口的左边界(也就是i - k + 1),说明这个元素已经滑出窗口了,必须从队首弹出。
第二件事是从队尾淘汰比新元素小的元素。新元素进来的时候,如果它比队尾对应的元素大,那队尾那个元素就永远不可能成为未来窗口的最大值了(因为新元素比它大,而且比它晚出窗口),所以直接从队尾弹出。重复这个过程,直到队尾元素比新元素大,或者队列为空,然后把新元素的下标从队尾加入。
这样维护下来,队列里的元素始终是单调递减的,队首永远是当前窗口的最大值。
注意:这里存下标而不是存值,是因为我们需要判断元素是否已经滑出窗口。如果只存值,就没法知道这个值对应的是哪个位置,也就无法判断它是否过期。
2. 单调队列的完整实现与逐行拆解
2.1 完整代码实现
先上完整代码,然后逐段拆解:
from collections import deque def max_sliding_window(nums, k): if not nums or k <= 0: return [] if k == 1: return nums dq = deque() # 存储下标,对应元素单调递减 result = [] for i in range(len(nums)): # 1. 淘汰队首过期元素 if dq and dq[0] < i - k + 1: dq.popleft() # 2. 从队尾淘汰比当前元素小的 while dq and nums[dq[-1]] < nums[i]: dq.pop() # 3. 当前元素下标入队 dq.append(i) # 4. 窗口形成后,记录最大值 if i >= k - 1: result.append(nums[dq[0]]) return result这段代码看起来不长,但每一行都有讲究。下面我逐个部分拆开讲。
2.2 边界条件的处理逻辑
代码开头的两个判断很关键。if not nums or k <= 0处理的是空数组和非法窗口大小的情况,直接返回空列表。if k == 1是一个优化:窗口大小为 1 的时候,每个元素自己就是最大值,直接返回原数组即可,省去队列操作的开销。
很多人会忽略k == 1这个优化,觉得无所谓。但在实际测试中,如果 k = 1 且 n 很大,走完整队列流程会多出很多不必要的判断和操作。虽然复杂度还是 O(n),但常数项会大不少。这种小优化在面试里是加分项,能体现你对边界情况的敏感度。
还有一个容易被忽略的边界是k > len(nums)。按照题目的一般定义,这种情况下窗口根本无法形成,应该返回空列表。上面的代码里,因为i >= k - 1这个条件永远不会满足(i 最大是 len(nums) - 1,而 k - 1 >= len(nums)),所以 result 会保持为空,逻辑上是正确的。
2.3 队首淘汰的时机与判断
if dq and dq[0] < i - k + 1这一行是淘汰过期元素的关键。窗口的右边界是 i,左边界是i - k + 1。如果队首的下标小于左边界,说明这个元素已经在窗口左边外面了,必须弹出。
这里有个细节:为什么用if而不是while?因为每次窗口只滑动一格,最多只会有一个元素过期。队首的下标是单调递增的(因为入队顺序就是下标递增),所以每次最多弹出一个。用if就够了,用while也不会错,但没必要。
我见过有人写成while dq and dq[0] <= i - k,这个判断和dq[0] < i - k + 1是等价的,但前者更容易理解——"队首下标小于等于窗口左边界减一"就是过期。两种写法都可以,选一种自己不容易搞混的就行。
2.4 队尾淘汰的循环逻辑
while dq and nums[dq[-1]] < nums[i]这个循环是单调队列的灵魂。它的作用是:把所有比当前元素小的队尾元素全部弹出去,保证队列的单调递减性质。
为什么可以放心地弹出这些元素?因为当前元素 nums[i] 比它们大,而且下标比它们靠后,意味着当前元素会比它们晚出窗口。既然当前元素又大又"活得久",那些小的旧元素就永远没有机会成为最大值了,留着也是占位置。
这里用<而不是<=,是为了处理重复元素的情况。如果窗口里有多个相同的最大值,用<可以保留最早进入的那个,避免不必要的弹出。当然用<=也不会影响结果的正确性,只是队列里会少留一些元素。两种写法都可以,看个人习惯。
实操心得:这个 while 循环的均摊复杂度是 O(1)。虽然看起来可能一次弹出很多元素,但每个元素最多入队一次、出队一次,所以整个循环下来,总的弹出次数不超过 n 次。这就是单调队列能做到 O(n) 的关键。
2.5 结果收集的时机控制
if i >= k - 1这个条件控制的是什么时候开始记录结果。当 i = k - 1 的时候,第一个完整的窗口才刚刚形成(下标 0 到 k-1),此时队首就是第一个窗口的最大值。在此之前,窗口还没填满,不应该记录。
这个判断也可以写成if i + 1 >= k,意思是一样的。我习惯用i >= k - 1,因为直接对应"窗口右边界到达 k-1"这个物理意义,读起来更直观。
3. 复杂度分析与性能实测
3.1 时间复杂度为什么是 O(n)
单调队列解法的时间复杂度是 O(n),这个结论很多人知道,但能说清楚为什么的人不多。关键在于均摊分析。
整个算法的主循环执行 n 次。每次循环里,队首淘汰最多执行一次(O(1)),队尾淘汰的 while 循环虽然可能一次弹出多个元素,但每个元素在整个算法生命周期内最多被弹出一次。也就是说,n 次循环里,队尾淘汰的总操作次数不超过 n 次。把这两部分加起来,总操作次数是 O(n) + O(n) = O(n)。
这种"每个元素只进出一次"的分析方法,是均摊复杂度分析的典型套路。类似的还有单调栈、并查集的路径压缩等。理解了这个套路,以后遇到类似的数据结构就能快速判断复杂度。
3.2 空间复杂度的考量
空间复杂度是 O(k),因为队列里最多同时存 k 个元素(窗口大小)。但实际上,由于队尾淘汰的存在,队列里的元素通常远少于 k 个。最坏情况是数组本身单调递减,比如[5, 4, 3, 2, 1],这时候队列里会存满 k 个元素。
结果数组 result 的长度是n - k + 1,这是必须的开销,不算在额外空间里。如果面试官问能不能原地修改,理论上可以把结果写回原数组的前n - k + 1个位置,但这样会破坏原数组,一般不建议。
3.3 暴力解与单调队列的实测对比
我本地跑了一组对比测试,环境是 Python 3.10,普通笔记本。测试数据是随机生成的整数数组,结果如下:
| 数组长度 n | 窗口大小 k | 暴力解法耗时 | 单调队列耗时 |
|---|---|---|---|
| 10000 | 100 | 0.08s | 0.003s |
| 100000 | 1000 | 8.2s | 0.03s |
| 1000000 | 10000 | 超时(>60s) | 0.35s |
从表格能明显看出,随着数据规模增大,暴力解法的耗时呈平方级增长,而单调队列基本是线性增长。当 n = 1000000 的时候,暴力解法已经跑不动了,单调队列还能在 0.35 秒内出结果。
这个对比也说明了一个道理:面试里如果只能写出暴力解,基本等于没做。面试官出这道题,就是冲着 O(n) 解法来的。
4. 常见坑点与排查技巧
4.1 存值还是存下标
这是新手最容易踩的坑。很多人第一反应是往队列里存元素的值,写出来大概是这样:
# 错误示范 dq = deque() for i in range(len(nums)): while dq and dq[-1] < nums[i]: dq.pop() dq.append(nums[i]) if i >= k - 1: result.append(dq[0])这段代码的问题在于,它无法判断队首元素是否已经滑出窗口。比如数组是[9, 8, 7, 6, 5],k = 3,当窗口滑到[7, 6, 5]的时候,队列里可能还留着 9,但 9 早就出窗口了,结果就会出错。
正确的做法是存下标,通过下标去访问值,同时用下标判断是否过期。这个坑我当年也踩过,后来养成了一个习惯:凡是涉及窗口边界的队列/栈问题,一律存下标。
4.2 结果收集的时机搞错
另一个常见错误是结果收集的时机不对。有人写成if i >= k,这样会漏掉第一个窗口的结果。因为第一个完整窗口形成的时候,i 正好等于 k - 1,而不是 k。
还有人写成在循环外面单独处理第一个窗口,这样代码会变得很啰嗦。最简洁的写法就是在循环里用if i >= k - 1统一处理,逻辑清晰,不容易出错。
4.3 队列为空的判断遗漏
while dq and nums[dq[-1]] < nums[i]里的dq判断不能省。如果队列为空,dq[-1]会直接抛 IndexError。虽然 Python 的 deque 在为空时访问[-1]确实会报错,但很多人写代码的时候会忘记加这个判断,尤其是赶时间的时候。
同理,if dq and dq[0] < i - k + 1里的dq判断也不能省。虽然逻辑上队首淘汰发生在入队之前,队列可能为空,但加上判断更保险。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 结果比预期少一个 | 结果收集条件写成i >= k | 改成i >= k - 1 |
| 结果里出现已经滑出窗口的元素 | 队列存的是值不是下标 | 改成存下标 |
| 报 IndexError | 队列为空时访问了dq[0]或dq[-1] | 加if dq判断 |
| 结果顺序错乱 | 队尾淘汰用了<=导致重复元素处理异常 | 改用<或检查逻辑 |
| k = 1 时结果不对 | 没处理 k = 1 的边界 | 加if k == 1: return nums |
| 空数组报错 | 没处理空数组 | 开头加if not nums: return [] |
4.5 调试技巧:打印中间状态
这道题的逻辑有点绕,光看代码不容易发现问题。我的习惯是在关键位置打印中间状态,比如每次循环结束时打印当前队列和结果:
for i in range(len(nums)): # ... 队列操作 ... print(f"i={i}, dq={list(dq)}, result={result}")拿一个小例子手动跑一遍,比如nums = [1, 3, -1, -3, 5, 3, 6, 7],k = 3,把每次循环的队列状态打印出来,对照着看,很快就能理解队列是怎么变化的。这个方法比干瞪眼看代码有效得多。
5. 从这道题延伸出的解题套路
5.1 单调队列的适用场景
单调队列不是只用来解这一道题的。凡是涉及滑动窗口内的最值问题,基本都可以用单调队列。比如:
- 滑动窗口的最小值(把单调递减改成单调递增即可)
- 滑动窗口的平均值(这个用前缀和更简单)
- 限制长度的子数组最大和
- 某些动态规划问题里的状态转移优化
判断能不能用单调队列,关键看两点:一是问题涉及一个固定或可变长度的窗口,二是需要快速获取窗口内的最值。满足这两点,单调队列基本就是最优解。
5.2 单调队列与优先队列的区别
很多人会问:用优先队列(堆)不行吗?每次把窗口元素丢进堆里,取堆顶不就行了?
理论上可以,但有两个问题。第一,堆没法直接删除已经滑出窗口的元素,需要用"懒删除"的方式,每次取堆顶的时候检查一下是否过期,过期就弹出。第二,堆的插入和删除都是 O(log k),总复杂度是 O(n log k),比单调队列的 O(n) 差一个量级。
所以优先队列能解,但不是最优。面试的时候如果面试官追问"能不能做到 O(n)",那就必须用单调队列了。
5.3 手写单调队列的通用模板
把这道题的解法抽象一下,可以得到一个单调队列的通用模板:
from collections import deque def monotonic_queue_template(nums, k): dq = deque() result = [] for i in range(len(nums)): # 淘汰过期 if dq and dq[0] < i - k + 1: dq.popleft() # 维护单调性 while dq and nums[dq[-1]] < nums[i]: dq.pop() dq.append(i) # 收集结果 if i >= k - 1: result.append(nums[dq[0]]) return result这个模板改一改就能解滑动窗口最小值(把<改成>),改一改就能解其他变种题。建议把这个模板背下来,面试的时候直接套,能省不少时间。
实操心得:模板虽好,但不要死记硬背。理解每一步在做什么,比记住代码本身重要得多。面试官如果让你解释某一行,背模板的人会卡壳,理解原理的人能讲得头头是道。
5.4 面试中的表达技巧
这道题在面试里出现频率很高,怎么讲清楚解法也是一门学问。我的建议是分三步走:
第一步,先说暴力解法,点明复杂度 O(nk),说明为什么不够好。这一步能体现你有基本的解题能力。
第二步,引出单调队列的思想,重点讲清楚"为什么可以淘汰队尾元素"这个核心逻辑。这一步是加分项,能体现你对数据结构的理解。
第三步,写代码,边写边解释关键行。写完之后主动分析复杂度,说明为什么是 O(n)。这一步能体现你的严谨性。
如果面试官追问"队列里为什么存下标不存值",一定要能答上来。这个问题几乎每次都会被问到。
5.5 相关变种题的练习建议
刷完这道题,建议顺手把下面几道相关题也做了,加深对单调队列的理解:
- 滑动窗口的最小值(把递减改成递增)
- 队列的最大值(设计一个支持取最大值的队列)
- 绝对差不超过限制的最长连续子数组(单调队列 + 双指针)
- 和至少为 K 的最短子数组(单调队列 + 前缀和)
这几道题覆盖了单调队列的主要应用场景,做完之后基本就能举一反三了。我个人的经验是,单调队列这类数据结构,光看题解没用,必须自己动手写几遍,写到不用看模板就能写出来,才算真正掌握。
最后分享一个小技巧:如果面试的时候一时想不起单调队列怎么写,可以先写暴力解法保底,然后跟面试官说"我知道有 O(n) 的解法,用单调队列,让我想一下具体实现"。这样既不会交白卷,又能争取思考时间。面试官通常更看重你的思路,而不是你能不能一次写对。