【数据结构与算法】滑动窗口详解

2,461 阅读8分钟

滑动窗口

1. 窗口

窗口就是一种特定的运动轨迹。

20211015112511.png

如上图,一开始窗口的左边界和右边界都停留在整个数组的最左侧,窗口是空的。

窗口的左边界 L 和右边界 R 只能向右移动,不能向左移动。同时在移动时必须遵循一个原则:左边界 L 一定不能移动到右边界 R 的右边(L 不能超越 R)。

只要不违反原则,L 和 R可以随时向右移动。

当 R 往右移动,表示数组中有若干个元素从窗口的右侧进窗口。

当 L 往右移动,表示数组中有若干个元素从窗口的左侧出窗口。

如果我们将 L 向右移动和 R 向右移动封装成两个接口,让开发人员来调用。这样每一次的调用,就会形成不同的窗口状态。

如果我们需要获得当前窗口状态下的最大值,通常做法是遍历整个窗口,这样代价就不够低。那么能不能通过某一种结构,使用很低的代价就能获得当前窗口状态的最大值或者最小值?

就是窗口内最大值或最小值更新结构

2. 引入

由一个题目引入该结构。

题目

有一个整型数组 arr 和一个大小为 w 的窗口从数组的最左边滑到最右边,窗口每次向右边滑—个位置。

例如,arr 为 { 4,3,5,4,3,3,6,7 } ,滑动窗口大小为 3 时:

[ 4 3 5 ] 4 3 3 6 7 窗口最大值 = 5

4 [ 3 5 4 ] 3 3 6 7 窗口最大值 = 5

4 3 [ 5 4 3 ] 3 6 7 窗口最大值 = 5

4 3 5 [ 4 3 3 ] 6 7 窗口最大值 = 4

4 3 5 4 [ 3 3 6 ] 7 窗口最大值 = 6

4 3 5 4 3 [ 3 6 7 ] 窗口最大值 = 7

如果数组长度为 n,窗口大小为 w,则一共产生 n-w+1 个窗口的最大值。

请实现一个函数,收集每一个窗口状态下的最大值。

输入:整型数组 arr,窗口大小 w

输出:一个长度为 n-w+1 的整形数组,记录每一个窗口状态下的最大值,如上述 [ 5,5,5,4,6,7 ]。

3. 最大值更新结构

在任意窗口状态下得到窗口中最大值的结构。

构建一个双端队列,双端队列就是可以从头部进出节点,也可以从尾部进出节点。

双端队列中存储的是数组中元素的下标。为什么不存储元素?是因为下标不仅仅能够表示元素,还能表示元素在数组中的位置,携带的信息更多。

如果是最大值更新结构,那么单调性是从大到小的,也就是需要保证双端队列从头到尾存储的下标对应的元素是从大到小的。

R 向右移动一位:

20211015165237.png

  • 如果双端队列为空,那么 R 新囊括的元素的下标从尾部直接进入双端队列。
  • 如果双端队列不为空,则 R 新囊括的元素需要与双端对列尾部的下标所指向的元素进行比较:
    • 如果新囊括的元素比双端对列尾部下标所指向的元素小,则直接从尾部进入双端对列。
    • 如果新囊括的元素比双端对列尾部下标所指向的元素大或者相等,则将尾部的下标从尾部弹出双端队列,让新囊括的元素与当前尾部下标所指向的元素继续比较。

只要从双端队列尾部弹出的下标永远不找回。

任何时候双端队列的最大值都是头部存储的元素或者元素代表的值。

设计这种规则实际上就是在严格维护双端队列的单调性。

L 向右移动一位:

20211015172313.png

  • 只需要将 L 新排出的元素对应的下标和双端队列头部的下标进行比较:
    • 如果新排出的元素对应的下标和双端队列头部下标一致,则将头部的下标从头部弹出双端队列。
    • 如果新排出的元素对应的下标和双端队列头部下标不一致,无需任何操作。

这里匹配的是下标,而不是下标所对应的元素值。

4. 最小值更新结构

最小值更新结构和最大值更新结构同理。

只需要修改单调性,保证双端队列从头到存储的下标对应的元素是从小到大的即可。

5. 原理

为什么每一个窗口状态的最大值都是当前双端队列头部的值呢?

那就需要研究双端队列到底维持的是什么信息。

维持的是如果此时不让 R 向右移动而选择让 L 依次向右移动,谁会依次成为最大值这个信息。

假设现在有一个数组 arr = { 6,5,4,3,5,7 },窗口区域是 [ 6,5,4,3 ]。

20211015180222.png

此时双端队列维持的最大值是 arr[0] = 6。

如果让 R 不动,L 向右移动一位,则 arr[0] 过期,arr[1] 成为了最大值。L 再向右移动一位,则 arr[1] 也过期了,arr[2] 成为了最大值。以此类推。

如果让 L 不动,R 向右移动一位,则 3、2 和 1 都要依次弹出,再让 4 进入。

3、2 和 1 弹出的原因是 arr[4] = arr[1] > arr[2] > arr[3],且 arr[1]、arr[2] 和 arr[3] 又一定比 arr[4] 更早过期,所以 arr[1] 、arr[2] 和 arr[3] 再也没有机会成为最大值了,因此可以直接从双端队列中弹出,让 arr[4] 压入双端队列就足够了。

因此,也可以说双端队列维持的是如果依次过期,谁会依次成为最大值这个信息。

6. 时间复杂度

当窗口在数组中向右滑动的时候,统计每个元素进出窗口的次数即可。

每一个元素最多进窗口1次,最多出窗口1次。已经出窗口的元素是不会再重新回到窗口的,所以不存在一个元素多次进窗口的情况。

所以当窗口向右滑动的元素是 N 个,双端队列更新的总代价一定是O(N),单次更新的代价就是O(N) / N,因此单次的平均代价就是O(1)。

注意,只是平均代价是O(1),并不代表每一次更新都是O(1)。

比如 arr = { 6,5,4,3,2,1,7 },窗口为 [ 6,5,4,3,2,1 ] 此时 L 动,R 向右移动一位,此时双端队列更新的代价为O(N)。但是 0~5 位置进入窗口双端队列更新代价都是O(1),可以说在某一个时刻,单个元素更新的复杂度可能比较高,但是总的代价平均下来是非常低的。

7. 实现

因为 L 和 R 在原理中是在 arr 中括住和排出元素的,没有实际的下标。因此在Coding实现时必须要给 L 和 R 与 arr 的下标相对应,这样才能对 L 和 R 进行操作。

本实现中,L 和 R 与 arr 下标的关系是:假如 L = 1,R = 3,则表示 L ~ R 窗口中包含的元素是 arr[1],arr[2],arr[3]。

20211017203753.png

public class SlidingWindow {

    // 使用LinkedList作为双端队列的基础数据结构
    private LinkedList<Integer> queue = new LinkedList<>();

    // 窗口左边界
    private int left;

    // 窗口右边界
    private int right;

    // 窗口滑动的数组
    private int[] arr;

    public SlidingWindow(int[] arr) {
        this.left = -1;
        this.right = -1;
        this.arr = arr;
    }

    // 窗口左边界向右滑动n个单位
    public void leftSlide(int n) {
        // 如果left和right交相交或者left越界,则滑动失败
        if (this.left + n > this.right) {
            return ;
        }

        // left向右滑动n个单位
        for (int i = 0; i < n; i ++) {
            this.left ++;
            // 如果left新排出的下标和队首下标相同,则队首下标出队列
            if (left - 1 == queue.getFirst()) {
                queue.pollFirst();
            }
        }
    }

    // 窗口右边界向右滑动n个单位
    public void rightSlide(int n) {
        // 如果right越界,则滑动失败
        if (this.right + n >= arr.length) {
            return ;
        }

        // right向右滑动n个单位
        for (int i = 0; i < n; i ++) {
            this.right ++;
            // 如果right新囊括的下标对应的元素大于等于队尾下标对应的元素,队尾下标出队列
            while (!queue.isEmpty() && arr[right] >= arr[queue.getLast()]) {
                queue.pollLast();
            }
            // right进队列
            queue.addLast(this.right);
        }
    }

    // 获取当前窗口内的最大值
    public int getMax() {
        // 如果当前双向队列为空
        if (this.queue.isEmpty()) {
            return -1;
        }

        // 获取对头下标对应的元素
        return arr[this.queue.peek()];
    }

}

8. 解题

题目

有一个整型数组 arr 和一个大小为 w 的窗口从数组的最左边滑到最右边,窗口每次向右边滑—个位置。

例如,arr 为 { 4,3,5,4,3,3,6,7 } ,滑动窗口大小为 3 时:

[ 4 3 5 ] 4 3 3 6 7 窗口最大值 = 5

4 [ 3 5 4 ] 3 3 6 7 窗口最大值 = 5

4 3 [ 5 4 3 ] 3 6 7 窗口最大值 = 5

4 3 5 [ 4 3 3 ] 6 7 窗口最大值 = 4

4 3 5 4 [ 3 3 6 ] 7 窗口最大值 = 6

4 3 5 4 3 [ 3 6 7 ] 窗口最大值 = 7

如果数组长度为 n,窗口大小为 w,则一共产生 n-w+1 个窗口的最大值。

请实现一个函数,收集每一个窗口状态下的最大值。

输入:整型数组 arr,窗口大小 w

输出:一个长度为 n-w+1 的整形数组,记录每一个窗口状态下的最大值,如上述 [ 5,5,5,4,6,7 ]。

分析:

上述结构可以求自由窗口中的最大值问题,这道题目将窗口尺寸固定,窗口滑动轨迹也固定了,因此比自由窗口中最大值问题简单很多。

代码:

public static int[] getSlidingWindowMax(int[] arr, int w) {
    int[] res = new int[arr.length - w + 1];

    SlidingWindow slidingWindow = new SlidingWindow(arr);

    // right先向右走w步
    slidingWindow.rightSlide(w);
    // left先向右走1步,此时窗口范围是 0 ~ (w-1),窗口大小就是w
    slidingWindow.leftSlide(1);

    // 获取每一个窗口状态的最大值
    for (int i = 0; i < res.length; i ++) {
        res[i] = slidingWindow.getMax();

        // 窗口整体向右移动1位
        slidingWindow.rightSlide(1);
        slidingWindow.leftSlide(1);
    }

    return res;
}