刷题的人应该都有这种体会:有些题,看了题解觉得“就这”,合上书自己写却怎么都写不对。76题“最小覆盖子串”就是这类题里最有代表性的一道。它是滑动窗口思想最经典的母题,也是很多面试里追问细节的高频考点。题目本身不难理解:给你两个字符串 s 和 t,在 s 里找出一个最短的连续片段,让这个片段里的字符次数能“凑齐”t 里的所有字符。顺序无所谓,多了可以,少了不行。我第一次独立写这题时,大方向知道要用双指针,但硬生生卡在了一个很基础的问题上——怎么高效判断当前窗口已经覆盖了 t,而不是每次都去遍历两个哈希表。后来在纸上对着一个长例子推了半天,才真正想明白关键答案:别去比较哈希表,维护一个“还缺多少字符”的缺口计数器就够了。
这篇文章我不会只丢一份代码,而是把从暴力思路到滑动窗口方案的完整推导过程、代码里容易踩的坑、以及后续一系列变种题都摊开讲清楚。无论你现在是刚学到双指针,还是已经刷了不少题但总在细节上翻车,这篇都值得慢慢看完。
1. 题目到底在找什么:先分清“包含”和“覆盖”
很多人在 76 题上最大的第一个误区,是没搞明白“最小覆盖子串”里的“覆盖”和平时说的“匹配/包含”有什么区别。这不是指在 s 里找到一个等于 t 的连续子串,而是找一个连续区间,使这个区间里每个字符的出现次数都不少于 t 中对应字符的出现次数,字符顺序完全无所谓。这个定义直接决定了后面滑动窗口的写法。
1.1 一个例子把“覆盖”讲清楚
看这个最经典的例子:s = "ADOBECODEBANC",t = "ABC"。肉眼扫一圈,能覆盖 ABC 的子串不少,“ADOBEC”可以,因为它包含 A、B、C;“BANC”也可以,而且它只有 4 个字符,明显更短。题目要的就是后者。
为什么“ADOBEC”不是答案?不是因为它缺字符,而是它太长。这引出覆盖问题的一个直觉:满足条件的窗口可能有很多,但我们要找的是其中最短的那个。窗口越短,往往意味着冗余字符越少,而对 t 来说,它根本不关心窗口里多了多少个 D、O、E 这种无关字符,只要 A、B、C 的次数达标就行。
1.2 重复字符:第一个容易低估的陷阱
如果 t 里出现了重复字符,问题会更微妙。比如 t = "AABC",那么窗口里必须至少有两个 A、一个 B、一个 C,少一个 A 都不算覆盖。再比如 t = "AA",窗口里至少要出现两个 A,s = "AABA" 里的“AA”可以,“ABAA”里最左边两个 A 形成的“AA”也可以,但只包含一个 A 的窗口统统不合格。
我见过不少初学者在这里用“字符集合”的思路去思考,比如把 t 去重成 set,然后判断窗口是否包含这些字符。这在 t 没有重复字符时能蒙对,一旦 t = "AA" 就立刻暴露问题:set 只有一个 'A',但窗口只出现一个 'A' 时不满足要求。所以核心不是“哪些字符出现过”,而是“每个字符出现了多少次”。后面要用哈希表统计频次,正是为了处理这种重复字符的问题。
2. 暴力解法为什么撑不住:先把成本算明白
看到“最短连续子串”,很多人第一反应是枚举。任何包含区间的问题,暴力做法都逃不过“枚举所有起点和终点”这个思路。
2.1 暴力枚举子串的成本
假设 s 的长度为 n,t 的长度为 m。枚举所有连续子串,起点有 n 种,终点有 n 种,一共是 O(n^2) 个子串。每拿到一个子串,还要判断它是否覆盖 t,这一步至少需要统计子串内字符频次,再和 t 的频次比较,代价是 O(|Σ|),其中 |Σ| 是字符集大小。总复杂度就是 O(n^2 * |Σ|),n 达到十万级别时,这个数字完全无法接受。
即使做一点优化:先预处理 s 的前缀频次数组,让“判断某个子串是否覆盖 t”变成 O(|Σ|),复杂度也只能降到 O(n^2 * |Σ|),本质还是平方级。真实面试场景里,输入长度动不动就是 10^5,平方级算法连测试都跑不完。
2.2 滑动窗口切入的关键观察
暴力枚举真正的浪费在哪里?它把每一个子串都当成独立的个体,重新统计一遍,却忽略了子串之间大量重复的信息。其实我们可以只维护一个当前窗口,窗口右边扩展、左边收缩,每一步都只更新一个字符的影响。
这里有一个很重要的观察:如果当前窗口已经覆盖了 t,那右指针继续向右扩展,窗口只会变得更长,不会出现更优的答案。真正可能缩短窗口的,只有把左指针向右移动。反过来,如果当前窗口还没覆盖 t,那左指针向右移动只会让窗口更缺字符,也不可能找到答案。所以整个搜索过程被天然分成两种动作:不满足时右指针前进,满足时左指针收缩。这种“右指针负责扩展、左指针负责收缩”的双指针结构,就是滑动窗口的核心,也是它能做到线性的根本原因。
很多人会担心:收缩的时候会不会漏掉最优解?不会。因为任何最短覆盖子串,一定会在某个右指针到达它的右端点时,成为一个覆盖窗口;随后左指针不断右移逼近它的左端点,算法会在收缩过程中捕捉到这个状态。换句话说,最优解不会凭空消失,它必然被某一轮“扩张—收缩”经过。
3. 窗口向右扩展:每吃进一个字符,缺口怎么变
理解双指针结构只是第一步,真正实现时最绕的是状态的维护。很多人写的滑动窗口不对,往往就是“如何记录窗口是否覆盖 t”这一关没过。我用一个“购物清单”的类比来解释。
3.1 把字符“吃进来”时的两个动作
把 t 当成一张购物清单,每种字符后面写着你还需要几件。窗口就是你的购物车。最先拿到手的每个字符,都要做两件事:一是看看这个字符在不在购物清单上,二是在对应需求次数上减一。
但注意,不是每遇到一个清单上的字符都能“填补缺口”。只有当你确实缺少它时,缺口才会减少。如果窗口里已经有 3 个 A,而 t 只需要 2 个 A,这时又来了一个 A,它只是冗余的 A,不会让“缺口”变少。因此正确的更新逻辑是:如果当前字符在 t 中,并且它的需求次数大于 0,说明它补齐了一个缺口,全局缺口计数器 gap 减 1;无论是否大于 0,都要把需求次数减 1,因为这个字符已经被窗口“消耗”掉了。
gap 的初始值就设为 len(t)。为什么是 len(t) 而不是 t 的字符种类数?因为 t = "AA" 时需要两个 A 才算覆盖,一个 A 只补一个缺口。gap 的含义是“还差多少个字符”,所以它必须从总字符数开始扣,直到归零才算达标。
3.2 不在 t 里的字符:不影响缺口,但影响长度
窗口里通常会混入大量不属于 t 的字符。在代码层面,这些字符对缺口没有影响,可以直接跳过。但在答案层面,它们并不是“无害”的,因为窗口长度变长了。比如 s 开头一大段“OOOOO”与 t = "ABC" 毫无关系,可它们出现在窗口里,会让每个覆盖窗口的起点更靠右、长度更大。
这也是滑动窗口为什么必须把左指针“能缩就缩”的原因。扩展时遇到无关字符,窗口变大,看起来是坏事,但它不可能被绕过;真正要做的是在窗口覆盖 t 之后,尽可能把左端点向右推,把那些无关字符和冗余字符从窗口头部踢出去。
4. 窗口收缩的艺术:在达标的每一刻尝试逼近最短
如果说右指针扩张是“刻意放宽”,那左指针收缩才是真正“精打细算”的一步,也是最容易写错的一步。
4.1 收缩时机与收缩节奏
收缩的触发条件只有一个:gap 等于 0。此时窗口已经覆盖 t,可以考虑缩短。但收缩不是一次到位,而是一个字符一个字符地尝试。为什么不能一步跳到某个位置?因为我们不知道最短窗口的左端点在哪里,只能通过“移走一个左边字符后,窗口是否仍然覆盖 t”来判断。
举个例子,s = "ADOBECODEBANC",t = "ABC",当右指针扫到第一个 'C' 时,窗口是 "ADOBEC",长度 6,gap 刚好变成 0。此时左指针尝试右移:移走 'A' 后,窗口缺 A,gap 变回 1,收缩终止。于是最优解继续等右指针往前走。
当右指针最终到达最后一个 'C',窗口变成 "ODEBANC",覆盖 ABC。收缩过程就精彩了:左指针依次移走 O、D、E,窗口仍然覆盖;移走 B 时,窗口从 "EBANC" 变成 "ANC",B 不够了,gap 变成 1,收缩停下。但在停下之前,我们在 "EBANC" 状态记录了长度 5,又在 "BANC" 状态记录了长度 4。答案就是这么一步步被“逼”出来的。
4.2 更新答案的时机:收缩循环内而不是循环外
这是最容易翻车的地方。我见过不少实现是在 while 收缩结束之后再更新答案,也就是“收缩到不能再缩才记录”。但最短的有效窗口往往不是收缩的终点,而是收缩过程中的某一个中间状态。
因为收缩的条件是“当前窗口仍然覆盖 t”,一旦移走某个必需字符,gap 变成 1,循环立刻结束。而这个刚被移走必需字符的窗口,可能是最短的有效窗口,但它已经被破坏,如果事后才更新,就永远错过了。正确做法是在每次左移之前,先记录当前窗口是否比已知答案更短。这个顺序问题,面试官特别喜欢拿出来追问,因为只要把更新答案挪到循环外,代码跑出来的结果在部分用例上就会出错。
5. 哈希表与缺口变量:从推导到一份能直接用的实现
把上面的逻辑落到代码,核心数据结构只需要一个哈希表(或定长数组)和一个计数器。
5.1 用哈希表还是定长数组
如果题目明确说明字符集是英文字母,用长度为 52 或 128 的数组会很快,因为下标访问比哈希表快不少。如果不确定字符集,或者面试官要求能适应任意字符,直接用哈希表更稳。其中哈希表只保存 t 中出现的字符,窗口每遇到一个字符就检查它在不在表里;数组方案则把 ASCII 码当索引,天然不用判断字符是否在 t 中,因为不在 t 中的字符初始频次是 0,减一后变成负数,之后加回 0 也不会影响缺口判断。
我给出两个版本,原理完全一致。
Python 版本:
def minWindow(s: str, t: str) -> str: if not t: return "" need = {} for ch in t: need[ch] = need.get(ch, 0) + 1 gap = len(t) # 还缺多少个字符 left = 0 best_left = 0 best_len = float('inf') for right in range(len(s)): ch = s[right] if ch in need: if need[ch] > 0: # 这个字符正好是缺口 gap -= 1 need[ch] -= 1 # 不论是否产生缺口,使用次数都要扣除 while gap == 0: # 窗口已覆盖 t,开始收缩 if right - left + 1 < best_len: best_len = right - left + 1 best_left = left left_ch = s[left] if left_ch in need: need[left_ch] += 1 if need[left_ch] > 0: # 从“刚好够”变成“缺一个” gap += 1 left += 1 return "" if best_len == float('inf') else s[best_left: best_left + best_len]C++ 版本:
string minWindow(string s, string t) { if (t.empty()) return ""; vector<int> need(128, 0); for (char c : t) need[c]++; int gap = t.size(); int left = 0; int bestLeft = 0; int bestLen = INT_MAX; for (int right = 0; right < s.size(); right++) { char c = s[right]; if (need[c] > 0) gap--; need[c]--; while (gap == 0) { if (right - left + 1 < bestLen) { bestLen = right - left + 1; bestLeft = left; } char lc = s[left]; need[lc]++; if (need[lc] > 0) gap++; left++; } } return bestLen == INT_MAX ? "" : s.substr(bestLeft, bestLen); }5.2 关键行到底在干什么
很多人看代码能看懂每一行,但合上之后还是会写错,主要卡在 need[ch] 变成负数之后,后面怎么判断缺口。我拆一下最核心的三处。
第一处,扩展时if need[ch] > 0: gap--。这个条件判断的是“当前这个字符是不是缺口”。need[ch] 在初始时记录的是 t 的需求量,窗口每吃进一个 ch,need[ch] 就减 1。因此:
- need[ch] 初始为 2,说明还缺 2 个 ch;
- 第一次遇到 ch,need 变成 1,此时 1 > 0,gap 减 1,表示补齐了一个;
- 第二次遇到 ch,need 变成 0,此时 0 > 0 不成立,gap 不变;
- 第三次遇到 ch,need 变成 -1,依然不成立,gap 不变,ch 是冗余字符。
第二处,收缩时need[left_ch]++; if need[left_ch] > 0: gap++。窗口左端字符被移出去时,需求次数要加回来。还是拿 A 举例:如果窗口里有 3 个 A,t 只需要 2 个 A,那移走一个 A 时,need[A] 从 -1 变成 0,并没到“缺”的程度,gap 不变;如果窗口里恰好只有 2 个 A,移走一个后 need[A] 从 0 变成 1,此时才是真正出现缺口,gap 加 1。
第三处,为什么while gap == 0而不是if。因为窗口可能非常冗长,左边连续一堆无效字符,一次收缩根本不够。比如窗口是 "XXXABC",左边三个 X 全是无效字符,移掉一个 X 后窗口仍然覆盖 t,那就继续移,直到移走 A 或 B 或 C 中的某个必需字符并产生缺口为止。
6. 复杂度分析的另一个视角:双指针为什么只用扫一遍
这道题的时间复杂度是 O(m + n),其中 m 是 t 的长度,n 是 s 的长度,很多人背下来了,但不理解为什么能到线性。
6.1 右指针和左指针的移动次数
右指针从 0 扫到 n-1,每次只前进一步,一共移动 n 次。左指针不会从头到尾重新移动,它最多也从左到右移动 n 次,因为每轮 while 收缩都会让 left 加 1,而 left 不可能超过 right。这里需要强调:left 并不是在每一轮 right 都从 0 重新开始,而是延续上一次的位置。
于是窗口中的每个字符至多被右指针“纳入”一次,被左指针“释放”一次,两个动作各是 O(1) 级别的哈希表操作。整体就是 O(n)。加上预处理 t 的 O(m),得到 O(m + n)。空间上最多保存 t 中出现的不同字符数,O(|Σ|),如果用定长 ASCII 数组,那就是 O(128) 的常数空间。
6.2 线性时间是不是最优
可以这样想:如果 t 中某个字符只出现在 s 的最后几个位置,那么任何算法都必须看到 s 的末尾才能判断是否覆盖。也就是说,不看遍 s 中的每个字符,你永远无法排除“答案就在末尾”的可能。因此问题本身存在 Ω(n) 的信息下界,O(m + n) 已经达到最优级别。这也是为什么面试官听到你分析复杂度时,会额外追问“还能更快吗”的原因——答案是不能从大 O 意义上再压了。
7. 最容易翻车的几个细节:来自真实提交的经验
代码能跑通并不代表你理解了这道题。很多时候,本地测试某几个用例没问题,提交到平台却报错,原因往往集中在几个细节上。
7.1 我踩过的坑
第一个坑是更新答案的时机。我之前自己写的时候,先收缩再更新,结果在 s = "ADOBECODEBANC" 上得到错误答案。后来把更新放到 while 第一行,一切正常。这个坑在前文已经解释过,但还是要单独强调一遍,因为它真的太隐蔽了。
第二个坑是 gap 的初始值没有设为 len(t),而是设成 len(need)。如果 t = "AABC",len(need) 等于 3,len(t) 等于 4,看起来只差 1。但覆盖要求必须是两个 A 都满足,gap 从 3 开始减,第一次遇到 A 变成 2,第二次遇到 A 变成 1,之后再遇到 B、C 各减一次变成 0,整个顺序就乱了。gap 的语义是“还缺的字符总数”,必须从 len(t) 开始。
第三个坑是在更新答案时直接保存子串。比如ans = s[left: right+1],每轮都切片,Python 里字符串切片是 O(k) 的,k 是切片长度,在长字符串上会把算法拖慢。正确做法是只记录 best_left 和 best_len,最后返回时才切一次。这个习惯不止适用于这一题,所有滑动窗口题都应该遵守。
7.2 一组可以直接跑的本地用例
如果你写完代码不知道对不对,先把下面这组用例过一遍。
| 输入 s | 输入 t | 期望输出 |
|---|---|---|
| "a" | "a" | "a" |
| "a" | "aa" | "" |
| "aa" | "aa" | "aa" |
| "ab" | "a" | "a" |
| "abc" | "abcd" | "" |
| "ADOBECODEBANC" | "ABC" | "BANC" |
| "ab" | "b" | "b" |
| "bba" | "ab" | "ba" |
其中 "bba" 和 t = "ab" 这组很有迷惑性。窗口 "bb" 不覆盖,右指针到 'a' 时窗口 "bba" 覆盖,左指针移掉第一个 'b' 后变为 "ba",依然覆盖,答案为 "ba"。只有当 left 再移一步变成 "a" 时才失败。如果你能把这组用例走明白,说明你对收缩的时机已经理解了。
8. 从最小覆盖子串出发:变种题与通用滑动窗口范式
76 题不是终点。它只是滑动窗口体系里的一个代表性母题,后面一大片题目可以说都是从同一个骨架演变出来的。理解了这一题,再去碰其他滑动窗口变种会快很多。
8.1 固定窗口型:排列与异位词
有一类题把“最短窗口”改成了“固定长度窗口”。比如“字符串的排列”,要求在 s2 中找到一个长度等于 len(s1) 的子串,它恰好是 s1 的某个排列。这类题和 76 题的唯一区别是,窗口长度固定,覆盖条件变成“窗口内每种字符频次和 s1 完全一致”。此时右指针正常扩展,但一旦窗口长度超过固定值,左指针就必须同步收缩,让窗口始终保持固定长度。
“找到字符串中所有字母异位词”更直接,它不仅要判断是否存在,还要输出所有满足条件的起始下标。这类题的做法是把左指针固定移动、右指针固定移动,形成一个长度恒定的滑窗,每次判断窗口频次是否与目标频次一致。有了 76 题的频次统计基础,这些题的核心代码几乎不用改,只需要把收缩条件和答案记录方式换一下。
8.2 顺序约束型:最小窗口子序列
还有一类题和 76 题长得像,但难度明显上升:要求子串里的字符按相对顺序出现。比如 t = "abc",窗口 "abbc" 就不满足,因为 a、b、c 的顺序虽然满足,但中间夹着多余的 b 会影响“最短”的判断;更严格的情况是窗口必须包含 a 在前、b 在中、c 在后的子序列。
这种题为什么不能用 76 题的频次覆盖解法?因为频次只统计“有没有、够不够”,它丢失了顺序信息。你需要的是在 s 中按顺序匹配 t 的字符,每匹配完一轮后,再把窗口左边界推进到“上一个匹配起点”的位置。它依然可以用双指针,但状态维护逻辑完全不同。很多人在刷完 76 题后直接去做“最小窗口子序列”,会觉得思路绕,就是这个原因。
8.3 通用模板与使用边界
刷多了之后,你可以总结出一个滑动窗口的通用骨架:右指针逐字符扩展,每走一步更新窗口状态;当窗口满足某种条件时,左指针逐步收缩,并在收缩过程中更新答案。伪代码大概是:
left = 0 for right in range(n): 将 s[right] 加入窗口,更新状态 while 窗口状态满足题目要求: 更新答案 将 s[left] 移出窗口,更新状态 left += 1但这个模板有三个地方的写法完全取决于题目:
- 窗口状态是什么。可能是频次表、缺口数、最大值、不重复集合;
- 满足条件是什么。76 题是 gap == 0,最长不重复子串是“窗口内没有重复”;
- 左指针收缩后,答案什么时候更新。最短类问题通常在收缩过程中更新,最长类问题往往在收缩结束后,窗口重新满足条件时判断。
状态设计、条件判断、更新时机,这三件事想清楚,任何滑动窗口题都能套,但想不清楚,套模板必翻车。
最后说说我刷这题前后的一个体会。很多人在学滑动窗口时会背模板,但 76 题恰恰是最不适合背模板的,因为你要处理的不是“一个简单条件”,而是一个带有重复字符约束的频次覆盖问题。我后来专门找了一个长例子,把每个字符进入和离开窗口时 gap 的变化从头到尾标了一遍,标完才真正理解为什么need[ch] > 0能判断缺口。建议你也拿 "ADOBECODEBANC" 这个用例亲手走一遍,走完你会发现,滑动窗口题其实都是在同一套逻辑上做加减法。