滑动窗口最大值
蛮力算法
如果对复杂度没有要求,本题可以用蛮力算法轻松实现,即设计函数 getMax(nums,start,k),获取 nums 数组从 start 开始 k 个元素的最大值。实现如下:
function getMax(nums,start,k){//获取num数组从start元素开始k个元素的最大值
let max = nums[start];
for(let i = 1;i<k;i++){//一共k个元素,循环k次,但首个元素已经是max,所以只需要从第二项开始,一共循环k-1次
if(nums[start+i]>max){
max = nums[start+i];
}
}
return max;
}
/**
* @param {number[]} nums
* @param {number} k
* @return {number[]}
*/
function maxSlidingWindow(nums,k){
const result = [];//返回结果
for(let i = 0;i<nums.length-k+1;i++){//遍历nums数组
result.push(getMax(nums,i,k));//将最大的push进去
}
return result;
}
这种蛮力解法显然已经可以解决问题,但是这里过于暴力,做了太多的重复运算,这导致时间复杂度较高,也无法通过力扣,所以我们必须对代码进行优化。
蛮力算法的简单优化
我们以题目中给出的例子为例
我们观察这个例子,我们很容易观察到,所谓的滑动窗口,本质上就是末尾移除一个元素,首部加上一个元素。
这就引出了一种优化思路,我们的一次滑动本质上就是把这一次的窗口首部加上一个元素,尾部减少一个元素,也就是说,移动一次窗口后,除了首部和尾部,其他的元素都是不会变化的,我们在图中标记出来。
显然,对于一个长度为 k 的窗口,滑动一次后,有 k-1 个元素都不会发生变化。我们这里就可以针对两种特定的情况进行优化。首先是上面这种情况,移出了元素 1,移入了元素-3。显然,在滑动之前,num[0]~num[2]
中最大的元素是 3,这是我们已知的,而我们滑动之后,3 没有被移出,并且移入的元素也比 3 小,这就意味着滑动之后区间内的最大的元素依然还是 3,对比前面的蛮力算法,这样的思路只要判断移入和移出元素,大大减少了重复运算。
我们再观察下面的情况
这种情况,我们移动窗口之前,我们知道 nums[0]~nums[3]
中最大的元素是 3,移动窗口后,新增的元素是一个比 3 还要大的元素,也就是说 nums[4]
比 nums[0]~nums[3]
都大,这个元素必然就是滑动后窗口的最大的元素。
这里,我们已经考虑了两种特殊情况
- 如果滑动后,移入的元素比滑动前窗口最大元素还要大,那么移入元素必然是滑动后窗口最大的元素
- 如果滑动后,移入的元素没有滑动前窗口的最大元素大,并且滑动前窗口的最大元素并没有在本次滑动被移出,那么滑动后窗口元素的最大值就是滑动前窗口的最大值
显然,上面两种情况无法覆盖所有情况,还有一种情况我们需要考虑,如果滑动后,移入的元素没有比滑动前窗口的最大元素大,但是此次滑动又导致了滑动前窗口的最大元素被移出窗口了,这种情况就没办法了,就只有重新获取。我们基于这种优化思路,写出代码:
function getMax(nums,start,k){//获取num数组从start元素开始k个元素的最大值
let max = nums[start];
for(let i = 1;i<k;i++){//一共k个元素,循环k次,但首个元素已经是max,所以只需要从第二项开始,一共循环k-1次
if(nums[start+i]>max){
max = nums[start+i];
}
}
return max;
}
function maxSlidingWindow(nums,k){
const result = [];//返回结果
//处理第一项
result.push(getMax(nums,0,k));//获取第一个窗口的最大值,push进去
for(let i = 1;i<nums.length-k+1;i++){//遍历nums数组,已经处理了第一项,所以从1开始遍历
//nums[i-1]为移出元素
//nums[i+k-1]为移入元素
if(nums[i+k-1] > result[result.length-1]){//如果移入元素比上一个窗口的最大元素更大
result.push(nums[i+k-1]);//移入元素为当前窗口最大元素
} else {
//否则,移入元素并不比当前窗口最大元素更大
if(nums[i-1] != result[result.length-1]){//如果移出的元素并不是上一个窗口的最大元素
result.push(result[result.length-1]);//上一个窗口的最大元素依然是滑动后窗口的最大元素
}else{//否则,最大的元素被移出,重新计算
result.push(getMax(nums,i,k));
}
}
}
return result;
}
这样优化后,还是无法通过力扣,有必要进一步优化。
蛮力算法的进一步优化
上面优化后的算法,在某些特定场景下就跟没有优化的蛮力算法一样,我们举个例子:
在这个场景下,首先,初始的最大值是 3,滑动一次后,由于最大值被滑出了,划入的值又没有大于这个最大值,所以导致了重新计算,但实际上窗口中不止有一个 3,我们完全没必要重新计算。显然,根本问题在于,当最大值被滑出的时候,窗口中是否还有其他最大值,如果窗口中还有其他最大值,我们就不重新计算。
我们可以用一个最大值计数器来解决这个问题,我们对代码进行如下改造
- getMax 函数不仅要获取到最大值,还要获取到窗口内最大值的个数
- 当移入元素等于最大值的时候,最大值计数器加一
- 当移入元素大于最大值的时候,最大值计数器置 1,最大值置为新值
- 当移出元素是最大值的时候最大值计数器减一
- 如果减一后,最大值计数器大于 0,表示窗口内还有其他最大值,不需要重新计算
- 如果减一后,最大值计数器小于等于 0,表示窗口内的最大值已经全部移除了,需要重新计算
基于这种思路,我们写出代码:
function getMax(nums,start,k){//获取num数组从start元素开始k个元素的最大值
let max = nums[start];
let counter = 0;
for(let i = 1;i<k;i++){//一共k个元素,循环k次,但首个元素已经是max,所以只需要从第二项开始,一共循环k-1次
if(nums[start+i]>max){
max = nums[start+i];
}
}
for(let i = 0;i<k;i++){//找到当前窗口最大值的个数
if(nums[start+i] == max){
counter++;
}
}
return {
max,
counter
};
}
function maxSlidingWindow(nums,k){
const result = [];//返回结果
let counter;
//处理第一项
let maxObj = getMax(nums,0,k);
result.push(maxObj.max);//获取第一个窗口的最大值,push进去
counter = maxObj.counter;//最大值计数器更新
for(let i = 1;i<nums.length-k+1;i++){//遍历nums数组,已经处理了第一项,所以从1开始遍历
//nums[i-1]为移出元素
//nums[i+k-1]为移入元素
//处理移入元素
if(nums[i+k-1] > result[result.length-1]){//如果移入元素比上一个窗口的最大元素更大
result.push(nums[i+k-1]);//移入元素为当前窗口最大元素
counter = 1;//清空计数器
}else{//否则,移入的不是新的最大元素
//处理移入元素
if(nums[i+k-1] == result[result.length-1]){//如果移入元素等于窗口的最大元素
counter++;
}
//处理移出元素
if(nums[i-1] != result[result.length-1]){//如果移出的元素并不是上一个窗口的最大元素
result.push(result[result.length-1]);//上一个窗口的最大元素依然是滑动后窗口的最大元素
}else{//否则,最大的元素被移移除
counter--;
if(counter > 0){//如果还有最大元素
result.push(result[result.length-1]);//上一个窗口的最大元素依然是滑动后窗口的最大元素
}else{
let maxObj = getMax(nums,i,k);
result.push(maxObj.max);//没有最大元素了,重新计算
counter = maxObj.counter;//更新计数器
}
}
}
}
return result;
}
基于这种思路实现的,优化后的代码已经可以通过力扣的测试。
除了使用最大值计数器的方式以外,我们还有另一种等价的优化思路,[[#蛮力算法的简单优化]]这里的代码之所以无法通过测试,本质上是因为我们滑动后,如果移出的是最大值,均会重新计算,但实际上,如果在窗口内还存在最大值,我们是不用重新计算的,所以我们才会考虑使用 counter 的方式来对最大值进行一个计数。但我们有另一种优化思路,这两种思路是等价的,就是我们获取的并不是最大值,而是当前窗口最后一个最大值的下标。也就是说,getMax 得到的就不是最大值了,而是当前窗口内最大值所在的下标。我们可以对代码作出这种优化:
- 用一个变量记录当前窗口内最后一个最大值的下标
- getMax 得到的是最后一个最大值的下标
- 如果移入的元素大于滑动前窗口的最大值,更新最大值下标为移入元素
- 如果移入元素等于滑动前窗口的最大值,更新最大值下标为移入元素
- 如果移出元素的下标就是当前窗口最后一个最大值的下标,说明当前窗口内所有的值都比最大值要小,重新计算
基于这种思路,我们给出代码:
function getMax(nums, start, k) {
//获取nums中,start下标开始,k个元素的最大下标
let max = 0;
for (let i = 1; i < k; i++) {
if (nums[start + i] >= nums[start + max]) max = i;
}
return start + max;
}
function maxSlidingWindow(nums,k){
const result = [];//返回结果
let lastMaxIndex;//最后一个最大元素的下标
//处理第一项
lastMaxIndex = getMax(nums,0,k);//最后一个最大元素的下标
result.push(nums[lastMaxIndex]);//获取第一个窗口的最大值,push进去
for(let i = 1;i<nums.length-k+1;i++){//遍历nums数组,已经处理了第一项,所以从1开始遍历
//nums[i-1]为移出元素
//nums[i+k-1]为移入元素
if(nums[i+k-1] >= result[result.length-1]){//如果移入元素比上一个窗口的最大元素更大,或者相等
lastMaxIndex = i+k-1;//换为移入元素的下标
result.push(nums[i+k-1]);//移入元素为当前窗口最大元素
} else {
//否则,移入元素更小,判断移除元素是不是最后一个最大元素
if(i-1 != lastMaxIndex){//如果移出的元素并不是上一个窗口的最大元素
result.push(result[result.length-1]);//上一个窗口的最大元素依然是滑动后窗口的最大元素
}else{//否则,最大的元素被移出,重新计算
lastMaxIndex = getMax(nums,i,k);
result.push(nums[lastMaxIndex]);
}
}
}
return result;
}
以上两种算法逻辑和思路基本是等价的,也都可以通过力扣,无非就是在判断窗口中还存不存在最大的元素这一问题上存在区别,但是从效率上来看,后面一种使用指针的算法实际上比使用计数器更好一点。
使用单调双端队列实现
以上的算法已经对蛮力求解做了很多优化,但效率依然不高,实际上我们知道,问题的核心其实是 getMax 方法,如果我们可以不使用这个方法,仅通过每次滑动的移入元素和移出元素就完成判断,那性能肯定是会大大提高的。
我们想想为什么我们会用到这个 getMax 函数,无非就是因为当窗口内所有最大元素都被移除之后,并且重新移入的元素也是一个小元素,由于我们只记录了最大元素,所以我们无法判断出下一个最大元素应该是谁,所以才直接导致了我们需要使用 getMax 重新计算出下一个最大元素。所以只要我们想办法记录出下一个,甚至下下一个最大元素是谁,我们当然就可以直接干掉 getMax 方法了。
接下来的问题就是如何记录这些信息,我们观察一下。
在这个例子中,窗口大小为 5,3 是最大值,如果我们不考虑后面的元素,我们光看窗口内的元素,也就是标红的元素,我们其实是可以找出哪些元素可能作为最大值的,在窗口中,可能作为最大值的元素只有 num[1]
和 num[4]
,为什么呢,我以 num[2]
为例,这个元素其实显然是不可能作为最大值的,因为这个元素会比 num[4]
更先移出窗口,所以只要这个元素在窗口中,那么 num[4]
就会一直在窗口中,而 num[4]
一定比他大,所以他就不可能作为最大元素。
我们将窗口右移一位看看
此时,原先可以作为最大元素的 num[4]
就不可能作为最大元素了,原理是一样的,因为我们现在移入了一个 3,也就是 num[5]
,num[4]
一定会比 num[5]
先移出窗口,并且 num[5]>num[4]
,所以 num[4]
必然不可能作为最大值。
我们可以观察出一个规律,一个元素左侧比他小的元素都不可能作为窗口内最大值,因为这个元素左侧比他小的元素必然比他先移出窗口,而在这些元素移出窗口之前,自己一定可以比他们大,所以他们一定不会是最大的。
反过来想,如果在窗口内部,一个元素的右侧有比自己还大的元素,那这个元素一定不可能作为最大的元素。在上面的窗口中,可以作为最大值的元素仅有 num[1]
和 num[5]
。
进一步观察,我们还可以得到,由于 num[1]
和 num[5]
同时可能作为最大值,说明 num[1]
右侧一定没有比他大的元素,因为如果 num[1]
右侧存在比他大的元素,他也不可能成为最大值,所以天然的就有 num[5]<=num[1]
,这是必然成立的。所以如果一个窗口内存在多个可能作为最大值的元素,那么最左侧的元素一定是最大的。进一步可以得出,如果窗口内有多个元素可以作为最大值,这几个元素一定是递减排序的,也就是最左侧元素一定最大。
我们移动窗口进一步观察
移动之后,num[1]
被移出,此时可以作为最大值的本来应该只有 num[5]
,但是由于移入了 num[6]
,导致了在窗口内, num[5]
后面出现了比他大的元素,所以 num[5]
不再可能是最大值,此时窗口中只有 num[6]
可能作为最大值。而由于 num[6]
是窗口内唯一一个有可能作为最大值的元素,当然也一定是最大的一个,所以最大值就是 num[6]
这里我们就衍生出了一种思路,如果我们可以实时记录下窗口中那些可能作为最大值的元素,而这些元素天然又是递增排序的。那当最左侧,也就是当前最大的元素被移出之后,第二个可能作为最大元素的元素自然就可以顶替上了,这就是使用单调双端队列来实现该算法的大体思路。
具体而言,我们可以创造一个双端队列,这个双端队列保存当前窗口中可能的最大的元素,当窗口移动的时候,如果移出的元素是最大的元素,那么左侧弹出一个。如果移入的元素比队列中的元素都大,那么队列中所有的元素都不再可能作为最大的元素出现,我们可以直接把队列清空,然后将新元素入队列。在其他情况下,也就是移入的元素只比队列中部分元素大,我们可以从右侧将所有比他小的元素都弹出,然后然他右侧入队列,因为只要这个元素比他小,那么这个元素就一定不可能再作为最大的元素出现了,所以我们自然就应该将其弹出。
下面给出具体代码:
/**
* @param {number[]} nums
* @param {number} k
* @return {number[]}
*/
var maxSlidingWindow = function(nums, k) {
let front = -1;//本次移除元素的下标
let rear = k-1;//本次新增的元素下标
const arr = [];//队列,保存当前窗口中有可能作为最大值的元素。
const result = [];//结果
//队列初始化
arr.push(nums[0]);//第一个元素右侧入队列
for(let i = 1;i<k;i++){
//如果当前最大值都比nums[i]小,清空
if(arr[0]<nums[i]){
arr.length = 0;
}else{//否则不是最大值
while(arr[arr.length-1]<nums[i]){//右侧弹出所有比自己小的元素
arr.pop();
}
}
arr.push(nums[i]);
}
result.push(arr[0]);//第一项处理
while(rear < nums.length-1){//只要没有到最后,就继续移动
front++;
rear++;
if(nums[front] == arr[0]){//如果当前移出的是最大的
arr.shift();//左侧弹出一个
}
if(nums[rear] > arr[0]){//如果右侧移入的比当前最大的都大
arr.length = 0;//清空
}else{
while(arr[arr.length-1]<nums[rear]){//右侧弹出比自己都小的元素
arr.pop();
}
}
arr.push(nums[rear]);//自己入队列
result.push(arr[0]);//将队列最左侧的元素输出
}
return result;
};