76. 最小覆盖子串#
从生活中理解这个问题#
想象你是一位珠宝设计师,要用一段项链(可能包含各种宝石)找出最短的一段,这段中必须包含顾客指定的所有种类的宝石。比如顾客要求必须有红宝石、蓝宝石和钻石,你需要找出包含这三种宝石的最短项链片段。
这就是”最小覆盖子串”问题的生活映射:在一个长字符串中找到包含目标字符串所有字符的最短子串。
问题描述#
题目目标#
LeetCode第76题“最小覆盖子串”是这样描述的:给你一个字符串 S 和一个字符串 T,请在 S 中找出包含 T 所有字符的最小子串。
示例 1#
输入: s = "ADOBECODEBANC", t = "ABC"
输出: "BANC"
说明: 把输入和输出放在一起看,会更容易直接抓住题目要求的变化。
解题套路:滑动窗口模板的应用#
滑动窗口是一类特殊的双指针技巧,特别适合处理子串、子数组的问题。我们先来理解这个通用模板。
滑动窗口通用模板#
// 通用的滑动窗口模板
public String slidingWindowTemplate(String s, String t) {
// 1) window:记录“当前窗口”中每个字符出现次数
Map<Character, Integer> window = new HashMap<>();
// 2) need:记录“目标字符串 t”中每个字符需要出现的次数
Map<Character, Integer> need = new HashMap<>();
// 3) 先遍历 t,把每个目标字符及其需求次数填入 need
for (char c : t.toCharArray()) {
need.put(c, need.getOrDefault(c, 0) + 1); // 若字符首次出现则从 0 开始累加
}
// 4) left/right 分别是窗口左右边界(左闭右开区间 [left, right))
int left = 0, right = 0;
// 5) valid 表示“已经满足 need 要求的字符种类数”
int valid = 0;
// 6) 外层循环:不断右移 right 扩大窗口
while (right < s.length()) {
char c = s.charAt(right); // 读取将进入窗口的字符
right++; // 右边界右移一格,表示把 c 纳入窗口
// 7) 根据题目要求,更新 window/valid 等窗口状态
...
// 8) 当窗口满足条件或超出限制时,进入内层循环收缩左边界
while (window needs shrink) {
char d = s.charAt(left); // 读取将移出窗口的字符
left++; // 左边界右移一格,表示把 d 移出窗口
// 9) 根据题目要求,回滚/更新 window/valid 等状态
...
}
}
return 最终结果; // 返回题目需要的输出(最值、计数、下标或子串)
}java这个模板的精髓在于:
- 两个指针:控制窗口的左右边界
- 数据结构:维护窗口内的状态
- 双层循环:外层扩展右边界,内层收缩左边界
- 更新规则:清晰的窗口数据更新规则
运用模板解决最小覆盖子串#
现在让我们用这个模板来解决我们的问题:
public String minWindow(String s, String t) {
Map<Character, Integer> need = new HashMap<>(); // 记录 t 中每个字符“需要”的次数
for (char c : t.toCharArray()) {
need.put(c, need.getOrDefault(c, 0) + 1); // 构建需求表:字符 -> 需求频次
}
Map<Character, Integer> window = new HashMap<>(); // 记录当前滑动窗口中字符出现次数
int left = 0, right = 0; // 左右指针,窗口区间为 [left, right)
int valid = 0; // 已满足“所需次数”的字符种类数(不是字符总个数)
int start = 0, minLen = Integer.MAX_VALUE; // 记录最短合法窗口的起点和长度
while (right < s.length()) {
char c = s.charAt(right); // 取出将要进入窗口的字符
right++; // 右边界右移,窗口扩大
// 只有当 c 是目标字符时,才需要更新窗口计数
if (need.containsKey(c)) {
window.put(c, window.getOrDefault(c, 0) + 1); // 更新窗口中 c 的出现次数
// 当某字符在窗口中的次数“刚好达到”需求次数时,满足种类数 +1
if (window.get(c).equals(need.get(c))) {
valid++;
}
}
// 当 valid == need.size() 时,说明当前窗口已覆盖 t 的所有字符需求
while (valid == need.size()) {
// 先尝试用当前合法窗口更新答案(目标是最短)
if (right - left < minLen) {
start = left; // 记录更优答案的起点
minLen = right - left; // 记录更优答案的长度
}
char d = s.charAt(left); // 取出将要移出窗口的字符
left++; // 左边界右移,窗口收缩
// 若 d 是目标字符,需要同步维护 window 和 valid
if (need.containsKey(d)) {
// 如果移除前 d 的数量正好满足需求,移除后将不再满足,valid 要减 1
if (window.get(d).equals(need.get(d))) {
valid--;
}
window.put(d, window.get(d) - 1); // 再将 d 的窗口计数减 1
}
}
}
return minLen == Integer.MAX_VALUE ? "" // 若从未找到合法窗口,返回空字符串
: s.substring(start, start + minLen); // 否则按记录的起点和长度截取最小覆盖子串
}java让我们用一个简单的例子来详细演示这个过程:
S = "ADOBEC"
T = "ABC"
1. 初始状态:
need = {A:1, B:1, C:1}
window = {}
valid = 0
2. 遇到'A':
window = {A:1}
valid = 1 // A达到要求
3. 遇到'D':
不是需要的字符,跳过
4. 遇到'O':
不是需要的字符,跳过
5. 遇到'B':
window = {A:1, B:1}
valid = 2 // B达到要求
6. 遇到'E':
不是需要的字符,跳过
7. 遇到'C':
window = {A:1, B:1, C:1}
valid = 3 // 所有字符都达到要求了
8. 开始收缩窗口...plaintext滑动窗口解题模板的四个重点#
-
窗口定义:
- 明确窗口应该包含什么
- 明确什么时候扩大窗口
- 明确什么时候缩小窗口
-
状态变量:
- 使用合适的数据结构记录状态
- 明确状态的更新规则
- 明确有效状态的判断条件
-
更新规则:
- 扩大窗口时如何更新
- 缩小窗口时如何更新
- 什么时候更新结果
-
边界条件:
- 初始化值的设置
- 结果不存在的处理
- 特殊情况的考虑
类似题目及解题思路#
这个模板可以解决很多类似的问题:
- 字符串的排列
- 找到字符串中所有字母异位词
- 无重复字符的最长子串
解决这类问题的通用步骤:
- 确定是否适合用滑动窗口
- 定义窗口的意义
- 确定状态变量和更新规则
- 套用模板编写代码
- 处理边界条件
小结#
掌握滑动窗口模板,就像学会了一把万能钥匙,可以解开许多字符串子串问题的大门。记住:
- 模板的核心是状态的维护和更新
- 左右指针的移动要有明确的逻辑
- 条件的判断要准确无误
下次遇到子串相关的问题,不妨先想想是否可以用这个模板来解决!