一个队列,怎么让滑动窗口从 O(nk) 变 O(n)?单调队列彻底搞懂

作者:烬羽日期:2026/8/3

滑动窗口最大值的 O(n) 解法:为什么"比你年轻还比你强"就该被踢出?


1// 给你一个数组和一个窗口大小 k,窗口从左滑到右,每滑一步输出窗口内最大值
2// 输入: nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
3// 期望: [3, 3, 5, 5, 6, 7]
4
5// 如果你第一次见到这题,大概率会这样写:
6var maxSlidingWindow = function(nums, k) {
7    const result = [];
8    for (let i = 0; i <= nums.length - k; i++) {
9        result.push(Math.max(...nums.slice(i, i + k)));
10    }
11    return result;
12};
13

你猜这段代码能不能过?LeetCode 上会直接超时。

我第一反应也是这么写的,然后盯着红色的 Time Limit Exceeded 陷入了沉思:Math.max 明明是 O(k),总共 n-k+1 个窗口,不就是 O(nk) 吗?数组长度到 10^5,k 到 10^4,乘起来 10^9——不超时才怪。

这篇文章就是解决这个问题的:我会从暴力解法出发,一步步推导出 O(n) 的单调队列解法。你看完不止会背模板,还能理解每一步优化"为什么"有效。


第一步:暴力解法的问题在哪?

先看清楚暴力解法做了什么。

1nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
2
3窗口1: [1, 3, -1] → max([1, 3, -1])   = 3   ← 扫描 3 个元素
4窗口2: [3, -1, -3] → max([3, -1, -3]) = 3   ← 又扫描 3 个元素
5窗口3: [-1, -3, 5] → max([-1, -3, 5]) = 5   ← 又扫描 3 个元素
6...
7

每次窗口右移一格,就重新扫描整个窗口求最大值。但你会发现——窗口 1 和窗口 2 有 [3, -1] 两个元素是重叠的!暴力解法完全无视了这些重叠,每次都像没见过这些元素一样重新算。

核心问题:暴力解法丢弃了上一个窗口的所有信息,而重叠部分的信息完全可以复用。

怎么复用?我们需要的不是"每个元素是什么",而是——当前窗口里谁最大。


第二步:一个朴素的优化思路

能不能维护一个"候选最大值"?当窗口滑动时:

  • 新元素进来 → 和当前候选比,谁大谁留下
  • 旧元素出去 → 如果它正好是候选最大值,需要找下一个
1stateDiagram-v2
2    [*] → 新元素入窗
3    新元素入窗 → 更新候选: 新元素 ≥ 候选?
4    更新候选 → 旧元素出窗
5    旧元素出窗 → 候选过期: 出去的正好是候选?
6    候选过期 → 重新扫描: 找新候选
7    重新扫描 → 取结果
8    旧元素出窗 → 取结果: 候选没走
9    取结果 → 新元素入窗
10

这个思路对了,但有个致命问题:"候选过期了怎么办?"

如果我们只记了一个最大值,它离开窗口时,我们又要 O(k) 重新扫描整个窗口找新最大值。最坏情况下(比如数组严格递减),每次都是候选离开窗口,复杂度又退化成 O(nk)。

这个思路的核心缺陷是:只记第一名是不够的,你得有一个"候补名单"。


第三步:引入候补名单

想象你是 NBA 球队经理,要一直知道队里谁最强。每个月都有球员加入、有球员合同到期离开。

你的策略:

  • 来了个新球员 → 看看他和现有队员谁强。如果他比队里某个人强,那个人就永远不可能成为"全队最强"了——因为有比他更强的、而且比他年轻的在队里。
  • 球员合同到期 → 如果他是当前最强,他走了,你从候补名单里找下一个。

"比你年轻还比你强——你永远没机会了,直接退役。" 这就是单调队列的全部思想。

用这个逻辑管理窗口:

1维护一个队列(存下标),保持队列对应值单调递减。
2队首 = 当前窗口最大值。
3
1graph LR
2    A[新元素 x 入窗] --> B{队尾元素 ≤ x?}
3    B -->|是| C[弹出队尾]
4    C --> B
5    B -->|否| D[x 入队尾]
6    D --> E{队首下标过期?}
7    E -->|是| F[弹出队首]
8    E -->|否| G[队首 = 当前最大]
9    F --> G
10

我们把这个逻辑一步步推演一遍,用题目数据:


核心代码讲解

1// 滑动窗口最大值 — 单调队列解法
2var maxSlidingWindow = function(nums, k) {
3    const result = [];
4    const deque = []; // 🔑 存的是下标,不是值
5
6    for (let i = 0; i < nums.length; i++) {
7        // ⚠️ 步骤1:维护单调递减 — 队尾所有 ≤ 当前值的,踢出去
8        // 为什么?因为它们更早进入窗口(更老),值还更小(更弱),
9        // 在当前值离开窗口之前,它们永远不可能成为最大值
10        while (deque.length && nums[deque[deque.length - 1]] <= nums[i]) {
11            deque.pop();
12        }
13        deque.push(i); // 新元素从队尾进入
14
15        // ⚠️ 步骤2:清理过期元素 — 队首下标滑出窗口左边界,踢出去
16        if (deque[0] <= i - k) {
17            deque.shift();
18        }
19
20        // ⚠️ 步骤3:窗口形成后才记录结果
21        // 前 k-1 个元素还没凑够一个完整窗口
22        if (i >= k - 1) {
23            result.push(nums[deque[0]]); // 🔑 队首永远是当前窗口最大值
24        }
25    }
26
27    return result;
28};
29

现在用数据一步一步跑:

1nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
2deque 存的是下标,为便于理解,写成 deque: [下标(值)] 的形式
3

i = 0,元素 1:

1deque 为空 → 0(1) 直接入队 → deque: [0(1)]
2窗口未形成(i < 2)
3

i = 1,元素 3:

1队尾 0(1) 的值 1 ≤ 3 → 弹出 0(1)   // 1 比 3 小还比 3 老 → 淘汰
20(1) 被弹出后 deque 为空 → 1(3) 入队 → deque: [1(3)]
3窗口未形成
4

i = 2,元素 -1:

1队尾 1(3) 的值 3 > -1 → 不弹出
22(-1) 入队 → deque: [1(3), 2(-1)]
3窗口形成 → 输出 deque[0] = 3  ✓  // 窗口 [1,3,-1] 最大值确实是 3
4

i = 3,元素 -3:

1队尾 2(-1) 的值 -1 > -3 → 不弹出
23(-3) 入队 → deque: [1(3), 2(-1), 3(-3)]
3检查过期:deque[0]=1,而 i-k=0,1 > 0,不过期
4输出 deque[0] = 3  ✓  // 窗口 [3,-1,-3] 最大值是 3
5

i = 4,元素 5:

1队尾 3(-3) 的值 -3 ≤ 5 → 弹出 3(-3)
2队尾 2(-1) 的值 -1 ≤ 5 → 弹出 2(-1)
3队尾 1(3) 的值 3 ≤ 5 → 弹出 1(3)
4全部弹出 → 4(5) 入队 → deque: [4(5)]
5输出 deque[0] = 5  ✓  // 窗口 [-1,-3,5] 最大值是 5
6

看到没?当 5 出现时,它一口气踢掉了前面三个——因为 5 是最新进的(最年轻),值又最大(最强),前面那三个在 5 离开之前永远没机会当最大值。

i = 5,元素 3:

1队尾 4(5) 的值 5 > 3 → 不弹出
25(3) 入队 → deque: [4(5), 5(3)]
3输出 deque[0] = 5  ✓  // 窗口 [-3,5,3] 最大值是 5
4

i = 6,元素 6:

1队尾 5(3) 的值 3 ≤ 6 → 弹出 5(3)
2队尾 4(5) 的值 5 ≤ 6 → 弹出 4(5)
3全部弹出 → 6(6) 入队 → deque: [6(6)]
4输出 deque[0] = 6  ✓  // 窗口 [5,3,6] 最大值是 6
5

i = 7,元素 7:

1队尾 6(6) 的值 6 ≤ 7 → 弹出 6(6)
27(7) 入队 → deque: [7(7)]
3输出 deque[0] = 7  ✓  // 窗口 [3,6,7] 最大值是 7
4

最终结果:[3, 3, 5, 5, 6, 7] ✓


为什么这叫"单调"队列?

因为队列里存的值永远是单调递减的:

1deque 的值变化过程:
2[1]
3[3]           ← 1 被 3 顶掉
4[3, -1]
5[3, -1, -3]
6[5]           ← 3,-1,-3 全被 5 顶掉
7[5, 3]        ← 3 不够强,乖乖排在后面
8[6]           ← 5,3 被 6 顶掉
9[7]           ← 6 被 7 顶掉
10

每个元素最多入队一次、出队一次,所以总操作次数是 O(2n) = O(n)。


对比:三种写法的差距有多大

我们跑一下实际数据对比(虽然截图中看不到,但你可以在本地运行):

解法时间复杂度n=10^5, k=10^4 的耗时LeetCode 结果
暴力 Math.max(...slice)O(nk)~10 秒TLE ❌
只记一个候选 + 过期重扫O(nk) 最坏~10 秒TLE ❌
单调队列O(n)~20 ms通过 ✅

差距是 500 倍。这就是"想清楚再写"的价值。


单调队列的通用模板

你掌握了滑动窗口最大值的推导过程,以后遇到所有「窗口 + 最值」问题都能套用这个框架:

1// 🔑 单调队列通用框架
2function slidingWindowTemplate(nums, k) {
3    const result = [];
4    const deque = []; // 存下标
5
6    for (let i = 0; i < nums.length; i++) {
7        // 1. 维护单调性(队尾淘汰)
8        while (deque.length && /* 队尾不满足单调条件 */) {
9            deque.pop();
10        }
11        deque.push(i);
12
13        // 2. 清理过期元素(队首淘汰)
14        if (deque[0] <= i - k) {
15            deque.shift();
16        }
17
18        // 3. 窗口形成后取结果
19        if (i >= k - 1) {
20            result.push(nums[deque[0]]);
21        }
22    }
23
24    return result;
25}
26

变体只需要改第 1 步的条件:

问题单调方向队尾淘汰条件
滑动窗口最大值递减nums[deque[last]] <= nums[i]
滑动窗口最小值递增nums[deque[last]] >= nums[i]
窗口内中位数需要更复杂结构单调队列不适用(上堆)

三个易错点,每个我都踩过

⚠️ 坑 1:deque 存下标还是存值?

必须存下标。因为你需要判断"队首元素是否滑出窗口"——如果存值,你根本不知道这个值的原始位置在哪,无法判断过期。

1// ❌ 存值 — 无法判断是否过期
2deque.push(nums[i]);
3// 你只知道 deque 里有 3,但不知道这个 3 是哪来的、是否还在窗口内
4
5// ✅ 存下标 — 可以判断过期
6deque.push(i);
7if (deque[0] <= i - k) { /* 过期了 */ }
8

⚠️ 坑 2:队尾淘汰用 < 还是 <=?

1// ≤(推荐):严格保持递减,每个值唯一。不会有重复值同时留在队列里。
2while (deque.length && nums[deque[deque.length - 1]] <= nums[i]) {
3
4// <:相等的值都保留,队列可能变长,但结果也正确(队首仍是最大值)。
5// 只是多存了几个无用的重复值,浪费一点空间。
6while (deque.length && nums[deque[deque.length - 1]] < nums[i]) {
7

两种都能 AC,但 <= 更优——相等时新的比老的年轻,老的留着也没机会,干脆踢掉。这个细节体现了你真正理解了「比你年轻还比你强」这条规则。

⚠️ 坑 3:为什么用 shift()?它不是 O(n) 吗?

对,数组 shift() 是 O(n) 的。但在这个场景里,每个元素最多被 shift 一次——因为队首元素被移出窗口后就再也回不来了。所以总 shift 次数 ≤ n,均摊 O(1)。

如果你在面试中想展示严谨性,可以自己实现双指针队列避免 shift():

1// 双指针优化(纯 O(1) 出队)
2class Deque {
3    constructor() {
4        this.data = {};
5        this.head = 0;
6        this.tail = 0;
7    }
8    pushBack(x)  { this.data[this.tail++] = x; }
9    popBack()    { return this.data[--this.tail]; }
10    popFront()   { return this.data[this.head++]; }
11    front()      { return this.data[this.head]; }
12    back()       { return this.data[this.tail - 1]; }
13    get size()   { return this.tail - this.head; }
14}
15

记住这句话:

"数据结构的选择,本质上是选择数据的处理顺序。栈是'后来的先处理',队列是'先来的先处理',而单调队列是'没用的不处理'——在进队之前就淘汰掉永远不可能成为答案的元素。"

下次你写滑动窗口相关代码时,问自己三个问题:哪些元素进来(入队条件)?哪些元素出去(过期条件)?哪些元素在进来之前就可以淘汰了(单调性条件)?这三个问题答上来,单调队列就真的掌握了。


延伸思考

如果窗口不是固定大小,而是动态扩张和收缩呢?如果要求不只是最大值,而是窗口内的中位数呢?如果数据不是一维数组,而是二维矩阵上的滑动窗口呢?

这些问题的解法不同,但思考方式一样——在数据的进出之间,找到"不可能成为答案"的那些元素,大胆淘汰掉。


欢迎在评论区聊聊:你在哪些实际业务场景里遇到过"滑动窗口 + 最值"类的问题?比如日志监控的 QPS 峰值、K 线图的技术指标、还是别的什么?



《一个队列,怎么让滑动窗口从 O(nk) 变 O(n)?单调队列彻底搞懂》 是转载文章,点击查看原文。


相关推荐


Agent & AI 名词大扫盲
我是一筐橙子2026/7/26

Agent & AI 名词大扫盲:一篇文章搞懂 10 个核心概念 你是不是经常在 AI 圈听到一堆名词——大模型、Agent、RAG、MCP、Function Call……每个都似懂非懂?这篇文章用最通俗的语言,一口气帮你扫清 10 个高频 AI 术语。 1. 什么是大模型? 一句话:大模型就是「读过全网、能对答如流」的超级大脑。 大模型(Large Language Model,LLM)是指参数规模巨大的神经网络模型,通过在海量文本数据上训练而来。它学到的不是「死记硬背」,而是语言的模式


MVI模式的完整历史、误解和现代Android范式
稀有猿诉2026/7/18

本文译自「Yes, That’s MVI: The Pattern’s Full History, Misconceptions, and Modern Android Form」,原文链接proandroiddev.com/yes-that-is…,由Eury Pérez Beltré发布于2025年5月29日。 每项传统背后都有其原因——而原因背后,又蕴藏着一个故事。 – 佚名 欢迎阅读本文!我的目标是帮助你真正理解 MVI 模式的本质,解释我为何对我的观点充满信心,以及该模式的起源


别再手动拼接路径了!Node.js path 模块的 9 个核心 API 详解
先吃饱再说2026/7/10

别再手动拼接路径了!Node.js path 模块的 9 个核心 API 详解 摘要:路径处理是每个 Node.js 项目都绕不开的活。本文从 path.join 和 path.resolve 的区别入手,逐一拆解 path 模块的 9 个核心 API,并结合代码演示跨平台路径处理的正确姿势。读完你会发现——原来我之前一直在用错误的方式拼接路径。 📑 目录 为什么需要 path 模块? path.join 与 path.resolve:最容易被搞混的两个 API path.dirname


用 Vibe Coding 搭了一个完整小程序「一定能成」
有趣的老凌2026/7/2

前言 这不是一篇技术教程,而是一个产品创作者的完整记录——从脑子里一个模糊的想法,到上线一个包含用户端、API、后台管理、AI 集成的真实产品,全程用 Codex 协作完成。 一、引子:一个想法的诞生 我一直有一个困扰。 市面上缺一个让我能把目标「真正执行下去」的工具。 Notion 太自由,需要自己搭建模板,搭完模板就没动力执行了。待办清单太简单,只能记「今天要做什么」,解决不了「今天为什么要做这个」和「明天该做什么」。OKR 太企业级,适合团队管理,不适合个人日常。 我想要的东西其实很简单


图解 MongoDB 08|ESR 原则:复合索引的字段顺序怎么定
十三Tech2026/6/23

复合索引是 MongoDB 性能优化里最常用、也最容易用错的工具。很多人建复合索引的方式是「查询用到哪几个字段,就按想到的顺序建一个」,结果发现索引只用了第一个字段,查询照样慢。问题不在「有没有建索引」,而在字段顺序。 复合索引的字段顺序,决定了它能服务哪些查询、能用上几个字段。同样的三个字段 {a, b, c},排成 {a, b, c} 和 {c, b, a} 是两棵完全不同的 B-tree,能加速的查询也完全不同。这一篇讲清楚复合索引字段排序的核心原则——ESR(Equality, Sort


古法编程秘籍(七):互联网到底是什么?把两台电脑怎么说话搞懂就够了
JustHappy2026/6/15

Hi!这里是 JustHappy 这是专为编程初学者准备的专栏。这次我们来“上网”,但是互联网不是网页,也不只是 HTTP,它本质上是不同机器上的程序按规则交换数据。看懂客户端、服务器、协议、操作系统、网卡这条链,再分清 HTTP 的“一问一答”和 WebSocket 的“持续通信”,你的网络世界观才算真正搭起来。 上一篇我们讲到这里: 代码会执行、会发起 IO、硬件会工作、中断会通知CPU、操作系统会处理。 最后程序收到事件,再继续执行。 写到这里,很多人会自然冒出一个新问题: 如果 IO


Java MyBatis-Plus 实战指南:用 BaseMapper、Wrapper 和分页写好数据层
唐青枫2026/6/8

简介 MyBatis-Plus 是一个基于 MyBatis 的增强工具。 它经常被简称为 MP。 它的核心定位是: 只做增强,不改变 MyBatis 原有能力。 普通 MyBatis 项目里,哪怕只是做一张表的增删改查,也经常要写: Mapper 接口 Mapper XML insert SQL delete SQL update SQL select SQL 分页 SQL 条件 SQL MyBatis-Plus 把这些单表常规操作封装成了通用方法。 最常见的写法是: public inte


基因泰克:检测级虚拟细胞基准!大语言模型+智能体
Omics Pro2026/6/1

摘要 机器学习与大规模生物数据的进展重新激发了构建虚拟细胞(预测细胞行为的计算模型,可加速生物学发现)的研究前景。该愿景的核心应用是体外表型筛选,即模型预测细胞扰动在未知生物场景下的效应,该任务融合异质文本输入与多样表型输出,高度适配大语言模型与智能体系统。但目前该任务缺乏标准化基准,现有研究仅聚焦分子层面读数,与真实药物研发流程中的表型终点脱节。本研究推出基于1,920个公开CRISPR筛选构建的表型筛选预测基准AssayBench,覆盖5大类细胞表型;将筛选预测任务定义为单筛选基因排序任务


Spring MVC 的核心知识点梳理
huohuopro2026/5/11

MVC 是什么 MVC 不是 Spring 发明的,而是一种设计模式,目的是“解耦”。 M(Model,模型):数据 + 业务逻辑。比如 Teacher 类,TeacherService。V(View,视图):展示数据的界面。比如 JSP、Thymeleaf 模板,或者是现代返回 JSON 的前端页面。C(Controller,控制器):接收用户请求,调用 Model,最后选择 View 来展示。 流程:用户点击一个链接 → Controller 拿到请求 → 调 Service 拿到数据(Mo


精准医学的数据平台化与Python编程实战(中)
Allen_Lyb2026/5/1

第五章:高性能数据处理与分析 5.1 使用Pandas进行临床数据清洗与特征工程 import pandas as pd import numpy as np from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler, OneHotEncoder # 加载模拟临床数据 df = pd.read_csv('clinical_cohort.csv') # 处理缺失值 nu

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读