使用单调队列解决 “滑动窗口最大值” 问题

使用,单调,队列,解决,滑动,窗口,最大值,问题 · 浏览次数 : 355

小编点评

## Summary of the Solution Class This solution provides two efficient approaches for finding the maximum element within a window of size `k` in an array `nums` while handling elements that fall outside this window. **1. Priority Queue Solution:** * This approach maintains a priority queue (`heap`) to store the elements of the window. * The solution offers the following operations: * `offer(index)`: Adds the current element to the priority queue. * `poll()`: Removes the element with the smallest value among elements in the priority queue. * The solution continuously adds elements to the priority queue until it contains elements within the window. * It then removes elements from the priority queue until it contains elements within the window. * This approach achieves the desired behavior by maintaining a queue of elements sorted in ascending order based on their values. * However, it has a time complexity of O(n) due to the repeated element comparisons and the need to maintain the priority queue. **2. Monotonic Stack Solution:** * This solution maintains a stack to store elements of the window. * The solution offers the following operations: * `push(element)`: Adds the element to the stack. * `top()`: Returns the element at the top of the stack. * The solution pushes elements into the stack until the stack contains elements within the window. * It then pops elements from the stack until the stack contains elements within the window. * This approach also achieves the desired behavior by maintaining a stack of elements sorted in ascending order based on their values. * However, it has a time complexity of O(n) due to the use of a stack and the need to maintain the sorting order. **Comparison between Priority Queue and Monotonic Stack:** | Feature | Priority Queue | Monotonic Stack | |---|---|---| | Data Structure | FIFO | LIFO | | Order of Element Access | Ascending | Descending | | Time Complexity | O(n) | O(n) | | Handling Out-of-Window Elements | By removing elements from the priority queue | By popping elements from the stack | | Maintaining Window Size | By maintaining the priority queue | By maintaining the stack | **Conclusion:** The solution effectively utilizes both priority queues and monotonic stacks to efficiently find the maximum element within the window while handling elements outside this window. However, the priority queue approach may have a slightly higher time complexity compared to the monotonic stack approach.

正文

本文已收录到  GitHub · AndroidFamily,有 Android 进阶知识体系,欢迎 Star。技术和职场问题,请关注公众号 [彭旭锐] 私信我提问。

前言

大家好,我是小彭。

在上一篇文章中,我们介绍了单调栈这种特殊的栈结构,单调栈是一种非常适合处理 “下一个更大元素问题” 的数据结构。今天,分享到单调栈的孪生兄弟 —— 单调队列(Monotonic Queue)。类似地,单调队列也是在队列的基础上增加了单调的性质(单调递增或单调递减)。那么单调队列是用来解决什么问题的呢?


学习路线图:


1. 单调队列的典型问题

单调队列是一种用来高效地解决 “滑动窗口最大值” 问题的数据结构。

举个例子,给定一个整数数组,要求输出数组中大小为 K 的窗口中的最大值,这就是窗口最大值问题。而如果窗口从最左侧逐渐滑动到数组的最右端,要求输出每次移动后的窗口最大值,这就是滑动窗口问题。这个问题同时也是 LeetCode 上的一道典型例题:LeetCode · 239. 滑动窗口最大值

LeetCode 例题

这个问题的暴力解法很容易想到:就是每次移动后遍历整个滑动窗口找到最大值,单次遍历的时间复杂度是 $O(k)$,整体的时间复杂度是 $O(n·k)$,空间复杂度是 $O(1)$。当然,暴力解法里的重复比较运算也是很明显的,我们每次仅仅往窗口里增加一个元素,都需要与窗口中所有元素对比找到最大值。

那么,有没有更高效的算法呢?

滑动窗口最大值问题

或许,我们可以使用一个变量来记录上一个窗口中的最大值,每增加一个新元素,只需要与这个 “最大值” 比较即可。

然而,窗口大小是固定的,每加入一个新元素后,也要剔除一个元素。如果剔除的元素正好是变量记录的最大值,那说明这个值已经失效。我们还是需要花费 $O(k)$ 时间重新找出最大值。那还有更快的方法寻找最大值吗?


2. 解题思路

我们先用 “空间换时间” 的通用思路梳理一下:

在暴力解法中,我们每移动一次窗口都需要遍历整个窗口中的所有元素找出 “滑动窗口的最大值”。现在我们不这么做,我们把滑动窗口中的所有元素缓存到某种数据容器中,每次窗口滑动后也向容器增加一个新的元素,而容器的最大值就自然是滑动窗口的最大值。

