CC++ & Algorithm

单调队列与滑动窗口——在移动窗口中快速找到最值

极难3
语言版本:通用
概述:单调队列结合了队列的先进先出和栈的单调性,常用于在滑动窗口中高效维护最值。本文用生活例子讲解原理,并给出C++和Python实现。

单调队列与滑动窗口——在移动窗口中快速找到最值

开头:这是什么?用来干什么?

想象你有一排考试成绩单,老师用一个“透明窗格”一次只看连续的3个成绩,然后记下最高分。窗格从左往右每次滑动一个位置,你需要知道每一个窗格里的最高分。如果每次都要重新比较窗格里的所有成绩,那效率太低。单调队列(Monotonic Queue)就是专门用来解决这类问题的:它可以在一个不断滑动的窗口中,快速(O(1)时间)告诉你当前窗口的最值(最大值或最小值),而整个过程只用O(n)时间。它是“队列”的升级版,结合了队列的先进先出和单调栈的单调性思想。

生活中的例子:排队体检的“最好视力”

学校体检,一排学生站好,老师用一个只能看到三个人的“视力窗口”从左向右移动。老师要记录每个窗口里视力最好的学生。

  • 如果老师每次都要重新看窗口里的三个人,那一个窗口看完再移一个位置,又要重新看三个人,非常累。
  • 聪明老师会这样:先看前三个,记住谁是最好。窗口向右移动一格时,左边出去一个,右边进来一个。如果新进来的视力比之前最好还好,那最好就换成新来的;如果出去的那个恰好是原来的最好,那老师就需要重新在窗口里找最好。这个过程就像维护一个“能快速知道最好”的队列。

单调队列就是这种“聪明老师”的计算机版本。它用一个双端队列(Deque,两端都可以进出)存储下标,并始终保持队列中的元素值单调递减(队首最大)或单调递增(队首最小)。这样,队首永远是最值。

单调队列的核心思想

问题的精确描述

给你一个数组 nums(比如考试成绩),和一个窗口大小 k,窗口从最左端滑到最右端,每次滑动一位,输出每个窗口的最大值(或最小值)。

例子:nums = [1,3,-1,-3,5,3,6,7],k=3
输出应为 [3,3,5,5,6,7] (每个窗口的最大值)

为什么不用暴力方法?

暴力方法:每个窗口遍历k个元素,找出最大值。总时间O(n*k),当n很大、k也很大时(比如n=10万,k=5万),可能要计算50亿次,太慢。单调队列只需O(n),每个元素最多入队一次、出队一次。

单调队列的设计细节

我们维护一个双端队列(deque),里面存的是数组下标,不是值。为什么存下标?因为下标可以判断元素是否还在窗口内,同时可以通过下标访问对应的值。

队列的单调性:从队首到队尾,下标对应的值严格单调递减(队首最大)。这样队首永远是当前窗口最大值。

入队规则(push)
新元素(下标 i)要进来时,先从队尾开始,把所有值小于等于 nums[i] 的元素全部弹出。因为那些元素的值更小,而且位置更靠左,在未来的窗口中绝对不可能成为最大值了(新元素更靠右且更大,窗口向右移动时,左边的小元素永远被新的大元素压制)。然后,把 i 从队尾入队。

出队规则(pop)
窗口向右滑动时,队首元素(下标)可能滑出窗口(即 队首下标 < i - k + 1),此时把它从队首弹出。

取最值
直接取队首元素对应的值,就是当前窗口的最大值。

为什么这样是对的?
因为窗口是连续滑动的,那些“老”且“小”的元素永远无法翻身(新的大元素总是比它们更靠右,所以在任何后续窗口中,只要新元素还在,就轮不到它们当最大值)。而即使当前最大值滑出了窗口,队列中剩下的下一个最大的元素会自动成为新队首(因为单调递减)。

ASCII示意图(用表格更清楚)

步骤当前下标inums[i]窗口范围队列(存下标)最大值动作
001入队0,队列 [0]
113[0]3>1,弹出0,入队1,队列 [1]
22-10~2[1]-1<3,直接入队2,队列 [1,2]
33-31~3[1,2]队首1在窗口内;-3<-1,入队3,队列 [1,2,3]
4452~4[1,2,3]队首1滑出(1<2),弹出1;5> -1? 弹出3;5>-1? 弹出2;队列空,入队4
5533~5[4]队首4在窗口内;3<5,直接入队5,队列 [4,5]
6664~6[4,5]队首4还在窗口;6>3? 弹出5;6>5? 弹出4;队列空,入队6
7775~7[6]队首6还在窗口;7>6? 弹出6;队列空,入队7

最终得到序列 [3,3,5,5,6,7]。

新手容易犯的错误

  1. 忘记检查队首是否滑出窗口
    每次滑动后,必须先检查队首下标是否小于 i - k + 1,否则可能把已经不在窗口中的元素当作最大值。
  2. 出队时用了错误的条件
    有些同学误用 while (!dq.empty() && dq.front() < i - k),正确应该是 < i - k + 1。因为窗口左边界是 i - k + 1,下标小于这个值才算滑出。比如 i=2,k=3时,左边界=0,队首0正好在窗口内,条件 0<0? 假,正确。若用 < i - k(即< -1),永远不触发,错了。
  3. 单调性判断用了大于还是小于
    求最大值时,要维持单调递减,所以从队尾弹出所有 <= nums[i] 的元素(即比新元素小或相等的,因为它们以后不可能成为最大值)。如果用了 <,那么相等的情况会保留,可能导致队列中保留多个相同值,虽然不影响结果但浪费空间。用 <= 更简洁。
  4. 存储值而非下标
    如果只存值,无法判断元素是否滑出窗口,因为不知道它的位置。一定要存下标。
  5. 窗口未完全形成时提前记录
    只有当下标 i >= k-1 时,窗口中才有 k 个元素,此时才能记录结果。

完整可运行代码示例

C++ 版本(含详细注释)

#include <iostream>
#include <vector>
#include <deque>
using namespace std;

// 函数:返回每个滑动窗口的最大值
vector<int> maxSlidingWindow(const vector<int>& nums, int k) {
    int n = nums.size();                 // 数组总长度
    if (n == 0 || k <= 0) return {};

    vector<int> result;                  // 存储每个窗口的最大值
    deque<int> dq;                       // 双端队列,存储下标,从队首到队尾递减

    for (int i = 0; i < n; ++i) {
        // 1. 移除队首已经滑出窗口的元素(下标 < 窗口左边界 i - k + 1)
        while (!dq.empty() && dq.front() < i - k + 1) {
            dq.pop_front();              // 从队首弹出过期下标
        }

        // 2. 维护单调递减:从队尾移除所有值小于等于当前元素的元素
        //    因为这些元素在当前位置 i 之后不可能再成为最大值
        while (!dq.empty() && nums[dq.back()] <= nums[i]) {
            dq.pop_back();               // 从队尾弹出较小的元素
        }

        // 3. 当前元素下标入队(它一定比队尾所有元素大,保持单调递减)
        dq.push_back(i);

        // 4. 当窗口形成(i >= k-1)时,记录当前窗口最大值(队首)
        if (i >= k - 1) {
            result.push_back(nums[dq.front()]);  // 队首即最大值
        }
    }
    return result;
}

// 辅助函数:打印数组
void printVector(const vector<int>& vec) {
    cout << "[";
    for (size_t i = 0; i < vec.size(); ++i) {
        cout << vec[i];
        if (i != vec.size() - 1) cout << ", ";
    }
    cout << "]" << endl;
}

int main() {
    vector<int> nums = {1, 3, -1, -3, 5, 3, 6, 7};
    int k = 3;
    vector<int> res = maxSlidingWindow(nums, k);
    cout << "原数组: ";
    printVector(nums);
    cout << "滑动窗口最大值: ";
    printVector(res);  // [3, 3, 5, 5, 6, 7]
    return 0;
}

Python 版本

from collections import deque

def max_sliding_window(nums, k):
    """返回滑动窗口最大值列表"""
    n = len(nums)                 # 数组长度
    if n == 0 or k <= 0:
        return []

    result = []                   # 存储结果
    dq = deque()                  # 双端队列,存储下标,单调递减(队首最大)

    for i in range(n):
        # 1. 移除队首过期下标(小于窗口左边界)
        while 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

# 测试
nums = [1, 3, -1, -3, 5, 3, 6, 7]
k = 3
res = max_sliding_window(nums, k)
print("原数组:", nums)
print("滑动窗口最大值:", res)  # [3, 3, 5, 5, 6, 7]

变体:求滑动窗口最小值

只需将单调性改为单调递增(队首最小),并将弹出条件改为 >= 即可。

