双指针②--滑动窗口

137 阅读10分钟

滑动窗口

如图:

image.png

指针i和指针j所限制的区间就是一个窗口,真的和快慢指针差不多诶😅,思考一下:

  • 滑动窗口维护的是什么?
    • 维护的是[i,j]区间中所有元素是否有满足某个条件的资格 vs 而快慢指针维护的是一个区间内是否满足某个条件
  • 还单调嘛?貌似也是单调的,左端点之所以左移是因为窗口内元素无法满足某个条件了,他如果可以回退,那么j也要回退,至少不动和前进都不行。

看些题目再着重分析把,此外,一定要记住循环不变量

LeetCode-209.长度最小的子数组

给定一个含有 n ****个正整数的数组和一个正整数 target

找出该数组中满足其总和大于等于 ****target ****的长度最小的 连续子数组 [numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度 如果不存在符合条件的子数组,返回 0 。

示例:

输入: target = 7, nums = [2,3,1,2,4,3]
输出: 2

首先确定循环不变量,我们要保证[i,j)区间的数是满足有条件大于等于目标值(和小于目标值) 的。 像快慢指针一样分析一下:

  • 初始化:[0,0)内无元素,因此可能在后面扩大窗口时大于等于目标值。此时最小长度我们应该定为一个大数(这里使用Infinity
  • 每次循环中:首先判断当前窗口内的元素和是否大于等于目标值:
    • 如果大于等于目标值,此时说明窗口内的值满足条件,先更新最小长度(因为是开区间,因此),然后缩小窗口,让左端点前移,并更新当前窗口内的和
    • 如果小于目标值,此时我们应该继续扩大窗口,让右端点前移,更新窗口内的和。

你会发现,两个指针都在前移(单调)。

代码如下:

var minSubArrayLen = function (target, nums) {
  let left = 0, right = 0;
  let sum = 0;
  let minLength = Infinity;
  while (left <= right && right <= nums.length) {
    if (sum < target) {
      sum += nums[right];
      right++;
    } else {
      minLength = Math.min(minLength, right - left);
      sum -= nums[left];
      left++;
    }
  }
  return minLength === Infinity ? 0 : minLength;
};

对于这类滑动窗口问题,可以使用如下y总提供的通用模板,当然掌握精髓后完全可以不需要模板:

var minSubArrayLen = function (target, nums) {
  for (let i = 0, j = 0; i <= nums.length; i++) {
    while (
      j <= i &&
      //缩小窗口的某个条件
    ) {
      //缩小窗口的逻辑...
      j++;
      //...
    }
    //题目逻辑具体逻辑...
  }
  return minLength === Infinity ? 0 : minLength;
};

我们可以套用模板写出如下代码:

var minSubArrayLen = function (target, nums) {
  let sum = 0;
  let minLength = Infinity;
  for (let i = 0, j = 0; i <= nums.length; i++) {
    while (
      j <= i &&
      //缩小窗口的条件:窗口总和大于等于目标值
      sum >= target
    ) {
      minLength = Math.min(minLength, i - j);
      sum -= nums[j];
      j++;
    }

    if (i >= nums.length) continue;
    sum += nums[i];
  }
  return minLength === Infinity ? 0 : minLength;
};

尽管如此,我们在书写过程中还是要考虑循环不变量,如上述代码中,我们维护的区间是[j,i),因此指针i要走到数组最后一个位置的下一个才能让AC,并且需要判断i是否越界。 如果我们修改区间为[j,i],那么我们需要在循坏开始时就求和,然后再经过while缩短区间,如下:

var minSubArrayLen = function (target, nums) {
  let sum = 0;
  let minLength = Infinity;
  for (let i = 0, j = 0; i < nums.length; i++) {
    sum += nums[i];
    while (
      j <= i &&
      //满足某个条件
      sum >= target
    ) {
      minLength = Math.min(minLength, i - j + 1);
      sum -= nums[j];
      j++;
    }
  }
  return minLength === Infinity ? 0 : minLength;
};

Next one!

LeetCode-904.水果成篮

你正在探访一家农场,农场从左到右种植了一排果树。这些树用一个整数数组 fruits 表示,其中 fruits[i] 是第 i 棵树上的水果 种类 。

你想要尽可能多地收集水果。然而,农场的主人设定了一些严格的规矩,你必须按照要求采摘水果:

  • 你只有 两个 篮子,并且每个篮子只能装 单一类型 的水果。每个篮子能够装的水果总量没有限制。
  • 你可以选择任意一棵树开始采摘,你必须从 每棵 树(包括开始采摘的树)上 恰好摘一个水果 。采摘的水果应当符合篮子中的水果类型。每采摘一次,你将会向右移动到下一棵树,并继续采摘。
  • 一旦你走到某棵树前,但水果不符合篮子的水果类型,那么就必须停止采摘。

给你一个整数数组 fruits ,返回你可以收集的水果的 最大 数目。

翻译翻译就是:一个数组,找到一个最大的连续区间,区间内只能包含两种元素。明显的滑动窗口题目。

直接分析:

  • 初始化:初始区间为[0,0],此时无元素。初始化maxTreeCount0,表示当前能够采摘的树数量。
  • 循环过程:每次循环先记录左端点数,为了方便记录出现次数,我们采用map存储,只要之前遇到过就+1,没遇到过就初始化为1。此时我们维护的区间可能出现如下情况:
    • map集合中只有两项,说明当前仍然可以采摘
    • map集合中有三项了,此时就需要前移左端点,不断缩小区间,直到map中只有两个时
  • 最后更新最大值,循环结束后返回最大值即可。

这里以图片展示代码,方便大家比较,区间选择对代码的影响,从中有所感悟:

非模板: image.png 模板: image.png 最后来一道困难题⛽⛽⛽!!!

LeetCode-76.最小覆盖子串

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

  • 对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
  • 如果 s 中存在这样的子串,我们保证它是唯一的答案。

示例:

输入: s = "ADOBECODEBANC", t = "ABC"
输出: "BANC"
解释: 最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。

翻译翻译:就是在字符串s中找到一个最小的子串,子串中包含t中的所有字符并个数也与其相等。

首先给出我的解法,利用了模板,但是运行时间和内存使用率有点差,不过思想是没问题的,后面也会讲到优化。

const isContain = (sMap, tMap) => {
  let res = true;
  for (const item of tMap.entries()) {
    //sMap中无item.key 或 个数小于item.value
    if (!sMap.get(item[0]) || sMap.get(item[0]) < item[1]) {
      res = false;
      break;
    }
  }
  return res;
}
var minWindow = function (s, t) {
  if (s.length < t.length) return "";
  //统计字串t各字符的出现次数
  const tMap = new Map();
  for (let i = 0; i < t.length; i++) {
    tMap.set(t[i], (tMap.get(t[i]) || 0) + 1);
  }
  //在s中滑动窗口,找到符合出现此处最小串长度
  let i = 0, j = 0;
  const sMap = new Map();

  let minLength = Infinity;
  let res = '';
  for (; i < s.length; i++) {
    sMap.set(s[i], (sMap.get(s[i]) || 0) + 1)
    while (j <= i && isContain(sMap, tMap)) {
      if (i - j + 1 < minLength) {
        res = s.substring(j, i + 1);
        minLength = i - j + 1;
      }
      sMap.set(s[j], sMap.get(s[j]) - 1);
      if (sMap.get(s[j]) === 0) sMap.delete(s[j]);
      j++;
    }
  }
  return res;
};

分析:

  • 初始化:我们需要知道字符串t中出现的所有字符的次数,首先就会想到使用哈希表来存储。其次这里的循环不变量设置为了[i,j]。我们需要得到符合条件的最小长度的子串,因此应该初始化minLength,并且为了统计当前维护区间内的字符以及出现次数,再引入一个哈希表来统计。
  • 每次循环:循环开始时,将i加入区间,保证区间不变。之后我们需要维护当前区间内的字符串是否满足了条件:
    • 满足条件,那么我们需要判断当前区间是否是目前最小的区间,如果是最小的区间,就更新结果以及minLength,之后就需要缩小区间了,把当前j所指字符的记录-1/删除,j前移
    • 不满足条件,i前移,继续寻找
  • 结束时res在每次满足条件后都会更新,因此直接返回即可。

时间复杂度和空间复杂度为什么都那么高呢?我们可以做哪些优化? 我在查看题解后意识到,我们不需要再多引入一个哈希表来统计当前区间的字符以及出现次数,这个引入使得我们增加了比较时的遍历的时间复杂度以及哈希表本身的空间复杂度。

完全可以根据字符串t对应的哈希表来表示当前区间是否满足条件。 优化代码如下:

const isContain = (tMap) => {
  let res = true;
  for (const item of tMap.entries()) {
    //sMap中无item.key 或 个数小于item.value
    if (item[1] > 0) {
      res = false;
      break;
    }
  }
  return res;
}
var minWindow = function (s, t) {
  if (s.length < t.length) return "";
  //统计字串t各字符的出现次数
  const tMap = new Map();
  for (let i = 0; i < t.length; i++) {
    tMap.set(t[i], (tMap.get(t[i]) || 0) + 1);
  }
  //在s中滑动窗口,找到符合出现此处最小串长度
  let i = 0, j = 0;
  let minLength = Infinity;
  let res = '';
  for (; i < s.length; i++) {
    if (tMap.has(s[i])) { //以tMap的减少 表示 所需字符的增加
      tMap.set(s[i], tMap.get(s[i]) - 1);
      //不能删除,否则改变了原有需要的字符种类
    }
    //判断条件要变为此时tMap的每一项是否<=0
    while (j <= i && isContain(tMap)) {
      if (i - j + 1 < minLength) {
        res = s.substring(j, i + 1);
        minLength = i - j + 1;
      }
      //如果删除的是所需类型,就得加一,表示缺少一个该类型字符
      if (tMap.has(s[j])) {
        tMap.set(s[j], tMap.get(s[j]) + 1);
      }
      j++;
    }
  }
  return res;
};

至此优化效果也不是很好,原因是我们在判断isContain时的后仍会循环当前tMap。直接来看看大佬的代码吧,通过引入typeCnt(目标字符种类数)来快速判断当前缺少什么。

var minWindow = function(s, t) {
  //构造一个map  标记出t字符串出现的频率 t中出现一次应该+1 然后在s字符串上进行滑动窗口的操作 如果出现对应的字符就减1 全部减好之后 记一个长度
  //最后取长度的最小值就是ans
 let needsMap = {}; //该容器存放所需的字符 对应的值 正数:还缺多少个 0:刚好不缺字符 负数:目标字符太过多
 for (const ch of t) {
 needsMap[ch] = (needsMap[ch] || 0) + 1;
 }
let typeCnt = Object.keys(needsMap).length; //目标字符种类数
let left = 0; //滑动窗口左边界
let right = 0; //滑动窗口右边界

let ans = Infinity; //表示最小字符串的长度
let minSubStr = ""; //表示符合的最小子串

 while(right < s.length){
     const charRight = s[right];
    if(charRight in needsMap){
        needsMap[charRight]--; //右指针找到了目标字符  即目标需求要减1
        if (needsMap[charRight] === 0) typeCnt--; //如果目标字符变成0,说明我们就不需要这个种类的字符了,于是目标字符就减掉1种
    }
    right++;
    while(typeCnt === 0){
        if(right - left < ans){
            ans = right - left;
            minSubStr = s.slice(left,right)
        }
        const charLeft = s[left];
        if(charLeft in needsMap){
           needsMap[charLeft]++;
            if(needsMap[charLeft] > 0) typeCnt++;
        }
        left++;
    }
 }

  return minSubStr;
};

总结

对于滑动窗口的题目,我们仍然可以像快慢指针一样来构思,不过再经过这几个例子后,我觉得滑动窗口中使用模板十分方便,大家各取所需,我们只要记得:循环不变量,单调性,维持区间特性这几个重点即可,这类题目基本上都会迎刃而解。