现在,我们的问题已经发生转变,问题变成了:“如何寻找数据容器中的最大值”。 另外,根据题目条件限制,这个容器是带有约束的:因为窗口大小是固定的,所以每加入一个新元素后,必然也要剔除一个元素。 我们的数据容器也要满足这个约束。 现在,我们把注意力集中在这个容器上,思考一下用什么数据结构、用什么算法可以更高效地解决问题。由于这个容器是我们额外增加的,所以我们有足够的操作空间。

先说结论:

  • 方法 1 - 暴力: 遍历整个数据容器中所有元素,单次操作的时间复杂度是 $O(k)$,整体时间复杂度是 $O(n·k)$;
  • 方法 2 - 二叉堆: 不需要遍历整个数据容器,可以用大顶堆维护容器的最大值,单次操作的时间复杂度是 $O(lgk)$,整体时间复杂度是 $O(n·lgk)$;
  • 方法 3 - 双端队列: 我们发现二叉堆中很多中间元素是冗余的,拉低了效率。我们可以在每处理一个元素时,可以先观察容器中刚刚加入的元素,如果刚刚加入的元素小于当前元素,则直接将其丢弃(后进先出逻辑)。最先加入容器的元素,如果超出了滑动窗口的范围,也直接将其丢弃(先进先出逻辑)。所以这个容器数据结构要用双端队列实现。因为每个元素最多只会入队和出队一次,所以整体的计算规模还是与数据规模成正比的,整体时间复杂度是 $O(n)$。

下面,我们先从优先队列说起。


3. 优先队列解法

寻找最值的问题第一反应要想到二叉堆。

我们可以维护一个大顶堆,初始时先把第一个滑动窗口中的前 $k - 1$ 个元素放入大顶堆。滑动窗口每移动一步,就把一个新的元素放入大顶堆。大顶堆会自动进行堆排序,堆顶的元素就是整个堆中 $k$ 个元素的最值。然而,这个最大值可能是失效的(不在滑动窗口的范围内),我们要怎么处理这个约束呢?

我们可以用一个小技巧:将元素的下标放入队列中,用 nums[index] 比较元素大小,用 index 判断元素有效性。 此时,每次取堆顶元素时,如果发现该元素的下标超出了窗口范围,就直接丢弃。

题解

class Solution {
    fun maxSlidingWindow(nums: IntArray, k: Int): IntArray {
        // 结果数组
        val result = IntArray(nums.size - k + 1) {-1}
        // 大顶堆
        val heap = PriorityQueue<Int> { first, second ->
            nums[second] - nums[first]
        }
        for (index in nums.indices) {
            if (index < k - 1) {
                heap.offer(index)
                continue
            }
            heap.offer(index)
            // while:丢弃堆顶超出滑动窗口范围的失效元素
            while (!heap.isEmpty() && heap.peek() < index - k + 1) {
                // 丢弃
                heap.poll()
            }
            // 堆顶元素就是最大值
            result[index - k + 1] = nums[heap.peek()]
        }
        return result
    }
}

我们来分析优先队列解法的复杂度:

  • 时间复杂度: 最坏情况下(递增序列),所有元素都被添加到优先队列里,优先队列的单次操作时间复杂度是 $O(lgn)$,所以整体时间复杂度是 $O(n·lgn)$;
  • 空间复杂度: 使用了额外的优先级队列,所以整体的时间复杂度是 $O(n)$。

优先队列解法的时间复杂度从 $O(n·k)$ 变成 $O(n·lgn)$,这个效果很难让人满意,有更好的办法吗?

我们继续分析发现,由于滑动窗口是从左往右移动的,所以当一个元素 $nums[i]$ 的后面出现一个更大的元素 $nums[j]$,那么 $nums[i]$ 就永远不可能是滑动窗口的最大值了,我们可以永久地将它剔除掉。然而,在优先队列中,失去价值的 $nums[i]$ 会一直存储在队列中,从而拉低了优先队列的效率。


4. 单调队列解法

我们可以维护一个数据容器,第一个元素先放到容器中,后续每处理一个元素,先观察容器中刚刚加入的元素,如果刚刚加入的元素小于当前元素,则直接将其丢弃(因为新增加的元素排在后面,会更晚地离开滑动窗口,所以中间的小元素永远没有资格了),避免拉低效率。

继续分析我们还发现:

  • 这个数据容器中后进入的元素需要先弹出与当前元素对比,符合 “后进先出” 逻辑,所以这个数据结构要用栈;
  • 这个数据容器中先进入的元素需要先弹出丢弃(离开滑动窗口),符合 “先进先出” 逻辑,所以这个数据结构要用队列;