// 滑动窗口最小值,C++实现
vector<int> minSlidingWindow(const vector<int>& nums, int k) {
    int n = nums.size();
    if (n == 0 || k <= 0) return {};
    vector<int> result;
    deque<int> dq;  // 存储下标,单调递增(队首最小)
    for (int i = 0; i < n; ++i) {
        // 移除过期下标
        while (!dq.empty() && dq.front() < i - k + 1) dq.pop_front();
        // 维护单调递增:弹出队尾所有值大于等于当前值的元素
        while (!dq.empty() && nums[dq.back()] >= nums[i]) dq.pop_back();
        dq.push_back(i);
        if (i >= k - 1) result.push_back(nums[dq.front()]);
    }
    return result;
}

单调队列的其他应用

  • 滑动窗口中的第k大/小值(常与topK问题结合,但单调队列只能解决最值,不是任意顺序)。
  • 动态规划优化:例如“最大子数组和”的滑动窗口版本,或者“最少跳跃次数”问题中维护单调队列来加速状态转移。
  • 滑动窗口中的中位数:需要更复杂的数据结构(如双堆+延迟删除),但单调队列是其中一部分。
  • LeetCode经典题目
    • 239. 滑动窗口最大值
      1. 绝对差不超过限制的最长连续子数组
      1. 和至少为 K 的最短子数组(结合前缀和+单调队列)

总结要点

  • 单调队列 = 双端队列 + 单调性:队列内元素的值从队首到队尾单调递减(或递增),从而O(1)获取最值。
  • 滑动窗口最大值是最典型应用,时间复杂度O(n),空间复杂度O(k)。
  • 核心操作:滑动时先“清理过期元素”(队首出队),再“维护单调性”(队尾弹出不可能成为最值的元素),然后入队新元素,最后记录结果。
  • 存储下标比存储值更通用,因为可以通过下标访问原始数组并判断是否出窗口。
  • 与单调栈的区别:单调栈用于“下一个更大/小元素”,通常解决一对一的比较;单调队列用于“滑动窗口内的连续最值”,解决一对多的维护。两者都利用单调性排除无用元素,但数据结构不同(栈 vs 队列)。

掌握了单调队列,你就掌握了一种高效的数据结构,能够解决许多看似复杂的滑动窗口问题。建议从“滑动窗口最大值”开始练习,然后尝试“滑动窗口最小值”、“和为S的连续正数序列”等变体,慢慢体会它的威力。

例题精讲

1单选题

关于单调队列在滑动窗口中的使用,下列说法正确的是:

A单调队列中的元素始终保持严格单调递减,因此队首元素永远是当前窗口的最大值。
B单调队列中存储的是窗口内所有元素的值,以便快速比较。
C使用单调队列求解滑动窗口最大值时,每个元素最多入队和出队一次,所以总时间复杂度为O(n)。
D单调队列只能用于求滑动窗口最大值,不能用于求最小值。
2单选题

在使用单调队列求解长度为k的滑动窗口最小值时,应维护一个单调( )的队列。

A递增
B递减
C先增后减
D不要求单调性
3判断题

单调队列实现滑动窗口最值时,队列中存储元素的索引比存储值更好,因为可以通过索引判断元素是否还在当前窗口内。

4填空题
完成函数 maxSlidingWindow,求给定数组 nums 和窗口大小 k 的滑动窗口最大值。使用双端队列 deque(假设已实现 push_back, push_front, pop_back, pop_front, front, back 等操作,且支持索引访问)。请补全代码。

function maxSlidingWindow(nums, k):
    n = length(nums)
    result = []
    deque = []  // 存储索引,维护单调递减
    for i from 0 to n-1:
        // 移除队首不在当前窗口的元素
        if deque and deque[0] < i - k + 1:
            ___(1)___
        // 维护单调性:移除队尾所有小于等于当前值的索引
        while deque and nums[deque[-1]] <= nums[i]:
            ___(2)___
        // 当前索引入队
        deque.push_back(i)
        // 当窗口形成后,记录结果
        if i >= k - 1:
            result.push_back(___ (3) ___)
    return result
5填空题
给定一个整数数组 nums 和一个整数 k,求所有滑动窗口中的最小值。请完成 minSlidingWindow 函数,使用单调队列。

function minSlidingWindow(nums, k):
    n = length(nums)
    result = []
    deque = []  // 存储索引,维护单调递增
    for i from 0 to n-1:
        // 移除过期队首
        if deque and deque[0] < i - k + 1:
            deque.pop_front()
        // 维护单调递增:移除队尾所有大于等于当前值的索引
        while deque and nums[deque[-1]] ___ (1) ___ nums[i]:
            deque.pop_back()
        deque.push_back(i)
        if i >= k - 1:
            result.push_back(___ (2) ___)
    return result