单调队列:滑动窗口最大值背后的“排队潜规则”

0 阅读15分钟

在上一次写完单调栈之后,答应大家写的单调队列,他们都是对栈这一数据结构的应用。

如果你已经学过队列,应该知道它最大的特点是:先进先出

谁先来,谁先走。

这听起来很公平,但在某些算法问题里,光公平还不够。比如我们经常会遇到这样一个场景:

有一个窗口在数组上不断滑动,每次都要快速知道窗口里的最大值或最小值。

这时候,普通队列就有点吃力了。

因为普通队列只知道谁先进来、谁先出去,却不知道当前队列里谁最大。你想找最大值,就只能遍历整个窗口。如果窗口大小是 k,数组长度是 n,那总复杂度可能变成 O(nk)

而单调队列就是为了解决这个问题诞生的。

它既保留了队列的时间顺序,又能快速告诉你当前窗口的最大值或最小值。

简单说:

单调队列 = 队列的先进先出 + 单调结构的快速最值能力。


一、为什么需要单调队列?

我们先看一个很常见的问题。

给你一个数组:

nums = [1, 3, -1, -3, 5, 3, 6, 7]

窗口大小:

k = 3

现在窗口从左往右滑动,每次输出窗口中的最大值。

过程如下:

窗口位置                          最大值
[1, 3, -1], -3, 5, 3, 6, 7          3
1, [3, -1, -3], 5, 3, 6, 7          3
1, 3, [-1, -3, 5], 3, 6, 7          5
1, 3, -1, [-3, 5, 3], 6, 7          5
1, 3, -1, -3, [5, 3, 6], 7          6
1, 3, -1, -3, 5, [3, 6, 7]          7

最终答案是:

[3, 3, 5, 5, 6, 7]

如果每次窗口滑动后都重新扫描窗口里的 k 个元素,那么当然可以做,但效率不高。

我们希望做到:

  • 每个元素最多进队一次;
  • 每个元素最多出队一次;
  • 每次都能 O(1) 获取当前最大值;
  • 整体复杂度控制在 O(n)

这正是单调队列擅长的事情。


二、普通队列的问题在哪里?

普通队列只维护元素的先后顺序。

比如窗口中有:

[1, 3, -1]

普通队列会老老实实保存:

1 -> 3 -> -1

但如果你问它:“当前最大值是谁?”

它并不知道。

它只能从头到尾扫一遍,发现最大值是 3

当窗口右移时,1 被移出,-3 被加入:

[3, -1, -3]

最大值仍然是 3

但如果下一次移出的刚好是最大值 3,窗口变成:

[-1, -3, 5]

这时最大值就变成了 5

问题来了:

当最大值被移出窗口后,如何快速知道新的最大值?

普通队列解决不了这个问题。

优先队列,也就是堆,可以快速获得最大值,但它不天然满足“先进先出”的窗口顺序。堆关心的是大小,不关心谁先进入窗口。

所以我们需要一种结构,既要懂顺序,又要懂大小。

这就是单调队列。


三、什么是单调队列?

单调队列本质上还是一个队列,只不过它内部的元素会保持某种单调性。

如果我们要维护最大值,就让队列从队头到队尾保持 单调递减

也就是说:

队头                         队尾
最大值  ->  次大值  ->  更小值
7      ->   5      ->   3

这样一来,队头永远是当前窗口中的最大值。

如果我们要维护最小值,就让队列从队头到队尾保持 单调递增

本文主要以“滑动窗口最大值”为例,所以我们维护的是一个单调递减队列。


image.png

图片位置建议一:放在这里

建议放一张“普通队列 vs 单调队列”的对比图。

图片内容可以这样设计:

  • 左边是普通队列:1 -> 3 -> -1,标注“需要遍历才能找到最大值”;
  • 右边是单调队列:3 -> -1,标注“队头就是最大值”;
  • 用箭头强调:单调队列会把没有竞争力的元素提前移除。

图片标题可以是:

普通队列只维护顺序,单调队列同时维护顺序和最值信息。


四、单调队列的核心规则

假设我们要维护窗口最大值。

当一个新元素 n 进入队列时,我们会从队尾开始比较:

  • 如果队尾元素小于 n,说明它以后不可能成为最大值;
  • 因为 n 比它大,而且 n 比它更晚进入窗口;
  • 只要 n 还在窗口里,那个更小的旧元素就永远没有机会成为最大值;
  • 所以可以直接把旧元素从队尾删除。

这就是单调队列最关键的思想:

新来的大元素,会淘汰前面比它小的元素。

举个例子。

当前单调队列是:

[5, 3, 1]

现在新元素 4 进来。

队尾是 1,比 4 小,删除。

队列变成:

[5, 3]

队尾是 3,也比 4 小,删除。

队列变成:

[5]

队尾是 5,比 4 大,停止。

最后把 4 加入队尾:

[5, 4]

整个队列依然保持单调递减。


五、为什么可以删除那些更小的元素?

这是理解单调队列的关键。

假设有两个元素:

  • 旧元素 a
  • 新元素 b

并且满足:

a < b

同时,ba 晚进入窗口。

那么在后续窗口滑动过程中,只要 a 还没出窗口,b 一定也还没出窗口。因为 b 是后来进入的,它会比 a 更晚离开。

既然:

  • ba 大;
  • ba 活得更久;

那么 a 就永远不可能成为最大值。

所以我们可以放心删除 a

这不是乱删,而是在提前清理“没有未来竞争力”的元素。


六、单调队列需要哪些 API?

普通队列一般有两个核心操作:

  • push:从队尾加入元素;
  • pop:从队头移除元素。

单调队列在此基础上多了一个获取最值的能力。

以维护最大值为例,它通常需要这几个方法:

class MonotonicQueue {
    // 在队尾加入元素,同时维护单调递减结构
    void push(int n);

    // 返回当前队列中的最大值
    int max();

    // 如果队头元素等于 n,就将它移出
    void pop(int n);
}

其中最特殊的是 pop(int n)

为什么出队时还要传入一个 n

因为有些元素在进入单调队列时,可能已经被更大的元素“淘汰”了。也就是说,它虽然曾经属于窗口,但已经不在单调队列里了。

所以窗口左边界移出某个元素时,我们只需要判断:

如果这个元素刚好等于单调队列队头,说明它确实还在队列中,而且它就是当前最大值,需要删除。

否则,不用管它。


七、单调队列的实现

以 Java 为例,我们可以用 LinkedList 来实现单调队列。

因为单调队列需要在队头和队尾都进行删除操作,所以双端队列结构非常合适。

import java.util.LinkedList;

class MonotonicQueue {
    private LinkedList<Integer> q = new LinkedList<>();

    public void push(int n) {
        // 队尾所有小于 n 的元素,都不可能再成为最大值
        while (!q.isEmpty() && q.getLast() < n) {
            q.pollLast();
        }

        // 将 n 加入队尾
        q.addLast(n);
    }

    public int max() {
        // 队头就是当前最大值
        return q.getFirst();
    }

    public void pop(int n) {
        // 只有当即将移出的元素等于队头时,才真正删除
        if (!q.isEmpty() && q.getFirst() == n) {
            q.pollFirst();
        }
    }
}

这段代码非常短,但思想很精妙。

push 负责维护单调性。

max 直接返回队头。

pop 负责配合滑动窗口移除过期元素。


image.png

八、用单调队列解决滑动窗口最大值

现在我们回到 LeetCode 239:滑动窗口最大值。

题目要求:

给定数组 nums 和整数 k,返回每个大小为 k 的滑动窗口中的最大值。

思路如下:

  1. 遍历数组;
  2. 先把前 k - 1 个元素放入窗口;
  3. 从第 k 个元素开始,每加入一个新元素,就形成一个完整窗口;
  4. 调用 window.max() 记录当前窗口最大值;
  5. 再把窗口最左边的元素移出。

完整代码如下:

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

class Solution {
    class MonotonicQueue {
        private LinkedList<Integer> q = new LinkedList<>();

        public void push(int n) {
            while (!q.isEmpty() && q.getLast() < n) {
                q.pollLast();
            }
            q.addLast(n);
        }

        public int max() {
            return q.getFirst();
        }

        public void pop(int n) {
            if (!q.isEmpty() && q.getFirst() == n) {
                q.pollFirst();
            }
        }
    }

    public int[] maxSlidingWindow(int[] nums, int k) {
        MonotonicQueue window = new MonotonicQueue();
        List<Integer> res = new ArrayList<>();

        for (int i = 0; i < nums.length; i++) {
            if (i < k - 1) {
                // 先填满窗口的前 k - 1 个元素
                window.push(nums[i]);
            } else {
                // 加入新元素,窗口形成
                window.push(nums[i]);

                // 当前窗口最大值就是单调队列队头
                res.add(window.max());

                // 移出窗口最左侧元素
                window.pop(nums[i - k + 1]);
            }
        }

        int[] ans = new int[res.size()];
        for (int i = 0; i < res.size(); i++) {
            ans[i] = res.get(i);
        }

        return ans;
    }
}

九、手动模拟一遍过程

还是用这个例子:

nums = [1, 3, -1, -3, 5, 3, 6, 7]

k = 3

我们看窗口滑动时,单调队列如何变化。

i = 0,加入 1
窗口未形成,单调队列:[1]

i = 1,加入 3
3 比 1 大,删除 1
窗口未形成,单调队列:[3]

i = 2,加入 -1
窗口 [1, 3, -1] 形成
单调队列:[3, -1]
当前最大值:3

