目录
[最小覆盖子串(滑动窗口 + 字符计数)](#最小覆盖子串(滑动窗口 + 字符计数))
[1. 暴力解法](#1. 暴力解法)
[2. 优化核心:滑动窗口](#2. 优化核心:滑动窗口)
[3. 辅助工具:字符计数数组](#3. 辅助工具:字符计数数组)
[关键步骤拆解(结合示例 1:s=ADOBECODEBANC, t=ABC)](#关键步骤拆解(结合示例 1:s=ADOBECODEBANC, t=ABC))
最小覆盖子串(滑动窗口 + 字符计数)
在字符串处理的算法题中,「最小覆盖子串」是一道经典的子串匹配 + 优化遍历问题。它要求在一个字符串中找到包含另一个字符串所有字符(含重复)的最短子串,核心考察对「滑动窗口」和「字符计数」技巧的运用。
本文将从「暴力解法→优化思路→核心算法→代码解析→进阶优化」逐步展开,让你能看懂代码,能理解背后的思维逻辑。
一、问题重述与核心难点
问题定义
传送门:最小覆盖字串
给定两个字符串 s(长度 m)和 t(长度 n),返回 s 中最短的窗口子串 ,使得该子串包含 t 中的每一个字符(包括重复字符)。若不存在则返回空字符串 "",且答案唯一。
示例直观理解
- 示例 1:
s=ADOBECODEBANC, t=ABC→ 输出BANC解析:ADOBEC(长度 6)、CODEBA(长度 6)、BANC(长度 4)均满足条件,最短为BANC。 - 示例 3:
s=a, t=aa→ 输出""解析:t需要 2 个a,但s只有 1 个,无法满足。
核心难点
- 如何高效判断「子串包含 t 的所有字符」 :不能每次都遍历子串和
t对比(时间开销太大)。 - 如何找到「最短子串」:避免枚举所有可能的子串(暴力解法会超时)。
二、从暴力到优化:思路演进
1. 暴力解法
思路 :枚举s中所有可能的子串,判断每个子串是否包含t的所有字符,记录最短的那个。
- 枚举子串:需要两层循环(左边界
i、右边界j),共O(m²)个可能的子串。 - 判断是否包含
t:对每个子串,需要统计字符频率并与t对比,耗时O(m+n)。 - 总时间复杂度:
O(m²(m+n)),对于m=1e4的场景直接超时。
结论:暴力解法思路简单,但效率极低,必须寻找更优的遍历方式。
2. 优化核心:滑动窗口
滑动窗口是解决「子串匹配 + 长度优化」问题的经典技巧,核心思想是用两个指针维护一个「动态窗口」,通过扩张和收缩窗口,高效遍历所有可能的有效子串。
滑动窗口的核心逻辑
- 扩张右指针(right) :扩大窗口范围,直到窗口包含
t的所有字符(找到一个有效窗口)。 - 收缩左指针(left):在窗口有效时,尽量缩小窗口范围(左指针右移),直到窗口不再有效,记录此时的最短窗口。
- 重复上述过程 :直到右指针遍历完
s。
为什么滑动窗口高效?
每个字符只会被「右指针」和「左指针」各访问一次,遍历次数为O(m),时间复杂度大幅降低。
3. 辅助工具:字符计数数组
要快速判断「窗口是否包含t的所有字符」,需要统计字符频率:
- 用
cnt_t[128]记录t中每个字符的出现次数(ASCII 码共 128 个,数组比哈希表访问更快)。 - 用
cnt_s[128]记录当前窗口中每个字符的出现次数。 - 判断条件:对所有字符
c,cnt_s[c] >= cnt_t[c](窗口中该字符的数量不少于t中的数量)。
三、AC代码解析
cpp
class Solution {
// 检查当前窗口是否满足条件:cnt_s的所有字符计数 >= cnt_t
bool check(int cnt_s[], int cnt_t[]) {
// 遍历大写字母(A-Z)
for (int i = 'A'; i <= 'Z'; i++) {
if (cnt_s[i] < cnt_t[i]) return false;
}
// 遍历小写字母(a-z)
for (int i = 'a'; i <= 'z'; i++) {
if (cnt_s[i] < cnt_t[i]) return false;
}
return true;
}
public:
string minWindow(string s, string t) {
int cnt_s[128]{}; // 窗口内字符计数(初始化为0)
int cnt_t[128]{}; // t中字符计数(初始化为0)
// 第一步:统计t中每个字符的出现次数
for (char c : t) {
cnt_t[c]++;
}
int m = s.size();
int resl = -1; // 最优窗口的左边界(初始为-1表示无有效窗口)
int rrer = m; // 最优窗口的右边界(初始为m,使得初始窗口长度为m+1,方便后续更新)
int left = 0; // 滑动窗口的左指针
// 第二步:扩张右指针,构建窗口
for (int right = 0; right < m; right++) {
char c = s[right];
cnt_s[c]++; // 右指针加入窗口,更新计数
// 第三步:窗口有效时,收缩左指针,优化窗口长度
while (check(cnt_s, cnt_t)) {
// 更新最优窗口:当前窗口更短时
if (right - left < rrer - resl) {
resl = left;
rrer = right;
}
// 左指针移出窗口,更新计数
cnt_s[s[left]]--;
left++;
}
}
// 若resl仍为-1,说明无有效窗口;否则返回子串
return resl < 0 ? "" : s.substr(resl, rrer - resl + 1);
}
};
关键步骤拆解(结合示例 1:s=ADOBECODEBANC, t=ABC)
- 初始化计数数组 :
cnt_t['A']=1, cnt_t['B']=1, cnt_t['C']=1,其余为 0。 - 扩张右指针 :
right=0(A):cnt_s[A]=1→ 不满足 check。right=1(D):cnt_s[D]=1→ 不满足。right=2(O):cnt_s[O]=1→ 不满足。right=3(B):cnt_s[B]=1→ 不满足。right=4(E):cnt_s[E]=1→ 不满足。right=5(C):cnt_s[C]=1→ 此时cnt_s[A]=1, B=1, C=1,满足 check。
- 收缩左指针 :
- 当前窗口
[0,5](ADOBEC),长度 6。更新resl=0, rrer=5。 - 左指针移出
A:cnt_s[A]=0→ check 不满足,退出 while 循环。
- 当前窗口
- 继续扩张右指针 :直到
right=10(B)、right=11(A)、right=12(N)、right=13(C),此时窗口[9,13](BANC),满足 check。- 收缩左指针:窗口长度 4 < 之前的 6,更新
resl=9, rrer=12(BANC)。 - 移出
B后 check 不满足,循环结束。
- 收缩左指针:窗口长度 4 < 之前的 6,更新
- 返回结果 :
s.substr(9,4)→ "BANC"。
四、复杂度分析
- 时间复杂度 :
O(m * 128)→ 简化为O(m)。解释:right和left各遍历s一次(O(m)),每次check遍历 128 个 ASCII 字符(O(1)),整体为线性时间。 - 空间复杂度 :
O(128)→ 简化为O(1)。解释:计数数组cnt_s和cnt_t大小固定(128),与输入规模无关。
对比暴力解法的O(m²(m+n)),滑动窗口的优势一目了然!
五、进阶优化
基础版的check函数每次遍历 128 个字符,虽然是O(1),但可以进一步优化:用一个变量valid记录「已满足cnt_s[c] >= cnt_t[c]的字符种类数」。
当valid == 目标种类数(k)时,直接判定窗口有效,无需遍历数组。
优化后的代码
cpp
class Solution {
public:
string minWindow(string s, string t) {
int cnt_s[128]{};
int cnt_t[128]{};
int k = 0; // t中不同字符的种类数
// 统计t的字符计数和种类数
for (char c : t) {
if (cnt_t[c] == 0) k++; // 新种类,k++
cnt_t[c]++;
}
int m = s.size();
int resl = -1, rrer = m;
int left = 0;
int valid = 0; // 已满足条件的字符种类数
for (int right = 0; right < m; right++) {
char c = s[right];
cnt_s[c]++;
// 当该字符的计数刚满足t的要求时,valid++
if (cnt_s[c] == cnt_t[c]) {
valid++;
}
// 窗口有效(所有种类都满足),收缩左指针
while (valid == k) {
// 更新最优窗口
if (right - left < rrer - resl) {
resl = left;
rrer = right;
}
char d = s[left];
// 若左指针移出的字符是满足条件的种类,valid--
if (cnt_s[d] == cnt_t[d]) {
valid--;
}
cnt_s[d]--;
left++;
}
}
return resl < 0 ? "" : s.substr(resl, rrer - resl + 1);
}
};
优化点说明
- 减少了
check函数的循环,实际运行效率提升明显(尤其当t的字符种类较少时)。 - 时间复杂度仍为
O(m),但常数项更小,更适合大数据量测试用例。
六、边界情况与通用模板
常见边界情况
t的长度大于s:直接返回""(如s="a", t="aa")。s与t长度相等且满足条件:返回s(如s="a", t="a")。t包含重复字符:必须保证窗口中该字符的数量不小于t中的数量(如t="AA",窗口需至少 2 个A)。
滑动窗口通用模板(子串问题)
cpp
// 初始化计数/哈希表
// 统计目标字符串的信息(如t的字符计数、种类数)
int left = 0, res = 初始值;
for (int right = 0; right < s.size(); right++) {
// 右指针加入窗口,更新状态
...
// 窗口满足条件时,收缩左指针
while (满足条件) {
// 更新最优解
...
// 左指针移出窗口,更新状态
...
left++;
}
}
return 最优解;
该模板适用于:最小覆盖子串、长度最小的子数组、最长无重复子串等「子串优化问题」。
七、总结
最小覆盖子串的核心是「滑动窗口 + 字符计数」:
- 滑动窗口解决「高效遍历所有有效子串」的问题,避免枚举所有子串。
- 字符计数解决「快速判断窗口是否有效」的问题,替代低效的字符串对比。
- 进阶优化通过
valid变量进一步降低常数项,提升实战效率。
掌握这一思路后,你可以轻松解决一系列子串匹配类问题。滑动窗口的关键是「动态维护窗口状态」,字符计数的关键是「精准匹配目标要求」。
如果觉得本文对你有帮助,欢迎点赞、收藏,也可以在评论区交流 其他滑动窗口应用场景!