前言
在算法面试中, “最小覆盖子串” (Minimum Window Substring,LeetCode 第 76 题)是一道高频且极具代表性的 滑动窗口(Sliding Window) 问题。它不仅考察你对双指针技巧的掌握,更深入检验你对哈希表、字符频次统计以及边界条件处理的理解。
题目要求:给定字符串 s 和 t,找出 s 中包含 t 所有字符的最短连续子串。若不存在,返回空字符串。
本文将从暴力解法出发,逐步优化至 O(m + n) 时间复杂度 的滑动窗口解法,并用 JavaScript 详细实现每一步逻辑,助你彻底掌握这一经典套路。
一、问题分析
核心要求
- 子串必须连续
- 必须包含
t中所有字符(包括重复字符的数量) - 返回长度最短的那个子串
示例回顾
js
编辑
s = "ADOBECODEBANC", t = "ABC"
// 最小覆盖子串是 "BANC"(包含 A、B、C 各至少一次)
关键难点
- 如何高效判断当前窗口是否“覆盖”了
t? - 如何动态调整窗口大小以找到“最小”?
二、解题思路:滑动窗口 + 哈希表
🎯 核心思想
使用两个指针 left 和 right 维护一个窗口:
- 扩张右边界(
right++):直到窗口包含t的所有字符 - 收缩左边界(
left++):在满足覆盖的前提下,尽可能缩小窗口 - 记录最小窗口
🔧 辅助工具:两个哈希表
| 哈希表 | 作用 |
|---|---|
need | 记录 t 中每个字符所需的最小数量 |
window | 记录当前窗口中每个字符的实际数量 |
✅ 覆盖条件判断
当 window 中每个字符的数量 ≥ need 中对应数量时,窗口有效。
为避免每次遍历整个 need,我们引入一个计数器 valid:
- 当某个字符
c在window[c] === need[c]时,valid++ - 当
valid === Object.keys(need).length时,窗口覆盖成功
三、算法步骤详解
-
初始化
- 构建
need哈希表(统计t的字符频次) - 初始化
window、left = 0、right = 0 - 初始化
valid = 0 - 初始化结果变量:
start = 0,len = Infinity
- 构建
-
扩张窗口(移动 right)
- 将
s[right]加入window - 若该字符在
need中,且window[c] === need[c],则valid++
- 将
-
收缩窗口(移动 left)
- 当
valid === need.size时,尝试收缩 - 更新最小窗口(记录
start和len) - 移除
s[left],若该字符在need中且移除后window[c] < need[c],则valid-- left++
- 当
-
返回结果
- 若
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"
七、大厂面试延伸问题
- 如果
t中有重复字符,如何保证数量足够?
→ 通过need和window的频次对比解决。 - 能否用数组代替
Map?
→ 可以!若字符集固定(如 ASCII),可用长度为 128 的数组,性能更高。 - 如何优化空间?
→ 使用数组或只维护need中出现的字符。 - 变体:求最长不重复子串?
→ 同样用滑动窗口,但条件变为“窗口内无重复”。
结语
“最小覆盖子串”是滑动窗口问题的典范,其核心在于:
- 用哈希表维护字符频次
- 用
valid计数器高效判断覆盖状态 - 双指针动态调整窗口
掌握这套模板,你不仅能解决本题,还能轻松应对“字符串排列”、“异位词分组”等同类问题。在面试中,清晰地阐述 need、window、valid 的设计意图,将极大提升你的专业形象。
记住:
“滑动窗口不是魔法,而是对问题本质的精准建模。”
—— 用数据结构说话,用指针控制节奏。