学过计算机网络的同学,都知道滑动窗口协议(Sliding Window Protocol),该协议是 TCP协议 的一种应用,用于网络数据传输时的流量控制,以避免拥塞的发生。该协议允许发送方在停止并等待确认前发送多个数据分组。由于发送方不必每发一个分组就停下来等待确认。因此该协议可以加速数据的传输,提高网络吞吐量。
滑动窗口算法其实和这个是一样的,只是用的地方场景不一样,可以根据需要调整窗口的大小,有时也可以是固定窗口大小。

滑动窗口算法(Sliding Window Algorithm)

滑动窗口算法是在给定特定窗口大小的数组或字符串上执行要求的操作。
该技术可以将一部分问题中的嵌套循环转变为一个单循环,因此它可以减少时间复杂度。
简而言之,滑动窗口算法在一个特定大小的字符串或数组上进行操作,而不在整个字符串和数组上操作,这样就降低了问题的复杂度,从而也达到降低了循环的嵌套深度。其实这里就可以看出来滑动窗口主要应用在数组和字符串上。

基本示例

如下图所示,设定滑动窗口(window)大小为 3,当滑动窗口每次划过数组时,计算当前滑动窗口中元素的和,得到结果 res。
image.png
可以用来解决一些查找满足一定条件的连续区间的性质(长度等)的问题。由于区间连续,因此当区间发生变化时,可以通过旧有的计算结果对搜索空间进行剪枝,这样便减少了重复计算,降低了时间复杂度。往往类似于“ 请找到满足 xx 的最 x 的区间(子串、子数组)的 xx ”这类问题都可以使用该方法进行解决。
需要注意的是,滑动窗口算法更多的是一种思想,而非某种数据结构的使用。

滑动窗口法的大体框架

在介绍滑动窗口的框架时候,大家先从字面理解下:

  • 滑动:说明这个窗口是移动的,也就是移动是按照一定方向来的。
  • 窗口:窗口大小并不是固定的,可以不断扩容直到满足一定的条件;也可以不断缩小,直到找到一个满足条件的最小窗口;当然也可以是固定大小。

为了便于理解,这里采用的是字符串来讲解。但是对于数组其实也是一样的。滑动窗口算法的思路是这样:

  1. 我们在字符串 S 中使用双指针中的左右指针技巧,初始化 left = right = 0,把索引闭区间 [left, right] 称为一个「窗口」。
  2. 我们先不断地增加 right 指针扩大窗口 [left, right],直到窗口中的字符串符合要求(包含了 T 中的所有字符)。
  3. 此时,我们停止增加 right,转而不断增加 left 指针缩小窗口 [left, right],直到窗口中的字符串不再符合要求(不包含 T 中的所有字符了)。同时,每次增加 left,我们都要更新一轮结果。
  4. 重复第 2 和第 3 步,直到 right 到达字符串 S 的尽头。

这个思路其实也不难,第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,最终找到最优解。左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动。
下面画图理解一下,needs 和 window 相当于计数器,分别记录 T 中字符出现次数和窗口中的相应字符的出现次数。
初始状态:
image.png
增加 right,直到窗口 [left, right] 包含了 T 中所有字符:
image.png
现在开始增加 left,缩小窗口 [left, right]。
image.png
直到窗口中的字符串不再符合要求,left 不再继续移动。
image.png
之后重复上述过程,先移动 right,再移动 left…… 直到 right 指针到达字符串 S 的末端,算法结束。
如果你能够理解上述过程,恭喜,你已经完全掌握了滑动窗口算法思想。至于如何具体到问题,如何得出此题的答案,都是编程问题,等会提供一套模板,理解一下就会了。
上述过程对于非固定大小的滑动窗口,可以简单地写出如下伪码框架:

  1. string s, t; // 在 s 中寻找 t 的「最小覆盖子串」
  2. int left = 0, right = 0;
  3. string res = s;
  4. while(right < s.size()) {
  5. window.add(s[right]);
  6. right++; // 如果符合要求,说明窗口构造完成,移动 left 缩小窗口
  7. while (window 符合要求) { // 如果这个窗口的子串更短,则更新 res
  8. res = minLen(res, window);
  9. window.remove(s[left]);
  10. left++;
  11. }
  12. }
  13. return res;

但是,对于固定窗口大小,可以总结如下:


int k // 固定窗口大小为 k     
string s;     
// 在 s 中寻找窗口大小为 k 时的所包含最大元音字母个数     
int  right = 0;while(right < s.size()) {         
  window.add(s[right]);         
  right++;         
  // 如果符合要求,说明窗口构造完成,         
  if (right>=k) {           
    // 这是已经是一个窗口了,根据条件做一些事情          
    // ... 可以计算窗口最大值等            
    // 最后不要忘记把 right -k 位置元素从窗口里面移除       
  }    
}     
return res;
  • 可以发现此时不需要依赖 left 指针了。因为窗口固定所以其实就没必要使用left,right 双指针来控制窗口的大小。
  • 其次是对于窗口是固定的,可以轻易获取到 left 的位置,此处 left = right-k;

    LeetCode题目

    无重复字符的最长子串

    image.png
    /**
    * @param {string} s
    * @return {number}
    */
    var lengthOfLongestSubstring = function(s) {
      const hash=new Set()
      const n=s.length
      let right=-1,max=0
      for(let i=0;i<n;i++){
          if(i!==0){
              hash.delete(s.charAt(i-1))
          }
          while(right+1<n && !hash.has(s.charAt(right+1))){
          hash.add(s.charAt(right+1))
          right++
          }
          max=Math.max(max,right-i+1)
      }
      return max;
    };
    

    字符串的排列

    image.png
    /**
    * @param {string} s1
    * @param {string} s2
    * @return {boolean}
    */
    var checkInclusion = function(s1, s2) {
    const l1=s1.length
    const l2=s2.length
    if(l2<l1) return false
    let maps1=new Array(26).fill(0)
    let maps2=new Array(26).fill(0)
      for (let i = 0; i < l1; ++i) {
          maps1[s1[i].charCodeAt()-'a'.charCodeAt()]++
          maps2[s2[i].charCodeAt()-'a'.charCodeAt()]++
      }
      if (maps1.toString() === maps2.toString()) {
          return true;
      }
      for (let i = l1; i < l2; ++i) {
          maps2[s2[i].charCodeAt() - 'a'.charCodeAt()]++
          maps2[s2[i - l1].charCodeAt() - 'a'.charCodeAt()]--
          if (maps1.toString() === maps2.toString()) {
              return true;
          }
      }
      return false;
    };