单调队列:滑动窗口最大值的O(n)解法

前言

单调队列是单调栈的孪生兄弟。如果说单调栈处理"下一个更大元素",那么单调队列就是处理**"滑动窗口的最大值"**。

我并没有能力让你看完就精通所有队列问题,我只是想让你理解单调队列的核心思想、和单调栈的区别、以及如何用双端队列维护窗口最值。掌握这个技巧,滑动窗口问题不再是难题。

摘要

从"滑动窗口最大值暴力超时"问题出发,剖析单调队列的核心思想与实现技巧。通过队首维护最大值、队尾维护单调性的图解演示、以及滑动窗口的详细推导,揭秘如何把窗口最值查询从O(k)优化到O(1)。配合LeetCode经典题目,给出单调队列的完整套路。


一、从滑动窗口最大值说起

周一早上,哈吉米遇到一道Hard题:

LeetCode 239 - 滑动窗口最大值

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。
你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回滑动窗口中的最大值。

示例:
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置                最大值
---------------               -----
[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

哈吉米的暴力代码:

Java版本

public int[] maxSlidingWindow(int[] nums, int k) {
    int n = nums.length;
    int[] result = new int[n - k + 1];
    
    for (int i = 0; i <= n - k; i++) {
        int max = nums[i];
        // 遍历窗口,找最大值
        for (int j = i; j < i + k; j++) {
            max = Math.max(max, nums[j]);
        }
        result[i] = max;
    }
    
    return result;
}

时间复杂度:O(n×k)

南北绿豆走过来:"数据量多大?"

哈吉米:"nums最多10万,k最多10万,O(n×k)=100亿,妥妥超时。"

南北绿豆:"这是单调队列的经典应用,O(n)解决。"


二、生活化场景:排队上公交

阿西噶阿西:"想象公交站排队上车。"

场景

队伍:[身高170, 165, 180, 175, 160]
公交车容量:3人

问题:每次3人上车,谁最高?

第1批:[170, 165, 180] → 最高1802批:[165, 180, 175] → 最高1803批:[180, 175, 160] → 最高180

暴力方法:每批都遍历3个人,找最高的。

单调队列方法

维护一个队列,队首永远是最高的人

规则:
1. 新人进来,比队尾矮的不影响(留着)
2. 新人进来,比队尾高,队尾的人踢出去(反正永远选不上)
3. 队首的人如果已经出窗口,踢出去

结果:队首永远是窗口内最高的人

哈吉米:"队列里只保留可能成为最大值的人,其他人踢掉。"

南北绿豆:"对,这就是单调队列:队列内元素单调递减(从队首到队尾)。"


三、单调队列 vs 单调栈

南北绿豆:"先看单调队列和单调栈的区别。"

对比项单调栈单调队列
数据结构栈(后进先出)双端队列(两端都能操作)
单调性从栈底到栈顶单调从队首到队尾单调
典型问题下一个更大元素滑动窗口最大值
操作只能从栈顶pop可以从队首队尾pop

为什么需要双端队列?

阿西噶阿西

  • 队首:弹出过期元素(滑出窗口的)
  • 队尾:弹出比当前元素小的(永远选不上的)

图示

flowchart TB
    A["单调队列<br/>双端队列Deque"]
    B["队首操作<br/>弹出过期元素"]
    C["队尾操作<br/>维护单调性"]
    D["队首<br/>永远是窗口最大值"]
    
    A --> B
    A --> C
    B --> D
    C --> D
    
    style D fill:#e1ffe1

四、单调队列原理

南北绿豆:"单调队列维护一个单调递减的队列(队首最大)。"

4.1 核心规则

1. 新元素入队前:
   - 从队尾开始,弹出所有比新元素小的元素
   - 然后新元素从队尾入队
   
2. 窗口滑动时:
   - 检查队首元素是否已经滑出窗口
   - 如果滑出,从队首弹出
   
3. 查询最大值:
   - 直接返回队首

为什么要弹出队尾比新元素小的?

阿西噶阿西:"因为新元素更大且更新,之前的小元素永远不会成为最大值了。"

队列:[5, 3](单调递减)
新元素:6

36小,且36前面(6更新)
  → 3永远选不上了(6在窗口内时,最大值一定是6而不是3)
  → 踢掉3
  
56小,也踢掉

队列:[6]

五、滑动窗口最大值详解

5.1 执行过程演示

示例nums = [1,3,-1,-3,5,3,6,7], k = 3

单调队列变化

inums[i]窗口队列(存索引)队首元素最大值
01[1][0]nums[0]=1-
13[1,3][1]nums[1]=3-
2-1[1,3,-1][1,2]nums[1]=33
3-3[3,-1,-3][1,2,3]nums[1]=33
45[-1,-3,5][4]nums[4]=55
53[-3,5,3][4,5]nums[4]=55
66[5,3,6][6]nums[6]=66
77[3,6,7][7]nums[7]=77

详细过程

i=0,nums[0]=1:
  队列空,1入队
  队列:[0](存的是索引)

i=1,nums[1]=3:
  3 > 1(队尾),弹出0
  3入队
  队列:[1]

i=2,nums[2]=-1:
  窗口形成(size=3),记录最大值
  -1 < 3(队尾),不弹出,直接入队
  队列:[1,2]
  最大值:nums[1]=3

i=3,nums[3]=-3:
  -3 < -1(队尾),直接入队
  队列:[1,2,3]
  最大值:nums[1]=3

i=4,nums[4]=5:
  5 > -3(队尾),弹出3
  5 > -1(队尾),弹出2
  5 > 3(队尾),弹出1
  5入队
  队列:[4]
  最大值:nums[4]=5

i=5,nums[5]=3:
  3 < 5,直接入队
  队列:[4,5]
  最大值:nums[4]=5

i=6,nums[6]=6:
  窗口滑动,检查队首4是否过期:4 < 6-3+1=4,不过期
  6 > 3(队尾),弹出5
  6 > 5(队尾),弹出4
  6入队
  队列:[6]
  最大值:nums[6]=6

哈吉米:"队列里只保留可能成为最大值的元素,其他都踢掉。"

5.2 代码实现

Java版本

public int[] maxSlidingWindow(int[] nums, int k) {
    int n = nums.length;
    int[] result = new int[n - k + 1];
    
    // 双端队列:存储索引
    Deque<Integer> deque = new ArrayDeque<>();
    
    for (int i = 0; i < n; i++) {
        // 移除队首过期元素(滑出窗口的)
        if (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
            deque.pollFirst();
        }
        
        // 维护单调性:移除队尾比当前元素小的
        while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) {
            deque.pollLast();
        }
        
        // 当前元素入队
        deque.offerLast(i);
        
        // 窗口形成后,记录最大值
        if (i >= k - 1) {
            result[i - k + 1] = nums[deque.peekFirst()];
        }
    }
    
    return result;
}

