3

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

 1 year ago
source link: https://www.cnblogs.com/pengxurui/p/16857570.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

本文已收录到  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),出队顺序由入队顺序决定 静态单调序列
优先队列 出队顺序与入队顺序无关,而由优先级顺序决定 整个堆不是单调的,但堆顶是动态单调的

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

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

更多同类型题目:


参考资料

02ffc209b9de45f3a60ae83398cc1a3e~tplv-k3u1fbpfcp-zoom-1.image

Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK