今天拆解 LeetCode 经典hard题——76. 最小覆盖子串,这道题是滑动窗口算法的核心应用场景,也是面试高频考点。题目不算难,但细节超多,尤其是“包含重复字符”的要求,很容易踩坑。本文会从题目解析、代码逐行拆解、关键注意点,到效率优化,一步步讲明白,新手也能轻松看懂~
一、题目核心解析
题目描述(简化版)
给定两个字符串 s(母串)和 t(目标串),返回 s 中 最短的窗口子串,要求该子串包含 t 中的每一个字符(包括重复出现的字符)。如果没有这样的子串,返回空字符串 ""。测试用例保证答案唯一。
核心关键词拆解
-
最短窗口:子串长度尽可能小,这是我们最终要优化的目标;
-
包含所有字符(含重复):比如 t = "aba",则子串中必须至少有 2 个 'a' 和 1 个 'b',少一个、多一个重复都不满足;
-
答案唯一:不用考虑多解的情况,找到最短的即可。
解题思路选择
这道题最直观的思路是暴力枚举所有子串,判断是否包含 t 的所有字符,但时间复杂度是 O(m²)(m 是 s 的长度),当 s 长度达到 10^5 时会直接超时。
最优解法是 滑动窗口(双指针),核心逻辑是:用两个指针(left、right)维护一个“窗口”,右指针扩展窗口、左指针收缩窗口,在扩展和收缩的过程中,找到满足条件的最短窗口。时间复杂度可以优化到 O(m),是这道题的标准解法。
二、完整代码(可直接运行)
先给出可直接提交到 LeetCode 的 TypeScript 代码(JavaScript 可直接删除类型注解使用),后文逐行拆解每一步的作用:
function minWindow(s: string, t: string): string {
const sL = s.length;
const tL = t.length;
// 边界判断:如果s或t为空,直接返回空串
if (sL === 0 || tL === 0) {
return "";
}
// tMap:存储t中每个字符的出现次数(目标频率)
const tMap = new Map();
for (const char of t) {
tMap.set(char, (tMap.get(char) || 0) + 1);
}
let left = 0; // 滑动窗口左指针
let right = 0; // 滑动窗口右指针
let matchCount = 0; // 已匹配t中字符的数量(含重复)
let minL = sL + 1; // 最短窗口长度(初始化为s长度+1,方便后续判断是否找到有效窗口)
let res = ''; // 最终结果子串
// tempMap:存储当前窗口中,t中存在的字符的出现次数(窗口频率)
const tempMap = new Map();
// 右指针扩展窗口:遍历整个s
while (right < sL) {
const currentWord = s[right];
// 只处理t中存在的字符,无关字符直接跳过
if (tMap.has(currentWord)) {
// 更新当前窗口中该字符的频率
const currentWordTempCount = (tempMap.get(currentWord) || 0) + 1;
tempMap.set(currentWord, currentWordTempCount);
// 只有当窗口中该字符的频率 ≤ t中该字符的频率时,才增加匹配计数(避免重复计数)
if (currentWordTempCount <= tMap.get(currentWord)) {
matchCount++;
}
}
// 当窗口满足条件(匹配数量等于t的长度),收缩左指针,寻找最短窗口
while (matchCount === tL) {
// 计算当前窗口长度
const currentWindowLength = right - left + 1;
// 更新最短窗口长度和结果子串
if (currentWindowLength < minL) {
minL = currentWindowLength;
res = s.substring(left, right + 1);
}
// 收缩左指针:移除左指针指向的字符,更新窗口频率和匹配计数
const leftChar = s[left];
if (tMap.get(leftChar) !== undefined) {
// 缓存值,减少Map冗余查找(提升效率)
const windowCount = tempMap.get(leftChar);
const tCount = tMap.get(leftChar);
// 窗口中该字符的频率减1
tempMap.set(leftChar, windowCount - 1);
// 只有当窗口中该字符的频率 < t中该频率时,才减少匹配计数(表示该字符不再满足要求)
if (tempMap.get(leftChar) < tCount) {
matchCount--;
}
}
// 左指针右移,收缩窗口
left++;
}
// 右指针右移,扩展窗口
right++;
}
// 返回结果(如果minL未更新,说明没有有效窗口,返回空串)
return res;
};
三、代码逐行拆解(关键细节必看)
滑动窗口的核心是“扩展-收缩”的循环,我们按代码执行顺序,拆解每一步的作用,重点标注容易踩坑的细节。
1. 边界判断(基础防护)
const sL = s.length;
const tL = t.length;
if (sL === 0 || tL === 0) {
return "";
}
最基础的边界处理:如果 s 或 t 为空,直接返回空串,避免后续无效运算。
2. 构建目标字符频率映射(tMap)
const tMap = new Map();
for (const char of t) {
tMap.set(char, (tMap.get(char) || 0) + 1);
}
作用:记录 t 中每个字符的出现次数,比如 t = "aba",tMap 最终为 Map(2) { 'a' => 2, 'b' => 1 }。
细节:(tMap.get(char) || 0) + 1是为了处理“字符首次出现”的情况——如果 Map 中没有该字符,get 返回 undefined,|| 0 取到默认值 0,再加 1 就是首次出现的次数。
3. 初始化滑动窗口相关变量
let left = 0; // 左指针(窗口左边界)
let right = 0; // 右指针(窗口右边界)
let matchCount = 0; // 已匹配t中字符的数量(含重复)
let minL = sL + 1; // 最短窗口长度(初始值大于s的长度,方便后续判断)
let res = ''; // 结果子串
const tempMap = new Map(); // 窗口内目标字符的频率映射
变量说明(重点理解 matchCount 和 tempMap):
-
matchCount:不是“匹配的不同字符个数”,而是“匹配的字符总个数(含重复)”。比如 t = "aba"(总长度 3),只有当 matchCount = 3 时,才说明窗口中包含了 t 的所有字符(2个a+1个b);
-
minL:初始化为 sL + 1,因为最短窗口长度最大不会超过 s 的长度,后续只要找到更短的有效窗口,就更新 minL;如果最终 minL 还是 sL + 1,说明没有找到有效窗口;
-
tempMap:和 tMap 对应,记录当前窗口中,t 中存在的字符的出现次数(无关字符不记录)。
4. 右指针扩展窗口(核心步骤1)
while (right < sL) {
const currentWord = s[right];
if (tMap.has(currentWord)) {
const currentWordTempCount = (tempMap.get(currentWord) || 0) + 1;
tempMap.set(currentWord, currentWordTempCount);
if (currentWordTempCount <= tMap.get(currentWord)) {
matchCount++;
}
}
// ... 收缩窗口逻辑 ...
right++;
}
作用:右指针从左到右遍历 s,不断扩大窗口,收集窗口内的目标字符(t 中存在的字符),并更新 tempMap 和 matchCount。
关键细节(避坑重点):
-
只处理 t 中存在的字符(
tMap.has(currentWord)):无关字符不会影响匹配结果,直接跳过,减少无效操作; -
matchCount 的更新条件(
currentWordTempCount <= tMap.get(currentWord)):避免重复计数。比如 t 中 'a' 出现 2 次,窗口中 'a' 从 2 增加到 3 时,已经满足 t 的要求,此时不需要再增加 matchCount(否则 matchCount 会超过 tL,导致后续判断失效)。
5. 左指针收缩窗口(核心步骤2)
while (matchCount === tL) {
const currentWindowLength = right - left + 1;
if (currentWindowLength < minL) {
minL = currentWindowLength;
res = s.substring(left, right + 1);
}
const leftChar = s[left];
if (tMap.get(leftChar) !== undefined) {
const windowCount = tempMap.get(leftChar);
const tCount = tMap.get(leftChar);
tempMap.set(leftChar, windowCount - 1);
if (tempMap.get(leftChar) < tCount) {
matchCount--;
}
}
left++;
}
作用:当窗口满足条件(matchCount === tL,即包含 t 的所有字符)时,左指针右移,不断收缩窗口,寻找当前情况下的最短窗口,并更新窗口状态。
关键细节(避坑重点):
-
窗口长度计算(
right - left + 1):因为字符串索引从 0 开始,比如 left=2、right=4,窗口包含 3 个字符(2、3、4),所以需要 +1; -
子串截取(
s.substring(left, right + 1)):substring 的第二个参数是“结束索引(不包含)”,所以用 right + 1 才能截取到 right 指向的字符; -
tempMap 和 matchCount 的更新:收缩窗口时,移除左指针指向的字符,如果该字符是目标字符,需要减少 tempMap 中对应的频率;只有当频率小于 t 中的频率时,才减少 matchCount(表示该字符不再满足 t 的要求,窗口需要重新扩展)。
6. 结果返回
return res;
细节:如果没有找到有效窗口(比如 s 中不包含 t 的所有字符),minL 不会被更新(仍为 sL + 1),此时 res 还是初始值空串,直接返回即可,符合题目要求。
四、常见踩坑点总结(必看!)
这道题的坑主要集中在“匹配计数”和“窗口边界”上,整理了 4 个最容易出错的点,帮你避开:
-
matchCount 计数错误:把“匹配的不同字符个数”当成“匹配的总字符个数”,导致判断窗口是否满足条件时失效;
-
窗口长度/子串截取错误:忘记 +1,导致窗口长度计算偏小、子串截取不完整;
-
matchCount 重复更新:没有加
currentWordTempCount<= tMap.get(currentWord)条件,导致 matchCount 超过 tL,窗口收缩逻辑无法触发; -
边界判断缺失:没有处理 s 或 t 为空的情况,或者没有判断“无有效窗口”的情况(虽然本题测试用例答案唯一,但严谨性很重要)。
五、效率优化建议(进阶)
上面的代码已经能通过 LeetCode 所有测试用例,但在处理超长字符串(比如 s 长度 10^5)时,还能进一步优化效率,核心优化方向有 3 个:
优化1:减少 Map 冗余查找
当前代码中,tempMap.get(currentWord)、tMap.get(currentWord) 等操作会被多次调用,Map 的哈希查找有一定开销。可以将查找结果缓存到变量中,一次查找多次使用,比如:
// 优化前
const currentWordTempCount = (tempMap.get(currentWord) || 0) + 1;
// 优化后
const currentTempCount = tempMap.get(currentWord) || 0;
const currentWordTempCount = currentTempCount + 1;
优化2:用 Object 替换 Map(提升访问速度)
Map 是复杂数据结构,有额外的迭代器和内存管理开销,而字符作为键,更适合用普通 Object 存储(Object 直接访问速度比 Map 快)。比如将 tMap 和 tempMap 替换为 Object:
// 替换 tMap 为 Object
const tObj: { [key: string]: number } = {};
for (const char of t) {
tObj[char] = (tObj[char] || 0) + 1;
}
// 替换 tempMap 为 Object
const windowObj: { [key: string]: number } = {};
优化3:超大字符串场景用 ASCII 数组
如果 s 和 t 的字符都是 ASCII 字符(0~127),可以用数组存储字符频率(数组是连续内存,访问速度最快),比如:
const tArr = new Array(128).fill(0);
for (const char of t) {
tArr[char.charCodeAt(0)]++;
}
六、总结
LeetCode 76. 最小覆盖子串的核心是 滑动窗口(双指针),记住一句话:右指针扩展窗口找“满足条件的窗口”,左指针收缩窗口找“最短的窗口”。
解题关键在于:
-
用频率映射(Map/Object/数组)记录目标字符和窗口字符的出现次数;
-
用 matchCount 记录匹配的字符总个数(含重复),作为窗口是否满足条件的判断依据;
-
注意窗口边界、计数条件等细节,避开常见踩坑点。
这道题虽然是 hard 题,但只要理解了滑动窗口的“扩展-收缩”逻辑,再掌握细节处理,就能轻松 AC。建议多动手调试代码,观察 left、right、matchCount、tempMap 的变化过程,加深对滑动窗口的理解~