继续往后:

移出 1
1 不等于队头 3,不用删除
单调队列:[3, -1]

i = 3,加入 -3
窗口 [3, -1, -3] 形成
单调队列:[3, -1, -3]
当前最大值:3

移出 3
3 等于队头 3,删除
单调队列:[-1, -3]

i = 4,加入 5
5 比 -3 大,删除 -3
5 比 -1 大,删除 -1
单调队列:[5]
当前最大值:5

你会发现,单调队列并不保存窗口里的所有元素。

它只保存那些“未来可能成为最大值”的元素。

这也是它高效的原因。


图片位置建议三:放在这里

image.png 建议放一张“滑动窗口与单调队列同步变化”的动态图或分步骤图。

可以分成三列:

当前窗口单调队列当前最大值
[1, 3, -1][3, -1]3
[3, -1, -3][3, -1, -3]3
[-1, -3, 5][5]5
[-3, 5, 3][5, 3]5
[5, 3, 6][6]6
[3, 6, 7][7]7

图片标题可以是:

滑动窗口向右移动时,单调队列只保留可能成为最大值的元素。


十、为什么时间复杂度是 O(n)?

很多人第一次看单调队列代码时,会有一个疑问:

push 方法里有一个 while 循环,那它不是可能退化成 O(n) 吗?

单独看某一次 push,确实可能删除很多元素。

但从整体来看,每个元素:

  • 最多被加入队列一次;
  • 最多被删除队列一次。

所以所有元素加起来,总操作次数不会超过 2n 级别。

因此总时间复杂度是:

O(n)

这是一种典型的摊还分析。

空间复杂度则和窗口大小有关:

O(k)

因为单调队列中最多保存窗口内的元素。


十一、单调队列和单调栈有什么区别?

单调队列和单调栈很像,都是通过维护单调性来提前淘汰无用元素。

但它们的使用场景不同。

单调栈更适合解决:

  • 下一个更大元素;
  • 下一个更小元素;
  • 每个元素左右两边第一个比它大或小的元素;
  • 柱状图最大矩形;
  • 每日温度。

单调栈通常关注的是:

某个元素附近第一个比它大或小的元素是谁。

单调队列更适合解决:

  • 滑动窗口最大值;
  • 滑动窗口最小值;
  • 固定区间内最值;
  • 动态窗口内最值;
  • 一些需要维护区间最优值的 DP 优化问题。

单调队列通常关注的是:

当前窗口或区间中的最大值、最小值是多少。

一句话总结:

单调栈解决“下一个更大/更小”,单调队列解决“窗口最值”。


十二、如何扩展为最小值队列?

如果想维护最小值,只需要把比较方向反过来。

维护最大值时,队列递减:

while 队尾元素 < 新元素,删除队尾

维护最小值时,队列递增:

while 队尾元素 > 新元素,删除队尾

最小值队列代码如下:

import java.util.LinkedList;

class MonotonicMinQueue {
    private LinkedList<Integer> q = new LinkedList<>();

    public void push(int n) {
        while (!q.isEmpty() && q.getLast() > n) {
            q.pollLast();
        }
        q.addLast(n);
    }

    public int min() {
        return q.getFirst();
    }

    public void pop(int n) {
        if (!q.isEmpty() && q.getFirst() == n) {
            q.pollFirst();
        }
    }
}

所以,单调队列并不是只能求最大值。

它可以求最大值,也可以求最小值。

关键看你维护的是递减队列还是递增队列。


十三、例题推荐

读完这篇文章,你可以尝试下面这些题目。


例题一:LeetCode 239. Sliding Window Maximum

中文题名:滑动窗口最大值

难度:困难

题意:

给定数组 nums 和窗口大小 k,窗口每次向右移动一位,返回每个窗口中的最大值。

示例:

输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]

解法关键词:

  • 滑动窗口;
  • 单调递减队列;
  • 队头维护最大值。

这是单调队列最经典的模板题,建议优先掌握。


例题二:LCR 184. 设计自助结算系统

这道题本质上是让你设计一个队列系统,支持:

  • 加入元素;
  • 移出元素;
  • 快速获取当前最大值。

它和普通队列最大的区别在于:

普通队列只能 O(1) 入队和出队,但不能 O(1) 获取最大值。

所以可以使用两个队列:

  1. 一个普通队列,维护真实入队顺序;
  2. 一个单调递减队列,维护当前最大值。

核心思路:

  • 入队时,普通队列正常加入;
  • 单调队列删除队尾所有更小元素,再加入当前元素;
  • 出队时,如果出队元素等于单调队列队头,也同步删除;
  • 查询最大值时,直接返回单调队列队头。

示意代码:

import java.util.LinkedList;
import java.util.Queue;

