[每日一题] 第二十六题:滑动窗口的最大值

题目描述

给定一个数组 nums 和滑动窗口的大小 k,请找出所有滑动窗口里的最大值。

示例:

输入: 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

提示:

你可以假设 k 总是有效的,在输入数组不为空的情况下,1 ≤ k ≤ 输入数组的大小。

题解

方法一:单调队列

解题思路:

设窗口区间为 [i,j],最大值为 Xj。当窗口向前移动一格,则区间变为 [i+1,j+1],即添加了 nums[j+1],删除了 nums[i]。

若只向窗口 [i,j] 右边添加数字 nums[j+1],则新窗口最大值可以 通过一次对比 使用 O(1) 时间得到,即:X j+1 = max(Xj,nums[j+1])

注:j+1 为下标,下同。

而由于删除的 nums[i] 可能恰好是窗口内唯一的最大值 Xj,因此不能通过以上方法计算 X j+1,而必须使用 O(j-i) 时间,遍历整个窗口区间 获取最大值,即:X j-1 = max(nums[i+1],……,nums[j+1])

根据以上分析,可得 暴力法 的时间复杂度为 O(n-k+1)k),约等于 O(nk)。

  • 设数组 nums 的长度为 n,则共有(n-k+1)个窗口。
  • 获取每个窗口最大值需线性遍历,时间复杂度为 O(k)。

[每日一题] 第二十六题:滑动窗口的最大值

本题难点:如何在每次窗口滑动后,将“获取窗口内最大值”的时间复杂度从 O(k) 降低至 O(1)。

可以使用 单调栈 实现随意入栈,出栈情况下的 O(1) 时间获取“栈内最小值”。

窗口对应的数据结构为 双端队列,本题使用 单调队列 即可解决以上问题。遍历数组时,每轮保证单调队列 deque:

  1. deque 内 仅包含窗口内的元素 => 每轮窗口滑动移除了元素 nums[i-1],需要将 deque 内的对应元素一起删除。
  2. deque 内的元素 非严格递减 => 每轮窗口滑动添加了元素 nums[j+1],需要将 deque 内所有 < nums[j+1] 的元素删除。

算法流程:

  1. 初始化:双端队列 deque,结果列表 res,数组长度 n;

  2. 滑动窗口:左边界范围 i 属于 [1-k,n+1-k],右边界范围 j 属于 [0,n-1];

    1. 若 i > 0 且队首元素 deque[0] = 被删除元素 nums[i-1]:则队首元素出队;
    2. 删除 deque 内所有 < nums[j] 的元素,以保持 deque 递减;
    3. 将 nums[j] 添加至 deque 尾部;
    4. 若已形成窗口(即 i >= 0):将窗口最大值(即队首元素 deque[0])添加至列表 res。
  3. 返回值:返回结果列表 res。

代码

class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        if(nums.length == 0 || k == 0) return new int[0];
        Deque<Integer> deque = new LinkedList<>();
        int[] res = new int[nums.length - k + 1];
        for(int j = 0, i = 1 - k; j < nums.length; i++, j++) {
            if(i > 0 && deque.peekFirst() == nums[i - 1])
                deque.removeFirst(); // 删除 deque 中对应的 nums[i-1]
            while(!deque.isEmpty() && deque.peekLast() < nums[j])
                deque.removeLast(); // 保持 deque 递减
            deque.addLast(nums[j]);
            if(i >= 0)
                res[i] = deque.peekFirst();  // 记录窗口最大值
        }
        return res;
    }
}

可以将 “未形成窗口”和“形成窗口后”两个阶段拆分到两个循环里实现。代码虽变长,但减少了冗余的判断操作。

