力扣热题——滑动窗口

149 阅读10分钟

概述

概念

  1. 核心思想:维护一个连续的区域(窗口),随着遍历的进行,窗口左边界、右边界或两者同时滑动,从而实时更新窗口内的信息。
  2. 窗口的定义:通常使用两个指针表示窗口的左右边界,区间为左闭右开[left, right),其中 left 为窗口的左边界,right 为窗口的右边界。
  3. 窗口的滑动:遍历过程中通过移动边界来滑动窗口,可以向右扩大窗口(右移right),也可以向右缩小窗口(右移left)。滑动方式需要根据实际问题进行选择。
  4. 窗口的更新:一般情况下,窗口内的最大值、最小值、求和或其他特性等需要在窗口滑动的过程中进行更新。

适用场景

  1. 最大/最小子序列或子数组问题:求解在一个序列中最大或最小的连续子序列或子数组的问题,比如"最大子序和"、"最长无重复字符的子字符串"等。
  2. 子序列或子数组的满足特定属性的问题:例如"长度最小的子数组"、"和等于给定值的最长子数组长度"等,在这类问题中,需要寻找满足特定条件的子序列或子数组。
  3. 子序列或子数组的计数问题:如"子数组的数目"、"所有子数组和的奇偶性"等问题。

优点

  1. 提高效率:滑动窗口的核心思想是"记住某些计算过的信息",避免了重复计算,从而大大提升了计算效率。例如,在计算最长连续子序列的问题中,不必对每个子序列进行遍历比较,只需要对边界值进行判断即可。
  2. 降低复杂性:业务逻辑复杂的问题,可以通过滑动窗口拆解为简单的单一步骤来实现,易于理解及实现。
  3. 空间效率:滑动窗口通常使用几个变量来管理窗口并记录最终结果,额外的空间开销往往较小。
  4. 泛用性强:滑动窗口不仅可以应用于数组和链表,还可以扩展到字符串及其他数据结构上。
  5. 灵活性:窗口的大小可以被动态改变,从而可以更好地适应不同的问题场景。

题目

无重复字符的最长子串

image.png
思路: 哈希+滑动窗口,利用Map结构存储每个字符的index值,实时更新,用left维护窗口的左边界值,遍历的i为窗口的右边界,按题目要求,当出现重复的字符时,即需更新窗口左边界,注意,此处需保证更新后的值比之前的要大,也就是myMap.get(s[i])>=left,因为可能出现重复的字符串原先存储的位置当前已经更新过后的left值小。
时间复杂度:使用了双指针 left 和 i,遍历整个字符串,时间复杂度为 O(n),其中 n 是输入字符串的长度。
空间复杂度:使用了常数级别的额外空间,因此空间复杂度也是 O(1)

/**
 * @param {string} s
 * @return {number}
 */
var lengthOfLongestSubstring = function(s) {
    let maxLen = 0
    let left = 0
    const myMap = new Map()
    for(let i = 0; i < s.length; i++){
        if(myMap.has(s[i]) && myMap.get(s[i]) >= left){
            left = myMap.get(s[i]) +1
        }
        myMap.set(s[i], i)
        maxLen = Math.max(maxLen, i - left + 1)
    }

    return maxLen
};

找到字符串中所有字母异位词

image.png
思路: 哈希+滑动窗口,主要分为两步u,一个是如何判断是不是异位词,一个是维持滑动窗口的大小

  1. 判断异位词:可以使用两个Map结构分别存储目标值p字符串出现的次数,和当前的字符键值对,如果两个Map结构的大小(依据size判断)不一致则必然不是,之后再依次遍历键值对判断是否一致;
  2. 维持窗口的大小:依据目标值p的长度来判断,如果当前索引值i大于或等于p.length,说明窗口的大小已经超出,需要去除最前方的字符,即删除或者次数减一即可。

时间复杂度:总体时间复杂度是 O(n * m),其中 n 是字符串 s 的长度,m 是字符串 p 的长度。由于 m 是一个常数,不随输入规模 n 的增加而增加,所以我们仍然可以将整体的时间复杂度表示为 O(n)。
空间复杂度:O(1),因为我们使用了常数大小的额外空间,虽然有两个 Map 对象,但 Map 的大小是固定的(与字符串 p 的长度相关),因此空间复杂度与输入规模无关

/**
 * @param {string} s
 * @param {string} p
 * @return {number[]}
 */
var findAnagrams = function(s, p) {
  if(s.length < p.length) return []
  const res = []
  const targetMap = new Map()
  const currentMap = new Map()

  // 设置目标map值
  for(let i = 0; i < p.length; i++){
      targetMap.set(p[i], (targetMap.get(p[i]) || 0) + 1)
  }

  for(let i = 0; i < s.length; i++){
      currentMap.set(s[i], (currentMap.get(s[i]) || 0) + 1)

      // 维持窗口长度
      if(i >= p.length){
          const str = s[i - p.length]
          if(currentMap.get(str) === 1){
              currentMap.delete(str)
          }else{
              currentMap.set(str, (currentMap.get(str) || 0) - 1)
          }
      }

      if(judge(targetMap, currentMap)){
          res.push(i - p.length + 1)
      }
  }

  // 判断是否是异位词
  function judge(targetMap, currentMap){
      if(targetMap.size !== currentMap.size) return false

      for(const [key, value] of targetMap){
          if(value !== currentMap.get(key)) return false
      }

      return true
  }

  return res
};

长度最小的子数组

image.png
思路: 需注意题目要求的连续子数组,所以不能先排序暴力做,会破环原来的字符顺序。正确做法依旧是维护一个窗口,外层循环移动右边界,内层while循环,当此时的总和大于等于目标值时,向右移动左边界,更新当前的最小计数值
时间复杂度:O(n)
空间复杂度:O(1)

/**
 * @param {number} target
 * @param {number[]} nums
 * @return {number}
 */
var minSubArrayLen = function (target, nums) {
    let minCount = Infinity
    let left = 0
    let currentSum = 0
    for (let i = 0; i < nums.length; i++) {
        currentSum += nums[i]
        while (currentSum >= target) {
            minCount = Math.min(minCount, i - left + 1)
            currentSum -= nums[left]
            left++
        }
    }
    return minCount !== Infinity ? minCount : 0
};

串联所有单词的子串

image.png

思路: 和字符串所有异位词那道题有点像,思路仍是哈希+滑动窗口,通过两个Map结构分别存贮当前子串的次数与字符串数组的子串次数,维护一个长度为单词长度的窗口,使用left、right两个指针表征左右两窗口的边界,用全局的count值统计当前已经符合条件的子串数量。如果当前单词不在字符数组,则将left值更新为right对应的值,其他像count、当前存储的Map结构currentMap均重置,如果在的话更新一下计数次数,注意处理当前统计的此单词出现次数是否超出的情况,具体实现如下。

  1. 首先,通过对 words 数组的遍历,构建一个 wordMap 对象,用于存储每个单词及其出现次数。
  2. 然后,使用一个外层循环,从字符串 s 的每个可能的起始位置开始,尝试构建符合条件的子串。
  3. 在外层循环的内部,使用两个指针 left 和 right 分别表示当前子串的起始位置和结束位置。同时,使用一个 currentMap 对象来记录当前子串中每个单词的出现次数,以及一个 count 变量来记录符合条件的单词个数。
  4. 在内层循环中,首先从 s 中获取当前位置的单词 currentWord。如果该单词不在 wordMap 中,说明当前子串不符合条件,需要重置指针和计数器;否则,更新 currentMap 和 count。
  5. 如果 count 等于 wordCount,说明当前子串符合条件,将其起始位置 left 加入结果数组 result。
  6. 如果 currentMap[currentWord] > wordMap[currentWord],说明当前子串中某个单词的出现次数超过了 wordMap 中的次数,需要移动左指针 left 直到恢复条件。
  7. 最终,返回结果数组 res。

时间复杂度:

  • 外层循环遍历单个单词的长度 wordLen 次,因此是 O(wordLen)。
  • 内层循环遍历字符串 s,因此是 O(s.length)。
  • 在内层循环中,涉及到 slice操作和 Map 的读写,它们的时间复杂度都是 O(1)。
  • 所以,总体时间复杂度是 O(wordLen * s.length)。

空间复杂度:

  • 使用了 res 数组来存储结果,最坏情况下可能存储所有的起始索引,因此空间复杂度是 O(s.length)。
  • wordMap 存储了单词数组 words 中每个单词及其出现次数,最坏情况下可能存储所有单词,因此空间复杂度是 O(words.length * wordLen)。
  • currentMap Map 存储了当前子串中每个单词及其出现次数,最坏情况下可能存储所有单词,因此空间复杂度是 O(words.length * wordLen)。
  • 因此,总体空间复杂度是 O(s.length + words.length * wordLen)。
/**
 * @param {string} s
 * @param {string[]} words
 * @return {number[]}
 */
