☰
LeetCode 76 最小覆盖子串:滑动窗口+双指针O(n)解法全解析
2026/10/6 9:59:10 网站建设 项目流程

LeetCode 76 这道题我做过的次数,大概比刷题列表里其他“困难”标签加起来还多。不是说它有多难,而是它太适合拿来考察“滑动窗口”这个基本功了。面试里问“最小覆盖子串”,本质上就是想看你能不能把双指针和窗口状态维护讲清楚。这题是有 O(n) 解法的,而且这个 O(n) 不是玄学,是严格可推导的。

我第一次遇到这题的时候,第一反应是暴力枚举所有子串,然后逐个判断是否包含 t。这个思路没错,但复杂度直接爆炸。后来老老实实把滑动窗口的进出逻辑理清楚,才发现所谓的“困难”,卡住的不是窗口本身,而是怎么高效地判断“当前窗口是否已经覆盖了 t”。

这篇就来完整拆一下 LeetCode 76:从题目本身开始,把滑动窗口的推导、代码实现、复杂度分析、以及我实际踩过的坑全部过一遍,最后再聊几个面试官常见的延展问法。

1. 题目还原与核心思路:这个“困难”到底难在哪

1.1 原题描述与最容易忽略的条件

先看题面。给定字符串s和t,要求在s中找到最短的一个连续子串,使得这个子串包含t中的所有字符。注意“包含”不是简单的出现过,而是t中每个字符出现的次数,在窗口里都要满足。

这个细节第一次刷题的人特别容易忽略。t = "AABC"时,窗口里必须至少有两个A、一个B、一个C,少了任何一个都算没覆盖。

题目还有一个隐藏条件:如果s中根本不存在这样的子串,就返回空字符串。这个边界情况看着简单,但很多人写完代码后在窗口左边界移动时把答案覆盖成了空串,最后返回了错误结果。后面会细说。

1.2 暴力的复杂度瓶颈在哪

暴力做法是枚举s的所有子串,复杂度 O(n²)。每个子串再拿哈希表统计字符次数,和t的统计结果比较,又是 O(|s|) 级别的操作。整体一下就是 O(n³) 级别,肯定过不了题目的长度限制。

更关键的不是指数爆炸,而是很多子串的统计其实在被反复计算。比如你计算了s[0..100]的字符统计,马上又要计算s[0..101],只多了一个字符,却把整个区间重新算了一遍。这种浪费在滑动窗口解法里会被彻底消除:每次移动右指针时,窗口只新增一个字符,移动左指针时,窗口只减少一个字符。

2. 滑动窗口 O(n) 解法推导:从直觉到不变量

2.1 窗口的“进”与“出”:维护一个动态区间

滑动窗口的模型很简单:两个指针left和right,一开始都指向s[0]。right不断向右移动,把新字符纳入窗口,相当于“进”;当窗口已经覆盖了t时,尝试移动left缩小窗口,相当于“出”。

这个模型的关键在于,窗口始终是一个连续区间,你永远只改变两个端点,不需要重新统计整个区间。

用生活化的比喻:你有一把尺子,先在s上从左往右滑,右侧不断扩展,直到尺子里的字符种类和数量都满足了t的要求,然后你开始从左边收缩尺子,看还能不能保持满足。一旦收缩到不满足,就又继续扩展右端。整个过程里,每个字符最多被右指针扫描一次,也被左指针扫描一次。

2.2 用什么判断窗口已经覆盖了 t

判断是这道题的解体关键。

最直接的笨办法是:每次窗口变化后,把窗口的字符统计哈希表拿出来,和t的统计哈希表逐项比对。这个操作是 O(字符集大小) 的。字符集如果固定为 128 个 ASCII 字符,那还好,但每次窗口移动都做一次 O(128) 的比较,整个算法复杂度就从 O(n) 变成了 O(128n),虽然常数很小,但写出来的逻辑不够优雅。

更好的方案是维护一个valid计数:表示当前窗口里,有多少种字符已经达到了t的需求数量。

具体来说:先统计t中每个字符的需求量,存入need。窗口扩展时,如果新字符c在need中,就把窗口计数window[c]加一,并且当window[c] == need[c]时,说明c这个字符的覆盖要求已经达标,valid加一。当valid == need.size()时,说明所有字符都达标了,窗口有效。

这个valid的思路本质上是从“比较两个哈希表”变成了“只维护一个整数”,把判断成本从 O(字符集) 降到了 O(1)。

2.3 为什么均摊下来是严格的 O(n)

要证明是 O(n),核心是观察每个指针的移动次数。

右指针right从头走到尾,最多移动n次。左指针left虽然会在窗口收缩时频繁右移,但它也是从0开始,一路只能往右走,不可能回退,所以整个算法过程里,left最多也移动n次。

