LeetCode239.滑动窗口最大值


滑动窗口最大值

在这里插入图片描述

蛮力算法

如果对复杂度没有要求,本题可以用蛮力算法轻松实现,即设计函数 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] 都大,这个元素必然就是滑动后窗口的最大的元素。

这里,我们已经考虑了两种特殊情况

  1. 如果滑动后,移入的元素比滑动前窗口最大元素还要大,那么移入元素必然是滑动后窗口最大的元素
  2. 如果滑动后,移入的元素没有滑动前窗口的最大元素大,并且滑动前窗口的最大元素并没有在本次滑动被移出,那么滑动后窗口元素的最大值就是滑动前窗口的最大值

显然,上面两种情况无法覆盖所有情况,还有一种情况我们需要考虑,如果滑动后,移入的元素没有比滑动前窗口的最大元素大,但是此次滑动又导致了滑动前窗口的最大元素被移出窗口了,这种情况就没办法了,就只有重新获取。我们基于这种优化思路,写出代码:

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,我们完全没必要重新计算。显然,根本问题在于,当最大值被滑出的时候,窗口中是否还有其他最大值,如果窗口中还有其他最大值,我们就不重新计算。

我们可以用一个最大值计数器来解决这个问题,我们对代码进行如下改造

  1. getMax 函数不仅要获取到最大值,还要获取到窗口内最大值的个数
  2. 当移入元素等于最大值的时候,最大值计数器加一
  3. 当移入元素大于最大值的时候,最大值计数器置 1,最大值置为新值
  4. 当移出元素是最大值的时候最大值计数器减一
    1. 如果减一后,最大值计数器大于 0,表示窗口内还有其他最大值,不需要重新计算
    2. 如果减一后,最大值计数器小于等于 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 得到的就不是最大值了,而是当前窗口内最大值所在的下标。我们可以对代码作出这种优化:

  1. 用一个变量记录当前窗口内最后一个最大值的下标
  2. getMax 得到的是最后一个最大值的下标
  3. 如果移入的元素大于滑动前窗口的最大值,更新最大值下标为移入元素
  4. 如果移入元素等于滑动前窗口的最大值,更新最大值下标为移入元素
  5. 如果移出元素的下标就是当前窗口最后一个最大值的下标,说明当前窗口内所有的值都比最大值要小,重新计算

基于这种思路,我们给出代码:

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;
 };
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值