滑动窗口经典题:最小覆盖子串(LeetCode 76)——JavaScript 实现与深度解析

79 阅读4分钟

前言

在算法面试中, “最小覆盖子串” (Minimum Window Substring,LeetCode 第 76 题)是一道高频且极具代表性的 滑动窗口(Sliding Window) 问题。它不仅考察你对双指针技巧的掌握,更深入检验你对哈希表、字符频次统计以及边界条件处理的理解。

题目要求:给定字符串 st,找出 s包含 t 所有字符的最短连续子串。若不存在,返回空字符串。

本文将从暴力解法出发,逐步优化至 O(m + n) 时间复杂度 的滑动窗口解法,并用 JavaScript 详细实现每一步逻辑,助你彻底掌握这一经典套路。


一、问题分析

核心要求

  1. 子串必须连续
  2. 必须包含 t 中所有字符(包括重复字符的数量)
  3. 返回长度最短的那个子串

示例回顾

js
编辑
s = "ADOBECODEBANC", t = "ABC"
// 最小覆盖子串是 "BANC"(包含 A、B、C 各至少一次)

关键难点

  • 如何高效判断当前窗口是否“覆盖”了 t
  • 如何动态调整窗口大小以找到“最小”?

二、解题思路:滑动窗口 + 哈希表

🎯 核心思想

使用两个指针 leftright 维护一个窗口:

  • 扩张右边界right++):直到窗口包含 t 的所有字符
  • 收缩左边界left++):在满足覆盖的前提下,尽可能缩小窗口
  • 记录最小窗口

🔧 辅助工具:两个哈希表

哈希表作用
need记录 t 中每个字符所需的最小数量
window记录当前窗口中每个字符的实际数量

✅ 覆盖条件判断

window每个字符的数量 ≥ need 中对应数量时,窗口有效。

为避免每次遍历整个 need,我们引入一个计数器 valid

  • 当某个字符 c 在 window[c] === need[c] 时,valid++
  • 当 valid === Object.keys(need).length 时,窗口覆盖成功

三、算法步骤详解

  1. 初始化

    • 构建 need 哈希表(统计 t 的字符频次)
    • 初始化 windowleft = 0right = 0
    • 初始化 valid = 0
    • 初始化结果变量:start = 0len = Infinity
  2. 扩张窗口(移动 right)

    • 将 s[right] 加入 window
    • 若该字符在 need 中,且 window[c] === need[c],则 valid++
  3. 收缩窗口(移动 left)

    • 当 valid === need.size 时,尝试收缩
    • 更新最小窗口(记录 start 和 len
    • 移除 s[left],若该字符在 need 中且移除后 window[c] < need[c],则 valid--
    • left++
  4. 返回结果

    • 若 len === Infinity,返回 ""
    • 否则返回 s.substring(start, start + len)

四、JavaScript 完整实现

js
编辑
/**
 * @param {string} s
 * @param {string} t
 * @return {string}
 */
var minWindow = function(s, t) {
    if (t.length > s.length) return "";

    // 1. 构建 need 哈希表
    const need = new Map();
    for (let char of t) {
        need.set(char, (need.get(char) || 0) + 1);
    }

    // 2. 初始化滑动窗口变量
    const window = new Map();
    let left = 0, right = 0;
    let valid = 0; // 记录 window 中满足 need 条件的字符个数

    // 3. 记录最小覆盖子串的起始索引及长度
    let start = 0, len = Infinity;

    // 4. 开始滑动窗口
    while (right < s.length) {
        // 扩张窗口:加入 s[right]
        const c = s[right];
        right++;

        if (need.has(c)) {
            window.set(c, (window.get(c) || 0) + 1);
            // 判断该字符是否满足需求
            if (window.get(c) === need.get(c)) {
                valid++;
            }
        }

        // 收缩窗口:当 valid === need.size 时
        while (valid === need.size) {
            // 更新最小覆盖子串
            if (right - left < len) {
                start = left;
                len = right - left;
            }

            // 移除 s[left]
            const d = s[left];
            left++;

            if (need.has(d)) {
                if (window.get(d) === need.get(d)) {
                    valid--; // 破坏满足条件
                }
                window.set(d, window.get(d) - 1);
            }
        }
    }

    // 5. 返回结果
    return len === Infinity ? "" : s.substring(start, start + len);
};

五、关键细节解析

1. 为什么用 Map 而不是对象?

  • Map 更适合动态键值对,且能正确处理任意字符(包括特殊符号)
  • 避免原型链污染问题

2. valid 计数器的作用

  • 避免每次遍历 need 判断是否覆盖(否则时间复杂度退化为 O(n²))
  • 仅当字符数量恰好相等时才增加 valid,确保精确匹配

3. 边界条件处理

  • t.length > s.length → 直接返回 ""
  • len === Infinity → 无解

4. 时间复杂度分析

  • 每个字符最多被 right 和 left 各访问一次 → O(m + n)
  • 空间复杂度:O(k),k 为字符集大小(常数级)

六、测试用例验证

js
编辑
console.log(minWindow("ADOBECODEBANC", "ABC")); // "BANC"
console.log(minWindow("a", "a"));               // "a"
console.log(minWindow("a", "aa"));              // ""
console.log(minWindow("ab", "b"));              // "b"

七、大厂面试延伸问题

  1. 如果 t 中有重复字符,如何保证数量足够?
    → 通过 needwindow 的频次对比解决。
  2. 能否用数组代替 Map
    → 可以!若字符集固定(如 ASCII),可用长度为 128 的数组,性能更高。
  3. 如何优化空间?
    → 使用数组或只维护 need 中出现的字符。
  4. 变体:求最长不重复子串?
    → 同样用滑动窗口,但条件变为“窗口内无重复”。

结语

“最小覆盖子串”是滑动窗口问题的典范,其核心在于:

  • 用哈希表维护字符频次
  • 用 valid 计数器高效判断覆盖状态
  • 双指针动态调整窗口

掌握这套模板,你不仅能解决本题,还能轻松应对“字符串排列”、“异位词分组”等同类问题。在面试中,清晰地阐述 needwindowvalid 的设计意图,将极大提升你的专业形象。

记住
“滑动窗口不是魔法,而是对问题本质的精准建模。”
—— 用数据结构说话,用指针控制节奏。