C++版本

vector<int> maxSlidingWindow(vector<int>& nums, int k) {
    int n = nums.size();
    vector<int> result;
    
    deque<int> dq;
    
    for (int i = 0; i < n; i++) {
        // 移除队首过期元素
        if (!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;
}

Python版本

def maxSlidingWindow(nums, k):
    from collections import deque
    
    result = []
    dq = deque()
    
    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

时间复杂度:O(n),每个元素最多入队出队各一次。


六、单调队列的核心思想

南北绿豆:"单调队列的核心:队列内元素单调递减(或递增),队首是最大值(或最小值)。"

6.1 为什么是O(n)?

哈吉米:"看起来有while循环,为什么不是O(n²)?"

阿西噶阿西:"和单调栈一样,每个元素最多入队一次、出队一次。"

数组长度n=8

入队次数:8次
出队次数:最多8次(队首过期+队尾弹出)

总操作 = 16次 = O(n)

6.2 单调队列图解

窗口滑动过程

sequenceDiagram
    participant N as nums数组
    participant D as 单调队列
    participant R as 结果
    
    Note over N: nums=[1,3,-1,-3,5,3,6,7], k=3
    
    N->>D: i=0, nums[0]=1
    Note over D: 队列[0]
    
    N->>D: i=1, nums[1]=3
    Note over D: 3>1,弹出0<br/>队列[1]
    
    N->>D: i=2, nums[2]=-1
    Note over D: -1<3,直接加<br/>队列[1,2]
    D->>R: 窗口形成,最大值=nums[1]=3
    
    N->>D: i=3, nums[3]=-3
    Note over D: -3<-1,直接加<br/>队列[1,2,3]
    D->>R: 最大值=nums[1]=3
    
    N->>D: i=4, nums[4]=5
    Note over D: 5>所有,全弹出<br/>队列[4]
    D->>R: 最大值=nums[4]=5

七、例题2:和至少为K的最短子数组

7.1 题目

LeetCode 862 - 和至少为 K 的最短子数组(Hard)

给你一个整数数组 nums 和一个整数 k ,找出 nums 中和至少为 k 的最短非空子数组 ,
并返回该子数组的长度。如果不存在这样的子数组 ,返回 -1 。

示例:
输入:nums = [2,-1,2], k = 3
输出:3

输入:nums = [1,2], k = 4
输出:-1

7.2 思路分析

南北绿豆:"这题结合了前缀和+单调队列。"

思路

  1. 计算前缀和数组prefix
  2. 问题转换:找最短的i到j,使得prefix[j] - prefix[i] >= k
  3. 用单调队列维护prefix,找满足条件的最短距离

阿西噶阿西:"单调队列维护prefix的递增序列。"

为什么要单调递增?

如果prefix[i] >= prefix[j]i < j):
  说明nums[i+1..j]的和<=0
  
对于后面的任意k:
  prefix[k] - prefix[j] >= k 时,
  prefix[k] - prefix[i]也一定>= k(因为prefix[i]<=prefix[j])
  
而j-k的距离更短
  → i永远用不上了,可以弹出

7.3 代码实现

Java版本

public int shortestSubarray(int[] nums, int k) {
    int n = nums.length;
    
    // 前缀和
    long[] prefix = new long[n + 1];
    for (int i = 0; i < n; i++) {
        prefix[i + 1] = prefix[i] + nums[i];
    }
    
    int minLen = n + 1;
    Deque<Integer> deque = new ArrayDeque<>();
    
    for (int i = 0; i <= n; i++) {
        // 找满足条件的最短子数组
        while (!deque.isEmpty() && prefix[i] - prefix[deque.peekFirst()] >= k) {
            minLen = Math.min(minLen, i - deque.pollFirst());
        }
        
        // 维护单调递增
        while (!deque.isEmpty() && prefix[i] <= prefix[deque.peekLast()]) {
            deque.pollLast();
        }
        
        deque.offerLast(i);
    }
    
    return minLen == n + 1 ? -1 : minLen;
}

C++版本

int shortestSubarray(vector<int>& nums, int k) {
    int n = nums.size();
    
    vector<long> prefix(n + 1, 0);
    for (int i = 0; i < n; i++) {
        prefix[i + 1] = prefix[i] + nums[i];
    }
    
    int minLen = n + 1;
    deque<int> dq;
    
    for (int i = 0; i <= n; i++) {
        while (!dq.empty() && prefix[i] - prefix[dq.front()] >= k) {
            minLen = min(minLen, i - dq.front());
            dq.pop_front();
        }
        
        while (!dq.empty() && prefix[i] <= prefix[dq.back()]) {
            dq.pop_back();
        }
        
        dq.push_back(i);
    }
    
    return minLen == n + 1 ? -1 : minLen;
}

Python版本

def shortestSubarray(nums, k):
    from collections import deque
    
    n = len(nums)
    
    prefix = [0] * (n + 1)
    for i in range(n):
        prefix[i + 1] = prefix[i] + nums[i]
    
    minLen = n + 1
    dq = deque()
    
    for i in range(n + 1):
        while dq and prefix[i] - prefix[dq[0]] >= k:
            minLen = min(minLen, i - dq.popleft())
        
        while dq and prefix[i] <= prefix[dq[-1]]:
            dq.pop()
        
        dq.append(i)
    
    return -1 if minLen == n + 1 else minLen

八、单调队列总结

8.1 核心要点

南北绿豆总结:

  1. 数据结构:双端队列(Deque)
  2. 单调性:队首到队尾单调递减(求最大)或递增(求最小)
  3. 队首:窗口内的最大/最小值
  4. 队尾:维护单调性,弹出无用元素
  5. 时间复杂度:O(n)

8.2 单调队列 vs 单调栈

对比项单调栈单调队列
数据结构双端队列
操作只能栈顶pop队首队尾都能pop
适用场景下一个更大/更小滑动窗口最值
典型题目LeetCode 739、84LeetCode 239、862

8.3 识别技巧

阿西噶阿西

  • 看到滑动窗口+最大/最小值,想单调队列
  • 看到固定窗口的最值,想单调队列
  • 看到区间最值,可能用单调队列

8.4 通用模板

Java版本

public int[] slidingWindowMax(int[] nums, int k) {
    int n = nums.length;
    int[] result = new int[n - k + 1];
    
    Deque<Integer> deque = new ArrayDeque<>(); // 存索引
    
    for (int i = 0; i < n; i++) {
        // 移除队首过期元素
        if (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
            deque.pollFirst();
        }
        
        // 维护单调递减:移除队尾比当前小的
        while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) {
            deque.pollLast();
        }
        
        deque.offerLast(i);
        
        // 窗口形成,记录最大值
        if (i >= k - 1) {
            result[i - k + 1] = nums[deque.peekFirst()];
        }
    }
    
    return result;
}

C++版本

vector<int> slidingWindowMax(vector<int>& nums, int k) {
    int n = nums.size();
    vector<int> result;
    
    deque<int> dq;
    
    for (int i = 0; i < n; i++) {
        if (!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;
}

Python版本

def slidingWindowMax(nums, k):
    from collections import deque
    
    result = []
    dq = deque()
    
    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

参考资料

  • 《算法第四版》- Robert Sedgewick
  • 《算法竞赛进阶指南》- 李煜东
  • LeetCode题解 - 单调队列专题