滑动窗口相关算法题

1,067 阅读7分钟

徐姣 2021年4月21日

一、滑动窗口相关概念

1.1 概念

滑动窗口算法可以用以解决数组/字符串的子元素问题,它可以将嵌套的循环问题,转换为单循环问题,降低时间复杂度。

1.1 应用场景的特点

  • 需要输出或比较的结果在原数据结构中是连续排列
  • 每次窗口滑动时,只需观察窗口两端元素的变化,无论窗口多长,每次只操作两个头尾元素,当用到的窗口比较长时,可以显著减少操作次数
  • 窗口内元素的整体性比较强,窗口滑动可以只通过操作头尾两个位置的变化实现,但对比结果时往往要用到窗口中所有元素

二、滑动窗口的解题思路

2.1 常见需要使用滑动窗口的算法

  • 无重复字符的最长子串:给定一个字符串,请你找出其中不含有重复字符的最长子串的长度

  • 长度最小的子数组:给定一个含有 n 个正整数的数组和一个正整数 target 。找出该数组中满足其和 ≥ target 的长度最小的 连续子数组 [numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0

  • 滑动窗口最大值: 给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

    返回滑动窗口中的最大值。

  • 最小覆盖子串: 给你一个字符串s, 一个字符串t 。返回s 中涵盖t所有字符的最小子串。如果s中不存在涵盖 t所有字符的子串,则返回空字符串'' 。

2.2 解题步骤

  • 初始化滑动窗口左右指针:最小覆盖子串求解中,left = 0; right = t.length
  • 边界条件判断,异常处理: 最小覆盖子串求解中,s.lenght < t.length情况处理;
  • 一般需要循环,确定跳出循环的条件: 最小覆盖子串求解中右指针大于字符串、数组长度 right > s.length, 跳出循环
  • 确定窗口左右指针移动的条件:
    • 什么场景下左指针移动: 最小覆盖子串求解中,滑动窗口中包含子串t, 左指针移动
    • 什么场景下右指针移动:最小覆盖子串求解中,滑动窗口中不包含子串t,右指针移动

三、滑动窗口经常用到的知识点有哪些

3.1 如何判断a字符串包含b字符串

  • 首先a.length >= b.length
  • 排序后:暴力判断,基本超时
    • sortA = a.split('').sort().join('')
    • sortB = b.split('').sort().join('');
    • sortA.indexOf(sortB) > -1
  • 使用对象:提高查找效率
    • 将a, b字符串处理成{字符:个数}的形式
    • b中的字符在a中都存在, 切b中同一个字符的个数<= a字符串中的数量
  • 使用JavaScript中的Map对象
    • Map.prototype.size: 返回Map对象中的个数
    • Map.prototype.set(value): 添加一个元素
    • Map.prototype.get(value): 获取一个元素
    • Map.prototype.has(value): 是否存在一个元素
    • Map.prototype.keys(): 返回一个引用的 Iterator对象。它包含按照顺序插入Map 对象中每个元素的key值
    • Map.prototype.values(): 返回一个引用的 Iterator对象。它包含按照顺序插入Map 对象中每个元素的values值

3.2 如何找出数组中最大值和最小值

  • 排序后: 暴力解决,基本超时
    • const sortA = a.sort((x, y) => x - y);
    • 最大值:sortA[a.length - 1]
    • 最小值:sortA[0]
  • 使用Math方法
    • 最大值:Math.max.apply(null, a);
    • 最小值:Math.min.apply(null, a);
    • ES6方法:Math.max(...a);

四、常见滑动窗口 -- 最小子串求解

4.1 描述:

给你一个字符串s, 一个字符串t 。返回s 中涵盖t所有字符的最小子串。如果s中不存在涵盖 t所有字符的子串,则返回空字符串'' 。

4.2 示例:

输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
    
输入:s = "a", t = "a"
输出:"a"  

4.3 图解步骤:

  • 初始化阶段:s = "ADBECOEBAC" t = "ABC"

    • 初始化窗口左右指针:left = 0; right = 2(t.length - 1)

    • 初始化最小字符串:minLength = "ADBECOEBACABC"; (s + t)

      83A4C406-2C5B-4925-821A-D2B70FCD34B7.png

  • 第1轮判断:窗口字符串ADB是否包含ABC;不包含:right指针右移;

    3CAFC138-5E68-4D5F-B161-D77F5DA6875F.png

  • 第2轮判断: 窗口字符串ADBE是否包含ABC;不包含:right指针右移

    246C5C4F-966E-4D1C-B7A7-1F517F3DB989.png

  • 第3轮判断:窗口字符串ADBEC是否包含ABC;包含:

    • minStr取初始值“ADBECOEBACABC”和当前窗口字符串“ADBEC”长度最小的,即此时minStr = "ADBEC";

    • left指针右移

      8A32F8FC-B03C-4F45-BE91-5EED4CDD7011.png

  • 第4轮判断:窗口字符串DBEC是否包含ABC, 不包含:right指针右移

    BD041B24-493E-4E99-81F7-3C175B47FD79.png

  • 第5轮判断:窗口字符串DBECO是否包含ABC, 不包含:right指针右移

    CBBFF175-6170-4CC3-9BEF-F2E2C2DCEAAD.png

  • 第6轮判断:窗口字符串DBECOE是否包含ABC, 不包含:right指针右移

    C073D707-6138-497B-A84E-C4DFB8F992BB.png

  • 第7轮判断:窗口字符串DBECOEB是否包含ABC, 不包含:right指针右移

    6391B416-DC22-449C-86D4-CF897CCE95A6.png

  • 第8轮判断:窗口字符串DBECOEBA是否包含ABC;包含:

    • minStr取初上次值“ADBEC”和当前窗口字符串“DBECOEBA”长度最小的,即此时minStr = "ADBEC";

    • left指针右移

      58D1D561-E04D-4ED8-B164-9A1FB2EF9174.png

  • 第9轮判断:窗口字符串BECOEBA是否包含ABC;包含:

    • minStr取初上次值“ADBEC”和当前窗口字符串“BECOEBA”长度最小的,即此时minStr = "ADBEC";

    • left指针右移

      D60232E9-7B8F-4E11-8481-E190E6DD433D.png

  • 第10轮判断:窗口字符串ECOEBA是否包含ABC;包含:

    • minStr取初上次值“ADBEC”和当前窗口字符串“ECOEBA”长度最小的,即此时minStr = "ADBEC";

    • left指针右移

      353117D7-A41A-4ABE-AB0C-DE63D90BA305.png

  • 第11轮判断:窗口字符串COEBA是否包含ABC;包含:

    • minStr取初上次值“ADBEC”和当前窗口字符串“COEBA”长度最小的,即此时minStr = "ADBEC";

    • left指针右移

      8EA22772-E06C-493D-9933-E46F67DC60BF.png

  • 第12轮判断:窗口字符串OEBA是否包含ABC;不包含:right指针右移

    A874DACB-35E9-4064-A817-3EB78823B5D9.png

  • 第13轮判断:窗口字符串OEBAC是否包含ABC;包含:

    • minStr取初上次值“ADBEC”和当前窗口字符串“OEBAC”长度最小的,即此时minStr = "ADBEC";

    • left指针右移

      7E1A07BB-5D35-4C2C-8D77-E03E7BF25DD2.png

  • 第14轮判断:窗口字符串EBAC是否包含ABC;包含:

    • minStr取初上次值“ADBEC”和当前窗口字符串“EBAC”长度最小的,即此时minStr = "EBAC";

    • left指针右移

      97F38DDE-4240-47C2-8856-30625A3A7794.png

  • 第15轮判断:窗口字符串BAC是否包含ABC;包含:

    • minStr取初上次值“ADBEC”和当前窗口字符串“BAC”长度最小的,即此时minStr = "BAC";

    • left指针右移

      A595EDB7-68B6-4E22-BE1E-46B7A34D4CCD.png

  • 第16轮判断:right指针达到最大值s.length -1 ; 窗口字符串AC长度小于比较字符串ABC, 跳出循环

    • 返回最终结果minStr = "BAC"

4.4 解题中需要注意的点:如何判断窗口字符串a包含需要比较的字符串b;

## 方法一:字符串排序后,用indexOf
/**
* 暴力排序解决,sort很耗费性能,基本超时用例过不了
*/
const isInclude = (a, b) => {
    const sortA = a.split('').sort((x, y) => x -y).join('');
    const sortB = b.split('').sort((x, y) => x -y).join('');
    return sortA.indexOf(sortB) > -1;
};

## 方法二:用Map方法,统计a中是否包含B中的字符个数,同一个字符a中要大于等于b 【也可以直接使用JavaScript中的object对象】, 对象的存取效率高

function createMap (b) {
    const map = new Map();
    [...b].forEach(res => {
        map.has(res) ? map[res]++ : map(res) = 1;
    });
    return map;
}

function isContainerStr(a, b) {
    const mapA = createMap(a);
    const mapB = createMap(b);
    const keys = mapB.keys(); 
}

4.5 实现

function createMap (b) {
    const map = new Map();
    [...b].forEach(res => {
        const value = map.has(res) ? map.get(res) + 1 : 1;
        map.set(res, value);
    });
    return map;
}
function isContainedStr(mapA, mapB) {
    let isContainer;
    mapB.forEach((value, key) => {
        if (!mapA.has(key) || mapA.get(key) < value) {
            isContainer = false;
        }
    });
    return isContainer === undefined;
}
function minWindow (s, t) {
    const tLength = t.length;
    const sLength = s.length;
    const con = s + t;
    if (sLength < tLength) {return '';}
    // 初始化滑动窗口左右指针
    let left = 0;
    let right = tLength;
    let minStr = con;
    const tObj = createMap(t);
    while (right <= sLength) {
        const subStr = s.substring(left, right);
        const subObj = createMap(subStr);
        const isContainer = isContainedStr(subObj, tObj);
        // 包含子串t
        if (isContainer) {
            minStr = subStr.length < minStr.length ? subStr : minStr;
            left++;
        } else {
            right++;
        }
    }
    return minStr === con ? '' : minStr;
};

4.6 上面的解法超时了,寻找优化点:

  • s = ADBECOEBAC 包含 t = ABC最新小的串是最后的BAC, 他的特点是: 连续的,我们中间计算时, 不需要记录中间串值,只需要记录left指针,和最小串的长度minLen。那么最终结果是:minStr = s.substr(left, minLen);
  • isContainedStr比较的时候使用forEach无法跳出循环,事实上只需要有一个key不满足要求,就可以终止循环并跳出。
  • 在循环s字符串循环过程中维护一个object(subObj),right++时添加添加字符或者字符数量加1,left++时删除字符或者字符数量减1。
4.7 优化实现
function minWindow (s, t) {
    // 窗口字符串生成的字符对象
    let subObj = {};
    // 比较字符串t所生成的字符对象,如:t="ABC",则objT = {A: 1, B: 1, C: 1}
    let objT = {};
    [...t].forEach(char => objT[char] ? objT[char]++ : objT[char] = 1);
    
    // 初始化窗口的左右指针,因为需要再循环中生成窗口对象subObj,所以right要从0开始循环了
    let left = 0;
    let right = 0; 
    
    // 初始化最小字符串的长度(默认无限大)
    let minLen = Infinity;
    
    // 初始化最小子串的起始点
    let start = -1; 
    
    let keyLen = Object.keys(objT).length;
    // 记录窗口字符串中和objT中字符数量一致的有多少字符,如果数量和keyLen一致,我们就认为当前的窗口字符串包含t
    let count = 0;
    
    while (right < s.length) {
        // 右边指针当前指向的字符
        let char = s[right++];
        // 循环中维护窗口字符串对象,无需内部再次循环比较
        subObj[char] ? subObj[char]++ : subObj[char] = 1;

        /** 循环过程中,如果t中的字符在窗口对象中都找到了,且数量相等。
        * 	我们记住此时的起始值start: left, 因为left后续会变动
        **  则此时的窗口字符串:minStr = s.substring(left, right)包含我们t
        **/
        if (subObj[char] === objT[char]) {count++;} 
        while(count === keyLen) {    
            if (right - left < minLen) {
                start = left;
                minLen = right - left;
            }
            // 满足条件下尽量收缩求自小子串: left指针右移
            let c2 = s[left++];
            if (subObj[c2]-- === objT[c2]) {count--;}
        }
    }
    return start === -1 ? '' : s.substr(start, minLen)
};