ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

最小覆盖子串详解:滑动窗口与频率计数核心原理

最小覆盖子串详解:滑动窗口与频率计数核心原理

1. 先搞清楚"最小覆盖子串"到底在找什么

聊一道很经典的字符串题:最小覆盖子串。题目给两个字符串 s 和 t,要求从 s 里找出一个最短的连续子串,让它包含 t 中的全部字符,而且每个字符的数量也要满足;如果找不到就返回空串。刚接触这道题的时候,大多数人不是看不懂题意,而是卡在"怎么高效判断窗口已经覆盖 t",以及"缩短窗口时计数器怎么维护"这两个地方。这篇文章把这两个核心问题拆开讲透,顺便把代码里那些容易写错的细节和效率优化一起列出来,适合已经掌握基本双指针概念、想彻底搞定滑动窗口问题的读者。

1.1 "覆盖"不是"包含",顺序也不重要

先看一个最常见误解:"覆盖"不等于"出现过"。如果 t = "AABC",那么 s 里的窗口必须包含两个 A、一个 B、一个 C,少一个都不行。比如窗口"BACA"里有 A 两个、B 一个、C 一个,算覆盖;窗口"AC"虽然也出现了 A 和 C,但 B 没有,而且 A 的数量不够,就不能算。这里强调的是字符出现的次数,不是"字符种类是否齐全"。

还有一个容易混淆的点:子串是连续的,不能像子序列那样跳着取。题目要求的是 s 中一段连续区间。不过窗口内部字符的顺序完全无所谓,t = "ABC",窗口"CBA"也能覆盖。这个特性决定了我们只需要统计字符频率,不需要关心相对顺序,为后面用哈希表或数组计数扫清了障碍。

1.2 暴力解法的问题

最直观的做法是枚举所有子串,也就是先固定左端点 i,再让右端点 j 从 i 一直走到末尾,对每个子串都检查一遍是否覆盖 t。检查覆盖需要统计窗口内每个字符的出现次数,再逐个和 t 的需求比对。这样做的复杂度大概是 O(n^2 * m),n 是 s 的长度,m 是 t 的长度。如果 s 是一万个字符,这个算法基本就跑不动了;如果 s 是十万、百万级别,暴力枚举更是直接不可行。

暴力解法虽然慢,但它给了两个重要信息:一是我们必须用"字符频率"来刻画覆盖关系;二是窗口在向右扩展的过程中,很多枚举区间是重复的。比如从 i=0 开始已经找到覆盖区间 [0, 5],那么从 i=1 开始就没必要重新从 j=1 逐个向右扫,完全可以在之前的状态基础上继续移动,这就是滑动窗口的出发点。

2. 为什么只有滑动窗口适合这题:单调性是关键

滑动窗口并不是万能的,它成立的前提是"窗口变化具有单调性"。所谓单调性,就是右指针不需要回头。这一点对于 76 题来说特别重要,因为一旦不能单方向移动,复杂度就会退化。

2.1 窗口扩展-收缩的单调性直觉

如果我们固定左端点 left,为了达到覆盖条件,需要不断把右端点 right 往右移动。第一次让窗口覆盖 t 的时候,我们就得到了"以 left 开头的最短覆盖子串"。这时候如果 left 向右移动,窗口会变小,原本覆盖的状态可能就失效了;要继续恢复覆盖,只能继续向右移动 right,让新的字符进入窗口。right 绝对不需要往左退,因为 left 已经右移,丢失的字符不可能在左边补回来。

这个过程可以类比成一根橡皮筋:右端不断向右拉伸,直到窗口能包住目标集合;然后左端向右收紧,压缩橡皮筋长度;一旦压缩过头,右端再继续向右拉伸。两端都只往一个方向走,整个遍历就是线性推进的。

2.2 收缩时的关键决策

窗口进入覆盖状态后,我们要尝试右移 left 来缩短窗口。注意,收缩过程不一定只移动一步就停:如果窗口里有多余字符,移走一个后窗口可能仍然覆盖 t,那就可以继续收缩;一直收缩到窗口不再覆盖,才回到扩展阶段。这里的"多余字符"可以是 t 中不存在的字符,也可以是 t 中某个字符超出需求数量的部分。

