聊到这道题的时候,我脑海里冒出来的第一个画面是几年前在出租屋里对着编译错误发呆的夜晚。按钮一按,红色的WA又来了。很多人第一次接触“最小覆盖子串”都是这种状态:题面明明就一句话,找包含目标串所有字符的最短子串,但真上手写,要么超时,要么边界漏得跟筛子一样。而我那年刚好把刷题计划排到第九天,题目编号又正好是76,这个“九 76”就成了绕不过去的坎。
这篇文章就把这道滑动窗口的招牌题彻底拆开,从暴力解法的复杂度账本,到计数表的精度设计,再到那些一写就错的缩窗口细节。代码、反例、面试表达,能给的都给你。适合正在系统刷题的新手,也适合想把自己代码写得更干净的老手重温一遍。
1. 题面拆解:别把“覆盖”理解成“包含”
1.1 输入输出的约定与边界
先把题面说透。给定一个源串s和一个目标串t,要求从s中找出最短的一个连续子串,这个子串要“覆盖”t中的全部字符。这里的覆盖不是集合意义上的包含,而是频次意义上的覆盖——t里如果出现了两个A,那子串里至少也要有两个A。
例子很经典:s = "ADOBECODEBANC",t = "ABC",最短覆盖子串是"BANC",长度4。注意"ADOBEC"也是覆盖ABC的,长度为6,但不是最短。边界条件是:如果s中根本不存在能覆盖t的子串,返回空串""。
我见过不少新手第一反应是用Set 去判断覆盖,把t拆成字符集合,然后看子串是否包含这个集合。这个思路一遇到重复字符就崩了。t = "AABC"的时候,Set只记得A、B、C三个字符,实际需要的A出现两次。这种需求用频次表比用集合更准确,这也是后面整个算法的基石。
1.2 暴力解的复杂度账本
暴力做法是枚举所有子串。s的长度记作n,t的长度记作m。子串起点有n种选择,终点有n种,共O(n^2)个子串。每个子串判断是否覆盖t,最坏要把子串扫一遍再和目标频次比对,复杂度O(n+m)。总复杂度O(n^2 * (n+m)),约等于O(n^3)。
n到1e4级别这个算法就已经跑不动了,更别说LeetCode上真正的大样本。那为什么暴力这么慢?根本原因是大量重复计算——同一个字符在不同起点终点组合中被反复扫描、反复比对频次,没有任何复用。
提示:很多双指针题优化的本质是把“重复遍历”变成“增量更新”。滑动窗口正是这一思想的直接产物。
2. 滑动窗口为什么是这道题的天选解法
2.1 从双指针到窗口维护的直觉
滑动窗口说白了就是两个指针:left和right,并且都只往右移动,不回退。right每走一步,窗口就多纳入一个字符;当窗口满足覆盖条件时,想办法移动left收缩窗口,试图找到更短的合法区间。
可以想象成一条拉链:右指针负责把布料往前推,拉到覆盖住所有需要的字符时暂停;左指针再从后面把多余的布料缩回去,直到再缩一个字符就会破坏覆盖为止。缩到极限后,记录当前窗口长度;然后右指针继续推进,重复这个过程。
这种思路之所以高效,是因为每个字符最多被left访问一次、被right访问一次,整体是O(n)级别的遍历。窗口内容的变化是增量式的,不再从零扫描。
2.2 计数表的设计:两个哈希一个计数器
代码实现的核心是三块:目标需求表need、当前窗口表window、覆盖状态计数器valid。
need负责记录t中每个字符的需求量。window记录当前窗口[left, right]范围内每个字符的实际出现次数。valid记录的是“当前窗口中已经满足需求量的字符种类数”。
举例说明:t = "AABC",need = {A:2, B:1, C:1},一共有3种字符。当窗口里的A出现次数已经不少于2,B不少于1,C不少于1,那么valid = 3。三种字符全部满足,覆盖成立。
新手容易忽略的一点是:valid记录的是“种类数”而不是“字符总数”。窗口里有5个A、0个B时,A的计数已经满足,但valid只能说A这一种达标,可能只有1。不要把valid累加成所有字符的出现次数,否则判断覆盖时会出现严重的逻辑错乱。
2.3 窗口收缩的触发条件与更新时机
收缩窗口的触发条件是valid == need.size(),也就是每个目标字符的频次都已达标。此时窗口是一个“合法覆盖”,但未必最短。于是移动left,把左侧字符移出窗口。
每移动一位left,窗口长度减少一位。移动之后,如果窗口仍然覆盖,说明还能继续缩短;如果某个字符的频次掉到需求量以下,覆盖被破坏,停止收缩,等待right继续扩展。
这里藏着整道题最容易写错的一个地方:更新答案的时机。应该在收缩过程中,每移动一次left都检查当前窗口长度是否小于已记录的最小值。如果只在收缩结束之后更新一次,可能会丢掉中间某一步更短的结果。实测下来,我最初就是在这一步漏了案例。
3. 可复现代码:C++实现与逐行解读
3.1 核心代码
#include <string> #include <unordered_map> #include <climits> using namespace std; class Solution { public: string minWindow(string s, string t) { if (s.empty() || t.empty()) return ""; unordered_map<char, int> need, window; for (char c : t) need[c]++; int left = 0, right = 0; int valid = 0; int start = 0, minLen = INT_MAX; while (right < s.length()) { char c = s[right]; right++; if (need.count(c)) { window[c]++; if (window[c] == need[c]) { valid++; } } while (valid == (int)need.size()) { if (right - left < minLen) { start = left; minLen = right - left; } char d = s[left]; left++; if (need.count(d)) { if (window[d] == need[d]) { valid--; } window[d]--; } } } return minLen == INT_MAX ? "" : s.substr(start, minLen); } };3.2 每一步在干什么
核心循环分两步。第一步是右指针扩展:把新字符计入window。如果该字符是t需要的,更新窗口计数。当window中的计数恰好等于need中的需求量时,说明这一种类已经覆盖,valid加一。
第二步是左指针收缩:当valid等于need.size(),说明当前窗口已覆盖。立刻检查并记录长度——注意这里是先判断是否更新答案,再移动left。为什么?因为移动left之后窗口就不一定合法了,你可能永远错过当前这个合法窗口的最短形态。
移动left时,先取出字符d,若d在need中,判断移除前window[d]是否等于need[d]。如果相等,说明移除这一位会使该字符的需求从“满足”变成“不满足”,valid要减一。然后再让window[d]减一。这个顺序极其重要,先减计数再判断valid会出错。
3.3 为什么用ASCII固定数组也可以
上面代码用了unordered_map。很多C++选手也喜欢用数组int window[128]和int need[128],在字符集是ASCII的场景下性能更好,常数更小。但一旦字符集扩展到Unicode,比如中文、表情符号,固定数组就撑不住了。实际面试推荐先问清楚字符范围,再决定用数组还是哈希表。能用数组时尽量用数组,代码短、运行快,也避免哈希函数带来的额外开销。
同理,Python和Java的哈希表用起来也方便,但Java里HashMap对Integer的缓存比较有陷阱,后面专门讲。
4. 踩坑记录与现场排查实录
4.1 常见的三个反例
反例一:s比t短,直接返回空。很多实现里循环跑完才判断是否minLen初始值,也能过,但提前判断是更好的防御式写法。
反例二:t中存在s完全没有的字符。need的size永远不可能被valid追平,循环结束时minLen保持初始值,返回空串。正确性没问题,但要注意不能拿“window中所有字符数量都不小于需求”这种条件替代valid判断,否则会漏。
反例三:收缩窗口过度。我见过有同学在移动left时不是一位一位移,而是直接跳到不再覆盖的地方,导致结果不是最短。滑动窗口必须一位一位收缩,每次收缩都检查答案。
4.2 HashMap取值用==比较的坑
Java版本里,如果写成window.get(c) == need.get(c),当计数值比较小时,Integer缓存可能导致结果偏差。小整数在-128到127之间会被缓存,==可能成立;一旦计数超过127,==会比较对象引用,直接失败。正确写法是intValue()比较或者equals()。C++的map没有这个问题,但Java的坑非常阴。
我实际遇到的情况:测试样例全都通过,一个隐藏大样例计数超过127后,结果全错。当时排查了很久,最后定位到这不显眼的==上。这就是典型的环境相关Bug。
4.3 更新答案的时机不对导致漏解
这是主观最容易踩的。假如在right扩展完、进入收缩循环之前更新一次答案,拿到的是第一次合法时的窗口长度,比如10。随后收缩,某个更短窗口长度7出现时,如果收缩循环里没有更新逻辑,就会错过。只要把更新逻辑放进while (valid == need.size())循环的开头,先比较再收缩,就不会漏。
4.4 常见问题速查表
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 结果返回空串 | s中不存在覆盖子串 | 判断minLen是否为初始值 |
| 结果不是最短 | 更新答案时机不对 | 收缩时每次移动left都检查 |
| valid计数错乱 | valid增减顺序错误 | 先判断再修改计数 |
| Java版本大样本WA | Integer缓存与==比较 | 改用equals或intValue |
| 覆盖判断出错 | 用Set而非频次表 | 用need和window两个计数表 |
5. 从76题到一类滑动窗口题的迁移
5.1 框架记忆法:四步模板
把滑动窗口的代码拆成固定模板,很多题都能套:
- right指针移动,字符进入窗口;
- 更新窗口内部的数据结构;
- 判断当前窗口是否满足题设条件;
- 收缩left指针,同时更新答案。
76题的“条件”是覆盖所有目标字符,用valid表示。换一道题,条件可能变成“窗口内没有重复字符”“窗口内只包含两种水果”“窗口内字符种类不超过k”等。关键是找到那个代表“满足条件”的计数器或者状态量。
5.2 无重复字符最长子串
经典题“无重复字符的最长子串”,条件变成窗口内不能有重复字符。于是用window记录字符出现次数。每次right扩展后,若window[c]大于1,说明有重复,收缩left直到重复消失。这里valid的概念被一个布尔条件替代,更简单一些,但框架完全一致。
这题值得注意的是求最长,所以每次right扩展后都更新答案,而不是等收缩过程。和76题正好相反。
5.3 最多包含两个不同字符的最长子串
这道题条件变成“窗口中不同字符数量 <= 2”。维护window表,同时维护一个变量count = window中非零键的数量。right扩展时,窗口新增字符若此前不存在,count增加;收缩left时,若某字符计数归零,count减少。其实和76题的valid有异曲同工之妙。
迁移时你就发现,valid和count本质上都是“当前窗口状态量”,只要维护好这个状态量,不同只是判断条件。这也是我后来刷题不再背模板、而是理解状态量的原因。
5.4 长度最小的子数组
另一道“长度最小子数组”给定数组和目标sum,求满足总和>=sum的最短连续子数组。窗口条件变成“窗口内元素和 >= sum”。此时窗口数据结构只需要一个sum变量。right扩展时加值,满足条件后收缩left,每次收缩都减去left位置的值。代码写起来比76题更短,但思路完全一致。
6. 面试现场的表现与复盘建议
6.1 拿到题先问什么
真实面试中,不要一上来就写代码。先和面试官确认三个关键点:字符集范围是否是ASCII;有没有要求返回所有满足条件的子串,还是只需返回任意一条;空串和不存在覆盖的边界怎么处理。这些问题能帮你避免用错数据结构,同时展现你对边界条件的敏感度。
有些面试官会在你说出“用哈希表记录频次”后就追问:“这个valid变量为什么不能省?”想清楚背后的逻辑:valid存的是满足条件的字符种类数,避免每次判断覆盖时去遍历哈希表比较,这是一个典型的空间换时间优化,也是滑动窗口这类问题能不能做到O(n)的关键之一。
6.2 手写代码时的取舍
手写代码命名要清晰,left/right/valid/need/window比i/j/k直观得多。尽量用英文全称,少写注释也能让阅卷者看懂逻辑。我习惯先把整体框架注释写清楚,再填实现,比如:
// 1. 初始化need和window // 2. right扩展 // 3. 满足条件时收缩left // 4. 更新最小长度这样即使中途写错,也能更快定位。
6.3 我自己复盘时的三个节奏点
第一遍刷题,重点在搞懂valid的增减时机,建议手写一个t含重复字符的用例,比如s = "aa",t = "aa",走一遍全流程。第二遍刷题,重点在默写框架,把76题的代码闭卷写出来,要求十分钟内完成。第三遍刷题,重点在变体迁移,同时写76题和“无重复字符的最长子串”,对比两者的更新时机差异。
我个人实际做了一遍又一遍之后,最大的体会是:这类题其实只有一个核心节奏,先让窗口合法,再让窗口最短。只要心里这个节奏不乱,代码里的指针、计数器、更新逻辑都不会乱。
最后再分享一个小技巧:正式写代码之前,在纸上画一组短的输入,把left、right、valid的变化过程一步步标记出来。虽然看起来费时间,但能省下调试时的大量时间。尤其是面试现场,白板上画一条坐标轴,把指针位置标出来,讲题都会顺畅很多。