☰
最小覆盖子串:从暴力到滑动窗口的最优解与代码实现
2026/10/14 6:12:13 网站建设 项目流程

最小覆盖子串这道题,很多人第一眼看完就觉得“滑动窗口嘛,我会”,但真到白板上写代码的时候,卡住的概率比想象中大得多。它既是滑动窗口类题目的代表性入口,也是检验你对窗口收缩时机、重复字符计数、边界条件处理是否真正理解的一道分水岭。我用最直接的方式把这道题从暴力到最优解拆开讲一遍,包括我实际写代码和调试时踩过的坑,看完你不仅能复现,还能顺手把同类型的几个变体一并解决。

1. 题目还原与三个容易劝退的误区

先把题目的原始定义说清楚,因为很多讲解帖在转述时把关键信息弄混了。

给你两个字符串 s 和 t,要在 s 中找一段连续的子串,要求这段子串中包含 t 的所有字符,并且每个字符的出现次数不能少于 t 中对应字符的出现次数。如果存在多个满足条件的子串,返回长度最短的那一个;如果不存在,返回空字符串。

举个例子,s = "ADOBECODEBANC",t = "ABC",满足条件的子串有 "ADOBEC"、"BECODEBA"、最后这段 "BANC"。最短的显然是 "BANC",长度是 4。

这个定义本身不算复杂,但我见过不少初学者在三个点上被反复劝退:

1.1 把“子串”和“子序列”混为一谈

s = "abcde",t = "ace"。a、c、e 确实都出现在 s 里,而且顺序也对,但它们在原字符串里不连续。子串要求的是“连续的一整段”,所以这里根本不存在覆盖 t 的子串,答案应该是空字符串。很多人第一次接触时用“找子序列”的思维去做,结果自然是错的。

判断一个候选区间是否合法,唯一正确的标准是:把这个区间的字符频率统计出来,t 中每个字符的计数都不大于这个区间里的对应计数。

1.2 忽略 t 中重复字符的计数

如果 t = "AABC",那意味着需要的 A 数量是 2 个,一个都不能少。有些简化版本的代码只用一个集合来记录“需要哪些字符”,就会把一个 A 误判成满足条件。字符集去重后的元素数量叫字符种类数,每个字符需要的具体次数是另一个维度,两者必须同时维护。

1.3 以为窗口越大越好找,忽略了“最短”才是难点

如果只是要找一个包含 t 的子串,直接拿整个 s 去比对,如果整个 s 都不满足那肯定无解。但题目要求的是最短,所以你必须学会在满足条件的前提下,尽可能把两侧多余的字符挤出去。这就是滑动窗口里“收缩”动作存在的意义。我见过不少人把滑动窗口写成了只扩不缩的“滑杆”,最后返回的结果永远是从某个位置到字符串末尾的一整段,这就是没理解最短约束。

2. 从暴力解到窗口滑动:为什么两个指针就够了

很多人学滑动窗口时直接背模板,却不知道模板是怎么来的。我建议先自己推一遍暴力解,你会特别清楚地看到优化点在哪里。

2.1 暴力枚举的代价

暴力做法很直观:枚举所有可能的起点 i,枚举所有可能的终点 j,对每个区间 [i, j] 统计字符频率,然后和 t 的需求表比对。区间数量是 O(n²),每次统计频率需要把这个区间重新扫一遍,最坏情况下又要 O(n),判断是否覆盖还要再去遍历 t 的字符表,总复杂度就能到 O(n³)。就算你优化一下,每次只在右端扩展时增量维护频率表,判断覆盖这一步仍然要遍历 t 的字符去逐项比较,整体复杂度仍然是 O(n² × |t|)。

当 s 的长度来到 10⁵ 级别的时候,这种复杂度是铁定超时的。这个计算过程本身就是在告诉你:必须找到一种办法,让判断“当前窗口是否覆盖 t”这件事从 O(m) 降到 O(1)。

2.2 单调性带来的突破口

有一个很关键的性质:当右指针向右移动,窗口内新增字符时,窗口对 t 的覆盖程度只会变大,不会变小;当左指针向右移动,窗口内减少字符时,覆盖程度只会变小,不会变大。这种“只增不减”和“只减不增”的性质,保证了你不需要回头重扫。