需要特别提醒的是,答案的记录时机通常放在收缩过程之中。因为每完成一次有效的 left 右移,窗口仍然覆盖且长度更短,这可能就是新的最小值。如果只在 while 循环外面记录一次,很容易漏掉"收缩到一半时出现的更短窗口"。

2.3 线性复杂度的保证

为什么左右指针可以总共只移动 O(n) 次?因为 left 最多从 0 移动到 n-1,right 也最多从 0 移动到 n-1,两个指针都不会回退。每次移动只需要 O(1) 的时间更新计数,所以总复杂度是 O(n)。这是滑动窗口相比暴力枚举的核心优势,但前提是"判断窗口是否覆盖"也要做到 O(1)。如果每次还要重新扫描窗口,复杂度就又会涨回去,这个点我在后面优化部分会专门展开。

3. 窗口状态怎么记录:need 数组和 valid 计数的完整语义

现在来到了这题最核心的地方:怎么用一份"缺口单"管理窗口状态,并且用 O(1) 时间回答"窗口是否覆盖 t"。

3.1 need 数组不是"窗口里有什么",而是"窗口还缺什么"

常见实现里有一个数组 need,但很多初学者误以为它记录的是窗口内字符的出现次数。其实它记录的是"相对于 t 的需求,窗口还缺多少个字符"。初始化时,遍历 t,把每个字符的需求量写进 need:need['A'] = 2 表示还缺两个 A。

当右指针扩展时,新字符进入窗口,它补上了一个缺口,所以 need[ch] 减 1。当左指针收缩时,字符离开窗口,它重新变成缺口的一部分,所以 need[ch] 加 1。

need 的数值有三种含义:大于 0 表示这个字符还缺多少个;等于 0 表示这个字符不多不少刚好满足;小于 0 表示窗口里这个字符已经超出需求,多了多少个。比如 need['A'] = -1,就说明窗口里的 A 比 t 要求的还多一个。

3.2 valid 为什么按字符种类计,不按字符数量计

valid 是一个计数器,记录的是"已经满足需求的字符种类数"。t 中可能存在重复字符,比如 "AABC",它有三个不同字符 A、B、C,所以最终需要让 valid 等于 3,也就是三个字符种类都补齐了。

valid 加一和减一的时机非常关键,它不是在 need 变成负数或正数时变化的,而是在"从缺到不缺"或"从不缺到缺"这个转换瞬间变化。右指针加入一个字符后,如果 need[ch] 恰好从 1 变成了 0,说明这个字符刚刚补齐,valid 加一。左指针移除一个字符后,如果 need[ch] 恰好从 0 变成了 1,说明这个字符从刚好够变成了缺失,valid 减一。

至于 need 从 2 减到 1,那只是缺口缩小,并没有补齐;need 从 -1 减到 -2,也只是多出来更多,valid 不该变化。反过来,need 从 -1 加到 0,说明"多出来的一个被移走了",但字符本身仍然刚好够,也没有新的缺口产生,valid 同样不该变化。理解这个转换点,整段代码就不会写错。

3.3 模拟一次完整的状态迁移

用一个小例子把状态变化走一遍。设 s = "CAABAC",t = "ABC"。初始时 need = {A:1, B:1, C:1},valid = 0,目标 valid 数 = 3。

操作窗口内容need 状态(A,B,C)valid是否覆盖
右移加入 CC(1,1,0)1否
右移加入 ACA(0,1,0)2否
右移加入 ACAA(-1,1,0)2否
右移加入 BCAAB(-1,0,0)3是,记录长度 4
左移移除 CAAB(0,0,1)2否,停止收缩
右移加入 AAABA(-1,0,1)2否
右移加入 CAABAC(-1,0,0)3是,长度 5
左移移除 AABAC(0,0,0)3是,长度 4
左移移除 ABAC(1,0,0)2否,停止收缩

结果得到最短覆盖子串 "BAC",长度为 3。注意收缩过程中,有时候移除的是多余字符,比如从 AABAC 移除第一个 A 时,need 从 -1 变回 0,valid 不变;再移除一个 A 时,need 才从 0 变成 1,valid 才减一。这个细节如果只靠背代码,很容易理解偏差。

4. 代码详解:数组版和哈希表版