每次移动右指针做的事情是:更新一个哈希表计数、可能更新一下valid,都是 O(1)。每次移动左指针做的事情同理,也是 O(1)。

所以总操作次数是 2n 级别的常数倍,再加上预处理t的 O(|t|),整体复杂度是 O(∣s∣ + ∣t∣)。这里的 O(n) 不是平均意义下的概率结论,而是严格均摊复杂度,和快速排序那种“期望 O(n log n)”完全不同。这也是为什么这题能被当作“手写 O(n) 算法”的经典考题。

3. 可直接复制的实现代码与关键行解读

3.1 C++ 实现:面向答案的严谨写法

class Solution { public: string minWindow(string s, string t) { if (s.empty() || t.empty() || s.size() < t.size()) return ""; vector<int> need(128, 0), window(128, 0); for (char c : t) need[c]++; int valid = 0; // 有多少种字符已经达到覆盖要求 int left = 0, right = 0; int start = 0, minLen = INT_MAX; while (right < s.size()) { char c = s[right]; right++; if (need[c] > 0) { window[c]++; if (window[c] == need[c]) { valid++; } } while (valid == need.size()) { if (right - left < minLen) { minLen = right - left; start = left; } char d = s[left]; left++; if (need[d] > 0) { if (window[d] == need[d]) { valid--; } window[d]--; } } } return minLen == INT_MAX ? "" : s.substr(start, minLen); } };

3.2 Python 实现:利用 Counter 和 defaultdict

class Solution: def minWindow(self, s: str, t: str) -> str: from collections import Counter, defaultdict if not s or not t or len(s) < len(t): return "" need = Counter(t) window = defaultdict(int) valid = 0 left = 0 start = 0 min_len = float('inf') for right in range(len(s)): c = s[right] if c in need: window[c] += 1 if window[c] == need[c]: valid += 1 while valid == len(need): if right - left + 1 < min_len: min_len = right - left + 1 start = left d = s[left] left += 1 if d in need: if window[d] == need[d]: valid -= 1 window[d] -= 1 return "" if min_len == float('inf') else s[start:start + min_len]

3.3 代码里最容易写错的三个地方

第一个是need和window的数组长度。题目虽然默认是大写小写英文字母,但直接用vector<int>(128, 0)是最保险的,因为所有 ASCII 字符都覆盖了。如果只开26,遇到大小写混合直接越界。

第二个是左边界收缩时的valid更新顺序。必须先判断window[d] == need[d],再执行window[d]--。反过来就错了,因为window[d]--之后,window[d]已经不满足need[d]了,但此时valid还没有减少,状态就出现短暂的不一致。

第三个是收缩循环while (valid == need.size())里是否每次都更新答案。我见过一种写法是把答案记录放在while外面,只记录第一次进入循环时的状态。这样当窗口收缩后仍然覆盖t时,就漏掉了更短的答案。正确做法是在每次收缩前都检查并更新答案。

4. 实操踩坑记录与排查思路

4.1 坑一:重复字符导致“窗口长度等于 t 长度但没覆盖”

调试s = "ADOBECODEBANC", t = "AABC"时特别容易出这个现象。窗口长度可能已经大于等于t的长度了,但t里的A出现两次,窗口里如果只有一个A,那就永远不算覆盖。

这个坑的根源在于习惯性用“窗口长度是否大于等于 t 长度”来提前判断覆盖,省去valid的比较。这种做法在某些简化版的滑动窗口题里确实成立,但用在“最小覆盖子串”这里会直接错。

排查方法也很简单:在窗口收缩后打印window和valid,肉眼检查每个字符的计数。真正验证通过的标准只有一个:所有t中出现的字符,window[c] >= need[c]全部成立。用valid == need.size()去判断,就是把这个条件压缩成了 O(1) 的比较。

4.2 坑二:左边界收缩时 valid 减错位置

这是我最开始写的版本里反复出的问题。

错误写法是这样的:

if (need[d] > 0) { window[d]--; if (window[d] < need[d]) { valid--; } }

看起来逻辑没什么毛病:先减少计数,如果不够了就减少valid。但问题在于,如果window[d]原来远远大于need[d],比如你需要 2 个A,窗口里有 5 个A,收缩掉一个A后,window[d]变成 4,仍然大于等于 2,这时valid不应该变。上面的写法虽然结果上也不会让valid减少,但有一种情况会出错:当window[d] == need[d]时,先执行window[d]--,此时window[d]变成need[d] - 1,然后再判断window[d] < need[d],会发现小于,于是valid减一。这个结果是对的,但逻辑顺序上容易让人忽略一个场景:window[d] > need[d]时,收缩后依然window[d] >= need[d],这时不能减valid。按上面的写法,判断条件window[d] < need[d]为 false,所以valid不变,也没错。

那为什么推荐“先判断,后减少”呢?因为这样写的语义更清晰:先判断当前字符是否处于“刚好达标”状态,如果刚好达标,说明这个字符是整个窗口覆盖的必要条件,移出它会导致覆盖状态被破坏,因此valid减一;然后才真正把它移除。否则用“减少后再判断”的写法,会多一层条件,容易写反。

4.3 坑三:字符集假设不同导致越界

LeetCode 官方题目给的是英文字母,但很多变体题会让你处理数字、空格、甚至 Unicode。

如果直接用vector<int> window(26),然后window[s[i] - 'a']++,遇到大写字符就直接变成负数下标了。我建议统一用128数组:

vector<int> need(128, 0), window(128, 0);

这样无论是字母、数字还是常见的英文标点,都能覆盖。对于 ASCII 里的非英文字母,虽然不常见,但也不会越界崩溃。

如果你遇到一个变体题,明确说字符范围是a到z,那可以用26数组,省一点空间,并配合- 'a'操作。但面试时我不建议为了这点空间去增加出错概率,直接128起步。

4.4 坑四:s 比 t 短时直接返回空串

这个边界情况虽然简单,但也容易写漏。

如果s.size() < t.size(),那就算s里所有字符都用于覆盖,数目上也不够,直接枚举滑动窗口也没有意义。我一般会把这个判断放在函数开头,提前返回空串,省掉后续所有无谓操作。

还有一种隐藏的边界情况是t为空字符串。题目一般会限制非空,但为了稳妥,加上判断也无妨。

5. 面试现场和刷题计划里的延展问题

5.1 变体一:如果 t 中允许重复字符

“最小覆盖子串”原题本身就允许t有重复字符,所以这不是变体,而是原题要求。但很多人会把这道题和“找到包含 t 所有字符的最短子序列”混淆,后者不考虑重复次数。如果面试官上来先说字符不能重复,那是降级版本,反而好写。

对于含重复字符的版本,唯一需要强调的就是need.size()表示的是“不同字符的数量”,而不是t的长度。比如t = "AABC",need.size()是 3,不是 4。valid == 3才代表三种字符都达标了。

5.2 变体二:如果要求返回覆盖次数而不是最短子串

有些面试官会问:直接统计有多少个子串满足条件,或者统计有多少个最短覆盖子串的位置。

这种情况我见过两种考法。第一种是求“覆盖次数”,也就是s中有多少个不同的连续子串满足覆盖条件。这个问题直接用滑动窗口,配合每个窗口内左指针的可移动范围来计数。难点在于左指针移动时,窗口的覆盖状态变化是单调的,所以可以维护一个有效区间的长度来累加。

第二种是求“所有最短覆盖子串的起始位置”,比如有可能存在多个长度相同的最短覆盖子串。处理方式是:滑动窗口过程中,当valid == need.size()时,把所有可能的左边界都尝试收缩,并记录长度最小时的所有起始位置。注意去重,因为同一个起点可能会因为收缩方式不同被记录多次。

5.3 变体三:如果 s 很长、t 很少,如何进一步优化

原题的 O(n) 已经是最优的大 O 复杂度了,因为每个字符至少要被扫描一次才能判断覆盖状态。但在工程场景下,如果s是一个上百 MB 的字符串,t只有几个字符,可以考虑先过滤掉s中不在t里的字符,把原始的s压缩成一个“有效字符位置数组”,然后只在这些位置上跑滑动窗口。这样可以把窗口的判断次数从n降低到s中的有效字符数量。

这个优化在 LeetCode 上通常不需要,但在实际日志分析或 DNA 序列处理场景里,能省下不少时间。原理也很简单:窗口里出现无关字符时,它不会影响valid的变化,白白增加一次字符串访问和哈希查找。

我个人的习惯是:刷题阶段先用标准的valid计数版本把思路跑通,再单独用“压缩有效位置”的版本练习一下,这样面到变体题时不会慌。这个优化思路在 “LeetCode 热门 100 题” 的滑动窗口分类里,也可以横向迁移到其他类似题上,比如 “找到字符串中所有字母异位词”,它们的核心都是同一套双指针计数模型。

最后再分享一个小技巧:如果你在面试或者周赛中拿到这题,写完代码后先自己在脑子里跑一个例子,s = "AAAB", t = "AB"就够用了。这个例子能同时验证重复字符、覆盖状态、收缩后依然覆盖这三个最容易出问题的点。跑通了再提交,基本一遍过。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询