【Leetcode hot 100】76.最小覆盖字串
一、题目理解
先明确问题:
给两个字符串s
和t
,请在s
中找到最短的子串,使得这个子串包含t
中所有字符(包括重复的字符,比如t
中有2个’a’,子串中至少也要有2个’a’)。如果没有这样的子串,返回空字符串。
举个例子:
- 输入
s = "ADOBECODEBANC",t = "ABC"
- 输出
"BANC"
(因为它包含’A’、‘B’、‘C’,且是最短的)
二、核心思路:滑动窗口 + 哈希表
为什么用滑动窗口?
因为我们要找的是连续的子串,且需要动态调整子串的范围(扩大或缩小)来找到“最小”的有效子串。滑动窗口通过左右两个指针维护一个“窗口”,能高效地在一次遍历中完成搜索。
为什么用哈希表?
我们需要跟踪两个信息:
t
中每个字符需要多少个(比如t=ABC
,需要’A’:1, ‘B’:1, ‘C’:1)。- 当前窗口中每个字符有多少个(比如窗口是
ADOBEC
,则’A’:1, ‘D’:1, ‘O’:1, ‘B’:1, ‘E’:1, ‘C’:1)。
通过比较这两个信息,就能判断当前窗口是否“有效”(即包含t
的所有字符)。
三、具体步骤拆解
我们分4步来实现:
步骤1:初始化“需求”和“当前窗口”的字符计数
- 用两个数组(
need
和window
,大小128,对应ASCII码)代替哈希表(效率更高,因为字符是英文字母)。 need[c]
:记录字符c
在t
中需要出现的次数(比如t
中有2个’a’,则need['a']=2
)。window[c]
:记录当前窗口中字符c
出现的次数。
同时,统计t
中不同字符的种类数(记为valid
)。比如t=ABC
有3种字符,valid=3
。
步骤2:扩大窗口(移动右指针)
- 右指针
right
从0开始,依次遍历s
中的每个字符c
。 - 每遍历一个字符,就将
window[c]
加1(更新当前窗口的计数)。 - 当
window[c]
的数量恰好等于need[c]
时(说明这个字符的需求被满足了),就将valid
减1(表示一种字符已满足)。
步骤3:当窗口有效时,缩小窗口(移动左指针)
- 当
valid=0
时,说明当前窗口已经包含t
中所有字符(有效窗口)。 - 此时尝试移动左指针
left
,缩小窗口范围,寻找更短的有效窗口:- 记录当前窗口的长度,如果比之前的“最小长度”更短,则更新最小长度和起始位置。
- 将左指针指向的字符
d
从窗口中移除(window[d]--
)。 - 如果
window[d]
的数量小于need[d]
(说明这个字符的需求不再满足),则valid
加1(此时窗口不再有效,停止缩小)。
步骤4:返回结果
- 遍历结束后,如果找到过有效窗口,就返回最短的子串;否则返回空字符串。
四、示例演示(结合代码逻辑)
以s = "ADOBECODEBANC",t = "ABC"
为例:
-
初始化:
need['A']=1, need['B']=1, need['C']=1
,其他为0;valid=3
。 -
扩大窗口(右指针移动):
right=0
(字符’A’):window['A']=1
,等于need['A']
,valid=2
。right=1
(‘D’):window['D']=1
(不影响valid
,因为need['D']=0
)。right=2
(‘O’):同上,valid
不变。right=3
(‘B’):window['B']=1
,等于need['B']
,valid=1
。right=4
(‘E’):不影响。right=5
(‘C’):window['C']=1
,等于need['C']
,valid=0
(窗口有效!当前窗口是[0,5]
,即"ADOBEC",长度6)。
-
缩小窗口(左指针移动):
- 记录当前窗口长度6。
- 左指针
left=0
(字符’A’):window['A']--
变为0,此时window['A'] < need['A']
,valid=1
(窗口不再有效)。左指针移到1。
-
继续扩大窗口:
右指针继续移动,直到再次valid=0
(比如窗口包含CODEBANC
中的部分字符),重复缩小步骤,最终找到最短窗口"BANC"
(长度4)。
五、Java代码实现
class Solution {public String minWindow(String s, String t) {// 初始化need和window数组(英文字母ASCII码范围0-127)int[] need = new int[128];int[] window = new int[128];// 填充need数组,统计t中每个字符的需求for (char c : t.toCharArray()) {need[c]++;}int left = 0; // 左指针int right = 0; // 右指针int valid = 0; // 已满足的字符种类数int start = 0; // 最小窗口的起始索引int len = Integer.MAX_VALUE; // 最小窗口的长度// 统计t中不同字符的种类数(初始化valid)for (int i = 0; i < 128; i++) {if (need[i] > 0) {valid++;}}// 扩大窗口(移动右指针)while (right < s.length()) {char c = s.charAt(right);right++; // 右指针右移// 如果当前字符是t中需要的,更新windowif (need[c] > 0) {window[c]++;// 当window中该字符数量恰好满足需求时,减少validif (window[c] == need[c]) {valid--;}}// 当窗口有效(valid=0),尝试缩小窗口while (valid == 0) {// 更新最小窗口if (right - left < len) {start = left;len = right - left;}// 左指针指向的字符即将被移除char d = s.charAt(left);left++; // 左指针右移// 如果移除的是t中需要的字符,检查是否还满足需求if (need[d] > 0) {// 若window中数量从满足变为不满足,增加validif (window[d] == need[d]) {valid++;}window[d]--;}}}// 若没找到有效窗口,返回空串;否则返回最短子串return len == Integer.MAX_VALUE ? "" : s.substring(start, start + len);}
}
六、关键细节总结
-
为什么用数组而不是HashMap?
英文字母的ASCII码范围固定(0-127),用数组访问速度更快,且避免了哈希表的哈希计算开销。 -
valid的作用?
它是优化的核心!通过valid
判断窗口是否有效,避免了每次都遍历整个need
数组(从O(128)优化到O(1)),最终使时间复杂度达到O(m+n)(m是s
长度,n是t
长度)。 -
窗口缩小的时机?
只有当窗口有效(valid=0
)时才缩小,确保我们只在“有效窗口”中寻找更短的解。
七、练习与思考
- 尝试用HashMap实现一遍,对比数组的效率差异。
import java.util.HashMap;
import java.util.Map;public class Solution {public String minWindow(String s, String t) {// 边界处理:s或t为空,或s长度小于t,直接返回空if (s == null || t == null || s.length() == 0 || t.length() == 0 || s.length() < t.length()) {return "";}// 统计t中各字符的需求数量Map<Character, Integer> need = new HashMap<>();for (char c : t.toCharArray()) {need.put(c, need.getOrDefault(c, 0) + 1);}Map<Character, Integer> window = new HashMap<>(); // 当前窗口的字符计数int left = 0, valid = 0; // valid:满足need数量的字符种类数int start = 0, minLen = Integer.MAX_VALUE; // 记录最小子串的起始位置和长度for (int right = 0; right < s.length(); right++) {char c = s.charAt(right);// 更新当前窗口的字符计数window.put(c, window.getOrDefault(c, 0) + 1);// 若当前字符满足t的数量要求,更新validif (need.containsKey(c) && window.get(c).intValue() == need.get(c).intValue()) {valid++;}// 当窗口包含t所有字符时,尝试收缩左边界while (valid == need.size()) {int currentLen = right - left + 1;// 更新最小子串if (currentLen < minLen) {minLen = currentLen;start = left;}// 收缩左边界,移除左指针的字符char leftChar = s.charAt(left);left++;// 若移除的字符是t需要的,检查是否影响validif (need.containsKey(leftChar)) {// 移除前,该字符数量刚好满足need,移除后会不满足,故valid--if (window.get(leftChar).intValue() == need.get(leftChar).intValue()) {valid--;}window.put(leftChar, window.get(leftChar) - 1);}}}// 若未找到有效子串,返回空;否则返回最小子串return minLen == Integer.MAX_VALUE ? "" : s.substring(start, start + minLen);}
}
- 思考:如果
s
和t
包含 Unicode 字符(不止英文字母),该如何修改代码?
当s
和t
包含Unicode字符时,核心修改思路是用哈希表(HashMap
)替代固定大小的数组,因为Unicode字符的范围远超ASCII(0-127),无法用有限大小的数组覆盖所有可能的字符。以下是具体的修改方案:
关键修改点分析
-
字符计数容器替换:
原代码用int[] need = new int[128]
和int[] window = new int[128]
存储字符计数(仅支持ASCII),需替换为HashMap<Character, Integer>
,因为Character
可以表示所有Unicode字符(包括中文、日文、特殊符号等)。 -
初始化逻辑调整:
统计t
中Unicode字符的需求时,直接遍历t
的每个字符(无论是否为Unicode),用HashMap
记录每个字符的出现次数。 -
窗口更新逻辑适配:
扩大/缩小窗口时,对Unicode字符的处理与原逻辑一致(更新计数、判断是否满足需求),但需通过HashMap
的getOrDefault
等方法操作,避免数组越界。 -
计数比较注意事项:
由于HashMap
中存储的是Integer
对象,比较计数时需用intValue()
获取实际数值(避免因Integer
缓存机制导致的==
判断错误)。
修改后的代码实现
import java.util.HashMap;
import java.util.Map;public class Solution {public String minWindow(String s, String t) {// 边界处理:空字符串或s长度小于t,直接返回空if (s == null || t == null || s.length() == 0 || t.length() == 0 || s.length() < t.length()) {return "";}// 用HashMap存储t中所有Unicode字符的需求(键:字符,值:需要的次数)Map<Character, Integer> need = new HashMap<>();for (char c : t.toCharArray()) {// 无论c是否为Unicode,均统计次数need.put(c, need.getOrDefault(c, 0) + 1);}// 用HashMap存储当前窗口中所有Unicode字符的计数Map<Character, Integer> window = new HashMap<>();int left = 0; // 左指针int valid = 0; // 已满足需求的字符种类数(初始为need中不同字符的数量)int start = 0; // 最短子串的起始索引int minLen = Integer.MAX_VALUE; // 最短子串的长度// 扩大窗口(移动右指针)for (int right = 0; right < s.length(); right++) {char c = s.charAt(right); // c可能是Unicode字符// 更新当前窗口的字符计数window.put(c, window.getOrDefault(c, 0) + 1);// 若当前字符是t中需要的,且窗口中该字符的数量恰好满足需求,更新validif (need.containsKey(c) && window.get(c).intValue() == need.get(c).intValue()) {valid++;}// 当窗口包含t中所有字符(valid等于need的大小),尝试缩小窗口while (valid == need.size()) {// 更新最短子串int currentLen = right - left + 1;if (currentLen < minLen) {minLen = currentLen;start = left;}// 缩小左边界,移除左指针指向的字符(可能是Unicode)char leftChar = s.charAt(left);left++;// 若移除的字符是t中需要的,检查是否仍满足需求if (need.containsKey(leftChar)) {// 若移除前数量恰好满足需求,移除后将不满足,故valid减1if (window.get(leftChar).intValue() == need.get(leftChar).intValue()) {valid--;}// 更新窗口中该字符的计数window.put(leftChar, window.get(leftChar) - 1);}}}// 若未找到有效子串,返回空;否则返回最短子串return minLen == Integer.MAX_VALUE ? "" : s.substring(start, start + minLen);}
}
核心逻辑说明
- 哈希表适配Unicode:
HashMap<Character, Integer>
可以存储任何Character
类型的键(包括所有Unicode字符),解决了数组无法覆盖大范围字符的问题。 - 计数比较安全性:通过
intValue()
获取Integer
的实际数值进行比较,避免了==
对Integer
对象比较时的缓存机制干扰(例如Integer.valueOf(128) == Integer.valueOf(128)
为false
)。 - 逻辑一致性:滑动窗口的核心逻辑(扩大窗口→判断有效→缩小窗口→更新最短子串)与原代码完全一致,仅替换了字符计数的容器,保证了算法的高效性(时间复杂度仍为
O(m+n)
,m
和n
分别为s
和t
的长度)。
通过以上修改,代码可完美支持包含Unicode字符的字符串处理。