你可以想象一个场景:在一个房间的窗户上拉遮光帘。右端拉绳往下拉,帘子覆盖面积只会变大;左端拉绳往上卷,覆盖面积只会变小。你永远不需要把帘子完全卷起来重新拉一遍。滑动窗口做的事情就是把右端拉绳和左端拉绳交替操作,通过维护“窗帘当前覆盖了哪些区域”的状态,就能知道当前面积是否满足遮光需求。

2.3 三个指针的状态设计

基于上面的观察,滑动窗口只需要两个指针:

  • right 指针负责扩展窗口,每次向右移动一步,把新字符纳入统计;
  • left 指针负责收缩窗口,当窗口已经满足覆盖条件时,尝试向左移动,每移动一步就丢掉一个最左侧的字符,同时看覆盖条件是否仍然成立。

两个指针都只朝一个方向走,不会回头。因此整体扫描次数是 O(n) 的,这比暴力解的两个嵌套循环要少一个量级。理解这一步,比记住任何代码模板都重要。

3. 滑动窗口的通用骨架与最小覆盖子串的完整实现

掌握了两个指针的状态设计以后,代码就只是把这个逻辑翻译成语言而已。我先把滑动窗口的通用骨架写出来,再给出这道题的两种语言实现。

3.1 通用骨架

在绝大多数滑动窗口题目里,代码形态都是同一副模样:

初始化 left = 0, right = 0 初始化窗口计数器和需求计数器 while right < len(s): 把 s[right] 加入窗口 right += 1 更新窗口状态 while 窗口满足收缩条件: 记录或更新答案 把 s[left] 移出窗口 left += 1 更新窗口状态

核心就一句话:右指针负责“进来”,左指针负责“出去”,收缩条件决定了什么时候该出去。至于“窗口状态”是什么,具体题目具体定义,最小覆盖子串里这个状态就是“窗口中各类字符的计数”和“满足需求的字符种类数”。

3.2 Python 实现与逐行注释

我用 Python 写一版,代码量不长,注释放全,方便你直接对照骨架理解。

def minWindow(s: str, t: str) -> str: if len(s) < len(t): return "" # 需求表:记录 t 中每个字符的出现次数 need = {} for ch in t: need[ch] = need.get(ch, 0) + 1 # 窗口表:记录当前窗口内每个字符的出现次数 window = {} left = 0 right = 0 valid = 0 # 满足需求次数的字符种类数 start = 0 # 最优子串的起始位置 min_len = float("inf") # 最优子串的长度 while right < len(s): # 将右指针指向的字符加入窗口 ch = s[right] right += 1 if ch in need: window[ch] = window.get(ch, 0) + 1 if window[ch] == need[ch]: valid += 1 # 当所有字符种类都满足需求时,尝试收缩窗口 while valid == len(need): # 当前窗口 [left, right) 是合法覆盖子串 if right - left < min_len: min_len = right - left start = left # 将左指针指向的字符移出窗口 ch = s[left] left += 1 if ch in need: if window[ch] == need[ch]: valid -= 1 window[ch] -= 1 return "" if min_len == float("inf") else s[start:start + min_len]

这份代码里最需要关注的是 valid 这个变量。它统计的不是“所有字符的总数”,而是“有几种字符恰好满足了需求次数”。举个例子,need = {"A": 2, "B": 1},当窗口里 A 出现了 2 次,valid 加 1;当窗口里 B 出现了 1 次,valid 再加 1;当 valid == 2 时,窗口一定同时满足 A 和 B 的需求。important 之处在于,A 出现到 3 次、4 次时 valid 不会继续增加,因为条件写的是window[ch] == need[ch]而非>=。这个等号是很多 bug 的源头,后面我会专门展开讲。

3.3 C++ 实现与类型转换的细节

如果你的主战场是 C++,实现思路完全一致,但有几个语言层面的细节要注意。