因此,这个数据结构同时具备栈和队列的特点,是需要同时在两端操作的双端队列。这个问题也可以形象化地思考:把数字想象为有 “重量” 的的杠铃片,滑动窗口每移动一次后,新增加的大杠铃片会把中间小的杠铃片压扁,后续不再考虑它们。

形象化思考

题解

class Solution {
    fun maxSlidingWindow(nums: IntArray, k: Int): IntArray {
        // 结果数组
        val result = IntArray(nums.size - k + 1) { -1 }
        // 单调队列(基于双端队列)
        val queue = LinkedList<Int>()
        for (index in nums.indices) {
            // while:队尾元素比当前元素小,说明队尾元素不再可能是最大值,后续不再考虑它
            while (!queue.isEmpty() && nums[queue.peekLast()] <= nums[index]) {
                // 抛弃队尾元素
                queue.pollLast()
            }
            queue.offerLast(index)
            if (index < k - 1) {
                continue
            }
            // if:移除队头超出滑动窗口范围的元素
            if (!queue.isEmpty() && queue.peekFirst() < index - k + 1) {
                queue.pollFirst()
            }
            // 从队头取目标元素
            result[index - k + 1] = nums[queue.peekFirst()]
        }
        return result
    }
}

单调队列与单调栈的思路是非常类似的,单调栈在每次遍历中,会将栈顶 “被压扁的小杠铃片” 弹出栈,而单调队列在每次遍历中,会将队尾中 “被压扁的小杠铃片” 弹出队。 单调栈在栈顶过滤无效元素,在栈顶获取目标元素,单调队列在队尾过滤无效元素,在队头获取目标元素。

理解了单调队列的解题模板后,我们来分析它的复杂度:

  • 时间复杂度: 虽然代码中有嵌套循环,但它的时间复杂度并不是 $O(n^2)$,而是 $O(n)$。因为每个元素最多只会入栈和出栈一次,所以整体的计算规模还是与数据规模成正比的,整体时间复杂度是 $O(n)$;
  • 空间复杂度: 最坏情况下(递增序列),所有元素被添加到队列中,所以空间复杂度是 $O(n)$。

5. 单调栈、单调队列、优先队列对比

5.1 单调栈与单调队列的选择

单调队列和单调栈在很大程度上是类似的,它们均是在原有数据结构的基础上增加的单调的性质。那么,什么时候使用单调栈,什么时候使用单调队列呢? 主要看你的算法中元素被排除的顺序,如果先进入集合的元素先排除,那么使用栈(LIFO);如果先进入集合的元素后排除,那么使用队列(FIFO)。 在例题中,甚至出现了同时结合栈和队列的情况。

上一篇文章里我们讨论过单调栈,单调栈是一种非常适合解决 ”下一个更大元素“ 的数据结构。在文章最后我也指出,单调栈的关键是 “单调性”,而不是 “栈”。我们学习单调栈和单端队列,应该当作学习单调性的思想在栈或者队列上的应用。

我们已经不是第一次讨论 “单调性” 了,老读者应该有印象,在讨论二分查找时,我们曾经指出 “单调性是二分查找的必要条件”。举个例子,对于一个单调递增序列,当中位数小于目标数时,那我们可以确定:左半区间一定不是解,右半区间可能有解,问题规模直接缩小一半。最终整个二分查找算法的时间复杂度从暴力查找 $O(n)$,降低到 $O(lgn)$。反之,如果数据并不是单调的,那么跟中位数比较就没有意义。

5.2 优先队列与单调队列对比

优先队列也叫小顶堆或大顶堆,每从堆顶取一个数据,底层的二叉堆会自动进行堆排序,保持堆顶元素是整个堆的最值。所以,虽然整个堆不是单调的,但堆顶是动态单调的。优先队列虽然也叫队列,但它并不能维护元素的位置关系,出队顺序和入队顺序无关。

数据结构 特点 单调性 / 有序性
单调栈 后进先出(LIFO),出队顺序由入栈顺序决定 静态
单调队列 先进先出(FIFO),出队顺序由入队顺序决定 静态单调序列
优先队列 出队顺序与入队顺序无关,而由优先级顺序决定 整个堆不是单调的,但堆顶是动态单调的

6. 总结

到这里,单调队列和单调栈的内容都讲完了。和单调栈一样,除了典型例题之外,大部分题目会将 “滑动窗口最大值素” 的语义隐藏在题目细节中,需要找出题目的抽象模型或转换思路才能找到,这是难的地方。

