给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回 滑动窗口中的最大值 。
示例 1:
输入: nums = [1,3,-1,-3,5,3,6,7], k = 3
输出: [3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
示例 2:
输入: nums = [1], k = 1
输出: [1]
引言:当暴力算法遇上性能瓶颈
面对滑动窗口最大值问题,初学者往往会写出这样的代码(没错,这就是你提供的暴力解法!):
function getMax(nums, left, right) {
let max = -Infinity;
for (let i = left; i <= right; i++) {
if (max < nums[i]) {
max = nums[i];
}
}
return max
}
var maxSlidingWindow = function (nums, k) {
// const len = nums.length;
const res = [];
let left = 0;
let right = k - 1;
while (right <= nums.length - 1) {
//获取left到right之间最大值
let A = getMax(nums, left, right);
res.push(A)
left++;
right++;
}
return res;
};
虽然这个解法能正确运行,但时间复杂度高达O(nk),当处理大规模数据时,就像"蜗牛爬行"。这时候就需要请出我们的秘密武器——双端队列!
注双端队列解析
核心代码注解
var maxSlidingWindow = function(nums, k) {
const res = [];
const dequeue = []; // 这就是我们的双端队列,用来存储索引
for (let i = 0; i < nums.length; i++) {
// 第一步:维护单调递减队列(从队尾开始修剪)
while (dequeue.length && nums[i] > nums[dequeue.at(-1)]) {
dequeue.pop(); // 移除比当前元素小的索引
}
dequeue.push(i); // 加入当前元素索引
// 第二步:清理过期元素(从队头开始修剪)
if (dequeue[0] < i - k + 1) {
dequeue.shift(); // 移除超出窗口左边界的索引
}
// 第三步:收集结果(当窗口形成时)
if (i >= k - 1) {
res.push(nums[dequeue[0]]); // 队头就是当前窗口最大值
}
}
return res;
};
关键步骤详解
-
维护单调递减队列
我们通过不断从队尾移除较小元素的索引,确保队列始终保持单调递减。这样做的神奇之处在于:- 队头元素始终是当前窗口的最大值
- 队列中每个元素都是潜在的最大值候选
-
清理过期元素
这里需要特别注意:当队列头部的索引超出窗口范围时(即dequeue[0] < i - k + 1),必须及时移除。这就像在窗口滑动时,把"过期"的元素扔出队列。 -
收集结果
当i >= k - 1时,说明窗口已经完整形成。此时直接取队头元素作为最大值,因为我们的单调队列保证了:- 队头元素在窗口范围内
- 队头元素是窗口内最大的那个数
代码详解
for (let i = 0; i < nums.length; i++) {
while (dequeue.length && nums[i] > nums[dequeue[dequeue.length - 1]]) {
//不断将比当前元素更小的元素弹出并且要防止队列为空
dequeue.pop(); }
dequeue.push(i);
以上维护了一个单调递减的栈,但我们还需要找出每次滑动框滑动后的最大值,问我们什么时候找最大值呢?
是不是当滑动窗口移动时,当前滑动框的索引减去单调递减队列的队首索引是否等于滑动窗口的长度k?
初始想法,当滑动窗口右边等于窗口长度的时候,但这是数组,所以需要长度减1即k-1
--------添加部分:队头元素删除 删除已超出滑动窗口的值,在求区间范围内的最大值
if (dequeue[0] < i - k + 1) dequeue.shift();
if (i >= k - 1) {
res.push(nums[dequeue[0]]);
}
return res;
};
但是这样有bug,当后续数字都比前一个数小的时候,队列没有随着滑动窗口更新范围内的数字,因此队列内最大值一直没有更新,所以我们需要判断随着滑动窗口的移动,上一个滑动窗口的最大值是否因窗口滑动而被排除在现在滑动窗口的外边。
那怎么判断? 通过判断上一个最大值的下标,我们存储的是下标,我们遍历到当前元素的下标减去滑动窗口的长度就是 滑动窗口左边前一个的位置的下标,当我们队列的队头元素下标小于等于滑动窗口左边前一个位置的下标或者 我们队列的队头元素下标小于滑动窗口左边位置的下标时,说明队头元素已经不再滑动窗口内,
因此要将队头元素从队列中删除,添加部分要在求最大值之前
为什么这个算法如此高效?
-
线性时间复杂度 O(n)
每个元素最多被加入和移除队列各一次,就像"过山车"只坐一次,不会重复计算。 -
空间换时间
虽然使用了额外的队列空间,但换来了:-
避免重复遍历窗口
-
实时维护当前窗口的最大值信息
-
已经完美解决了这个问题。这个判断条件实际上在检查:
- 当前窗口的左边界是
i - k + 1 - 如果队头索引小于左边界,说明这个最大值候选已经"过期"
实战演练:以 [1,3,-1,-3,5,3,6,7] 为例
| 索引 i | 元素 nums[i] | 队列变化 | 窗口范围 | 最大值 |
|---|---|---|---|---|
| 0 | 1 | [0] | - | - |
| 1 | 3 | [1] (移除0) | - | - |
| 2 | -1 | [1,2] | [0-2] | 3 |
| 3 | -3 | [1,2,3] | [1-3] | 3 |
| 4 | 5 | [4] (移除1,2,3) | [2-4] | 5 |
| 5 | 3 | [4,5] | [3-5] | 5 |
| 6 | 6 | [6] (移除4,5) | [4-6] | 6 |
| 7 | 7 | [7] (移除6) | [5-7] | 7 |
总结:双端队列的智慧
通过这个算法,学到了:
- 数据结构的选择比算法本身更重要
双端队列的双向操作特性,完美匹配滑动窗口的需求 - 维护有序性的艺术
通过局部有序性(单调队列)获取全局最优解 - 空间与时间的精妙平衡
用O(k)的额外空间,换取O(n)的时间复杂度
[239. 滑动窗口最大值 - 力扣(LeetCode)](url)
** 如有错误欢迎指正,欢迎各位友友评论点赞**