一眼识破“最小覆盖子串”(LeetCode 76):我为什么一看就知道是滑动窗口?(两种写法 + 详细思路)
【面试高频题解析】滑动窗口解决最短覆盖子串问题本文针对面试高频题"最短覆盖子串"提供两种高效解法:HashMap标准滑动窗口(通用模板):通过维护need表和formed计数实现,时间复杂度O(|s|+|t|),适用于任意字符集计数数组+剩余需求(更高效):利用ASCII数组和remain计数,适合字符集较小的情况解题关键点:识别滑动窗口的三要素:子串连续、包含特定字符、求最短/最长正确处理边界条
适合收藏的面试高频题解析。本文带你从“识题→思路→代码→优化→类题拓展”一步到位,两种可直接过OJ的实现,并讲清楚:为什么遇到这种题,第一反应就是滑动窗口。
题目速览
给定字符串 s 和 t,返回 s 中包含 t 所有字符(含出现次数)的最短子串;若不存在返回空串。
例如:s="ADOBECODEBANC", t="ABC",答案是 "BANC"。
为什么一看就知道是滑动窗口?
把这几个“关键词”画重点:
-
子串(连续)
-
包含/覆盖(要求窗口内某些元素的计数达到目标)
-
最短/最长(一边扩、一边收)
当一个题同时出现这三类信号时,经验法则:80% 是滑动窗口 + 计数。
其中,“包含/覆盖”意味着要维护一个需求表(HashMap/数组),“最短/最长”意味着右指针扩张满足条件,左指针收缩尽量短。
解法一:HashMap 标准滑动窗口(通用、好记)
思路
-
用
need统计t中每个字符需要的次数;required = need 的键数。 -
右指针
r向右扩,记录window[ch]++。当某个字符ch的当前计数恰好等于need[ch]时,formed++。 -
当
formed == required,说明窗口已覆盖t;尝试用左指针l收缩,能收就收,同时维护最优答案。 -
左指针每左移一步,减少计数;如果某个
need被破坏(window[ch] < need[ch]),formed--,再去扩张。
复杂度
-
时间:
O(|s| + |t|) -
空间:
O(字母表)(Map 或数组)
代码(js)
// O(n + m) 时间,O(字符种类) 空间
function minWindow(s, t) {
if (!s || !t || s.length < t.length) return "";
// 1) 统计需求
const need = new Map();
for (const ch of t) need.set(ch, (need.get(ch) || 0) + 1);
const required = need.size;
// 2) 窗口计数
const window = new Map();
let formed = 0;
let l = 0;
let bestLen = Infinity, bestL = 0;
// 3) 右指针扩张
for (let r = 0; r < s.length; r++) {
const ch = s[r];
window.set(ch, (window.get(ch) || 0) + 1);
if (need.has(ch) && window.get(ch) === need.get(ch)) formed++;
// 4) 满足覆盖后尝试收缩
while (l <= r && formed === required) {
if (r - l + 1 < bestLen) {
bestLen = r - l + 1;
bestL = l;
}
const leftCh = s[l];
window.set(leftCh, (window.get(leftCh) || 0) - 1);
if (need.has(leftCh) && window.get(leftCh) < need.get(leftCh)) {
formed--;
}
l++;
}
}
return bestLen === Infinity ? "" : s.slice(bestL, bestL + bestLen);
}
常见坑位
-
“恰好等于需要次数时才
formed++”,不能写成大于等于。 -
收缩左边时,先更新答案再减计数,顺序不能颠倒。
-
t有重复字符(例如"aa")时,Map 的正确性尤为重要。
解法二:计数数组 + 剩余需求(更简洁、速度通常更快)
解法一用的是“种类满足”的思路;解法二换个角度:维护“还差多少个字符”。
具体做法:
-
need[128]统计t中每个 ASCII 字符需要的次数,remain = len(t)。 -
右指针每读到一个字符
c,把need[c]--;如果need[c]仍然 ≥ 0,说明这个c正好能“抵债”,remain--。 -
当
remain == 0说明已经覆盖,开始用左指针收缩:-
若
need[s[l]] < 0,说明窗口里该字符多了,need[s[l]]++并左移; -
否则当前窗口已经是该右端下的最短,尝试更新答案,然后把
s[l]退出窗口:need[s[l]]++,remain++,左移一格,回到扩张阶段。
-
代码(Js)
// O(n + m) 时间,O(常数) 空间(ASCII 128)
function minWindow2(s, t) {
if (!s || !t || s.length < t.length) return "";
const need = new Array(128).fill(0);
for (let i = 0; i < t.length; i++) need[t.charCodeAt(i)]++;
let remain = t.length; // 还差多少字符
let l = 0, bestL = 0, bestLen = Infinity;
for (let r = 0; r < s.length; r++) {
const cr = s.charCodeAt(r);
if (need[cr] > 0) remain--; // 抵债成功
need[cr]--;
// 覆盖后尽量收缩
while (remain === 0) {
if (r - l + 1 < bestLen) {
bestLen = r - l + 1;
bestL = l;
}
const cl = s.charCodeAt(l);
need[cl]++; // 退还左端字符
if (need[cl] > 0) remain++; // 退还后又欠债
l++;
}
}
return bestLen === Infinity ? "" : s.slice(bestL, bestL + bestLen);
}
优点:纯数组,常数因子小;“抵债/欠债”语义非常直观。
适用:字符集不大(ASCII、扩展 ASCII、甚至 Unicode 也可用 HashMap 变形实现)。
两种写法如何选?
-
喜欢通用模板、字符集未知:用解法一(HashMap 模板)。
-
追求更快、字符集较小(ASCII 等):用解法二(计数数组 + remain)。
-
s很长、t很短:在二者之上叠加索引过滤优化。
测试用例(覆盖边界)
const cases = [
["ADOBECODEBANC", "ABC", "BANC"],
["a", "a", "a"],
["a", "aa", ""],
["ab", "b", "b"],
["aa", "aa", "aa"],
["bba", "ab", "ba"]
];
for (const [s, t, expect] of cases) {
console.log("Map :", minWindow(s, t), " <=> expect:", expect);
console.log("Array:", minWindow2(s, t), " <=> expect:", expect);
console.log("---");
}
这类题的“识题清单”(拿走就能用)
-
出现 “子串/窗口 + 覆盖/包含/至少 + 最短/最长” → 优先想 滑动窗口。
-
需要统计个数 → 准备 Counter/Map/计数数组。
-
“最短”/“最长” → 右扩满足条件,左收尽量逼近。
-
代码骨架:
for r in range(n): 扩张→while 满足条件: 更新答案 + 收缩。
延伸练手(同一模板)
-
567. 字符串的排列(判断是否存在长度为
len(t)的“覆盖窗口”) -
438. 找到字符串中所有字母异位词
-
3. 无重复字符的最长子串(窗口内维护去重)
结语
如果这篇对你有帮助,一键三连就是对我最大的支持!也欢迎在评论区分享你的“滑动窗口心得/易错点”,一起把这道“面试经典题”玩明白。
更多推荐



所有评论(0)