先说一个我实际写代码时的习惯:核心逻辑我通常用数组实现,给读者讲原理时再用哈希表解释。因为数组版更贴近底层,性能也更好;哈希表版可读性更高,但存在额外开销。两边都写出来,遇到什么场景都能直接应对。

4.1 数组版完整代码

def minWindow(s: str, t: str) -> str: if len(s) < len(t): return "" # 1. 初始化需求数组,ASCII 字符用 128 足够 need = [0] * 128 for ch in t: need[ord(ch)] += 1 # 2. 统计 t 中有多少种字符,作为 valid 的目标值 need_cnt = sum(1 for v in need if v > 0) left = 0 matched = 0 start = 0 min_len = len(s) + 1 for right, ch in enumerate(s): # 3. 右指针扩展,进入窗口的字符补上一个缺口 idx = ord(ch) need[idx] -= 1 if need[idx] == 0: matched += 1 # 4. 窗口覆盖 t 后,尝试收缩左边界 while matched == need_cnt: cur_len = right - left + 1 if cur_len < min_len: min_len = cur_len start = left left_ch = s[left] left_idx = ord(left_ch) need[left_idx] += 1 if need[left_idx] > 0: matched -= 1 left += 1 return "" if min_len == len(s) + 1 else s[start:start + min_len]

4.2 关键步骤逐行说明

先看初始化部分。提前判断len(s) < len(t)可以直接返回空串,因为 s 长度都不够,根本不可能覆盖 t。这里的 need 数组用了 128,Python 的ord会把字符转成 ASCII 码,大小写英文字母都在这个范围内。need_cnt是"需要满足的字符种类总数",比如 t = "AABC",need_cnt = 3。

再看不变量更新。右指针移动时,无论进来的字符是不是 t 需要的,都会执行need[idx] -= 1。如果这个字符是 t 外的字符,need 会变成负数,但它永远不会触发need[idx] == 0?其实可能触发:如果该字符初始 not in need,那么初始是 0,减去 1 变成 -1,不触发。只有那些初始为正数的字符,减到 0 时才会触发 matched 加一。也就是说,matched 只统计真正"从缺到不缺"的种类。

收缩判断条件need[left_idx] > 0是这段代码最容易写错的地方。当左指针移出一个字符时,need 加一。如果加一后大于 0,说明这个字符从"刚好满足"变成了"缺失",matched 减一;如果加一后仍然小于等于 0,说明这个字符原本就是多余状态,移走一个不足为奇,覆盖状态没有破坏。

4.3 哈希表版完整代码

from collections import Counter def minWindow_map(s: str, t: str) -> str: if len(s) < len(t): return "" need = Counter(t) need_cnt = len(need) window = Counter() matched = 0 left = 0 ans = (float("inf"), 0, 0) for right, ch in enumerate(s): window[ch] += 1 if ch in need and window[ch] == need[ch]: matched += 1 while matched == need_cnt: if right - left + 1 < ans[0]: ans = (right - left + 1, left, right) left_ch = s[left] window[left_ch] -= 1 if left_ch in need and window[left_ch] == need[left_ch] - 1: matched -= 1 left += 1 return "" if ans[0] == float("inf") else s[ans[1]: ans[2] + 1]

哈希表版的逻辑和数组版完全一致,区别在于window记录窗口内真实频次,need记录目标频次。判断覆盖时,如果某个字符在窗口的出现次数恰好到达需求值,matched 加一;如果移出后低于需求值,matched 减一。哈希表更直观,但 Counter 是通用数据结构,每次访问键值都有哈希开销。

4.4 两个版本的取舍

对比维度数组版哈希表版
可读性中等,理解 need 的负数语义需要时间较高,window 和 need 语义接近直觉
时间常数小,索引访问 O(1)大,哈希查表有额外开销
字符集限制适合 ASCII,若扩展 Unicode 需扩容无限制,适合任意字符
面试/笔试推荐输入为英文字母时推荐逻辑讲解时推荐

如果题目明确只有大小写英文字母,我会优先选择数组版。如果是通用场景或者字符集不确定,哈希表版更保险。两个版本我都建议亲手写一遍,尤其是数组版,能加深对"缺口单"这个模型的理解。

5. 效率优化:从 O(N*M) 到 O(N) 的进化路线

