LeetCode 76. 最小覆盖子串:手写滑动窗口的完整实现与逐行解析

29 阅读5分钟

【题目描述】

给定两个字符串 st,请在 s 中找出包含 t 所有字符的最短连续子串
该子串必须满足:对于 t 中的每一个字符(包括重复字符),其在子串中的出现次数不少于在 t 中的出现次数。
如果不存在这样的子串,则返回空字符串 ""

示例:

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

这是一道经典的滑动窗口问题,考察双指针、哈希统计与边界控制能力。


【我的完整实现代码】

以下是我编写的 JavaScript 解法:

/**
 * @param {string} s
 * @param {string} t
 * @return {string}
 */
var minWindow = function(s, t) {
    let left=0;
    let right=0;
    let len=Infinity;
    let vail=0;
    const slen=s.length;
    const tlen=t.length;
    if(tlen>slen){
        return '';
    }
    const smap=new Map();
    const tmap=new Map();let start=0;
    for(let i=0;i<tlen;i++){
        tmap.set(t[i],(tmap.get(t[i])||0)+1);
    }
    while(right<slen){
      
        
        if(tmap.has(s[right])){
             smap.set(s[right],(smap.get(s[right])||0)+1);
             if(smap.get(s[right])===tmap.get(s[right])){
                vail++;
             }
        }
         right++;
        while(vail===tmap.size){
         
            if(right-left<len){
                start=left;
                len=right-left;
            }
            if(tmap.has(s[left])){
                 if (smap.get(s[left]) === tmap.get(s[left])) {
                    vail--;
                }
                smap.set(s[left], smap.get(s[left]) - 1);
            }
               left++;
        }
    }
   return len === Infinity ? "" : s.substring(start, start + len);
};

【逐块解析】

块 1:初始化变量

var minWindow = function(s, t) {
    let left=0;
    let right=0;
    let len=Infinity;
    let vail=0;
    const slen=s.length;
    const tlen=t.length;

功能解释:
这里完成了滑动窗口所需的基础变量声明:

  • left 和 right 是窗口的左右边界,初始都为 0,表示空窗口;
  • len 用于记录当前找到的最短覆盖子串长度,初始化为 Infinity,便于后续用 Math.min 思想更新;
  • vail表示当前窗口中“满足 t 中字符数量要求”的字符种类数;
  • slen 和 tlen 缓存字符串长度,避免重复访问 .length,提升性能并增强可读性。

这些变量共同构成了滑动窗口的状态机基础。


块 2:提前剪枝

    if(tlen>slen){
        return '';
    }

功能解释:
这是一个高效的提前终止判断。如果目标字符串 t 的长度大于源字符串 s,那么 s 中不可能包含足够多的字符来覆盖 t(即使所有字符都匹配,数量也不够)。因此直接返回空字符串,避免后续无意义计算。这是算法优化中常见的“剪枝”技巧。


块 3:构建目标字符频次表

    const smap=new Map();
    const tmap=new Map();let start=0;
    for(let i=0;i<tlen;i++){
        tmap.set(t[i],(tmap.get(t[i])||0)+1);
    }

功能解释:

  • tmap 用于统计字符串 t 中每个字符的出现次数,即“我们需要多少个每种字符”;
  • smap 用于动态记录当前滑动窗口 [left, right) 中各字符的实际出现次数;
  • start 用于记录最终答案子串的起始索引;
  • 使用 Map 而非普通对象,是因为 Map 对任意字符串键(包括特殊字符)支持更好,且 .has() 方法语义清晰、性能稳定;
  • 循环遍历 t,逐个累加字符频次,构建出完整的需求表。

块 4:右指针扩展窗口

    while(right<slen){
      
        
        if(tmap.has(s[right])){
             smap.set(s[right],(smap.get(s[right])||0)+1);
             if(smap.get(s[right])===tmap.get(s[right])){
                vail++;
             }
        }
         right++;

功能解释:
这是滑动窗口的扩张阶段

  • 每次循环将 s[right] 纳入窗口(注意:此时窗口为 [left, right],但 right 随后自增,实际维护的是左闭右开区间 [left, right));
  • 只有当 s[right] 是 t 中需要的字符(即 tmap.has(...) 为真)时,才更新 smap
  • 当该字符在窗口中的数量刚好等于 t 中的需求量时,说明这一类字符已“达标”,vail 加 1;
  • 为什么是“等于”而不是“≥”?  因为一旦超过,再次增加不会让“达标种类数”变多,否则会导致 vail 被重复计算;
  • 最后 right++,准备下一轮扩展。

块 5:判断窗口是否合法

        while(vail===tmap.size){

功能解释:

  • tmap.size 表示 t 中不同字符的种类数
  • vail === tmap.size 意味着:当前窗口中,每一种 t 中的字符都至少达到了所需数量,即窗口“合法”;
  • 使用 while 而非 if 是因为:在收缩过程中,可能连续多次满足条件(例如窗口很长),需要持续收缩直到不再合法;
  • 一旦进入此循环,就说明找到了一个可行解,可以开始尝试优化(缩短)它。

块 6:收缩左边界并更新答案

            if(right-left<len){
                start=left;
                len=right-left;
            }
            if(tmap.has(s[left])){
                 if (smap.get(s[left]) === tmap.get(s[left])) {
                    vail--;
                }
                smap.set(s[left], smap.get(s[left]) - 1);
            }
               left++;

功能解释:
这是滑动窗口的收缩与优化阶段,顺序至关重要:

  1. 先更新答案:当前窗口 [left, right) 是合法的,其长度为 right - left。若比历史最短还小,则更新 start 和 len

  2. 再处理 s[left]

    • 如果它是 t 中的字符,需检查:在移除前,它的数量是否“刚好满足”需求?

      • 如果是,移除后就会“不满足”,因此 vail--
    • 无论是否影响 vail,都要将 smap 中该字符计数减 1;

  3. 最后 left++ :真正将左边界右移,缩小窗口。


块 7:返回最终结果

    }
   return len === Infinity ? "" : s.substring(start, start + len);
};

功能解释:

  • 如果 len 仍是 Infinity,说明从未找到合法窗口,返回空串;

  • 否则,从 start 开始截取长度为 len 的子串;

  • s.substring(start, start + len) 是安全的:

    • 因为 start 始终 ≤ right ≤ slen
    • start + len = start + (right - left),而在更新时 right ≤ slenleft ≥ 0,所以不会越界;
  • 注意:JavaScript 的 substring 不会因终点超出长度而报错,会自动截断到字符串末尾,但在此算法中不会发生这种情况。


【总结】

这段代码完整实现了滑动窗口算法的核心思想:通过双指针动态维护一个“可行解”窗口,并在满足条件时不断尝试优化其长度。尽管变量命名略显简略(如 vail 应为 valid),但逻辑严谨、边界处理得当,能够高效解决最小覆盖子串问题。

关键在于理解:

  • 扩张阶段right 移动):收集信息,直到窗口首次覆盖 t
  • 收缩阶段left 移动):在保持覆盖的前提下缩短窗口;
  • 状态同步:通过 smapvail 精确反映当前窗口是否合法。

掌握这一模式,不仅能解决本题,还能迁移到大量子串匹配类问题中。滑动窗口的本质,是一种“试探—验证—优化”的工程思维。