【LeetCode刷题日记】239.滑动窗口最大值:单调队列解法(困难)

🔥个人主页:北极的代码(欢迎来访)

🎬作者简介:java后端学习者

❄️个人专栏:苍穹外卖日记SSM框架深入JavaWeb

命运的结局尽可永在,不屈的挑战却不可须臾或缺!

摘要:

这篇文章详细讲解了滑动窗口最大值问题的解决方案。

通过分析暴力解法和堆结构的局限性,提出了使用单调队列的优化方法。

文章深入剖析了单调队列的设计原理,包括pop和push操作规则,以及如何维护队列单调递减的特性。通过示例数组[1,3,-1,-3,5,3,6,7]和k=3的动画演示,直观展示了窗口滑动时队列的变化过程。

最后给出了完整的Java代码实现,包括处理边界条件和计算窗口个数的数学推导。这种单调队列的解法将时间复杂度优化到O(n),是处理滑动窗口最大值问题的高效方案。

题目背景:256.滑动窗口最大值

这是刷题的第一个难题,让我们一起看看吧

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

返回 滑动窗口中的最大值

示例 1:

复制代码
输入: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

示例 2:

复制代码
输入:nums = [1], k = 1
输出:[1]

提示:

  • 1 <= nums.length <= 105
  • -104 <= nums[i] <= 104
  • 1 <= k <= nums.length

题目分析:

这是使用单调队列的经典题目。

难点是如何求一个区间里的最大值呢? (这好像是废话),暴力一下不就得了。

暴力方法,遍历一遍的过程中每次从窗口中再找到最大的数值,这样很明显是O(n × k)的算法。

有的同学可能会想用一个大顶堆(优先级队列)来存放这个窗口里的k个数字,这样就可以知道最大的最大值是多少了, 但是问题是这个窗口是移动的,而大顶堆每次只能弹出最大值,我们无法移除其他数值,这样就造成大顶堆维护的不是滑动窗口里面的数值了。所以不能用大顶堆。

此时我们需要一个队列,这个队列呢,放进去窗口里的元素,然后随着窗口的移动,队列也一进一出,每次移动之后,队列告诉我们里面的最大值是什么。

这个队列应该长这个样子:

复制代码
class MyQueue {
public:
    void pop(int value) {
    }
    void push(int value) {
    }
    int front() {
        return que.front();
    }
};

每次窗口移动的时候,调用que.pop(滑动窗口中移除元素的数值),que.push(滑动窗口添加元素的数值),然后que.front()就返回我们要的最大值。

然后再分析一下,队列里的元素一定是要排序的,而且要最大值放在出队口,要不然怎么知道最大值呢。

但如果把窗口里的元素都放进队列里,窗口移动的时候,队列需要弹出元素。

那么问题来了,已经排序之后的队列 怎么能把窗口要移除的元素(这个元素可不一定是最大值)弹出呢。

大家此时应该陷入深思.....

其实队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的。

那么这个维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。

对于窗口里的元素{2, 3, 5, 1 ,4},单调队列里只维护{5, 4} 就够了,保持单调队列里单调递减,此时队列出口元素就是窗口里最大元素。

此时大家应该怀疑单调队列里维护着{5, 4} 怎么配合窗口进行滑动呢

设计单调队列的时候,pop,和push操作要保持如下规则:

  1. pop(value):如果窗口移除的元素value等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作
  2. push(value):如果push的元素value大于入口元素的数值,那么就将队列入口的元素弹出,直到push元素的数值小于等于队列入口元素的数值为止

保持如上规则,每次窗口移动的时候,只要问que.front()就可以返回当前窗口的最大值。

为了更直观的感受到单调队列的工作过程,以题目示例为例,输入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3,动画如下:

那么我们用什么数据结构来实现这个单调队列呢

使用deque最为合适,在文章栈与队列:来看看栈和队列不为人知的一面 (opens new window)中,我们就提到了常用的queue在没有指定容器的情况下,deque就是默认底层容器。


代码流程解析

自定义 MyQueue

poll(val)

只有当要移除的元素等于队头元素时才弹出(说明该最大值即将离开窗口)

核心问题 :滑动窗口在移动时,会移除窗口最左边的元素 。但队列里存的不一定是这个元素,因为也有可能这个元素在添加的时候就被淘汰了。

举例说明

假设 nums = [3,1,2,5]k = 2

第一步:第一个窗口 [3,1]

  • 添加 3:队列 [3]

  • 添加 1:1 比 3 小,直接加 → 队列 [3,1]

  • 窗口最大值 = 3

第二步:窗口右移,移除 3,加入 2

现在窗口是 [1,2]

关键

  • 要移除的是 nums[0] = 3

  • 但当前队列是 [3,1]

  • 队头 deque.peek() = 3

