文章目录
- 一、题目描述
- 二、为什么这道题值得弄懂?
- 三、字符频率统计:哈希表的核心作用
- 四、思路演进:从哈希+暴力到滑动窗口+哈希
- 五、滑动窗口的核心操作:进窗口、判断、出窗口
- 六、题目拆解:抓住关键约束
- 七、算法实现:右扩左缩的动态调整
- 八、实现过程中的坑点总结
- 九、下题预告
这是封面原图,还有AI生成的动图,嘿嘿:
一、题目描述
题目链接:最小覆盖子串
题目描述:
示例 1:
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含了字符串 t 的所有字符 'A'、'B'、'C'。
示例 2:输入:s = "a", t = "a"
输出:"a"
解释:整个字符串 s 就是最小覆盖子串。
示例 3:输入: s = "a", t = "aa"
输出: ""
解释: t 中有两个 'a',而 s 中只有一个 'a',所以不存在覆盖子串。
提示:m == s.length
n == t.length
1 <= m, n <= 10⁵
s 和 t 由英文字母组成
二、为什么这道题值得弄懂?
作为滑动窗口在字符串覆盖问题中的经典代表,LeetCode 第 76 题的价值体现在:
- 帮你掌握"动态调整窗口大小"的滑动窗口用法------与"找到字符串中所有字母异位词"的固定窗口不同,本题窗口需根据匹配情况灵活伸缩,是滑动窗口的另一核心应用场景;
- 带你深化"字符频率匹配"的逻辑------不仅要统计频率,还要判断"窗口是否包含目标所有字符(频率≥目标频率)",更贴近实际场景中"覆盖"的需求;
- 让你理解"优化暴力枚举"的本质------通过窗口的"右扩左缩"避免重复遍历,将原本 O(n²) 的暴力思路优化到 O(n) 级别,体会算法效率提升的关键。
学会这道题,能为解决"子串覆盖""字符包含"类问题提供通用思路,比如后续遇到"找到包含所有字符的最短子串""检查子串是否覆盖目标字符集"等问题,都能直接复用核心逻辑。
三、字符频率统计:哈希表的核心作用
要判断"子串是否涵盖 t
所有字符",首先需要明确"t
中每个字符需要出现多少次"以及"当前子串中每个字符实际出现多少次"------这正是哈希表的用武之地:通过建立"字符-频率"的映射,实现高效的统计与对比。
1. 用数组模拟哈希表的实现
本题中,s
和 t
的字符均为英文字母(ASCII 码范围 0-127),因此可用大小为 128 的数组直接模拟哈希表(比标准哈希表更高效):
- 数组下标:对应字符的 ASCII 码(如
'A'
对应 65,'a'
对应 97); - 数组值:对应字符的出现次数。
以代码中 hashi1
(统计 t
的频率)为例:
cpp
int hashi1[128] = {0}; // 初始化为0,代表所有字符初始频率为0
for(auto ch : t) {
hashi1[ch]++; // 遍历t,每遇到一个字符,对应下标位置的值+1
}
比如 t = "ABC"
,遍历后 hashi1['A']=1
、hashi1['B']=1
、hashi1['C']=1
,其余位置仍为 0------清晰记录了 t
中每个字符的出现次数。
同理,hashi2
数组用于统计当前窗口内的字符频率,通过对比 hashi2
与 hashi1
,即可判断窗口是否满足"覆盖"条件。
四、思路演进:从哈希+暴力到滑动窗口+哈希
1. 哈希+暴力:看似可行却低效
有了哈希表统计频率,暴力解法的思路会更清晰:
- 用
hashi1
预处理t
的频率(O(m) 时间,m 为t
长度); - 枚举
s
中所有子串s[i...j]
(i≤j),用hashi2
统计子串频率(O(j-i+1) 时间); - 对比
hashi2
与hashi1
,若hashi2[ch]≥hashi1[ch]
对所有ch
成立,则记录子串长度(O(128) 时间)。
但暴力解法的致命问题是重复遍历 :比如当 i=0
时已遍历 s[0...5]
,i=1
时又需重新遍历 s[1...5]
,大量字符被多次统计,导致时间复杂度高达 O(n³)(n 为 s
长度),完全无法应对 10⁵ 级别的输入。
2. 为什么滑动窗口能优化?避免重复遍历的核心逻辑
滑动窗口的本质是通过"右扩左缩"的动态调整,让每个字符仅被遍历一次。为什么能做到?我们通过反推"暴力解法的重复场景"来理解:
假设用 left
和 right
表示子串的左右边界,暴力解法中,当 right
固定时,left
从 0 递增;当 left
固定时,right
又从 left
递增------这会导致两种重复:
- 场景1 :
right
不动,left
右移后窗口仍满足条件。比如s="ABBC"
,t="ABC"
,当left=0
、right=3
时窗口满足条件,left=1
、right=3
时窗口仍满足(子串 "BBC" 不对,此处仅为举例逻辑)。暴力解法会重新统计left=1
时的频率,而滑动窗口可直接基于left=0
的频率更新,无需重复计算。 - 场景2 :
right
不动,left
右移后窗口不满足条件。此时暴力解法会让right
从left
重新开始递增,而滑动窗口中right
始终单向移动,只需继续右扩即可,避免"回到起点"的重复。
滑动窗口通过"right
右扩找可能的覆盖窗口,left
左缩求最短窗口"的单向移动逻辑,彻底避免了上述重复,让时间复杂度降至 O(n)。
五、滑动窗口的核心操作:进窗口、判断、出窗口
滑动窗口的实现需明确三个关键步骤:如何将字符加入窗口(进窗口)、如何判断窗口是否有效(判断)、如何将字符移出窗口(出窗口)。结合哈希表和 count
变量(优化判断效率),流程如下:
1. 进窗口:更新频率,标记满足的字符种类
当 right
右移时,字符 in = s[right]
进入窗口,需做两件事:
- 更新频率 :
hashi2[in]++
(窗口内该字符的频率+1); - 判断是否满足种类 :若
hashi2[in] == hashi1[in]
,则count++
。
这里的 count
是"满足'频率≥t
中频率'的字符种类数"。比如 t
中 'A'
需出现 2 次,当窗口中 'A'
的频率从 1 增至 2 时,hashi2['A']
恰好等于 hashi1['A']
,说明 'A'
这个种类从"未满足"变为"满足",因此 count
加 1。
2. 判断:通过 count
快速确认窗口有效性
当 count == kinds
(kinds
是 t
中不同字符的种类数)时,说明窗口内所有字符的频率均≥t
中对应字符的频率------窗口有效,可尝试左缩优化。
为什么不用直接对比两个数组?若每次判断都遍历 128 个字符,时间复杂度会增加 O(128n);而 count
只需 O(1) 即可判断,是关键优化点。
3. 出窗口:更新频率,修正满足的字符种类
当窗口有效时,left
右移,字符 out = s[left]
移出窗口,需做两件事:
- 判断是否失去满足种类 :若
hashi2[out] == hashi1[out]
,则count--
(移出前该字符频率恰好满足,移出后会不足); - 更新频率 :
hashi2[out]--
(窗口内该字符的频率-1)。
注意顺序:必须先判断再更新频率。比如 hashi2[out]
原本等于 hashi1[out]
,若先减 1,会错误地认为"移出前不满足",导致 count
未正确减少。
4. 与 438 题的 count
对比:种类 vs 数量
我们之前一篇文章讨论过438.找到字符串中所有字母异位词这道题的优化办法和今天的类似但也有不同
本题的 count
与"找到字符串中所有字母异位词"(438 题)的 count
作用相似,但本质不同:
- 438 题:
count
统计"频率与p
完全相等的字符数量"(需严格一一对应),最终count
等于p
的长度时窗口有效; - 本题:
count
统计"频率≥t
中频率的字符种类数"(只需覆盖,无需相等),最终count
等于t
的字符种类数时窗口有效。
两者的差异源于问题目标:438 题找"字母异位词"(字符数量和种类完全相同),本题找"覆盖子串"(字符种类和频率≥目标)。
六、题目拆解:抓住关键约束
结合题目要求和示例,核心要素如下:
- 输入是两个字符串
s
(主串)和t
(目标串),长度均可达 10⁵(需严格控制时间复杂度)。 - 核心约束是"子串需涵盖
t
所有字符",即:- 子串中每种字符的出现次数至少 与
t
中对应字符的出现次数相同; - 子串需是
s
中连续的一段。
- 子串中每种字符的出现次数至少 与
- 目标是找到最短的满足条件的子串,若不存在则返回空字符串。
关键点提炼:
- 窗口大小动态:子串长度不固定,需从"可能覆盖"的窗口中找到最短的那个;
- 匹配核心是"覆盖" :窗口内字符频率≥
t
中对应字符频率,无需严格相等; - 效率要求极高:暴力枚举所有子串必然超时,需用滑动窗口实现"一次遍历+动态调整";
- 需记录最短子串:不仅要判断是否覆盖,还要实时更新"当前最短子串的长度和起始索引"。
七、算法实现:右扩左缩的动态调整
实现代码
cpp
class Solution {
public:
string minWindow(string s, string t) {
int hashi1[128] = {0};
int kinds = 0;
// 预处理t:统计频率并记录字符种类数
for(auto ch : t)
{
if(hashi1[ch] == 0)
{
kinds++;
}
hashi1[ch]++;
}
int hashi2[128] = {0};
int count = 0; // 记录窗口中满足"频率≥t中频率"的字符种类数
int minlen = INT_MAX, start = -1; // 最短子串的长度和起始索引
// 滑动窗口:left左边界,right右边界
for(int left = 0, right = 0; right < s.size(); right++)
{
// 右扩窗口:将s[right]加入窗口
char in = s[right];
hashi2[in]++;
// 若加入后该字符频率恰好等于t中频率,说明该种类从"未满足"变为"满足",count+1
if(hashi2[in] == hashi1[in])
count++;
// 当窗口已覆盖t所有字符时,尝试左缩窗口找最短子串
while(count == kinds)
{
// 更新最短子串:若当前窗口更短,则记录长度和起始索引
if(right - left + 1 < minlen)
{
minlen = right - left + 1;
start = left;
}
// 左缩窗口:将s[left]移出窗口
char out = s[left];
// 若移出前该字符频率等于t中频率,说明该种类从"满足"变为"未满足",count-1
if(hashi2[out] == hashi1[out])
count--;
hashi2[out]--;
left++;
}
}
// 根据start判断是否找到有效子串
if(start == -1)
return "";
else return s.substr(start, minlen);
}
};
代码细节拆解:
-
预处理
t
的频率与种类 :
hashi1
数组统计t
中每个字符的出现次数,kinds
记录t
中不同字符的种类数(例如t="ABC"
时kinds=3
)。这一步是后续判断"是否覆盖"的基准。 -
右扩窗口与频率更新 :
right
指针右移,每次将s[right]
加入窗口(hashi2[in]++
)。当hashi2[in]
恰好等于hashi1[in]
时,count
加 1------这意味着该字符的频率从"不足"变为"满足",有效种类数增加。 -
左缩窗口与最短子串更新 :
当
count == kinds
时,窗口已覆盖t
所有字符,进入while
循环左缩:- 先判断当前窗口是否更短,若是则更新
minlen
(最短长度)和start
(起始索引); - 再将
s[left]
移出窗口:若移出前hashi2[out]
等于hashi1[out]
,则count
减 1(该字符频率从"满足"变为"不足"),随后更新hashi2[out]--
并左移left
。
- 先判断当前窗口是否更短,若是则更新
-
结果处理 :
若
start
仍为 -1,说明未找到有效子串,返回空字符串;否则返回s
中从start
开始、长度为minlen
的子串。
时间复杂度与空间复杂度
时间复杂度
O(m + n),其中 m 是 t
的长度,n 是 s
的长度:
- 预处理
t
耗时 O(m); - 滑动窗口中
right
和left
均单向移动,每个字符仅被访问 2 次(进入和离开窗口),耗时 O(n); - 其余操作均为常数级,总耗时可忽略。
空间复杂度
O(1)(常数级):
- 用于统计频率的
hashi1
和hashi2
数组大小固定为 128,与输入长度无关; - 其他变量(
kinds
、count
等)均为单个整数,占用空间恒定。
八、实现过程中的坑点总结
-
count
的更新条件错误- 错误 :当
hashi2[in] ≥ hashi1[in]
时就给count
加 1(例如t
中'A'
出现 2 次,窗口中'A'
从 2 次增至 3 次时,再次count++
)。
这会导致count
超过kinds
(例如kinds=3
时count=4
),后续无法正确判断"是否覆盖"。 - 解决 :仅当
hashi2[in] == hashi1[in]
时count++
(即"恰好满足频率"时才标记该种类已满足,后续频率增加不重复计数)。
- 错误 :当
-
左缩窗口时的顺序错误
- 错误 :先执行
hashi2[out]--
,再判断是否需要减少count
。
例如t
中'A'
出现 2 次,窗口中'A'
原本是 2 次:若先减为 1,再判断"1 == 2",会错误地认为不需要减count
,但实际移出前是满足条件的,应该减count
。 - 解决 :先判断"移出前的频率是否等于
hashi1[out]
",再更新hashi2[out]--
。即先执行if (hashi2[out] == hashi1[out]) count--;
,再执行hashi2[out]--;
。
- 错误 :先执行
-
未初始化
minlen
和start
导致结果错误- 错误 :未将
minlen
初始化为较大值(如INT_MAX
),或未将start
初始化为 -1(例如初始化为 0)。
若s
中无覆盖子串,start
仍为 0,会错误返回s[0]
;若初始minlen
太小,可能无法更新到更短的子串。 - 解决 :严格初始化
minlen = INT_MAX
、start = -1
,最后通过start == -1
判断是否找到有效子串。
- 错误 :未将
-
忽略
t
中字符在s
中不存在的情况- 错误 :未考虑"
t
中有s
没有的字符"(例如s="ABC"
,t="ABD"
),此时count
永远无法等于kinds
,但未处理start
仍为 -1 的情况。 - 解决 :最终通过
if(start == -1) return ""
统一处理"无有效子串"的情况,无需单独判断字符是否存在。
- 错误 :未考虑"
九、下题预告
明天将讲解 力扣704.二分查找,这是算法入门阶段最基础也最核心的查找算法之一,看似简单却暗藏不少细节。
提前思考方向:
- 二分查找的核心逻辑是什么?为什么它的时间复杂度能做到 O(log n)?
- 实现时如何确定"左闭右闭""左闭右开"等不同的区间定义?不同区间定义下,
left
和right
的初始化、循环条件以及中间值mid
的更新方式有何差异? - 当数组中不存在目标值时,如何避免死循环并正确返回结果?
如果这道题的解析对你有帮助,不妨持续关注,明天一起攻克二分查找的核心要点!
如果觉得这篇解析有帮助,不妨:
🌟 点个赞,让更多人看到这份清晰思路
⭐ 收个藏,下次复习时能快速找回灵感
👀 加个关注,明天见!