这一节集中说说性能问题。很多人能写出正确代码,但常数很大,或者判断覆盖时多扫了一层循环,结果在大数据量下表现很差。

5.1 最常见的低效写法是什么

最容易出现的坏写法是:扩展右指针后,每次都调用一个check()函数,重新统计窗口内所有字符频次,再和 t 的需求比对。比如这样:

def is_valid(s, left, right, need): window = Counter(s[left:right+1]) for ch in need: if window[ch] < need[ch]: return False return True

这种写法每次判断都要新建一个 Counter,复杂度至少是 O(窗口长度)。加上外层 for 循环,整体会退化到 O(n^2) 甚至 O(n * |t|)。在大字符串上,运行时间会急剧膨胀。问题不是出在"统计频次"本身,而是每次移动指针都重新统计了全窗口,丢弃了之前的状态信息。

5.2 valid 变量究竟省在哪里

滑动窗口的精髓是增量维护状态:右指针加入一个字符,只需要更新一个计数;左指针移除一个字符,也只更新一个计数。判断是否覆盖时,不需要重新扫窗口,只需要看 valid 是否等于 need_cnt。这个从"每次扫描窗口"到"维护一个整数"的变化,正是 O(n * m) 变成 O(n) 的关键。

如果觉得 valid 不好理解,可以换一种说法:valid 记录的是"目前已经有多少种字符恰好满足需求"。窗口覆盖 t,等价于所有字符种类都满足了,也就是 valid == need_cnt。每次只在一个字符的计数从缺变为不缺,或从不缺变为缺时,valid 才变化一次。由于每个字符的计数变化方向只有一次到多、多到少的切换,所以总变化次数也是 O(n) 量级。

5.3 数组代替哈希表和提前终止

代码层面的优化有三件小事值得做。第一,用定长数组代替哈希表,避免每次Counter的哈希计算和内存分配。第二,初始化时顺便统计need_cnt,不用每轮再遍历整个 need 数组检查是否都满足。第三,可以加一个提前终止的判断:如果当前剩余字符串长度已经不可能超过当前最短长度,直接结束循环。

一个可行的提前终止写法是,在主循环内部根据当前 min_len 判断:

for right in range(len(s)): if len(s) - right < min_len: break # ... 扩展逻辑

这个判断放在扩展逻辑之前,意思是:从当前 right 到末尾的剩余总长度都不足 min_len 了,那即使后面全都能覆盖,也不可能产生更短的窗口,直接退出。注意,这个优化只在已经找到有效答案之后才有意义;如果 min_len 还是初始的 len(s)+1,这个判断永远不会触发。

5.4 只遍历候选字符位置:长字符串加速技巧

还有一个更激进的优化思路,适合 s 很长、t 很短、t 的字符在 s 中出现稀疏的场景。既然 s 中大量字符和 t 无关,那可以让双指针只跑在"有可能影响状态的位置"上。

做法是先用一个列表记录 s 中所有属于 t 的字符的下标,比如:

pos = [i for i, ch in enumerate(s) if need[ord(ch)] > 0]

注意这个need要提前初始化好,ord(ch)在 need 中对应值大于 0,说明该字符在 t 里出现过。然后双指针 left_idx 和 right_idx 在这个 pos 列表上移动,但计算窗口真实长度时,要用pos[right_idx] - pos[left_idx] + 1。

这个优化把主循环从"遍历整个 s"变成"只遍历 s 中相关字符的位置"。如果 s 长 10 万但相关字符只有 100 个,主循环直接从 10 万轮降到 100 轮。代价是代码更绕,容易把"位置数组的下标"和"真实字符串下标"搞混。我的建议是:笔试中先写基础的数组版,保证正确;如果性能确实成为瓶颈,再引入这个技巧。

6. 易错点与边界 Case 复盘

写这题时容易踩的坑,我基本都踩过一遍,这里集中复盘几个最典型的。

6.1 用 Set 判断覆盖:字符种类骗局

有的代码会用一个 set 记录 t 的字符,然后判断窗口是否包含 set 中的所有元素。这个做法在 t 所有字符都不重复时才勉强成立。一旦 t 存在重复,比如 t = "AA",s = "BA",窗口 "BA" 中确实出现了 A,set 会认为已经覆盖,但实际还需要两个 A。这是最隐蔽的数量错误。覆盖判断必须落到"每个字符的需求量"上,而不是字符种类上。

