LeetCode 76:最小覆盖子串
LeetCode 76:最小覆盖子串
问题定义与核心挑战
给定字符串 s
和 t
,需找到 s
中包含 t
所有字符(含重复)的最短子串。若不存在则返回空字符串。核心难点:
- 字符匹配的精确性:
t
中重复字符需在子串中对应数量匹配(如t="AA"
,子串需至少含 2 个A
)。 - 高效区间搜索:直接枚举所有子串(
O(n²)
)会超时,需通过 滑动窗口(双指针) 优化。
核心思路:滑动窗口 + 哈希表
利用 双指针(左 left
、右 right
) 维护动态窗口,结合 哈希表 跟踪字符频率:
- 扩展右指针:扩大窗口,记录字符频率,直到窗口包含
t
所有字符。 - 收缩左指针:在窗口合法时,尝试左移缩小窗口,更新最小子串。
- 哈希表优化:通过
formed
变量快速判断窗口是否合法(无需每次遍历哈希表)。
算法步骤详解
步骤 1:预处理 t
的字符频率
- 用哈希表
countT
记录t
中每个字符的出现次数。 - 计算
required
:t
中不同字符的数量(窗口需匹配这些字符的频率)。
Map<Character, Integer> countT = new HashMap<>();
for (char c : t.toCharArray()) {countT.put(c, countT.getOrDefault(c, 0) + 1);
}
int required = countT.size();
步骤 2:初始化滑动窗口变量
left=0
:窗口左边界。right=0
:窗口右边界。formed=0
:当前窗口中满足t
频率要求的字符数(如t="ABC"
,窗口含A
、B
、C
各至少 1 个时,formed=3
)。windowCounts
:记录当前窗口内字符的频率。minLen=∞
,start=0
:记录最小窗口的长度和起始位置。
Map<Character, Integer> windowCounts = new HashMap<>();
int left = 0, formed = 0;
int minLen = Integer.MAX_VALUE;
int start = 0;
步骤 3:扩展右指针,构建窗口
遍历 s
的每个字符(右指针 right
移动):
- 更新窗口频率:将
s[right]
加入windowCounts
。 - 判断是否满足频率要求:若
s[right]
在countT
中,且windowCounts
中其频率等于countT
中的频率,则formed++
。 - 当窗口合法(
formed == required
),尝试收缩左指针。
for (int right = 0; right < s.length(); right++) {char c = s.charAt(right);// 更新窗口频率windowCounts.put(c, windowCounts.getOrDefault(c, 0) + 1);// 若当前字符是t的目标字符,且频率刚满足要求,formed加1if (countT.containsKey(c) && windowCounts.get(c).intValue() == countT.get(c).intValue()) {formed++;}// 窗口合法时,收缩左指针while (formed == required) {// 更新最小窗口int currentLen = right - left + 1;if (currentLen < minLen) {minLen = currentLen;start = left;}// 收缩左指针:移除s[left]char leftChar = s.charAt(left);windowCounts.put(leftChar, windowCounts.get(leftChar) - 1);// 若移除后,该字符频率不再满足t的要求,formed减1if (countT.containsKey(leftChar) && windowCounts.get(leftChar).intValue() < countT.get(leftChar).intValue()) {formed--;}left++; // 左指针右移}
}
步骤 4:返回结果
若找到合法窗口(minLen
未被更新为 ∞
),则截取 s[start, start+minLen)
;否则返回空字符串。
return minLen == Integer.MAX_VALUE ? "" : s.substring(start, start + minLen);
关键逻辑解析
-
formed
变量的作用:
避免每次检查整个windowCounts
是否匹配countT
(O(m)
时间,m
是t
的不同字符数),而是通过formed
实时跟踪已满足频率要求的字符数,达到O(1)
判断窗口合法性。 -
收缩左指针的条件:
仅当窗口合法(formed == required
)时,才尝试收缩,确保每次收缩都在合法区间内进行,避免遗漏更短的合法窗口。 -
字符频率的精确匹配:
仅当windowCounts[c]
恰好等于countT[c]
时,formed
才增加;收缩时,若windowCounts[c]
小于countT[c]
,formed
才减少。这保证了formed
仅统计完全满足频率要求的字符。
完整代码(Java)
import java.util.HashMap;
import java.util.Map;class Solution {public String minWindow(String s, String t) {// 步骤1:预处理t的字符频率和requiredMap<Character, Integer> countT = new HashMap<>();for (char c : t.toCharArray()) {countT.put(c, countT.getOrDefault(c, 0) + 1);}int required = countT.size();// 滑动窗口变量初始化Map<Character, Integer> windowCounts = new HashMap<>();int left = 0, formed = 0;int minLen = Integer.MAX_VALUE;int start = 0;// 步骤2:扩展右指针,构建窗口for (int right = 0; right < s.length(); right++) {char c = s.charAt(right);// 更新窗口内字符频率windowCounts.put(c, windowCounts.getOrDefault(c, 0) + 1);// 若当前字符是t的目标字符,且频率刚满足要求,formed加1if (countT.containsKey(c) && windowCounts.get(c).intValue() == countT.get(c).intValue()) {formed++;}// 窗口合法时,收缩左指针while (formed == required) {// 更新最小窗口int currentLen = right - left + 1;if (currentLen < minLen) {minLen = currentLen;start = left;}// 收缩左指针:移除s[left]char leftChar = s.charAt(left);windowCounts.put(leftChar, windowCounts.get(leftChar) - 1);// 若移除后,该字符频率不再满足t的要求,formed减1if (countT.containsKey(leftChar) && windowCounts.get(leftChar).intValue() < countT.get(leftChar).intValue()) {formed--;}left++; // 左指针右移}}// 步骤3:返回结果return minLen == Integer.MAX_VALUE ? "" : s.substring(start, start + minLen);}
}
示例验证(以示例 1 为例)
输入:s = "ADOBECODEBANC", t = "ABC"
推导过程:
- 预处理:
countT = {'A':1, 'B':1, 'C':1}
,required=3
。 - 右指针扩展:
- 当
right=5
(字符C
),窗口[0,5]
(ADOBEC
):windowCounts
含A:1, B:1, C:1
,formed=3
,进入收缩阶段。 - 收缩左指针到
left=3
(字符B
),窗口[3,5]
(BEC
):长度 3,记录为候选。 - 继续扩展右指针,最终找到窗口
[9,11]
(BANC
),长度 4,为最小。
- 当
复杂度分析
- 时间复杂度:
O(n)
,其中n
是s
的长度。双指针各移动n
次,哈希表操作均为O(1)
。 - 空间复杂度:
O(m)
,m
是t
中不同字符的数量(最多 26 个字母,故为O(1)
)。
该方法通过 滑动窗口 + 哈希表 高效解决了最小覆盖子串问题,核心在于动态维护窗口的合法性,并通过 formed
变量优化判断逻辑,确保了线性时间复杂度。