看到“2024年题22”这个编号,估计不少在备赛算法类测试的人会心里一紧——这个位置通常是整套卷子的分水岭。前面二十来题考的是单一知识点,而第22题开始,往往要把两到三个数据结构配合起来用,一道题卡住半小时都正常。
我当时拿到的题,是一道典型的“最短覆盖子串”问题:给定一个字符串 S 和一个模式串 T,要求找出 S 中包含 T 中所有字符(含重复次数)的最短连续子串,如果不存在就返回空串。这道题表面上是字符串处理,实际上把滑动窗口、哈希计数、边界控制、复杂度分析全揉在了一起。如果你是准备算法笔试、面试手撕代码,或者单纯想补一补双指针和哈希表配合的用法,这一篇把从暴力解到最优解、从思路到代码、从踩坑到验收的完整链路都拆开讲透。
1. 题目定位复盘:为什么它能稳稳坐在第22题的位置上
1.1 原题的核心描述与考察意图
题目原文去掉壳之后,核心信息其实就这两条:
- 输入两个字符串 S 和 T,T 中字符可能出现重复;
- 在 S 中找一个最短的连续子串,使这个子串对 T 的字符覆盖是完整的——“覆盖”指的是子串里每个字符的出现次数不少于 T 中该字符的出现次数。
比如 S = "ADOBECODEBANC",T = "ABC",那么最短覆盖子串是 "BANC",长度是 4。T 里有两个相同字符的情况,例如 S = "AABC",T = "AA",则答案应该是 "AA" 而不是 "A",因为单个 A 覆盖不了两个 A。
这道题放在第22题而不是第2题,原因很直接:它的难点不在“能否想出解法”,而在“能否把多种技巧稳定地组合起来写对”。第一,你需要意识到朴素做法不可行;第二,你需要设计两个指针移动的时机;第三,你还需要维护一个能实时判断“当前窗口是否满足覆盖”的状态量。这三层思考叠在一起,加上边界条件多,正好卡在一道“中高难度”考题的位置上。
1.2 常见误判:这不是一道“背模板”就能过的题
很多人在刷题时会背一套“滑动窗口模板”,左指针右指针、for循环、while收缩,背得很熟。但放到这道题里,容易翻车:模板往往针对“无重复字符的最长子串”这类只需要单一哈希计数的题,而覆盖类问题要求的是“多字符计数并判断是否全部达标”,比单纯的“去重”多了一个维度。
也就是说,第22题真正考察的是你有没有理解滑动窗口为什么能保证不漏解、什么时候能收缩、收缩之后状态怎么恢复。如果只是硬套模板,T 中出现重复字符、或者 S 中某个字符数量特别多时,valid 值的增减逻辑就会写乱。这就回到了题目位置的用意:检验“原理理解 + 编码实现”的综合能力。
1.3 难度分层与得分策略
从整体卷面来看,这类题通常不是最难的压轴题,但它是“区分度”最高的一道。高手能在十五分钟内完成,中等水平的选手可能花五十分钟还没调通边界,更常见的结局是超时后不得不写回 O(n²) 暴力解,拿不到全部分数。
所以面对第22题,合理的策略是:先快速判断出“这题一定不能用三重循环”,再按“右指针扩张 -> 满足覆盖 -> 左指针收缩 -> 更新答案”的节奏走。如果链路清晰,代码写完其实也就四五十行。
2. 我实际走过的弯路:从三重循环到 O(n²) 再到 O(n)
2.1 第一版朴素解法:三重循环直接超时
我第一次做这道题时,第一反应是最直接的三重循环:枚举子串的左边界 i、右边界 j,再用一个哈希表去统计当前子串里各字符的数量,看是否覆盖 T。这样做的复杂度是 O(n³),当 S 长度到 10^4 以上时基本就是超时预定。
代码如下:
def min_window_brutal(s: str, t: str) -> str: n = len(s) need = {} for ch in t: need[ch] = need.get(ch, 0) + 1 ans_left, ans_right = -1, n for i in range(n): for j in range(i, n): window = {} for k in range(i, j + 1): window[s[k]] = window.get(s[k], 0) + 1 ok = True for ch, cnt in need.items(): if window.get(ch, 0) < cnt: ok = False break if ok and j - i < ans_right - ans_left: ans_left, ans_right = i, j return "" if ans_left == -1 else s[ans_left:ans_right + 1]这段代码跑小样例没问题,但一旦 S 的规模上去,里面有三处浪费:重复统计窗口内容、每次都要重新遍历 need 判断覆盖、枚举的窗口数量本身是平方级别。我用一个长度为 5000 的随机串测试,运行时间已经到了不可接受的程度,这也促使我思考下一步。
2.2 第一次优化:固定左端点,只移右端点
三重循环最大的浪费,在于同一个子串被反复统计。于是我先做了一个基础优化:固定左端点 i,让右端点 j 从 i 一直向右移动,移动过程中逐步维护一个窗口计数,而不是每次重新数一遍。
def min_window_quadratic(s: str, t: str) -> str: n = len(s) need = {} for ch in t: need[ch] = need.get(ch, 0) + 1 ans_left, ans_right = -1, n for i in range(n): window = {} for j in range(i, n): window[s[j]] = window.get(s[j], 0) + 1 ok = True for ch, cnt in need.items(): if window.get(ch, 0) < cnt: ok = False break if ok: if j - i < ans_right - ans_left: ans_left, ans_right = i, j break # 因为右端点继续右移只会让子串更长,不是最优 return "" if ans_left == -1 else s[ans_left:ans_right + 1]这个版本复杂度降到 O(n²),已经能通过部分数据,但仍然不够。问题出在:每移动一次右端点,我都要重新拿 need 里的所有字符去和 window 比较一次。如果字符集很大,这个常数开销会让 n = 10^5 的测试也撑不住。
2.3 瓶颈定位:判断“覆盖”这个动作必须做到 O(1)
把复杂度从 O(n²) 压到 O(n) 的关键,不是减少窗口移动次数(其实每个字符仍然要被访问若干次),而是把“判断当前窗口是否覆盖 T”这件事从 O(字符集大小) 降到 O(1)。
这里引出一个常见的思维盲区:我们不需要每次比较 window 和 need 的每个字符,只需要知道“还有多少个有效字符种类没达标”。你可以维护一个变量 valid,表示当前窗口里已经达到 need 要求的字符种类数。每当某个字符从“不达标”变成“达标”,valid 加一;反之,从“达标”变成“不达标”,valid 减一。当 valid 等于 need 的键的数量时,窗口就是覆盖的。
这个思路就像查作业:need 里记录了每个“科目”的及格线,你不需要每次把每科成绩都翻一遍,只需要记一个计数器——有几科及格了。等计数器等于总科目数,就知道全及格了。这个 “O(1) 判断覆盖” 的技巧,是整个最优解的发动机。
3. 滑动窗口方案的完整推导与标准实现
3.1 两个指针维护窗口的框架
有了“O(1) 判断覆盖”的思路,整体框架就很清晰了:
- 右指针不断向右移动,把新字符加入窗口计数;
- 每次加入后,检查该字符是否刚好达到 need 所需数量,如果是,valid 加一;
- 当 valid 等于 need 的键数量时,说明当前窗口已经覆盖了 T,这时尝试收缩左指针,看能否在不破坏覆盖的前提下缩短子串;
- 收缩时,如果被移出的字符让某个种类从达标变成不达标,valid 减一;
- 在窗口合法期间,记录最短的左右区间。
在 Python 里的标准实现如下:
def min_window(s: str, t: str) -> str: if not s or not t: return "" need = {} for ch in t: need[ch] = need.get(ch, 0) + 1 window = {} valid = 0 left = 0 ans_left, ans_right = -1, len(s) for right, ch in enumerate(s): # 扩张:把 s[right] 加入窗口 if ch in need: window[ch] = window.get(ch, 0) + 1 if window[ch] == need[ch]: valid += 1 # 收缩:当前窗口已经覆盖 t,尝试缩短 while valid == len(need): if right - left + 1 < ans_right - ans_left + 1: ans_left, ans_right = left, right left_ch = s[left] if left_ch in need: if window[left_ch] == need[left_ch]: valid -= 1 window[left_ch] -= 1 left += 1 return "" if ans_left == -1 else s[ans_left:ans_right + 1]如果你用字符串 S = "ADOBECODEBANC",T = "ABC" 跑一遍,手推几个窗口,会发现整个过程中左指针和右指针都不会回退,这就是滑动窗口的线性特性。
3.2 计数恢复细节:为什么 only 在相等时增减 valid
这是一个很容易写错的地方。很多人会写成:只要 window[ch] >= need[ch] 就让 valid 加一。这个写法在字符数量“远大于”需要的场景下会翻车。
看个例子:T = "AB",S 里 A 已经出现了 10 次。如果按“大于等于”来判定,那么每多进一个 A 都会让 valid 尝试增加,但 valid 已经饱和了,最终导致覆盖状态被错误地长期保持。正确做法是:只有当 window[ch] 从 need[ch] - 1 跳到 need[ch] 的那一刻才 valid += 1,也就是从“不足”到“恰好达标”的量变瞬间,valid 才改变。带负反馈的其他写法,通常还需要额外的布尔数组去标记“这个字符是否已经计过”,反而容易错。
同理,收缩时 valid 减少的时机也要精确:只有当移出前 window[left_ch] 恰好等于 need[left_ch],说明移出后会从“达标”变成“不达标”,valid 才减一;如果窗口里该字符数量远超需要,那移走一个并不会破坏覆盖,valid 不动。
3.3 更新答案的位置:收缩前记录,不能只等右指针停下
这个位置也容易踩坑。标准做法是在收缩循环内、每次 left 移动之前记录当前窗口区间。如果只在 while 结束后记录一次,那么你实际上记录的是“最靠左的合法窗口”,而不是“最短的合法窗口”——因为 while 会把窗口缩到不能再缩,但最短窗口可能出现在收缩过程的中间状态。
举个例子:S = "ABB",T = "AB"。右指针到索引 1 时,窗口 "AB" 合法,长度 2;如果继续扩张到 "ABB",然后 while 收缩到 "BB"(不合法退出),这时你再看,长度 3 的窗口已经错过了“长度为 2 的 AB”。所以记录最短答案必须放在收缩过程中的每一步,每次左指针移动前都计算一次当前窗口长度,才能保证不漏掉最优解。
3.4 正确性边界:为什么一定能找到全局最短
有人会质疑:左指针一直向右收缩,会不会把某些潜在的最优解跳过去?不会。滑动窗口的核心不变式是:在任意时刻,当前窗口是“以当前右指针为右端点、能覆盖 T 的最短窗口”。右指针每次移动一格,窗口必然扩张;一旦覆盖,就立刻把左边界收缩到临界点。因为右指针的移动是单调的,任何以更右位置为右端点的窗口,其左边界都不会比当前的收缩结果更靠左,所以不会被跳过。
换句话说,这个算法本质上是在枚举所有可能成为“答案右端点”的位置,在每一个位置维护该位置对应的最长左边界收缩结果,并从中取全局最优。这个逻辑成立的前提是:长度越短的窗口越优,而收缩过程能保证“当前右边界下”的最短。
4. 编码与测试阶段踩过的七个坑,逐个排查全记录
4.1 空串、单字符串返回值问题
第一类问题是输入异常。S 或 T 为空时直接返回空串,这没有争议。但单字符串的情况容易被忽略,例如 S = "a",T = "a",正确结果应该是 "a"。如果你把 ans_left 和 ans_right 初始化成 -1 和 len(s),并且更新条件写成了if j - i < ans_right - ans_left,那么初始长度差是 n+1,单字符窗口长度为 1 时可以正常更新,不会出错。真正容易出错的是后续输出切片时写成了s[ans_left:ans_right],丢掉了末尾字符,改成s[ans_left:ans_right + 1]之后问题消失。这种“差一”问题在评测时最容易无脑丢分。
4.2 T 中存在重复字符时的计数细节
T 中重复字符是覆盖类问题最典型的坑。比如 T = "AA",那么在窗口里 A 的数量至少要达到 2。用 need dict 记录{'A': 2},valid 的满值就是 1,也就是只有 A 一个种类达标。实现里要特别注意:valid 代表的是“种类”,不是“字符总个数”,要是写成 valid == len(t),那 T 有重复字符时永远不可能正确。
我当时测试的用例:
- S = "AABA",T = "AA",预期 "AA";
- S = "ABAA",T = "AA",预期 "AA";
- S = "BAAA",T = "AA",预期 "AA"。
这三个用例能过滤掉大部分错误写法。尤其是第二个,如果没处理好右指针先越过两个 A 再收缩的逻辑,很可能输出 "BAA"。
4.3 收缩时 left 指向的字符不在 need 中
这是滑动窗口实现里最家常的疏忽。收缩循环中,我们通常会写left_ch = s[left],然后判断它是否在 need 中。很多精简版代码会顺手写成:
if left_ch in need: window[left_ch] -= 1 if window[left_ch] < need[left_ch]: valid -= 1这个写法的问题在于:如果 left_ch 不在 need 里,窗口计数器不需要更新,但 left 仍然要前进。如果你把left += 1写在 if 的外面,那没问题;如果程序员手误把它写进了 else 分支,左指针就会卡住。这种问题在压力测试下会出现死循环,很值得提前检查。
4.4 窗口计数器本身的失效问题
有一个细节我刚学的时候没注意:window 字典里会记录那些不在 need 中的字符,比如 S 中的 'X'、'Y'。这些字符对 valid 没有贡献,但必须被计数吗?其实不必须。你可以选择不将这些字符加入 window,因为它们的数量对覆盖判断没有影响。但对排错来说,把每个字符都加入 window 会让调试打印更直观,方便肉眼检查“当前窗口到底包含什么”。两种做法都能 AC,个人建议在初学阶段保留所有字符的计数,理解透彻后再优化成只记录 need 中的字符,减少内存占用和哈希操作开销。
4.5 大字符集下的哈希表性能
如果输入字符集只包含小写字母,那用长度为 26 的数组存储计数其实更稳,哈希表的开销虽然不大,但在 n = 10^6 级别的测试中,常数差异会被放大。我当时做了个简单对照:Python 里 dict 的 get 与赋值,比 list 的索引操作慢约 3 倍。对于 10^5 长度的输入,差距在几十毫秒级别,不算致命;但如果字符种类少、字符串超级长,list 实现确实更稳妥。
用 list 的实现可以这样写:
def min_window_list(s: str, t: str) -> str: if not s or not t: return "" need = [0] * 128 for ch in t: need[ord(ch)] += 1 window = [0] * 128 need_count = sum(1 for x in need if x > 0) valid = 0 left = 0 ans_left, ans_right = -1, len(s) for right, ch in enumerate(s): idx = ord(ch) window[idx] += 1 if need[idx] > 0 and window[idx] == need[idx]: valid += 1 while valid == need_count: if right - left + 1 < ans_right - ans_left + 1: ans_left, ans_right = left, right left_idx = ord(s[left]) if need[left_idx] > 0 and window[left_idx] == need[left_idx]: valid -= 1 window[left_idx] -= 1 left += 1 return "" if ans_left == -1 else s[ans_left:ans_right + 1]这份代码能稳定应对大规模输入,并且在字符集为 ASCII 的场景下不会出现哈希冲突问题。当然,如果字符集是 Unicode 或中文,那字典方案反而更通用,list 开 128 就会越界。
4.6 左指针越过右指针的特殊情况
有一种边界情况是:当窗口收缩时,left 可能会移动到 right 之后吗?理论上不会,因为收缩循环的终止条件是 valid 不再满,而 valid 满的前提是窗口里至少含有一个 T 中所需的字符。但如果 T 为空串,则 need_count 为 0,valid 初始就是 0,此时 while 条件恒成立,left 会无限前进直至越界。这也是为什么要在一开始对 T 做空串判断。
还有一种更隐蔽的情况:S 的长度小于 T,或者 S 中某些字符数量不够。此时 valid 永远不可能等于 need_count,循环结束后 ans_left 保持 -1,返回空串。代码里不需要额外判断 S 与 T 的长度关系,因为算法本身会自然处理。
4.7 顺序无关但字符数要求苛刻的用例
覆盖类问题与“子序列”问题的最大区别是:覆盖子串不在乎字符的顺序,只在乎数量。所以一个用例 S = "BAANC",T = "ABC",预期结果 "BAANC" 本身也是合法的覆盖子串,虽然它包含多余字符。这里有个易错点是,如果 T 中有 3 个字符,而 S 中某个窗口长度只有 2,那这个窗口必然不合法。有些初学者会先按长度排序候选窗口再判断,这是绕远路;正确做法是直接按覆盖判断,长度只是最后比较优劣的指标。
5. 从题22看一类题的通用框架,顺便聊聊怎么扩展
5.1 滑动窗口的两种基础模式:定长与变长
第22题属于变长滑动窗口中最经典的“最小覆盖子串”模式。与它并列的是“最长无重复子串”“最多替换 K 次后的最长重复子串”等。区别在于左右指针的移动条件不同:
- 固定窗口:左右指针同时前进,窗口中元素数量恒定,常用于“大小为 k 的子数组最大平均值”;
- 变长窗口:右指针负责扩张,左指针只在某种条件满足后收缩,常用于“满足某条件的最短/最长子数组”。
识别变长窗口题型的信号有三个:输入是一个线性的序列(数组或字符串);要求的答案是一个连续的子段/子串;这个子段需要满足某个可以随着长度单调变化的性质。
5.2 把“valid 计数法”推广到其他题型
valid 计数法不只适用于最短覆盖子串。比如“字符串 S 中包含所有 T 字符的子串个数”这种统计类问题,也可以用类似逻辑:维护窗口覆盖状态,一旦窗口合法,那么以当前左指针为起点的所有右端点窗口都合法,可以直接用右指针长度累加答案。再比如,“允许替换 K 个字符使窗口内全为某字符的最长窗口”问题,需要维护的是“窗口内出现频率最高的字符数量”,判断能否替换的条件是window_len - max_freq <= K,这里的 max_freq 就是窗口状态的核心指标。
这种“用一个状态量实时判断窗口是否满足条件”的思路,比反复扫描窗口内容要高效得多,也是滑动窗口题从 O(n²) 优化到 O(n) 的共性手段。
5.3 和二分、前缀和组合的进阶联想
变长窗口不是万能的。当窗口的收缩条件不能用简单计数器表达,或者右指针的扩张会导致状态量非线性变化时,就需要和前缀和、二分搜索组合使用。例如“和至少为 K 的最短子数组”问题,由于数组中可能有负数,窗口内和的单调性被破坏,此时需要用前缀和配合单调队列;再比如“乘积小于 K 的子数组个数”问题,乘积单调性好,滑动窗口可以直接用,但窗口左边界收缩时要注意乘积的除法有除零风险,需要特殊处理。
第22题实际是这些组合题的地基。把基础的“哈希计数 + valid 判断 + 双指针移动”吃透,后面遇到“变种题”时,你能很快定位它是在哪个维度上做了变化,是字符集的统计方式变了,还是窗口移动条件变了,还是判断指标从“种类数”变成了“频率最大值”。
5.4 工程实践中写滑动窗口的几个习惯
刷题是一回事,真在工程里写类似的扫描逻辑,我一般会注意三件事:
第一,写清楚左右指针的循环不变量。比如“[left, right] 始终表示当前窗口”“valid 表示已达标字符种类数”,这些注释写好了,后面维护就不容易乱。
第二,尽量把“更新答案”独立成一个小函数或显式代码块。如果埋在 while 循环里,逻辑一多,很容易在重构时不小心改动位置。
第三,对窗口的收缩条件保持严格性。宁可多写几行显式判断,也不要为了省代码用隐式规则。比如“只记录 need 中的字符”这种优化,等正确性验证过了再做也不迟。
我后来给 A 同学讲这道题的时候,特意教他一个自检方式:把窗口内容打印出来,肉眼确认每次收缩前后它是否仍然覆盖 T。他在一次模拟测试里就是这样发现了自己在收缩时提前减少了 valid 的问题——窗口打印出来看,明明还差一个 'A',但 valid 却显示满值。这种直观验证,比纯看代码逻辑更容易发现隐性 bug。
第22题刷完之后,我自己的体感是:滑动窗口这类题,标准和代码长度都不长,真正拉开差距的是对“状态量何时变更、如何变更”的精确理解。能在一次编码里把这些细节想清楚,比急着多刷十道类似题要有用得多。后来再遇到“最长覆盖子串”“最短窗口子序列”这类变体,我基本不需要重新推导框架,只需要顺着“右指针扩张 -> 状态更新 -> 判断合法性 -> 左指针收缩 -> 更新答案”这条线,针对具体题目调整状态量的定义就行。如果你也在备赛或者准备手撕代码,建议把这道题当作一个基准点:什么时候你能在不看题解的情况下,一次写对并跑通所有边界用例,那就说明这类题的底层逻辑你已经真正握在手里了。