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

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


javascript 复制代码
// 给你一个数组和一个窗口大小 k,窗口从左滑到右,每滑一步输出窗口内最大值
// 输入: nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
// 期望: [3, 3, 5, 5, 6, 7]

// 如果你第一次见到这题,大概率会这样写:
var maxSlidingWindow = function(nums, k) {
    const result = [];
    for (let i = 0; i <= nums.length - k; i++) {
        result.push(Math.max(...nums.slice(i, i + k)));
    }
    return result;
};

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

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

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


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

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

ini 复制代码
nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3

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

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

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

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


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

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

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

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

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

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


第三步:引入候补名单

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

你的策略:

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

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

用这个逻辑管理窗口:

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

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


核心代码讲解

javascript 复制代码
// 滑动窗口最大值 --- 单调队列解法
var maxSlidingWindow = function(nums, k) {
    const result = [];
    const deque = []; // 🔑 存的是下标,不是值

    for (let i = 0; i < nums.length; i++) {
        // ⚠️ 步骤1:维护单调递减 --- 队尾所有 ≤ 当前值的,踢出去
        // 为什么?因为它们更早进入窗口(更老),值还更小(更弱),
        // 在当前值离开窗口之前,它们永远不可能成为最大值
        while (deque.length && nums[deque[deque.length - 1]] <= nums[i]) {
            deque.pop();
        }
        deque.push(i); // 新元素从队尾进入

        // ⚠️ 步骤2:清理过期元素 --- 队首下标滑出窗口左边界,踢出去
        if (deque[0] <= i - k) {
            deque.shift();
        }

        // ⚠️ 步骤3:窗口形成后才记录结果
        // 前 k-1 个元素还没凑够一个完整窗口
        if (i >= k - 1) {
            result.push(nums[deque[0]]); // 🔑 队首永远是当前窗口最大值
        }
    }

    return result;
};

现在用数据一步一步跑:

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

i = 0,元素 1:

arduino 复制代码
deque 为空 → 0(1) 直接入队 → deque: [0(1)]
窗口未形成(i < 2)

i = 1,元素 3:

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

i = 2,元素 -1:

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

i = 3,元素 -3:

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

i = 4,元素 5:

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

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

i = 5,元素 3:

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

i = 6,元素 6:

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

i = 7,元素 7:

arduino 复制代码
队尾 6(6) 的值 6 ≤ 7 → 弹出 6(6)
7(7) 入队 → deque: [7(7)]
输出 deque[0] = 7  ✓  // 窗口 [3,6,7] 最大值是 7

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


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

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

csharp 复制代码
deque 的值变化过程:
[1]
[3]           ← 1 被 3 顶掉
[3, -1]
[3, -1, -3]
[5]           ← 3,-1,-3 全被 5 顶掉
[5, 3]        ← 3 不够强,乖乖排在后面
[6]           ← 5,3 被 6 顶掉
[7]           ← 6 被 7 顶掉

每个元素最多入队一次、出队一次,所以总操作次数是 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 倍。这就是"想清楚再写"的价值。


单调队列的通用模板

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

javascript 复制代码
// 🔑 单调队列通用框架
function slidingWindowTemplate(nums, k) {
    const result = [];
    const deque = []; // 存下标

    for (let i = 0; i < nums.length; i++) {
        // 1. 维护单调性(队尾淘汰)
        while (deque.length && /* 队尾不满足单调条件 */) {
            deque.pop();
        }
        deque.push(i);

        // 2. 清理过期元素(队首淘汰)
        if (deque[0] <= i - k) {
            deque.shift();
        }

        // 3. 窗口形成后取结果
        if (i >= k - 1) {
            result.push(nums[deque[0]]);
        }
    }

    return result;
}

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

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

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

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

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

javascript 复制代码
// ❌ 存值 --- 无法判断是否过期
deque.push(nums[i]);
// 你只知道 deque 里有 3,但不知道这个 3 是哪来的、是否还在窗口内

// ✅ 存下标 --- 可以判断过期
deque.push(i);
if (deque[0] <= i - k) { /* 过期了 */ }

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

arduino 复制代码
// ≤(推荐):严格保持递减,每个值唯一。不会有重复值同时留在队列里。
while (deque.length && nums[deque[deque.length - 1]] <= nums[i]) {

// <:相等的值都保留,队列可能变长,但结果也正确(队首仍是最大值)。
// 只是多存了几个无用的重复值,浪费一点空间。
while (deque.length && nums[deque[deque.length - 1]] < nums[i]) {

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

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

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

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

javascript 复制代码
// 双指针优化(纯 O(1) 出队)
class Deque {
    constructor() {
        this.data = {};
        this.head = 0;
        this.tail = 0;
    }
    pushBack(x)  { this.data[this.tail++] = x; }
    popBack()    { return this.data[--this.tail]; }
    popFront()   { return this.data[this.head++]; }
    front()      { return this.data[this.head]; }
    back()       { return this.data[this.tail - 1]; }
    get size()   { return this.tail - this.head; }
}

记住这句话:

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

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


延伸思考

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

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


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


相关推荐
XH华1 小时前
C++语言第四章:模板初阶
数据结构·c++·算法
纳兰青华1 小时前
拼图大师:从“暴力尝试“到“动态规划“的拆字艺术
java·算法·动态规划
星马梦缘1 小时前
算法设计导论 课堂笔记
算法·动态规划·分治·时间复杂度·最优二叉树
猫猫不是喵喵.11 小时前
Vue3 Props 属性
前端·javascript·vue.js
luj_176812 小时前
星火科技助力边远地区防病攻坚
c语言·开发语言·c++·经验分享·算法
木木子2214 小时前
# 鸿蒙 ArkTS 实战:秒表 Stopwatch(示例 5)
数据结构·华为·list·harmonyos
geats人山人海15 小时前
数据结构第六章c语言 树的存储下二叉树的概念和存储
c语言·数据结构·算法
漂流瓶jz15 小时前
UVA-12627 奇怪的气球膨胀 题解答案代码 算法竞赛入门经典第二版
算法·图论·递归·aoapc·算法竞赛入门经典·uva·12627
jarvisuni15 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法