「力扣」第 239 题:滑动窗口的最大值


「力扣」第 239 题:滑动窗口的最大值

题解地址:最大索引堆 + 双端队列存索引值的思路分析(Python 代码、Java 代码)

说明:文本首发在力扣的题解版块,更新也会在第 1 时间在上面的网站中更新,这篇文章只是上面的文章的一个快照,您可以点击上面的链接看到其他网友对本文的评论。

传送门:239. 滑动窗口最大值

给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口 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 ≤ 输入数组的大小,且输入数组不为空。

提示:

  • 1 <= nums.length <= 10^5
  • -10^4 <= nums[i] <= 10^4
  • 1 <= k <= nums.length

最大索引堆 + 双端队列存索引值的思路分析(Python 代码、Java 代码)

这道问题最直接的想法就是“通过切片操作得到滑动窗口”,“切片”是 Python 中的一个操作,即截取一个序列,可以是字符串也可以是列表,中的连续子序列,再从中得到最大值,这是暴力解法。

  • 动态得到“滑动窗口”的最大值,有一个数据结构是容易想到的,那就是“堆”(“优先队列”),特别地,应该使用“索引堆”。

  • 根据「力扣」第 155 题:最小栈这个问题的思路,我们想到需要一个辅助的数据结构,当“滑动窗口”在移动的时候,我们直接看这个数据结构,就可以很快知道此时“滑动窗口”的最大值。

面试的时候,写“双端队列”,我肯定两个思路都回答。不写“索引堆”是因为代码量太多,并且“堆”我是写过很多遍,但是还是会写不好的,更别说“索引堆”了。

“双端队列存索引值”是我参考了众多优秀的题解之后总结出来的,希望我能够解释出使用“双端队列”的合理性。在编写本题解的过程中,由于要解释大佬们是如何想到“双端队列”的,我又想到了“索引堆”,以前学习“索引堆”的时候被这个东西“折磨”过,所以印象比较深,不过我个人觉得“索引堆”的思想更自然直接。

方法一:双端队列存索引值

如果不使用“索引堆”要定位要更新的元素的索引,还是有一定技巧的,当然挖掘题目中的已知条件可以帮助我们找到解决方案。

(1)根据「力扣」第 155 题:最小栈这个问题的思路,我们想到需要一个辅助的数据结构,当“滑动窗口”在移动的时候,我们直接看这个数据结构,就可以很快知道此时“滑动窗口”的最大值;

下面这一点是很关键的!

如果“当前考虑的数”比之前来的数还要大,那么之前的数(如果还没有划出“滑动窗口”)就一定不会是“滑动窗口”的最大值,应该把它们移除,因为它们“永远不会有出头之日”。

(2)根据上面的分析,之前的数离开,后来的数加进来,感觉这个数据结构可能是“队列”,但是之前可能还有的数比“当前考虑的数”还要大,还不能出“队列”,感觉这个数据结构还有可能是“栈”(不好意思,又把你绕晕了,不妨先看后面)。又感觉是“队列”,又感觉是“栈”的,我们先停一下,还有一个问题没有解决:左边界出“滑动窗口”的时候怎么办?。

(3)“左边界出滑动窗口的时候”只有 1 种情况需要考虑,那就是:

左边界恰好就是上一轮循环的滑动窗口的最大值,在这一轮“滑动窗口”右移,它必须被移出

“左边界出滑动窗口”,左边界一定比当前考虑的数先进这个辅助的数据结构, 再结合 3(2),我们可以分析出这个辅助的数据结构即要是队列(先进先出),也要是栈(后进先出),因此“双端队列”是首选,因为要检测到左边界移除,左边界的索引很重要,知道索引也就知道了具体的值,因此“双端队列”里存的应该是数组的索引

参考代码 1:“滑动窗口”存放的是数组的索引值。

Java 代码:

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;

public class Solution {

    public int[] maxSlidingWindow(int[] nums, int k) {
        int len = nums.length;
        // 特判
        if (len == 0) {
            return new int[]{};
        }
        // 结果集
        List res = new ArrayList<>();
        // 滑动窗口,注意:保存的是索引值
        ArrayDeque deque = new ArrayDeque<>(k);

        for (int i = 0; i < len; i++) {
            // 当元素从左边界滑出的时候,如果它恰恰好是滑动窗口的最大值
            // 那么将它弹出
            if (i >= k && i - k == deque.getFirst()) {
                deque.pollFirst();
            }

            // 如果滑动窗口非空,新进来的数比队列里已经存在的数还要大
            // 则说明已经存在数一定不会是滑动窗口的最大值(它们毫无出头之日)
            // 将它们弹出
            while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
                deque.pollLast();
            }
            deque.add(i);
            // 队首一定是滑动窗口的最大值的索引
            if (i >= k - 1) {
                res.add(nums[deque.peekFirst()]);
            }
        }

        int size = res.size();
        int[] result = new int[size];

        for (int i = 0; i < size; i++) {
            result[i] = res.get(i);
        }
        return result;
    }
}

Python 代码:

from typing import List
from collections import deque


class Solution:
    def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
        size = len(nums)

        # 特判
        if size == 0:
            return []
        # 结果集
        res = []
        # 滑动窗口,注意:保存的是索引值
        window = deque()

        for i in range(size):
            # 当元素从左边界滑出的时候,如果它恰恰好是滑动窗口的最大值
            # 那么将它弹出
            if i >= k and i - k == window[0]:
                window.popleft()

            # 如果滑动窗口非空,新进来的数比队列里已经存在的数还要大
            # 则说明已经存在数一定不会是滑动窗口的最大值(它们毫无出头之日)
            # 将它们弹出
            while window and nums[window[-1]] <= nums[i]:
                window.pop()
            window.append(i)

            # 队首一定是滑动窗口的最大值的索引
            if i >= k - 1:
                res.append(nums[window[0]])
        return res

方法二:使用最大索引堆

动态得到“滑动窗口”的最大值,有一个数据结构是容易想到的,那就是“堆”(“优先队列”),但是问题来了。当“滑动窗口”要把左边界移除的时候,我们虽然左边界是哪个元素,但是没有从一个堆中移除非最堆顶元素的操作,于是我就想“索引堆”可不可以呢?找到即将要滑出边界的那个索引,更新一下它的值就好,那么索引值如何更新呢?在纸上写一写,你就会发现规律:新进来的那个数的索引,把自己这个索引对 $k$ 取模的那个索引更新一下就好了

使用“索引堆”的原因:“索引堆”有一个更新操作,可以针对某个索引更新它的值,然后索引发生调整,使得索引数组组成最大堆。因为能方便地定位到某个元素,因此可以在新进来一个数的时候,使用更新操作,把马上要出“滑动窗口”的那个索引位置上的值更新成新进来的这个数的值就可以了(说起来很拗口,看代码会简洁很多)。

239-1.png

不过很纠结的是 Python 和 Java 都没有现成的“索引堆”,需要自己编写,请见“参考代码 2”。

  • 对“索引堆”感兴趣的朋友,可以参考我的学习笔记《【算法日积月累】11-索引堆》,注意文中提到的“索引数组”和“反向查找”技巧是值得借鉴的,文后还给出了示例代码和参考资料。

参考代码 2:“最大索引堆”得自己写,正好就利用了 LeetCode 的测试用例检验一下自己有没有写对。

Python 代码:

from typing import List


class Solution:

    def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
        size = len(nums)

        # 特判
        if size == 0:
            return []

        # 初始化最大索引堆,其容量是滑动窗口的大小
        index_max_heap = Solution.IndexMaxHeap(k)
        # 首先先把前 k 个元素加进去
        for i in range(k):
            index_max_heap.insert(i, nums[i])

        # 结果集
        res = []
        # 然后就是产生滑动窗口的过程,先输出最大值,
        # 而后把当前考虑的数插入到将要滑出的左边界的索引位置

        for i in range(k, size):
            res.append(index_max_heap.peek_max_value())
            index_max_heap.change(i % k, nums[i])
        # 因为最大索引堆更新了最后 1 个数,因此最后还要看一眼最大值
        res.append(index_max_heap.peek_max_value())
        return res

    class IndexMaxHeap:
        def __init__(self, capacity):
            self.data = [None for _ in range(capacity + 1)]
            # 初值设置为 0 ,表示该位置还没有放置元素
            self.indexes = [0 for _ in range(capacity + 1)]
            self.reverse = [0 for _ in range(capacity + 1)]
            self.count = 0
            self.capacity = capacity

        def size(self):
            return self.count

        def is_empty(self):
            return self.count == 0

        # 此时 insert 要给一个索引位置
        def insert(self, i, item):
            if self.count + 1 > self.capacity:
                raise Exception('堆的容量不够了')
            i += 1
            self.data[i] = item

            # 这一步很关键,在内部索引数组的最后设置索引数组的索引
            self.indexes[self.count + 1] = i
            self.reverse[i] = self.count + 1

            self.count += 1
            self.__shift_up(self.count)

        def __shift_up(self, k):
            while k > 1 and self.data[self.indexes[k // 2]] < self.data[self.indexes[k]]:
                self.indexes[k // 2], self.indexes[k] = self.indexes[k], self.indexes[k // 2]

                self.reverse[self.indexes[k // 2]] = k // 2
                self.reverse[self.indexes[k]] = k

                k //= 2

        def extract_max(self):
            if self.count == 0:
                raise Exception('堆里没有可以取出的元素')
            ret = self.data[self.indexes[1]]
            self.indexes[1], self.indexes[self.count] = self.indexes[self.count], self.indexes[1]
            self.reverse[self.indexes[1]] = 1
            self.reverse[self.indexes[self.count]] = self.count

            self.reverse[self.indexes[self.count]] = 0

            self.count -= 1
            self.__shift_down(1)
            return ret

        def __shift_down(self, k):
            while 2 * k <= self.count:
                j = 2 * k
                if j + 1 <= self.count and self.data[self.indexes[j + 1]] > self.data[self.indexes[j]]:
                    j = j + 1
                if self.data[self.indexes[k]] >= self.data[self.indexes[j]]:
                    break
                self.indexes[k], self.indexes[j] = self.indexes[j], self.indexes[k]

                self.reverse[self.indexes[k]] = k
                self.reverse[self.indexes[j]] = j

                k = j

        # 新增方法
        def extract_max_index(self):
            assert self.count > 0
            # 减 1 是为了符合用户视角
            ret = self.indexes[1] - 1
            self.indexes[1], self.indexes[self.count] = self.indexes[self.count], self.indexes[1]
            self.count -= 1
            self.__shift_down(1)
            return ret

        # 新增方法
        def get_item(self, i):
            # 内部数组的索引比用户视角多 1
            return self.data[i + 1]

        # 新增方法
        def change(self, i, new_item):
            # 把用户视角改成内部索引
            i += 1
            self.data[i] = new_item

            # 重点:下面这一步是找原来数组中索引是 i 的元素
            # 在索引数组中的索引是几,这是一个唯一值,找到即返回
            # 优化:可以引入反向查找技术优化

            j = self.reverse[i]
            self.__shift_down(j)
            self.__shift_up(j)

        # 为 LeetCode 第 239 题新增的方法,看一眼此时索引堆的最大索引是多少(没用上,我想多了,留到以后用吧)
        def peek_max_index(self):
            if self.count == 0:
                raise Exception('堆里没有可以取出的元素')
            # 注意:与用户认为的索引值有一个偏差
            return self.indexes[1] - 1

        # 为 LeetCode 第 239 题新增的方法,看一眼此时索引堆的最大值是多少
        def peek_max_value(self):
            if self.count == 0:
                raise Exception('堆里没有可以取出的元素')
            return self.data[self.indexes[1]]


if __name__ == '__main__':
    solution = Solution()
    nums = [1, 3, -1, -3, 5, 3, 6, 7]
    k = 3
    res = solution.maxSlidingWindow(nums, k)
    print(res)

Java 代码:

import java.util.Arrays;

/**
 * @author liwei
 * @date 2019/7/10 9:27 AM
 */
public class Solution2 {

    public int[] maxSlidingWindow(int[] nums, int k) {
        int len = nums.length;
        if (len == 0) {
            return new int[]{};
        }

        // 初始化最大索引堆,其容量是滑动窗口的大小
        IndexMaxHeap indexMaxHeap = new IndexMaxHeap(k);

        // 首先先把前 k 个元素加进去
        for (int i = 0; i < k; i++) {
            indexMaxHeap.insert(i, nums[i]);
        }

        // 结果集
        int[] res = new int[len - k + 1];

        // 然后就是产生滑动窗口的过程,先输出最大值,
        // 而后把当前考虑的数插入到将要滑出的左边界的索引位置
        for (int i = k; i < len; i++) {
            res[i - k] = indexMaxHeap.peekMaxValue();
            indexMaxHeap.change(i % k, nums[i]);
        }
        // 因为最大索引堆更新了最后 1 个数,因此最后还要看一眼最大值
        res[len - k] = indexMaxHeap.peekMaxValue();
        return res;
    }


    class IndexMaxHeap {

        private int[] data;
        private int count;
        private int capacity;
        private int[] indexes;
        private int[] reverse;

        // 使用了反向查找技术的最大索引堆

        public IndexMaxHeap(int capacity) {
            data = new int[capacity + 1];
            indexes = new int[capacity + 1];
            reverse = new int[capacity + 1];
            count = 0;
            this.capacity = capacity;
        }


        public int getSize() {
            return count;
        }


        public boolean isEmpty() {
            return count == 0;
        }


        public void insert(int i, int item) {
            assert count + 1 <= capacity;
            assert i + 1 >= 1 && i + 1 <= capacity;
            i += 1;
            data[i] = item;

            indexes[count + 1] = i;
            reverse[i] = indexes[count + 1];

            count++;
            shiftUp(count);
        }


        private void shiftUp(int k) {
            while (k > 1 && data[indexes[k / 2]] < data[indexes[k]]) {

                swap(indexes, k / 2, k);
                // 注意分析这行代码,即使上一行 indexes 的两个元素交换了位置,但是并没有改变他们的值
                // 每一次交换了 indexes 索引以后,还要把 reverse 索引也交换
                swap(reverse, indexes[k / 2], indexes[k]);

                k /= 2;
            }
        }


        private void shiftUp1(int k) {
            while (k > 1 && data[indexes[k / 2]] < data[indexes[k]]) {
                swapIndexes(k / 2, k);
                k /= 2;
            }
        }


        private void swapIndexes(int index1, int index2) {
            if (index1 == index2) {
                return;
            }
            int temp = indexes[index1];
            indexes[index1] = indexes[index2];
            indexes[index2] = temp;

            reverse[indexes[index1]] = index2;
            reverse[indexes[index2]] = index1;
        }


        private void swap(int[] data, int index1, int index2) {
            if (index1 == index2) {
                return;
            }
            int temp = data[index1];
            data[index1] = data[index2];
            data[index2] = temp;
        }


        /**
         * @return
         */
        public int extractMax() {
            // 将此时二叉堆中的最大的那个数据删除(出队),返回的是数据,不是返回索引
            assert count > 0;
            int ret = data[indexes[1]];
            // 只要设计交换的操作,就一定是索引数组交换
            // 每一次交换了 indexes 索引以后,还要把 reverse 索引也交换
            swap(indexes, 1, count);
            swap(reverse, indexes[1], indexes[count]);
            count--;
            shiftDown(1);
            return ret;
        }


        /**
         * @return
         */
        public int extractMax1() {
            // 将此时二叉堆中的最大的那个数据删除(出队),返回的是数据,不是返回索引
            assert count > 0;
            int ret = data[indexes[1]];
            // 只要设计交换的操作,就一定是索引数组交换
            // 每一次交换了 indexes 索引以后,还要把 reverse 索引也交换
            swapIndexes(1, count);
            count--;
            shiftDown(1);
            return ret;
        }


        private void shiftDown(int k) {
            while (2 * k <= count) {
                int j = 2 * k;
                if (j + 1 <= count && data[indexes[j + 1]] > data[indexes[j]]) {
                    j = j + 1;
                }
                if (data[indexes[k]] >= data[indexes[j]]) {
                    break;
                }

                // 每一次交换了 indexes 索引以后,还要把 reverse 索引也交换
                swap(indexes, k, j);
                swap(reverse, indexes[k], indexes[j]);

                k = j;
            }
        }


        private void shiftDown1(int k) {
            while (2 * k <= count) {
                int j = 2 * k;
                if (j + 1 <= count && data[indexes[j + 1]] > data[indexes[j]]) {
                    j = j + 1;
                }
                if (data[indexes[k]] >= data[indexes[j]]) {
                    break;
                }
                // 每一次交换了 indexes 索引以后,还要把 reverse 索引也交换
                swapIndexes(k, j);
                k = j;
            }
        }


        public int extractMaxIndex() {
            assert count > 0;
            int ret = indexes[1] - 1;

            // 每一次交换了 indexes 索引以后,还要把 reverse 索引也交换
            swap(indexes, 1, count);
            swap(reverse, indexes[1], indexes[count]);

            count--;
            shiftDown(1);
            return ret;
        }


        public int extractMaxIndex1() {
            assert count > 0;
            int ret = indexes[1] - 1;
            // 每一次交换了 indexes 索引以后,还要把 reverse 索引也交换
            swapIndexes(1, count);
            count--;
            shiftDown(1);
            return ret;
        }


        public int getItem(int i) {
            return data[i + 1];
        }


        public void change(int i, int item) {
            i = i + 1;
            data[i] = item;

            // 原先遍历的操作,现在就变成了这一步,是不是很酷
            int j = reverse[i];
            shiftDown(j);
            shiftUp(j);
        }


        /**
         * 为 LeetCode 第 239 题新增的方法,
         * 看一眼此时索引堆的最大索引是多少(没用上,我想多了,留到以后用吧)
         *
         * @return
         */
        public int peekMaxIndex() {
            if (this.count == 0) {
                throw new RuntimeException("堆里没有可以取出的元素");
            }
            // 注意:与用户认为的索引值有一个偏差
            return indexes[1] - 1;
        }

        /**
         * 为 LeetCode 第 239 题新增的方法,
         * 看一眼此时索引堆的最大索引是多少(没用上,我想多了,留到以后用吧)
         *
         * @return
         */
        public int peekMaxValue() {
            if (this.count == 0) {
                throw new RuntimeException("堆里没有可以取出的元素");
            }
            return data[indexes[1]];
        }
    }

    public static void main(String[] args) {
        Solution2 solution2 = new Solution2();
        int[] nums = {1, 3, -1, -3, 5, 3, 6, 7};
        int k = 3;

        int[] res = solution2.maxSlidingWindow(nums, k);
        System.out.println(Arrays.toString(res));
    }
}

文章作者: liweiwei1419
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 liweiwei1419 !
评论
 上一篇
「力扣」第 438 题:找到字符串中所有字母异位词 「力扣」第 438 题:找到字符串中所有字母异位词
「力扣」第 438 题:找到字符串中所有字母异位词 中文网址:438. 找到字符串中所有字母异位词 ; 英文网址:438. Find All Anagrams in a String 。 给定一个字符串 s 和一个非空字符串 p,找到
下一篇 
「力扣」第 220 题:存在重复元素 III 「力扣」第 220 题:存在重复元素 III
「力扣」第 220 题:存在重复元素 III传送门:220. 存在重复元素 III; 题解地址:滑动窗口 + 二叉搜索树找上下边界(Python 代码、Java 代码)。 给定一个整数数组,判断数组中是否有两个不同的索引 i 和 j,使得
  目录