6.2 收缩判断条件写成 >= 0 的后果

再看数组版收缩时那行代码,如果写成if need[left_idx] >= 0而不是> 0,会发生什么?考虑一个已经覆盖但有多余 A 的窗口,移除一个多余的 A 时,need 会从 -1 变回 0。字符明明没有从"够"变"缺",valid 不应该变化,但如果条件写成了 >= 0,这个场景下 need 从 -1 变 0 后等于 0,会误判为"字符缺失",导致 valid 错误减一。紧接着,窗口明明仍覆盖 t,却因为 valid 不满足要求,错过了一个可能的更短窗口。判断条件只需要关心"是否从 0 变成正数",所以> 0才是正确写法。

6.3 答案更新位置和 min_len 初始值

答案更新一定要放在 while 收缩循环内部,而不是 while 循环结束之后。原因我在第 2.2 节提过:一次收缩过程中,可能连续移出多个多余字符,中间窗口仍然覆盖且更短,这些长度都必须参与比较。如果只在退出 while 后更新,会漏掉中间状态。至于 min_len 初始值,我习惯用len(s) + 1,既方便最终判断是否找到答案,又避免用inf导致后续类型混乱。

6.4 一组值得手推的边界用例

st期望结果
"ADOBECODEBANC""ABC""BANC"
"a""a""a"
"a""aa"""
"ab""b""b"
"aB""ab"""
"abdbca""abc""bca"
"aa""aa""aa"

这些用例虽然短,但每一个都覆盖一种边界情况:空结果、单字符匹配、重复字符不足、大小写敏感、覆盖子串在中间。写完代码后,我建议把这几个用例都跑一遍,能跑通基本就稳了。

7. 举一反三:从 76 题到滑动窗口模板

这道题绝不仅仅是背一个答案,它背后的滑动窗口模板可以迁移到一整个题目家族。这里我把模板抽出来,再说说怎么改造成其他题型。

7.1 通用模板四步走

滑动窗口几乎所有变体都可以归纳成四步:一看需求容器,二扩展右端,三收缩左端,四更新答案。需求容器可以是数组、哈希表,也可以是一个计数器,核心是能表达"当前状态是否满足题目条件"。扩展右端时更新状态;当状态满足条件时,进入收缩阶段;收缩阶段每次移动左端,同时更新答案。最后循环结束,根据记录的结果返回。

具体到 76 题,条件就是"窗口覆盖 t"。对另一类题目,条件可能是"窗口内没有重复字符",也可能是"窗口长度恰好等于某个固定值"。变化的是条件判断,不变的是双指针的单向移动框架。

7.2 相关变形题与调整点

有一类题是判断一个字符串是否包含另一个字符串的排列。这类题可以直接套用本题模板,但收缩条件要换成"窗口长度等于 t 的长度",而且一旦找到就立即返回。还有一类题是找出所有异位词的起始位置,同样需要固定长度窗口,并在移动过程中比较窗口频次和目标频次是否相等。另有一类题要求最长无重复子串,这类题甚至更简单,因为条件是"窗口内无重复",不满足时收缩左端直到重建合法状态即可。

这些变形题核心都没变:搞清楚"什么条件进入收缩阶段""答案在什么时候更新""状态如何增量维护",就能从一道 76 题打通一串题目。

7.3 我建议的练习顺序和习惯

我的个人做法是:第一次接触时不要只背代码,用一个短例子手动推一遍 need 和 valid 的状态变化,尤其是负数区域的变化。推完再对照数组版代码写上几遍,直到闭着眼睛也能把收缩条件写对。之后再做变形题时,先把模板四个步骤写在草稿纸上,再填这套题的"条件"和"答案更新时机"。

最后分享一个小技巧:每次写完 76 题,我会强制自己把数组版和哈希表版各写一遍,再故意改坏一个条件,比如把> 0改成>= 0,观察结果会不会出错。这样既加深了对"缺口单"模型的理解,也把最容易错的边界条件焊死在记忆里。能把这个过程走完,滑动窗口这类题基本不会再翻车。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进