滑动窗口简介

102 阅读6分钟

简介

滑动窗口是一个问题解决方法通过求解答案的特性将双重循环的时间复杂度从 O(n^2) 降低到 O(n), 在日常算法刷题或面试中,滑动窗口也比较常见,比如 无重复最长子串。接下来就让我们一起学习一下滑动窗口。

滑动窗口尝试解决什么类型的问题

首先我们明确滑动窗口在什么场景下使用:

  1. 通过维护一个固定窗口,解决连续数中平均值问题。
  2. 解决相邻对问题:处理有序数据结构中的相邻对
  3. 查找子数组中目标值问题:当需要在原数组中,针对连续的元素找到目标值时,可以通过维护窗口的大小找到符合目标值的子数组。
  4. 最长/最短/最优的序列问题:当需要找到序列中相邻元素的目标值时,对比扫描整个序列,滑动窗口会更加有效。

滑动窗口在以上几个场景中,对比暴力解决,能够减少计算的复杂度。总结为一句话:在一个大的序列中,求解连续子序列中的数据,可以使用滑动窗口来对暴力解法进行优化。

滑动窗口是如何降低时间复杂度的:

滑动窗口主要通过维护原数据的子数组或区间范围值来减少时间复杂度,对比暴力解法的针对于结果值遍历每一个对应的子数组,滑动窗口只需要维护在原数组中的连续元素的值。连续元素值的大小可以通过固定的窗口或不固定的窗口更新维护,通常使用哈希,双指针或者混合法来维护窗口内的数据。

  • 混合法是双指针 + 哈希,通常哈希有着较快的查询速度可以提高滑动窗口算法的效率。而双指针通常来指向窗口的开始与结束。

滑动窗口解决法与模版

窗口的维护一般为:

  • 固定窗口

  • 不固定窗口 根据问题的定义,选择对应的窗口模式;以下提供了JS滑动窗口的模板,

  • 滑动窗口的模版:

const slidingWindow = (nums, k) => {
	let leftPtr = 0, rightPtr = 0; // 双指针维护窗口的起始与结束位置
	let myStore = new Map(); // 使用一个容器记录窗口中的数据

	while (rightPtr < nums.length) { // 当窗口结束位置并没有超过list长度时
		myStore.set(nums[rightPtr], rightPtr);
		rightPtr++;

            if (condition) { // 窗口中的数据满足计算的情况时
                /** 进行目标值计算 **/

                // 缩小窗口
                myStore.remove(nums[leftPtr];
                leftPtr++;
            }
        }
    }

如果算法题中的情况为不固定窗口时,我们则需要考虑到其他两个点来修改模版中 if (condition){} 的部分:

  • 何时计算窗口中维护的值
  • 如何修改窗口的大小

leetcode实战

固定窗口大小的问题

题目描述:给你一个由 n 个元素组成的整数数组 nums 和一个整数 k 。请你找出平均数最大且 长度为 k 的连续子数组,并输出该最大平均数。

在本题中,在集合nums中,求解连续K个的数字之和的最大平均数,如果使用暴力解法,我们则需要针对每个长度为K的子数据求解最大的平均值并返回其中最大的。那么则需要O(n^2),当中会有重复的计算。

for (let i = 0; i < n-k+1; i++) {
    let currSum = nums[i];
    for (let j = 1; j < k; j++) {
        currSum += nums[i + j];
    }
    maxSum = Math.max(maxSum, currSum)
}

我们可以通过维护一个K长度的固定窗口来将时间复杂度优化为 O(n), 减少重复计算。

var findMaxAverage = function(nums, k) {
    let leftPtr = 0, rightPtr = 0;
    const n = nums.length;

    let maxSum = Number.MIN_SAFE_INTEGER;
    let currSum = 0;
    while (rightPtr < n) {
        currSum += nums[rightPtr];

        if ((rightPtr - leftPtr + 1) === k) { // 当目前窗口满足固定窗口的大小时
            maxSum = Math.max(currSum, maxSum);

            currSum -= nums[leftPtr];
            leftPtr++;
        }

        rightPtr++;
    }

    return maxSum/k;
};

对比针对于每个数据都计算当前的值,并与最大值进行比较选择最大值,我们可以直将窗口的最右和最左值进行移除与移入的操作,并每次当窗口中的数据长度为K时进行更新最大值的操作。

题目描述:给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。异位词 指由相同字母重排列形成的字符串(包括相同的字符串)。 在题目中,我们可以确定需要找到s中所有p的子串,如果使用暴力解法则和上面643的解决大同小异,双重遍历(这里的双重遍历不包含判断两个子串是否满足异味词要求的计算时间复杂度)查找每个长度为p.length的子串并计算是否满足异位词的要求,如果满足则将结果加一。而复杂度高的原因则是存在着重复的子串计算。我们可以将 p.length作为固定窗口的大小来简化时间复杂度; 以下代码还有优化空间只是作为滑动窗口的例子

var findAnagrams = function(s, p) {
    const cnts = new Array(26).fill(0); // 这里基于ASCII码的特性作为容器进行数据存储,可以使用Map
    for (let i = 0; i < p.length; i++){
        cnts[p[i].charCodeAt() - 'a'.charCodeAt()] += 1;
    }

    const isSame = (l1, l2) => { // toString这里可以选择其他复杂度低的方式进行判断两个数组是否相等
        return l1.toString() === l2.toString() ? true : false;
    }

    let leftPtr = 0, rightPtr = 0;
    const n = s.length, fixedWindowSize = p.length;
    const res = [];
    let windowData = new Array(26).fill(0);

    while (rightPtr < n) {
        let currChar = s[rightPtr];
        windowData[currChar.charCodeAt() - 'a'.charCodeAt()] += 1
        rightPtr++;

        if (rightPtr - leftPtr === fixedWindowSize) {
            if (isSame(cnts, windowData)) {
                res.push(leftPtr);
            }

            windowData[s[leftPtr].charCodeAt() - 'a'.charCodeAt()] -= 1;
            leftPtr++;
        }
    }

    return res;
};

希望基于这两个问题,已经对固定窗口大小的滑动窗口有了了解,感兴趣的读者也可以通过lc30.串联所有单词的子串继续锻炼固定窗口的滑动窗口算法。

不固定窗口大小的问题

题目描述:给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。 当遇到这个问题,如果使用暴力解法则需要针对于每一个字符,判断每一个连续的无重复子串的长度并进行计算。问题也和之前的两个问题一样,存在着重复计算。我们可以通过不固定窗口大小的滑动窗口算法来优化。我们可以确定问题中找到的是连续的子串,

如果使用暴力解法,我们则需要针对每一个字符判断其最长无重复最长子串,我们将无重复最长子串的长度作为滑动窗口的窗口大小,使用 Set的去重特性作为容器维护窗口内的数据,每次移动右指针并添加到容器中,当 Set 内有重复数据时,我们则移动右指针并减去右边的字符值。

var lengthOfLongestSubstring = function(s) {
    if (!s) return 0;

    let leftPtr = 0, rightPtr = 0;
    let hashStore = new Set();
    const n = s.length;
    let res = 1;
    while (rightPtr < n) {
        let currChar = s[rightPtr];

        while (hashStore.has(currChar)) {
            hashStore.delete(s[leftPtr]);
            leftPtr++;
        }

        hashStore.add(currChar);
        res = Math.max(res, rightPtr - leftPtr + 1);
        rightPtr++;
    }

    return res;
};

给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。 注意: 对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。如果 s 中存在这样的子串,我们保证它是唯一的答案

其实针对于lc76这道 hard 题,使用滑动窗口的方法过程是差不多,确定何时计算目标值并更新,明确窗口的大小就可以对暴力解法进行优化。

var minWindow = function(s, t) {
    if (s.length === 0 || t.length === 0) {
        return '';
    }
    const sLen = s.length
    const needs = new Map();
    for (let char of t) {
        if (needs.has(char)) {
            needs.set(char, needs.get(char) + 1)
        } else {
            needs.set(char, 1);
        }
    }

    let leftPtr = 0, rightPtr = 0;
    let minLen = Number.MAX_SAFE_INTEGER;
    let minSubStr = "";

    while (rightPtr < sLen) {
        let currRightChar = s[rightPtr];
        if (needs.has(currRightChar)) {
            needs.set(currRightChar, needs.get(currRightChar) - 1);
        }
        rightPtr++;
        while (checkExist(needs) && leftPtr <= rightPtr) {
            let currLeftChar = s[leftPtr];
            if (rightPtr - leftPtr < minLen) {
                minLen = rightPtr - leftPtr;
                minSubStr = s.slice(leftPtr, rightPtr)
            }
            if (needs.has(currLeftChar)) {
                needs.set(currLeftChar, needs.get(currLeftChar) + 1);
            }
            leftPtr++;
        }
    }

	return minSubStr;
};

const checkExist = (needs) => {
    for (let [_, val] of needs) {
        if (val > 0) return false;
    }

    return true;
}

总结

滑动窗口是一种优化暴力解法的策略,在解决有序集合中连续子序列的问题中有时间复杂度的优化。使用双指针维护左右窗口的大小,当不满足计算目标值要求时,不断增加右指针的长度,扩大窗口;当满足目标值计算时,计算目标值,并缩短窗口左指针。当完成一次遍历后,返回目标值。

References: