【题目描述】
给定两个字符串 s 和 t,请在 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++;
功能解释:
这是滑动窗口的收缩与优化阶段,顺序至关重要:
-
先更新答案:当前窗口
[left, right)是合法的,其长度为right - left。若比历史最短还小,则更新start和len; -
再处理
s[left]:-
如果它是
t中的字符,需检查:在移除前,它的数量是否“刚好满足”需求?- 如果是,移除后就会“不满足”,因此
vail--;
- 如果是,移除后就会“不满足”,因此
-
无论是否影响
vail,都要将smap中该字符计数减 1;
-
-
最后
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 ≤ slen,left ≥ 0,所以不会越界;
- 因为
-
注意:JavaScript 的
substring不会因终点超出长度而报错,会自动截断到字符串末尾,但在此算法中不会发生这种情况。
【总结】
这段代码完整实现了滑动窗口算法的核心思想:通过双指针动态维护一个“可行解”窗口,并在满足条件时不断尝试优化其长度。尽管变量命名略显简略(如 vail 应为 valid),但逻辑严谨、边界处理得当,能够高效解决最小覆盖子串问题。
关键在于理解:
- 扩张阶段(
right移动):收集信息,直到窗口首次覆盖t; - 收缩阶段(
left移动):在保持覆盖的前提下缩短窗口; - 状态同步:通过
smap和vail精确反映当前窗口是否合法。
掌握这一模式,不仅能解决本题,还能迁移到大量子串匹配类问题中。滑动窗口的本质,是一种“试探—验证—优化”的工程思维。