还是那句话,学习单调队列和单调栈,应该当作学习单调性的思想在这两种数据结构上的应用。你觉得呢?

更多同类型题目:

单调队列 难度 题解
239. 滑动窗口最大值 Hard 【题解】
918. 环形子数组的最大和 Medium 【题解】
面试题59 - II. 队列的最大值 Medium 【题解】

参考资料

与使用单调队列解决 “滑动窗口最大值” 问题相似的内容:

使用单调队列解决 “滑动窗口最大值” 问题

本文已收录到 GitHub · AndroidFamily,有 Android 进阶知识体系,欢迎 Star。技术和职场问题,请关注公众号 [彭旭锐] 私信我提问。 前言 大家好,我是小彭。 在上一篇文章中,我们介绍了单调栈这种特殊的栈结构,单调栈是一种非常适合处理 “下一个更大元素问题” 的数据结

文心一言 VS 讯飞星火 VS chatgpt (112)-- 算法导论10.2 3题

三、用go语言,用一个单链表L实现一个队列。要求操作 ENQUEUE 和 DEQUEUE 的运行时间仍为O(1)。 文心一言: 在 Go 语言中,使用单链表实现队列的操作,包括入队(ENQUEUE)和出队(DEQUEUE),并保持操作的时间复杂度为 O(1),需要利用两个指针,一个指向队头,另一个指

数组模拟环形队列的思路及代码

JAVA实现数组模拟环形队列的思路及代码 前言 在对Java实现数组模拟队列零了解的情况下,建议先去阅读《JAVA实现数组模拟单向队列的思路及代码》一文,可以辅助理解本文核心思想。 一、环形数组队列 实现:让数组达到复用的效果,即:当我们从数组队列中取出了数据,那取出数据后后这个空间可以再次使用。

微服务项目搭建之技术选型

1、什么是微服务 Java微服务是一种架构风格,通过将单个Spring Boot应用程序拆分为一组小型、独立的Spring Boot服务来构建分布式系统。每个微服务都运行在自己的进程中,并使用轻量级通信机制(如HTTP或消息队列)来进行相互之间的通信。微服务的设计目标是提高系统的灵活性、可伸缩性和可

使用单调栈解决 “下一个更大元素” 问题

本文已收录到 GitHub · AndroidFamily,有 Android 进阶知识体系,欢迎 Star。技术和职场问题,请关注公众号 [彭旭锐] 私信我提问。 前言 大家好,我是小彭。 今天分享到一种栈的衍生数据结构 —— 单调栈(Monotonic Stack)。栈(Stack)是一种满足后

揭秘报表新玩法!标配插件不再单调,如何用柱形图插件让你的报表瞬间高大上!

> 摘要:本文由葡萄城技术团队于博客园原创并首发。葡萄城为开发者提供专业的开发工具、解决方案和服务,赋能开发者。 # 前言 图表作为一款用于可视化数据的工具,可以帮助我们更好的分析和理解数据,并发现数据之间的关系和趋势。下面以柱形图为例介绍如何使用JavaScript在报表中引入图表。 本文使用软件

如何使用单纯的`WebAssembly`

一般来说在.net core使用WebAssembly 都是Blazor ,但是Blazor渲染界面,.net core也提供单纯的WebAssembly这篇博客我将讲解如何使用单纯的WebAssembly 安装WebAssembly模板 dotnet new install Microsoft.N

一起单测引起的项目加载失败惨案

最近在开发一个功能模块时,在功能自测阶段,通过使用单测测试功能的完整性,在测试单测联通性使用到静态方法测试时,发现单测报错,通过查阅解决方案发现需要对Javaassist包进行排包或者升版本处理。通过排包解决掉单测报错,在部署项目时发现频繁报bean注入失败问题,最终定位发现是因为对Javaassist包排包引起的bean加载失败。故而对Javaassist包相关知识进行学习整理文章如下。

路由react-router-dom的使用

react-router-dom路由简介 现代的前端页面大多是SPA(单页面应用程序), 也就是只有一个HTML页面的程序,这样用户体验好,服务器压力小,所以更受欢迎。路由是使用单页面来管理原来多页面的功能。 路由的功能:从一个页面,跳转到另一个页面。 在React中,路由是一套映射规则,是URL路

[转帖]【Redis】Redis中使用Lua脚本

Lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放,其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。 Lua具体语法参考:https://www.runoob.com/lua/lua-tutorial.html 脚本的原子性 Redis使用单个Lua解释器去运