LeetCode11 - 滑动窗口最大值
📝 题目描述
题目链接:滑动窗口最大值
给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回 滑动窗口中的最大值 。
示例:
1 |
|
提示:
1 <= nums.length <= 10^5-10^4 <= nums[i] <= 10^41 <= k <= nums.length
💡 解题思路
方法一:优先队列
对于保持一段数据的最大值,很容易想到的一个数据结构就是“堆”,也就是C++里面的优先队列,默认的大顶堆可以帮助我们实时维护一系列元素中的最大值。
具体而言,我们的流程可以按下面的描述进行:
- 首先,在保证堆不空的情况下,检查堆顶元素(也就是最大值)是否过期(也就是是否还在长度为
k的窗口内),是则不断弹出堆顶元素,直到不过期; - 将当前元素加入堆中;
- 若窗口填充满,则查看堆顶元素,记录到答案数组中。
上面的流程有一些细节要注意。
首先堆中存储的数据结构,因为堆的维护需要进行元素的大小比较,而判断过期又要需要用到元素的索引,所以为了方便,我们可以存储一个 pair 到堆中,具体是 (nums[i], i) ,这样就可以满足我们的需求。
方法二:单调队列
我们可以顺着方法一的思路继续进行优化。
由于我们需要求出的是滑动窗口的最大值,如果当前的滑动窗口中有两个下标 i 和 j,其中 i 在 j 的左侧(i < j),并且 i 对应的元素不大于 j 对应的元素(nums[i] ≤ nums[j]),那么会发生什么呢?
当滑动窗口向右移动时,只要 i 还在窗口中,那么 j 一定也还在窗口中,这是 i 在 j 的左侧所保证的。同时,由于 nums[j] 的存在,nums[i] 一定不会是滑动窗口中的最大值了,我们可以将 nums[i] 永久地移除。
因此我们可以使用一个队列存储所有还没有被移除的下标。在队列中,这些下标按照从小到大的顺序被存储,并且它们在数组 nums 中对应的值是严格单调递减的。
按照上面的想法,我们的流程可以按下面的描述进行:
- 首先,在保证队列不空的情况下,检查队首的元素(也就是最大值)是否过期(也就是是否还在长度为
k的窗口内),是则不断移除队列队首元素,直到不过期; - 在保证队列不空的情况下,将当前元素不断与队尾元素比较,因为我们要求队列严格递减,因此如果队尾元素小于等于当前元素,则将其移除;
- 将当前元素加入队尾中;
- 若窗口填充满,则查看队首元素,记录到答案数组中。
方法三:分块 + 预处理
这是一个另类的解法,假设 nums 数组中有 n 个元素,我们按照 k 个一组的方式,从左往右进行分组(最后一组中元素的数量可能会不足 k 个)。此时如果我们希望求出 nums[i] 到 nums[i + k − 1] 的最大值,就会有两种情况:
- 如果
i是k的倍数,那么nums[i]到nums[i + k − 1]恰好是一个分组。我们只要预处理出每个分组中的最大值,即可得到答案; - 如果
i不是k的倍数,那么nums[i]到nums[i + k − 1]会跨越两个分组,占有第一个分组的后缀以及第二个分组的前缀。假设j是k的倍数,并且满足i < j ≤ i + k − 1,那么nums[i]到nums[j−1]就是第一个分组的后缀,nums[j]到nums[i + k − 1]就是第二个分组的前缀。如果我们能够预处理出每个分组中的前缀最大值以及后缀最大值,同样可以在 的时间得到答案。
以上面的图片为例:
假设n = 15, k = 4, i = 6,我们寻找 nums[6] 到 nums[6 + 4 - 1 = 9] 区间的最大值,那么此时跨越了两个分组,因此我们需要找出 6 < j ≤ 9 同时 j 也要是 k 的倍数,即 j = 8。
此时nums[6] 到 nums[7]就是第一个分组的后缀,nums[8] 到 nums[9] 就是第二个分组的前缀。
那么如何预先处理好每个分组中的前缀最大值以及后缀最大值呢?对于每个元素 nums[i] :
限定在本组中,以 nums[i] 结尾的前缀最大值prefixMax的处理如下:
限定在本组中,以 nums[i] 开头的后缀最大值suffixMax的处理如下:
需要注意在递推 suffixMax[i] 时需要考虑到边界条件 suffixMax[n − 1] = nums[n − 1],而在递推 prefixMax[i] 时的边界条件 prefixMax[0] = nums[0] 恰好包含在递推式的第一种情况中,因此无需特殊考虑。
在预处理完成之后,对于 nums[i] 到 nums[i + k − 1] 的所有元素,如果 i 不是 k 的倍数,那么窗口中的最大值为 suffixMax[i] 与 prefixMax[i + k − 1] 中的较大值;如果 i 是 k 的倍数,那么此时窗口恰好对应一整个分组,suffixMax[i] 和 prefixMax[i + k − 1] 都等于分组中的最大值,因此无论窗口属于哪一种情况,
即为答案
🔧 代码实现
1、优先队列
1 | class Solution { |
2、单调队列
1 | class Solution { |
3、分块 + 预处理
1 | class Solution { |
📊 复杂度分析
1、优先队列
- 时间复杂度:,其中 n 是数组 nums 的长度。在最坏情况下,数组 nums 中的元素单调递增,那么最终优先队列中包含了所有元素,没有元素被移除。由于将一个元素放入优先队列的时间复杂度为 O(logn),因此总时间复杂度为 O(nlogn)。
- 空间复杂度:,即为优先队列需要使用的空间。这里所有的空间复杂度分析都不考虑返回的答案需要的 O(n) 空间,只计算额外的空间使用。
2、单调队列
- 时间复杂度:,其中 n 是数组 nums 的长度。每一个下标恰好被放入队列一次,并且最多被弹出队列一次,因此时间复杂度为 O(n)。
- 空间复杂度:。与方法一不同的是,在方法二中我们使用的数据结构是双向的,因此“不断从队首弹出元素”保证了队列中最多不会有超过 k+1 个元素,因此队列使用的空间为 O(k)。
3、分块 + 预处理
- 时间复杂度:,其中 n 是数组 nums 的长度。我们需要 O(n) 的时间预处理出数组 prefixMax,suffixMax 以及计算答案。
- 空间复杂度:,即为存储 prefixMax 和 suffixMax 需要的空间。
🎯 总结
- 核心思想:要熟练掌握标准解法(前两种),想起来使用优先队列/双端队列。