var findSubstring = function (s, words) {
    const wordsLen = words[0].length  // 单个单词的长度
    const wordsCount = words.length  // 单词的总数
    const res = []
    const wordsMap = new Map()

    for(const item of words){
        wordsMap.set(item, (wordsMap.get(item) || 0) + 1)
    }

    // 以一个单词的长度确定滑动窗口的范围
    for(let i = 0; i < wordsLen; i++){
        const currentMap = new Map()
        let count = 0
        let left = i
        let right = i

        while(right + wordsLen <= s.length){
            const currentStr = s.slice(right, right + wordsLen)
            right += wordsLen
            if(!wordsMap.has(currentStr)){
                // 如果当前单词不在单词数组中,直接跳过
                currentMap.clear()
                count = 0
                left = right
            }else{
                currentMap.set(currentStr, (currentMap.get(currentStr) || 0) + 1)
                count++

                while(currentMap.get(currentStr) > wordsMap.get(currentStr)){
                    // 如果 currentMap 中某个单词的出现次数超过 wordMap 中该单词的出现次数,说明当前窗口不满足条件,需要左移 left 直到恢复条件
                    const leftStr = s.slice(left, left + wordsLen)
                    left += wordsLen
                    count--
                    currentMap.set(leftStr, (currentMap.get(leftStr) || 0) - 1)
                }

                if(count === wordsCount){
                    // 如果 count 等于 wordCount,说明当前子串符合条件
                    res.push(left)
                }
            }
        }
    }

    return res
};

最小覆盖子串

image.png
思路: 依旧沿袭之前题目的思路,额外实现一个judge函数用于判断当前键值对与t存储的键值对是否匹配,维护一个滑动窗口,外层右边界遍历for循环,内层依据判断函数while循环更新最小值与其相应字符串,再同步更新左边界,减少相应字符在sMap的存储次数
时间复杂度:

  • 初始化 tMap 的时间复杂度为 O(t),其中 t 是目标字符串 t 的长度。
  • 初始化 sMap 的时间复杂度为 O(s),其中 s 是源字符串 s 的长度。
  • 然后,我们使用双指针来遍历源字符串 s。右指针向右移动,每次移动都会更新 sMap 中的字符频率。在最坏的情况下,每个字符都可能被访问两次,因此这部分的时间复杂度为 O(2s)。
  • 内部的 while 循环最坏情况下也需要访问字符串中的所有字符,因此 while 循环的时间复杂度为 O(s)。
  • 所以总体时间复杂度为 O(t) + O(s) + O(2s) + O(s) = O(t + 4s) = O(t + s)。

空间复杂度:

  • 需要额外的空间来存储 tMap 和 sMap,它们的空间复杂度均为 O(t) 和 O(s)。
  • 其他变量和常数的空间占用是常量级别的,不随输入规模变化。
  • 因此,总体空间复杂度为 O(t + s)。
/**
 * @param {string} s
 * @param {string} t
 * @return {string}
 */
var minWindow = function(s, t) {
    if (!s || !t || s.length < t.length) return '';

    // 初始化目标字符串 t 的字符频率映射 tMap
    const tMap = new Map();
    for (const item of t) {
        tMap.set(item, (tMap.get(item) || 0) + 1);
    }

    // 初始化滑动窗口的字符频率映射 sMap
    const sMap = new Map();
    let left = 0; // 左指针

    let minLength = Infinity; // 记录最小窗口长度
    let minWindowStr = ''; // 记录最小窗口字符串

    // 右指针循环遍历字符串 s
    for (let right = 0; right < s.length; right++) {
        const char = s[right];
        sMap.set(char, (sMap.get(char) || 0) + 1);

        // 判断当前窗口是否包含目标字符串 t
        while (judge(sMap, tMap)) {
            const currentLength = right - left + 1;
            // 更新最小窗口信息
            if (currentLength < minLength) {
                minLength = currentLength;
                minWindowStr = s.slice(left, right + 1);
            }

            // 更新左边界和字符频率映射
            const leftChar = s[left];
            sMap.set(leftChar, sMap.get(leftChar) - 1);
            left++;
        }
    }

    return minWindowStr;
};

/**
 * 判断 sMap 是否包含 tMap 中的所有字符及其频率
 * @param {Map} sMap - 滑动窗口的字符频率映射
 * @param {Map} tMap - 目标字符串 t 的字符频率映射
 * @returns {boolean} - 是否包含
 */
function judge(sMap, tMap) {
    if (sMap.size < tMap.size) return false;
    for (const [key, val] of tMap) {
        if (!sMap.has(key) || val > sMap.get(key)) return false;
    }
    return true;
}