执行 poll(3)

java 复制代码
java

if (!deque.isEmpty() && 3 == 3) {  // 相等!
    deque.poll();  // 弹出队头的 3
}

add(val)

从尾部移除所有比新元素小的元素,保证单调递减

这里添加元素的过程,是核心逻辑

我们在添加的时候,要和队尾的元素进行比较,如果大于队尾元素,直接弹出队尾,我们这里的队列维护的是最大值和所有可能成为下一次移动的最大值的元素,因此如果新添加的元素已经大于存在的队友,直接弹出,然后继续进行比较新的队尾,知道队列为空或者遇到比新元素大的元素,然后把新元素加进队尾。

假设 nums = [3,1,2,5],看 add 的执行过程:

插入 3

  • 队列空 → 不进入 while

  • 直接添加 → [3]

插入 1

  • 1 > 3?❌ 不成立

  • 直接添加 → [3, 1]

插入 2

  • 队列 [3, 1]deque.getLast() = 1

  • 2 > 1?✅ 成立 → removeLast() → 移除 1,队列变成 [3]

  • 再次检查:2 > 3?❌ 不成立

  • 添加 2 → [3, 2]

为什么移除 1

因为 1 在 2 的前面,且比 2 小。当 2 存在时,窗口最大值永远不可能是 1(要么是 3,要么是 2),所以 1 是冗余的。

想象运动员排队:

  • 队头是当前冠军(最大值)

  • 新来的运动员如果比队尾强,队尾就被淘汰(因为他们永远赢不了新人)

  • 最后新运动员站到队尾

peek()

返回当前窗口最大值

这里没什么好说的,栈顶元素始终是最大值,


maxSlidingWindow 方法

先处理第一个窗口(前 k 个元素)

记录最大值滑动窗口:移除窗口最左边的元素 + 加入新元素 → 记录新最大值

res[num++] = myQueue.peek() 这里 num 初始值是 0,这是第一个窗口的最大值。

这里我们先确定第一个窗口,为下面移动的起始范围做准备,因为窗口是k,起始索引是0,所以第一个窗口是0到k-1,后面的起始位置就是k

下面进行移动操作(k到最后)

我们这里循环的起始位置是k,因为遍历的是:nums[k], nums[k+1], nums[k+2], ..., nums[n-1]

因为我们第一个位置的最大值已经找到,也就是说0到k-1这第一个窗口已经处理完了,后面我们通过循环来处理后面的窗口

然后我们进行移动的时候,左边会失去一个元素,右边要添加一个元素,但这个元素不是直接弹出的,也就是我们上面的poll方法。

  • 如果离开的元素正好是当前队列的最大值(在队头),就把它弹出

  • 如果离开的元素不是最大值(早就被淘汰了),就什么都不做

java 复制代码
// 调用时:把要移出的那个元素的值传进去
myQueue.poll(nums[i - k]);   // nums[i-k] 是一个整数,比如 3

// 在 MyQueue 内部
void poll(int val) {   // val 接收的就是这个整数,比如 3
    if (!deque.isEmpty() && val == deque.peek()) {  // 拿 3 和队头的值比较
        deque.poll();   // 相等才移除队头
    }
}
if (nums.length == 1) { return nums; }

作用

处理数组只有一个元素的特殊情况。

为什么需要

当数组长度为 1 时:

  • 滑动窗口长度 k 只能是 1(因为 k ≤ nums.length)

  • 结果数组长度 = 1 - 1 + 1 = 1

  • 最大值就是这唯一的元素

是否必须

不是必须的,但可以避免后续循环的麻烦:

java 复制代码
java

// 如果不加这个判断,下面代码也能正常工作:
int len = nums.length - k + 1;  // len = 1
int[] res = new int[len];       // res 长度 = 1

// 第一个 for 循环:i < k → i < 1 → 添加 nums[0]
// res[num++] = myQueue.peek() → res[0] = nums[0]
// 第二个 for 循环:i = k → i = 1, i < nums.length? 1 < 1? false → 不执行
// 返回 [nums[0]] ✅

所以这行可以省略,但加上可以让代码意图更清晰。

int len = nums.length - k + 1;

作用

计算结果数组的长度,也就是滑动窗口的个数。

公式推导

  • 数组长度:n = nums.length

  • 窗口大小:k

  • 窗口个数 = 能滑动的次数 + 1

具体计算

text

窗口起始位置可以从 0 到 n-k

所以窗口个数 = (n-k) - 0 + 1 = n - k + 1

举例验证

数组长度 n 窗口大小 k 窗口个数 计算
8 3 6 8-3+1=6
5 1 5 5-1+1=5
5 5 1 5-5+1=1
1 1 1 1-1+1=1

整体流程概览

text

复制代码
初始化 → 处理第一个窗口 → 滑动窗口 → 返回结果

关键对应关系

当前窗口 要移除的元素 要加入的元素
[0,1,2] nums[0] nums[3]
[1,2,3] nums[1] nums[4]
[2,3,4] nums[2] nums[5]
... ... ...

题目答案:

java 复制代码
//解法一
//自定义数组
class MyQueue {
    Deque<Integer> deque = new LinkedList<>();
    //弹出元素时,比较当前要弹出的数值是否等于队列出口的数值,如果相等则弹出
    //同时判断队列当前是否为空
    void poll(int val) {
        if (!deque.isEmpty() && val == deque.peek()) {
            deque.poll();
        }
    }
    //添加元素时,如果要添加的元素大于入口处的元素,就将入口元素弹出
    //保证队列元素单调递减
    //比如此时队列元素3,1,2将要入队,比1大,所以1弹出,此时队列:3,2
    void add(int val) {
        while (!deque.isEmpty() && val > deque.getLast()) {
            deque.removeLast();
        }
        deque.add(val);
    }
    //队列队顶元素始终为最大值
    int peek() {
        return deque.peek();
    }
}

class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        if (nums.length == 1) {
            return nums;
        }
        int len = nums.length - k + 1;
        //存放结果元素的数组
        int[] res = new int[len];
        int num = 0;
        //自定义队列
        MyQueue myQueue = new MyQueue();
        //先将前k的元素放入队列
        for (int i = 0; i < k; i++) {
            myQueue.add(nums[i]);
        }
        res[num++] = myQueue.peek();
        for (int i = k; i < nums.length; i++) {
            //滑动窗口移除最前面的元素,移除是判断该元素是否放入队列
            myQueue.poll(nums[i - k]);
            //滑动窗口加入最后面的元素
            myQueue.add(nums[i]);
            //记录对应的最大值
            res[num++] = myQueue.peek();
        }
        return res;
    }
}

补充:我们这里是自定义队列来实现的,还有另一种直接用双向队列,其实逻辑都是一样的,实现起来还更简单:

对比项 解法一 解法二
队列类型 LinkedList(实现 Deque ArrayDeque(实现 Deque
存储内容 数值 下标
是否封装 是(自定义 MyQueue 类) 否(直接操作)
移除过期 比较数值 val == peek() 比较下标 peek() < i-k+1
底层实现 链表 循环数组(性能更好)
java 复制代码
//利用双端队列手动实现单调队列
/**
 * 用一个单调队列来存储对应的下标,每当窗口滑动的时候,直接取队列的头部指针对应的值放入结果集即可
 * 单调递减队列类似 (head -->) 3 --> 2 --> 1 --> 0 (--> tail) (左边为头结点,元素存的是下标)
 */
class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        ArrayDeque<Integer> deque = new ArrayDeque<>();
        int n = nums.length;
        int[] res = new int[n - k + 1];
        int idx = 0;
        for(int i = 0; i < n; i++) {
            // 根据题意,i为nums下标,是要在[i - k + 1, i] 中选到最大值,只需要保证两点
            // 1.队列头结点需要在[i - k + 1, i]范围内,不符合则要弹出
            while(!deque.isEmpty() && deque.peek() < i - k + 1){
                deque.poll();
            }
            // 2.维护单调递减队列:新元素若大于队尾元素,则弹出队尾元素,直到满足单调性
            while(!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) {
                deque.pollLast();
            }

            deque.offer(i);

            // 因为单调,当i增长到符合第一个k范围的时候,每滑动一步都将队列头节点放入结果就行了
            if(i >= k - 1){
                res[idx++] = nums[deque.peek()];
            }
        }
        return res;
    }
}

结语:如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!

相关推荐
Irissgwe2 小时前
优选算法精讲(专题一)
数据结构·算法
wuxinyan1232 小时前
Java面试题53:一文深入了解RAG(检索增强生成)核心概念
java·人工智能·机器学习·面试·rag
睡觉就不困鸭2 小时前
第十五天 反转字符串
数据结构·算法
果汁华2 小时前
Claude Agent SDK Python:构建自主 AI 代理的官方引擎
开发语言·人工智能·python
常利兵2 小时前
安卓启动页Logo适配秘籍:告别“奇形怪状”的展示
android·java·开发语言
生物信息与育种2 小时前
JIPB | 一个表观多组学整合分析与可视化工具OmicsCanvas
运维·人工智能·算法·自动化·transformer
txz20352 小时前
2,使用功能包组织C++节点
开发语言·c++·ros
程序员阿明2 小时前
spring boot3集成企业微信推送消息
java·spring boot·企业微信
SamDeepThinking2 小时前
用工厂模式和模板方法统一封装所有第三方的Access Token
java·后端·架构