LeetCode 76 - 最小覆盖子串
问题描述
给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。
注意:
- 对于
t中重复字符,我们寻找的子字符串中该字符数量必须不少于t中该字符数量。 - 如果
s中存在这样的子串,我们保证它是唯一的答案。
示例
示例 1:
1 | 输入:s = "ADOBECODEBANC", t = "ABC" |
示例 2:
1 | 输入:s = "a", t = "a" |
示例 3:
1 | 输入: s = "a", t = "aa" |
约束条件
m == s.lengthn == t.length1 <= m, n <= 10^5s和t由英文字母组成
进阶: 你能设计一个在 $O(m+n)$ 时间内解决此问题的算法吗?
解题思路
这是一道经典的滑动窗口问题,但比普通滑动窗口更复杂,因为需要:
- 确保窗口包含 t 的所有字符
- 保证每个字符的数量不少于 t 中的数量
- 找到满足条件的最短子串
核心思想
使用滑动窗口 + 哈希数组的方法:
- 统计 t 的字符需求:使用
need数组记录 t 中每个字符的需求量 - 维护滑动窗口:使用
window数组记录窗口中每个字符的数量 - 跟踪满足条件:使用
valid变量记录当前窗口中满足需求的字符种类数 - 扩展和收缩窗口:
- 右指针扩展窗口,添加字符
- 当窗口满足所有需求时,收缩左边界寻找更短子串
- 更新最小长度:记录满足条件的最短子串起始位置和长度
算法步骤详解
步骤 1:初始化数据结构
1 | // 字符需求数组(ASCII 码范围 0-127) |
关键点:
- 使用数组而非 HashMap,因为字符范围有限(ASCII 0-127),数组访问更快
required表示 t 中有多少种不同的字符,用于判断窗口是否满足条件
步骤 2:滑动窗口遍历
1 | int left = 0, right = 0; // 滑动窗口左右指针 |
步骤 3:扩展右边界
1 | char c = s.charAt(right); |
逻辑说明:
- 只有当
need[c] > 0时才处理,即该字符在 t 中出现过 window[c] == need[c]表示该字符在窗口中的数量恰好满足需求valid++表示又多了一种字符满足需求
步骤 4:收缩左边界
1 | // 当窗口满足所有需求时,尝试收缩左边界 |
关键点:
valid == required表示窗口中所有字符类型都满足需求right - left计算窗口长度(因为 right 已经自增,指向下一个位置)- 当
window[d] == need[d]时,说明移除该字符后会导致不满足需求,需要valid--
执行过程示例
以 s = "ADOBECODEBANC", t = "ABC" 为例,详细跟踪每一步执行过程:
初始化阶段
1 | // 输入:s = "ADOBECODEBANC", t = "ABC" |
初始状态总结:
need数组:只有 ‘A’, ‘B’, ‘C’ 的位置为 1,其他为 0window数组:所有元素为 0required = 3:需要满足 3 种字符valid = 0:当前满足的字符种类数为 0- 窗口:
[left=0, right=0),当前为空窗口
滑动窗口遍历过程
迭代 1:right = 0, c = ‘A’
1 | 读取字符:c = s.charAt(0) = 'A' |
迭代 2:right = 1, c = ‘D’
1 | 读取字符:c = s.charAt(1) = 'D' |
迭代 3:right = 2, c = ‘O’
1 | 读取字符:c = s.charAt(2) = 'O' |
迭代 4:right = 3, c = ‘B’
1 | 读取字符:c = s.charAt(3) = 'B' |
迭代 5:right = 4, c = ‘E’
1 | 读取字符:c = s.charAt(4) = 'E' |
迭代 6:right = 5, c = ‘C’(关键步骤)
1 | 读取字符:c = s.charAt(5) = 'C' |
收缩窗口(第一次满足条件)
1 | while(valid == required) { // valid = 3 == required = 3,进入循环 |
迭代 7:right = 6, c = ‘O’
1 | 读取字符:c = s.charAt(6) = 'O' |
迭代 8:right = 7, c = ‘D’
1 | 读取字符:c = s.charAt(7) = 'D' |
迭代 9:right = 8, c = ‘E’
1 | 读取字符:c = s.charAt(8) = 'E' |
迭代 10:right = 9, c = ‘B’(重要步骤)
1 | 读取字符:c = s.charAt(9) = 'B' |
迭代 11:right = 10, c = ‘A’(关键步骤)
1 | 读取字符:c = s.charAt(10) = 'A' |
收缩窗口(第二次满足条件,寻找更短子串)
1 | while(valid == required) { // valid = 3 == required = 3 |
迭代 12:right = 11, c = ‘N’
1 | 读取字符:c = s.charAt(11) = 'N' |
迭代 13:right = 12, c = ‘C’(最后一次扩展)
1 | 读取字符:c = s.charAt(12) = 'C' |
关键收缩过程(找到最小子串)
1 | // 持续收缩,直到找到最短满足条件的子串 |
最终结果
1 | // 当 right = 13 时,退出主循环(right >= s.length()) |
最终答案验证:
- 窗口:
s[9..12] = "BANC" - 包含的字符:’B’, ‘A’, ‘N’, ‘C’
- t 中的字符:’A’, ‘B’, ‘C’ ✓ 全部包含
- ‘A’ 出现 1 次,需求 1 次 ✓
- ‘B’ 出现 1 次,需求 1 次 ✓
- ‘C’ 出现 1 次,需求 1 次 ✓
- 长度:4(最短)
最终结果:
start = 9minLen = 4- 返回
s.substring(9, 9 + 4) = "BANC"✓
执行过程总结表
| 迭代 | right | left | 窗口内容 | window状态 | valid | 操作 | 更新minLen |
|---|---|---|---|---|---|---|---|
| 初始 | 0 | 0 | “” | A:0,B:0,C:0 | 0 | - | MAX |
| 1 | 1 | 0 | “A” | A:1,B:0,C:0 | 1 | 扩展 | - |
| 2 | 2 | 0 | “AD” | A:1,B:0,C:0 | 1 | 扩展 | - |
| 3 | 3 | 0 | “ADO” | A:1,B:0,C:0 | 1 | 扩展 | - |
| 4 | 4 | 0 | “ADOB” | A:1,B:1,C:0 | 2 | 扩展 | - |
| 5 | 5 | 0 | “ADOBE” | A:1,B:1,C:0 | 2 | 扩展 | - |
| 6 | 6 | 0 | “ADOBEC” | A:1,B:1,C:1 | 3 | 扩展 | 6 |
| 6收缩 | 6 | 1 | “DOBEC” | A:0,B:1,C:1 | 2 | 收缩 | - |
| 7-9 | 7-9 | 1 | “DOBEC…” | A:0,B:1,C:1 | 2 | 扩展 | - |
| 10 | 10 | 1 | “DOBECODEBA” | A:1,B:2,C:1 | 3 | 扩展 | - |
| 10收缩 | 10 | 9 | “BANC” | A:1,B:1,C:1 | 3 | 收缩 | 4 ✓ |
| 11-13 | 13 | 9+ | “BANC” | - | - | 最终 | - |
最终答案:"BANC"(从索引 9 开始,长度为 4)
实现细节
边界条件处理
1 | // 边界条件处理:若 s 比 t 短或 t 为空,则直接返回空字符串 |
为什么需要这个判断:
- 如果
s.length() < t.length(),s 中不可能包含 t 的所有字符 - 提前返回可以避免不必要的计算
哈希数组优化
使用数组而非 HashMap 的优势:
| 方面 | 数组 | HashMap |
|---|---|---|
| 访问速度 | O(1) 直接索引 | O(1) 但需要哈希计算 |
| 内存占用 | 固定 128 个整数 | 动态分配,只存储出现字符 |
| 缓存友好 | 连续内存,缓存命中率高 | 散列存储,缓存效果差 |
| 实现简洁 | 代码更简洁 | 需要处理键值对 |
对于字符范围已知的情况(ASCII 0-127),数组是更好的选择。
valid 变量的作用
valid 变量是关键优化,它记录满足需求的字符种类数:
- 当
window[c] == need[c]时,该字符满足需求,valid++ - 当
window[d] == need[d]且要移除d时,该字符将不再满足需求,valid-- - 当
valid == required时,窗口包含 t 的所有字符且数量满足要求
为什么需要 valid:
- 如果不使用
valid,每次判断都需要遍历need数组检查所有字符是否满足需求,时间复杂度会增加到 $O(n \times m)$ - 使用
valid可以将判断优化到 $O(1)$
代码实现
1 | class Solution { |
代码关键点解析
字符数组索引:
1
2int[] need = new int[128];
need[c] ++; // c 会被自动转换为 ASCII 码值作为索引Java 中
char可以直接转换为int,范围 0-127 的字符可以直接作为数组索引。right 指针的处理:
1
2
3
4char c = s.charAt(right);
right ++;
// ...
minLen = right - left; // right 已经指向下一位在更新
minLen时,right已经自增,指向下一个位置,所以窗口长度是right - left而不是right - left + 1。valid 的判断时机:
1
2
3if(window[c] == need[c]){
valid ++;
}只有当数量恰好相等时才增加
valid,如果window[c] > need[c]不会再增加valid。收缩窗口的条件:
1
while(valid == required)
使用
while而非if,因为收缩一个字符后可能仍然满足条件,需要继续收缩寻找更短子串。
复杂度分析
时间复杂度
$O(m + n)$,其中 $m$ 是字符串 s 的长度,$n$ 是字符串 t 的长度。
分析:
- 初始化
need数组:$O(n)$ - 计算
required:$O(128) = O(1)$ - 滑动窗口遍历:
- 每个元素最多被右指针访问一次:$O(m)$
- 每个元素最多被左指针访问一次:$O(m)$
- 总时间复杂度:$O(m)$
因此总时间复杂度为 $O(m + n)$。
空间复杂度
$O(1)$。
分析:
need数组:固定大小 128,$O(1)$window数组:固定大小 128,$O(1)$- 其他变量:常数空间,$O(1)$
虽然使用了两个大小为 128 的数组,但这是固定大小,不随输入规模增长,因此空间复杂度为 $O(1)$。
方法比较
| 方面 | 哈希数组方法 | HashMap 方法 |
|---|---|---|
| 时间复杂度 | $O(m+n)$ | $O(m+n)$ |
| 空间复杂度 | $O(1)$ | $O(1)$ |
| 代码简洁度 | ★★★★☆ | ★★★☆☆ |
| 执行效率 | 更快(直接索引) | 较慢(哈希运算) |
| 内存占用 | 固定 256 个整数 | 动态分配 |
| 适用场景 | 字符范围已知 | 通用场景 |
| 推荐度 | ★★★★★ | ★★★☆☆ |
关键收获
算法要点
滑动窗口的扩展与收缩:
- 右指针扩展窗口,添加字符
- 左指针收缩窗口,移除字符
- 收缩时寻找满足条件的最短子串
valid 变量的优化:
- 使用
valid变量跟踪满足需求的字符种类数 - 避免每次都检查所有字符是否满足需求
- 将判断时间复杂度从 $O(n)$ 优化到 $O(1)$
- 使用
哈希数组的选择:
- 对于字符范围已知的情况(如 ASCII),使用数组比 HashMap 更高效
- 数组访问更快,缓存友好,代码更简洁
常见陷阱
忘记处理边界条件:
- 需要检查
s.length() < t.length()的情况 - 需要处理
null输入
- 需要检查
valid 的判断错误:
1
2
3
4
5// 错误:只要 window[c] >= need[c] 就增加 valid
if(window[c] >= need[c]) valid ++;
// 正确:只有当 window[c] == need[c] 时才增加 valid
if(window[c] == need[c]) valid ++;如果使用
>=,会导致valid被重复累加。窗口长度计算错误:
1
2
3
4
5// 错误:right 已经自增,不需要 +1
minLen = right - left + 1;
// 正确:right 已经指向下一个位置
minLen = right - left;使用 if 而非 while:
1
2
3
4
5// 错误:只收缩一次
if(valid == required) { ... }
// 正确:持续收缩直到不满足条件
while(valid == required) { ... }
相关问题
- LeetCode 3: 无重复字符的最长子串 - 滑动窗口基础题
- LeetCode 209: 长度最小的子数组 - 滑动窗口求和问题
- LeetCode 904: 水果成篮 - 滑动窗口最多包含 K 种元素
- LeetCode 438: 找到字符串中所有字母异位词 - 固定窗口大小问题
总结
这道题是滑动窗口算法的经典应用,也是Hard 难度的代表题目。它要求我们:
核心要点
- 滑动窗口 + 哈希数组:使用双指针维护窗口,数组记录字符计数
- valid 变量优化:通过跟踪满足条件的字符种类数,实现 $O(1)$ 判断
- 扩展与收缩策略:
- 右指针扩展,寻找满足条件的窗口
- 左指针收缩,寻找最短满足条件的子串
学习价值
这道题不仅考查滑动窗口的基本应用,还涉及:
- 数据结构选择:数组 vs HashMap 的权衡
- 算法优化技巧:使用
valid变量避免重复计算 - 边界条件处理:正确处理各种边界情况
- 代码实现细节:指针移动、条件判断的精确控制
通过这道题,我们可以深刻理解滑动窗口算法在实际问题中的应用,并为解决类似的子串问题打下坚实基础。掌握这道题后,可以更好地解决其他滑动窗口变种问题。