class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        if(nums.length == 0 || k == 0) return new int[0];
        Deque<Integer> deque = new LinkedList<>();
        int[] res = new int[nums.length - k + 1];
        for(int i = 0; i < k; i++) { // 未形成窗口
            while(!deque.isEmpty() && deque.peekLast() < nums[i])
                deque.removeLast();
            deque.addLast(nums[i]);
        }
        res[0] = deque.peekFirst();
        for(int i = k; i < nums.length; i++) { // 形成窗口后
            if(deque.peekFirst() == nums[i - k])
                deque.removeFirst();
            while(!deque.isEmpty() && deque.peekLast() < nums[i])
                deque.removeLast();
            deque.addLast(nums[i]);
            res[i - k + 1] = deque.peekFirst();
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度O(N) :其中 n 为数组 nums 长度;线性遍历 nums 占用 O(N);每个元素最多仅入队和出队一次,因此单调队列 deque 占用 O(2N)。
  • 空间复杂度 O(k) : 双端队列 deque 最多同时存储 k 个元素(即窗口大小)。

个人理解

  1. Queue 是队列,只能一头进,另一头出。而 Deque 允许两头都进,两头都出,叫做双端队列(Double Ended Queue)。

  2. peekFirst 方法是取队首元素但不删除,peekLast 方法是取队尾元素但不删除。

  3. removeLast 方法是取队首元素并删除。

  4. addLast 方法是添加元素到队尾。

  5. 需要删除队列内所有小于 nums[i] 的元素,所以是一个 while 循环。

  6. 为什么这样做可以实现呢? 我们来举一个小栗子。

    1. 首先未形成窗口的时候我们应该可以理解,将数字一个一个放入队列中,然后比较。这个时候队列中的数据是什么呢?其实是一个非严格递减的队列。为什么这么说呢,我们看一下未形成窗口时的代码,他是比较当前元素和队列尾部中的最后一个元素的大小,如果小的话,将他移除,直到比较到第一个。所以这个队列是从尾部插入,从头部弹出的一个非严格递减的双端队列
    2. 为什么要这么存数据呢?因为我们要实现的是获取窗口最大值的时间复杂度为 O(1)。那么会遇到一个这么个问题,如果当前窗口的最大值恰好是窗口的最左边的元素,当窗口向右移动的时候,最左边的值就会被删除,我们其实不知道剩下的几个元素中最大的值是什么。使用了上述方法呢,我们其实维护了一个非严格递减的队列,又提到这个名词了。我的理解是不拿实际例子来举例,实际例子举例的话可能一下子就明白了,但是记得不扎实,下次可能还忘,我们在脑海中演示这个过程。
    3. 过程演示。当我们在未形成窗口的时候遍历,这个时候最大值肯定是在队首,那么次大值呢?应该排在队首后面,因为我们需要这个值,假设队首是最左元素,下次窗口移动的时候,最大值会弹出,所以我们可以取次大值进行比较。
    4. 这个非严格递减队列有什么规律呢?首先他并不是一个窗口的递减排序,递减排序没有意义对于我们这道题。那这个值应该怎么取?我们这样想,我们要一个一个比较,不需要重复比,所以最大值这一部分肯定是没问题的,假设我们取到最大值,那么最大值前面的数字还有意义吗?其实是没有意义了,滑动窗口从 index - k 到 最大值下标 index 这段范围内,最大值都不会是前面这几个数字了。按照这个思路,假设我们找到当前比较的最大值了,后面的值应该怎么处理?如果比当前值大,就替换,并抛弃掉前面的这些值,如果不比当前最大值大,就留着,插入队尾,当次大值使用,如果删除了最大值,次大值就是最大值。接下来的所有比较也是如此,如果队列中的值比当前值小,就全部删除,因为当前值能保证 [index-k,index] 这段范围内都有最大值。
  7. 注意:形成窗口后,一定要判断队列中最大值和窗口中最左边的值是否相等,如果相等则删除,这是形成队列和未形成队列的主要区别。

作者:jyd
链接:leetcode-cn.com/problems/hua-dong-...
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。`

题解来源

作者:jyd
链接:leetcode-cn.com/problems/hua-dong-...
来源:力扣(LeetCode)

题目来源

来源:力扣(LeetCode)
链接:leetcode-cn.com/problems/hua-dong-...

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!