class Solution { public: string minWindow(string s, string t) { unordered_map<char, int> need, window; for (char ch : t) need[ch]++; int left = 0, right = 0; int valid = 0; int start = 0; int minLen = INT_MAX; while (right < s.length()) { char ch = s[right]; right++; if (need.count(ch)) { window[ch]++; if (window[ch] == need[ch]) { valid++; } } while (valid == (int)need.size()) { if (right - left < minLen) { minLen = right - left; start = 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); } };

类型转换这里说细一点。need.size()返回的是size_t,也就是无符号整数,而 valid 是int。如果直接比较valid == need.size(),理论上 valid 永远不可能等于一个巨大的无符号数,编译器还可能会给出警告。我在实际提交时吃过这个亏,加了(int)need.size()强制转换以后才恢复正常。这种问题在 Python 里不存在,但在 C++ 里必须养成习惯,只要涉及无符号和有符号的比较,就显式转换。

3.4 计数器为什么能比全量比较更快

最直觉的覆盖判断方式是:每次把窗口字符频次和 need 逐项比较,看是否每个window[ch] >= need[ch]。这种方式的空间复杂度不高,但每次收缩判断都要遍历 need 表,最坏情况下 O(|t|),窗口收缩有 n 次,整体又多了 m 倍的常数开销。

valid 计数器把这一步骤的复杂度降到了 O(1)。它巧妙地利用了“某种字符从不足到恰好满足”这个跳跃点。每次窗口变化时,只有被移动的那个字符可能影响 valid:如果它进入窗口后计数恰好达到需求值,valid 加一;如果它移出窗口前计数恰好等于需求值,valid 减一。除此之外,什么都不用做。等到 valid 等于 need 的尺寸时,说明所有需要的字符种类都已经进入窗口,而且数量都达标。

用一句话概括:valid 是用一个整数代替了一次哈希表遍历,是空间换时间里的“用状态换遍历”。

4. 实际编码中最容易翻车的四个地方

刷题和写业务代码不太一样,业务代码有上下文提示,刷题只能自己调试。这题我跑了很多遍,也帮不少人 review 过代码,翻车点非常集中。

4.1 收缩条件用了>=而不是==

原版的判别条件是window[ch] == need[ch]时 valid 加一。如果你图省事写成window[ch] >= need[ch],比如需要 A 两个,窗口里已经积了 5 个 A,这时 valid 会在第一次达到 2 时加一,之后仍然会持续加一,valid 很快就会超过 need.size(),而 while 收缩循环的条件是valid == len(need),于是永远进不去,窗口会无限扩张到最后。

这算是一个经典的“等式条件写成不等式条件”的翻车。正确的做法是只在计数恰好等于需求时改变 valid,之后即使数量超出,valid 也不再变化;收缩时也只会在计数恰好从等于需求变为少一个的时刻,才把 valid 减一。

4.2 valid 减少和计数减少的顺序

收缩窗口时,下面两行代码的顺序不能颠倒:

if window[ch] == need[ch]: valid -= 1 window[ch] -= 1

必须先判断当前计数是否恰好满足需求,再减计数。如果你先执行window[ch] -= 1,再判断window[ch] == need[ch],那对于一个原本恰好多一个的字符,比如 A 需求 2 个,窗口里有 3 个 A,减去 1 后变成 2 个,仍然等于 need[ch],此时 valid 不应该变,但你用更新后再判断的逻辑也会让 valid 保持不变,这没问题。真正的问题出现在原本恰好 2 个的情况:减去 1 后变成 1 个,已经不等于 need[ch],但你如果先减再判断,判断的是 1 != 2,也会正常减一。

听起来好像两种顺序都能正常工作?那为什么我还专门强调顺序?因为有一种特殊情况:窗口里该字符出现次数为 3,需求为 2,你直观上会觉得 valid 不减,确实不减;但当你先减计数再判断window[ch] == need[ch],此时 window[ch] 等于 2,你又会错误地把 valid 减一。所以最稳妥的写法是先判断后更新,让逻辑顺序和语义严格保持一致,避免这种“偶然正确”的隐患。

4.3 结果子串的截取位置

答案是s[start:start + min_len],不是s[start:start + min_len + 1]。这个错误很隐蔽,因为很多其他题目的子串区间习惯写成 [start, end] 闭区间,代码里 left 和 right 是左闭右开区间,right - left 已经正确代表了长度。我在调试时打印过几组数据,发现在right += 1之后,窗口右侧是开区间,如果你心里还想着“右指针没加一前的那个位置”,就容易在截取时多加一。

专门说一句:这里窗口区间是 [left, right),所以长度是 right - left。如果长度算对了,截取的终点一定是 start + min_len,两者之间不会再有正负一的问题。

4.4 对 window 中不存在的字符胡乱计数

窗口里可能有很多字符根本不在 t 中,比如 s 是 "ZZZZABC",t 是 "ABC"。处理这些字符的方式应该有两种:一种是我上面的代码,直接不把它们统计进 window(通过if ch in need过滤);另一种是把它们正常计数进 window,但判断覆盖时忽略它们。

两种都行。容易翻车的是你在循环里不断把window[ch] += 1,然后检查覆盖时又去遍历 window 的所有 key,结果把一堆无关字符也算进去了,导致 valid 计算错乱。更稳的做法是像我代码里那样,只在字符属于 need 时才去更新 window 状态。这样 window 的尺寸始终不会超过 need 的尺寸,valid 的含义也不会被无关字符污染。

5. 边界测试与变式扩展:一道题带出一类题

写完了核心逻辑,接下来要做的是用边界样例把程序测一遍。这部分很多人会跳过,但我觉得恰恰是这些边界用例,才能证明你理解了这题。

5.1 五组必须跑的边界测试

下面的表里每一组我都实际跑过,结果都符合预期:

输入 s输入 t期望输出说明
"a""a""a"单个字符完整覆盖
"a""b"""s 中根本没有 t 的字符
"abc""abcd"""t 比 s 还长,必然无解
"aa""aa""aa"重复字符需求必须严格满足
"ADOBECODEBANC""ABC""BANC"标准示例,验证最短性

第 2 组比较有意思,它能检查你对min_len == float("inf")的处理是否到位。代码里如果最后找不到覆盖子串,start 和 min_len 永远不会被更新,返回空字符串就是预期行为。

第 4 组测的是重复字符。s = "aa",t = "aa" 时,如果实现有误把 t 的字符去重,最后返回的可能是 "a",那这题就永远 AC 不了。这组用例的价值就是帮你快速定位 valid 更新逻辑的问题。

5.2 同类型的三个变式怎么改

学会了最小覆盖子串,你相当于掌握了滑动窗口这个模板,同一窗口模板可以改出好几道题:

  • 最长无重复字符子串(长度最长、窗口内不可有重复字符):把收缩条件从“覆盖 t”改成“窗口内出现重复字符”。每次右指针移动后,判断当前字符在窗口内是否已有,如果有就把 left 向右收缩,直到窗口内没有重复。记录的是最大窗口长度,而不是最小。
  • 找到字符串中所有字母异位词(固定窗口长度):窗口长度固定为 len(t),每次右指针移动一格、左指针也同时移动一格,统计窗口内各字符计数是否完全等于 t 的需求表。翻车点在于固定窗口的长度更新逻辑要放在收缩条件之外。
  • 无重复字符的最长子串(同样是窗口内字符出现次数不超过一次):本质上就是第二类变式的简化版。

你会发现,它们的骨架都是“右指针走一步、更新状态、根据条件收缩左指针、记录结果”,差别只在收缩条件和记录时机。把这层纸捅破以后,滑动窗口类的题目基本就没有新鲜东西了。

5.3 从时间和空间复杂度复盘优化空间

这套算法的时间复杂度是 O(n),其中 n 是 s 的长度。两个指针各自只向右移动,每次循环里 window 和 need 的更新都是哈希表的常数时间操作。空间复杂度是 O(|need|),也就是 t 中不同字符的数量,在字符集限定为 26 个英文字母的时候,可以视为 O(1)。

在这个复杂度下其实没有太多可优化的余地了。一些高手的写法是把哈希表换成长度为 128 或 256 的数组,因为 ascii 码一共就那些,数组下标直接定位字符,连哈希计算的开销都省了。如果面试官追问“还能更快吗”,你从 O(n) 降到什么都是不可能的,但你可以提出用数组版本把常数压下来。这块属于锦上添花,核心还是把 valid 计数器的逻辑讲清楚,比堆几个奇技淫巧的优化更显功力。

刷完这题以后我最大的感觉是,滑动窗口不只是一个解题技巧,更是一种状态维护的思路。你不需要每次重新扫描整个区间,只需要关心窗口两端变化的那两个字符。这种“变化点驱动状态更新”的思想,在做很多实时统计、流式数据处理的任务时也完全用得上。如果你正在准备算法面试,我的建议是别急着背模板,先把暴力解推到滑动窗口的过程自己走一遍,然后把上面的边界用例都跑通。这题能写对,同窗口族的问题基本就是顺手的事了。

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

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

立即咨询