单调队列 滑动窗口最大值

85 阅读11分钟

灵神笔记

239. 滑动窗口最大值

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

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

image.png

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

滑动窗口最大值是说给你一个数组,让你求出每个长为 k 的连续子数组的最大值 举个例子 比如数组元素为 [2 1 4 2 3 2],求出每个长为 3 的连续子数组的最大值,那么 [2 1 4] 最大值为 4,[1 4 2] 最大值为 4,[4 2 3] 最大值还是 4,[2 3 2] 最大值为 3

如果暴力去算的话,可以枚举每个长为 k 的连续子数组,对每个子数组遍历,找到它的最大值,但如果 k 是 n/2, 也就是数组长度的一半,那么就会有大约 n/2 个,长为 n/2 的连续子数组,总共要循环大约 (n^2)/4 次,所以暴力做法的时间复杂度就是 O(n^2).

那么有没有更快的做法呢,想象你在一架飞机上,透过窗户向下看,你看到了一座座连绵起伏的山,数字就是山的高度,那么随着飞机从左往右飞,你视野内的最高山峰是会发生变化的。

image.png

比如一开始你在视野内只看到了 2 1 4,最高为 4,那么随着飞机向右移动,2 和 1 有没有可能成为最高的山呢? 这是不可能的,因为它们的高度不仅比 4 小 而且也更早地离开了你的视野,所以当你看到 4 之后 2 和 1 永远不可能作为你视野中的最高的山了 好继续向右走,现在遇到了 2,2 其实有可能成为后续的最大值, 如果右边的数都比 2 小的话,但现在还不知道右边有哪些数,所以就把 2 记录下来,现在最大值还是 4,然后遍历到 3。 由于 2 比 3 小 和前面一样,2 永远不可能成为最大值了,那么去掉 2 记录 3,那现在这三个数的最大值还是 4,

最后遍历到 2,注意现在只能看到 [2 3 2] 了,在记录 2 之前,由于 4 已经不在视野中了,可以先把 4 去掉,再记录 2 那现在的最大值就变成 3 了,从这个过程可以发现,由于我们每次遇到一个数 都把前面比它小的数去掉了,当然如果有相同数字也可以去掉,我们保留最右边那个就行 所以我们记录的这些数从左到右是严格递减的,那么最大值就自然是第一个数了.

我们需要一个什么样的数据结构来维护这些数呢 想一想我们有哪些操作 有添加 有删除,并且都是发生在最左边和最右边的 所以可以用双端队列实现,此外由于元素值从队首到队尾是单调递减的,这样的双端队列也叫做单调队列 好 来看看代码怎么写

先来说一说滑动窗口的框架,我们初始化答案为空 然后遍历数组,这里的 x 就是 nums[i], 滑动窗口这个框架分为三步 第一是入 也就是元素进入窗口, 第二是出 也就是元素离开窗口, 第三就是记录答案

看样例 我们实际上是遍历到 -1,才开始记录答案的,-1 的下标是 2,也就是 k-1.

所以当 i 大于等于 k-1 的时候,我们才去记录答案,然后来实现单调队列的逻辑,首先我们初始化一个双端队列,然后这里的入 跟单调栈很像,当队列不为空,并且队列的最后一个元素是小于等于 x 的,那我就把队尾弹出去,然后把下标 i 插入队尾,那么出 大家可以想一想

如果这个窗口要包含队首的话,那么这个窗口的大小至少是多少呀 那就是 i 减去队首(下标) +1

如果说它是大于 k 的,那就表示队首其实已经移出窗口了,那么就把它 pop 掉。
当然你也可以这样写,这两种写法是等价的,注意我这里写的是 if
因为对于每个下标 i 我都做了一个这样的检查,当队首刚刚离开窗口的时候,它就触发这里的 if 了
所以队首出队之后,队列中的剩余元素必然都在窗口里面, 所以只需要写一个 if,不需要写 while
最后我们记录答案,那由于单调队列从队首到队尾是单调递减的,所以队首就是最大值
好 我们来分析一下时间复杂度,这里的分析类似单调栈,每个下标入队出队至多一次
所以这个二重循环的循环次数是 O(n) 的,所以时间复杂度是 O(n)
那么空间复杂度 在不考虑返回值的前提下,由于窗口大小是 k,所以双端队列中有 O(k) 元素,所以空间复杂度是 O(k)
不过我们还可以分析的再细一点,由于双端队列中是没有重复元素的
因为我在相等的时候,把队尾弹出了,那么设 U 为 nums 中的不同元素个数,所以双端队列的大小也不会超过 U 比如这道题,数组中至多有 20001 个不同的数字,但是 k 呢最大可以是 n,也就是 10^5
所以空间复杂度严格来说呢,你可以写成 O(min(k,U))
最后我用十六个字总结一下这个算法 及时去掉无用数据,保证双端队列有序,这里分两点
第一 是像单调栈那样 在把元素加入队尾之前,根据大小关系弹出队尾元素
第二 当队首元素离开窗口后,也需要及时地把它弹出队列

func maxSlidingWindow(nums []int, k int) []int {
    ans := make([]int, 0, len(nums)-k+1) // 预先分配好空间
    q := []int{}
    for i, x := range nums {
        // 1. 入
        for len(q) > 0 && nums[q[len(q)-1]] <= x {
            q = q[:len(q)-1] // 维护 q 的单调性
        }
        q = append(q, i) // 入队
        // 2. 出
        if i-q[0] >= k { // 队首已经离开窗口了
            q = q[1:] // Go 的切片是 O(1) 的
        }
        // 3. 记录答案
        if i >= k-1 {
            // 由于队首到队尾单调递减,所以窗口最大值就是队首
            ans = append(ans, nums[q[0]])
        }
    }
    return ans
}

复杂度分析
时间复杂度:O(n),其中 n 为 nums 的长度。由于每个下标至多入队出队各一次,所以二重循环的循环次数是 O(n) 的。
空间复杂度:O(min(k,U)),其中 U 是 nums 中的不同元素个数(本题至多为 20001)。双端队列至多有 k 个元素,同时又没有重复元素,所以也至多有 U 个元素,所以空间复杂度为 O(min(k,U))。返回值的空间不计入。

1438. 绝对差不超过限制的最长连续子数组

给你一个整数数组 nums ,和一个表示限制的整数 limit,请你返回最长连续子数组的长度,该子数组中的任意两个元素之间的绝对差必须小于或者等于 limit 

如果不存在满足条件的子数组,则返回 0 。

image.png

提示:

  • 1 <= nums.length <= 10^5
  • 1 <= nums[i] <= 10^9
  • 0 <= limit <= 10^9

在实际代码中,我们使用一个单调递增的队列 queMin 维护最小值,一个单调递减的队列 queMax 维护最大值。这样我们只需要计算两个队列的队首的差值,即可知道当前窗口是否满足条件。

func longestSubarray(nums []int, limit int) (ans int) {
    var minQ, maxQ []int
    left := 0
    for right, v := range nums {
        for len(minQ) > 0 && minQ[len(minQ)-1] > v {
            minQ = minQ[:len(minQ)-1]
        }
        minQ = append(minQ, v)
        for len(maxQ) > 0 && maxQ[len(maxQ)-1] < v {
            maxQ = maxQ[:len(maxQ)-1]
        }
        maxQ = append(maxQ, v)
        for len(minQ) > 0 && len(maxQ) > 0 && maxQ[0]-minQ[0] > limit {
            if nums[left] == minQ[0] {
                minQ = minQ[1:]
            }
            if nums[left] == maxQ[0] {
                maxQ = maxQ[1:]
            }
            left++
        }
        ans = max(ans, right-left+1)
    }
    return
}

func max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

862. 和至少为 K 的最短子数组

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

子数组 是数组中 连续 的一部分。

示例 1:

**输入:**nums = [1], k = 1 **输出:**1

示例 2:

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

示例 3:

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

提示:

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

求出 nums 的前缀和 s 后,我们可以写一个暴力算法,枚举所有满足 i>j 且 s[i]−s[j]≥k 的子数组 [j,i),取其中最小的 i−j 作为答案。

但这个暴力算法是 O(n^2) 的,如何优化呢?

我们可以遍历 s,同时用某个合适的数据结构来维护遍历过的 s[i],并及时移除无用的 s[i]。

优化一:

image.png

遍历到s[i]时,考虑左边的某个s[j],如果s[i]-s[j]>=k,那么无论s[i]右边的数字是大是小,都不可能把j当作子数组的左端点,得到一个比i-j更短的子数组。因此s[j]没有任何作用,弹出s[j]

优化二:

image.png

如果s[i] < s[j],假如后续有数字x能和s[j]组成满足要求的子数组,即x-s[j] >= k,那么必然也有x-s[i] >= k,由于从s[i]到x的这段子数组更短,因此s[j]没有任何作用了,弹出s[j]

做完这两个优化后,再把 s[i] 加到这个数据结构中。

由于优化二保证了数据结构中的 s[i] 会形成一个递增的序列,因此优化一移除的是序列最左侧的若干元素,优化二移除的是序列最右侧的若干元素。我们需要一个数据结构,它支持移除最左端的元素和最右端的元素,以及在最右端添加元素,故选用双端队列。

注:由于双端队列的元素始终保持单调递增,因此这种数据结构也叫做单调队列。

func shortestSubarray(nums []int, k int) int {
    n := len(nums)
    s := make([]int, n+1)
    for i, x := range nums {
        s[i+1] = s[i] + x // 计算前缀和
    }
    ans := n + 1
    q := []int{}
    for i, curS := range s {
        for len(q) > 0 && curS-s[q[0]] >= k {
            ans = min(ans, i-q[0])
            q = q[1:] // 优化一
        }
        for len(q) > 0 && s[q[len(q)-1]] >= curS {
            q = q[:len(q)-1] // 优化二
        }
        q = append(q, i)
    }
    if ans > n {
        return -1
    }
    return ans
}

func min(a, b int) int { if a > b { return b }; return a }

另一种写法是,在计算前缀和的同时去计算答案,这需要在双端队列中额外存储前缀和的值。

由于前缀和的初始值 0 在遍历 nums 之前就算出来了,因此需要在遍历之前,往双端队列中插入前缀和 0 及其下标 −1。

注 1:为什么是 −1?因为上面遍历的是 s,下面遍历的是 nums,这两者的下标偏移了一位。 注 2:该写法在 nums 是一个流的时候也适用。

func shortestSubarray(nums []int, k int) int {
    type pair struct{ s, i int }
    q := []pair{{0, -1}}
    ans, curS := math.MaxInt32, 0
    for i, x := range nums {
        curS += x // 计算前缀和
        for len(q) > 0 && curS-q[0].s >= k {
            ans = min(ans, i-q[0].i)
            q = q[1:] // 优化一
        }
        for len(q) > 0 && q[len(q)-1].s >= curS {
            q = q[:len(q)-1] // 优化二
        }
        q = append(q, pair{curS, i})
    }
    if ans == math.MaxInt32 {
        return -1
    }
    return ans
}

func min(a, b int) int { if a > b { return b }; return a }

复杂度分析
时间复杂度:O(n)。虽然我们写了个二重循环,但站在 nums[i] 的视角看,它在二重循环中最多入队出队各一次,因此整个二重循环的时间复杂度为 O(n)。
空间复杂度:O(n)。最坏情况下单调队列中会存储 O(n) 个元素。