news 2026/11/5 23:21:29

滑动窗口最大值:单调队列从暴力到O(n)的优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
滑动窗口最大值:单调队列从暴力到O(n)的优化实战

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暴力解法耗时单调队列耗时
100001000.08s0.003s
10000010008.2s0.03s
100000010000超时(>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) 的解法,用单调队列,让我想一下具体实现"。这样既不会交白卷,又能争取思考时间。面试官通常更看重你的思路,而不是你能不能一次写对。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/5 23:20:37

Linux运维必懂的七大核心原理:启动、DHCP、DNS、NFS、FTP与PXE

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/2 0:19:35

UML建模图书管理系统需求分析:用例图、类图、时序图与状态图实战

简介&#xff1a;一份完整的UML图书管理系统需求分析报告&#xff0c;面向软件工程学习者、系统分析设计人员及UML初学者&#xff0c;完整演示了从需求分析、用例建模、静态类图到动态顺序图、协作图&#xff0c;直至构件图与部署图的一整套建模流程。资源为单个doc文档&#x…

作者头像 李华
网站建设 2026/11/3 8:34:04

python的机器人基础原理工业场景模拟第十一篇:码垛仿真,同一末端目标位姿,统计逆运动学可行解数量,区分肘上,肘下构型。

《Python机器人基础原理》第十一篇&#xff1a;码垛仿真——同一末端目标位姿下的逆解可行解统计与肘上/肘下构型区分1. 实际工业应用场景周二上午&#xff0c;某电商物流分拣中心的码垛工位。三条传送带同时送来不同尺寸的纸箱&#xff0c;一台4-DOF关节型码垛机器人需要把纸箱…

作者头像 李华
网站建设 2026/10/24 14:08:29

HSV颜色识别原理与工业级调试实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 17:24:17

高效错题整理方法论与十年实践心得

1. 错题整理的价值与方法论错题整理这件事&#xff0c;我从2013年开始坚持到现在已经整整十年。最初只是为了应付考试&#xff0c;后来发现这套方法对知识体系的构建有着惊人的效果。特别是2013年到2016年这段时间积累的错题本&#xff0c;至今仍是我最常翻阅的学习资料之一。错…

作者头像 李华