class Checkout {
    private Queue<Integer> data = new LinkedList<>();
    private LinkedList<Integer> maxq = new LinkedList<>();

    public int get_max() {
        if (maxq.isEmpty()) {
            return -1;
        }
        return maxq.getFirst();
    }

    public void add(int value) {
        data.offer(value);

        while (!maxq.isEmpty() && maxq.getLast() < value) {
            maxq.pollLast();
        }

        maxq.addLast(value);
    }

    public int remove() {
        if (data.isEmpty()) {
            return -1;
        }

        int value = data.poll();

        if (!maxq.isEmpty() && maxq.getFirst() == value) {
            maxq.pollFirst();
        }

        return value;
    }
}

这道题非常适合帮助你理解:

单调队列不是替代普通队列,而是辅助普通队列维护最值。


例题三:剑指 Offer 59 - II. 队列的最大值

如果你在刷剑指 Offer,这道题和 LCR 184 非常类似。

题目要求设计一个队列,支持:

  • max_value():获取最大值;
  • push_back(value):入队;
  • pop_front():出队。

这也是典型的“普通队列 + 单调队列”设计题。

建议你在写完 LeetCode 239 后立刻做这题。

因为 239 偏算法应用,而这题偏数据结构设计。


例题四:滑动窗口最小值

题意:

给定数组 nums 和窗口大小 k,返回每个窗口中的最小值。

示例:

输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[-1,-3,-3,-3,3,3]

这道题和滑动窗口最大值几乎一样,只需要把单调递减队列改成单调递增队列。

也就是:

while 队尾元素 > 当前元素,删除队尾

这题可以帮助你确认自己是不是真的理解了单调队列,而不是只背了最大值模板。


例题五:窗口最大值与最小值之差不超过 limit

这是一个更进阶的滑动窗口问题。

题意大致是:

给定数组 nums 和整数 limit,找出最长连续子数组,使得子数组中的最大值和最小值之差不超过 limit

这类题一般需要同时维护:

  • 一个单调递减队列,用来获取最大值;
  • 一个单调递增队列,用来获取最小值。

当:

max - min > limit

说明窗口不合法,需要移动左边界。

这类题能让你真正体会到单调队列在复杂滑动窗口中的威力。


十四、单调队列模板总结

维护最大值模板:

class MaxQueue {
    LinkedList<Integer> q = new LinkedList<>();

    void push(int x) {
        while (!q.isEmpty() && q.getLast() < x) {
            q.pollLast();
        }
        q.addLast(x);
    }

    int max() {
        return q.getFirst();
    }

    void pop(int x) {
        if (!q.isEmpty() && q.getFirst() == x) {
            q.pollFirst();
        }
    }
}

维护最小值模板:

class MinQueue {
    LinkedList<Integer> q = new LinkedList<>();

    void push(int x) {
        while (!q.isEmpty() && q.getLast() > x) {
            q.pollLast();
        }
        q.addLast(x);
    }

    int min() {
        return q.getFirst();
    }

    void pop(int x) {
        if (!q.isEmpty() && q.getFirst() == x) {
            q.pollFirst();
        }
    }
}

滑动窗口模板:

for (int i = 0; i < nums.length; i++) {
    if (i < k - 1) {
        window.push(nums[i]);
    } else {
        // 新元素入窗口
        window.push(nums[i]);

        // 记录当前窗口答案
        res.add(window.max());

        // 旧元素出窗口
        window.pop(nums[i - k + 1]);
    }
}

十五、最后总结

单调队列是一种专门为“动态窗口最值”服务的数据结构。

它的核心思想可以总结成三句话:

  1. 队头永远保存当前窗口的最值。
    如果维护最大值,就让队列从队头到队尾单调递减;如果维护最小值,就让队列从队头到队尾单调递增。

  2. 新元素入队时,会淘汰队尾中所有不可能成为答案的元素。
    维护最大值时,队尾比新元素小的元素会被删除;维护最小值时,队尾比新元素大的元素会被删除。

  3. 窗口左侧元素离开时,只在它等于队头元素时才弹出。
    因为有些元素可能早就在入队时被淘汰了,所以出窗口时不一定还存在于单调队列中。

所以,单调队列并不是一个复杂的数据结构,它真正巧妙的地方在于:

提前删除那些“未来不可能成为最值”的元素,只保留有竞争力的候选者。

在滑动窗口最大值这类问题中,单调队列可以让我们在 O(1) 时间获取当前窗口最大值,并让整体算法保持 O(n) 的时间复杂度。

如果你能理解这句话:

比我小、比我早进来的元素,在我存在期间永远不可能成为最大值。

那么你就真正理解了单调队列的灵魂。

它看起来像是在排队,但其实每个元素都在竞争“窗口最值”的位置。能留下来的,都是在未来某个时刻仍然有机会成为答案的元素。

一些练习题我总结在文章尾部

image.png