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

前言

大家好,我是小彭。

在上一篇文章中,我们介绍了单调栈这种特殊的栈结构,单调栈是一种非常适合处理 “下一个更大元素问题” 的数据结构。今天,分享到单调栈的孪生兄弟 —— 单调队列(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. 单调队列解法

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

继续分析我们还发现:

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

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

形象化思考

Untitled 3.png

题解

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 【题解】

参考资料

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,064评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,606评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,011评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,550评论 1 269
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,465评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,919评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,428评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,075评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,208评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,185评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,191评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,914评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,482评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,585评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,825评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,194评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,703评论 2 339

推荐阅读更多精彩内容