问题描述
给定两个字符串 s 和 t,返回 s 中涵盖 t 所有字符的最小子串。如果不存在符合条件的子串,返回空字符串。
示例:s = “ADOBECODEBANC”, t = “ABC”,最小覆盖子串为 “BANC”。
该问题在 DNA 序列分析、文本摘要提取、日志模式匹配等场景中具有实际意义。
核心思路:滑动窗口
维护一个可变长度的窗口 [left, right],通过双指针控制:
– 扩张:右指针右移,将字符纳入窗口,直到窗口包含 t 中所有字符。
– 收缩:左指针右移,尝试缩小窗口,同时保持覆盖条件。
import java.util.HashMap;
import java.util.Map;
public class MinimumWindowSubstring {
// O(|s| + |t|) 时间,O(|字符集|) 空间
public static String minWindow(String s, String t) {
if (s == null || t == null || 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, right = 0;
int valid = 0; // 窗口中满足need条件的字符种类数
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);
// 注意:只有刚好满足需求时才增加valid
if (window.get(c).equals(need.get(c))) {
valid++;
}
}
// 收缩:当窗口已覆盖t中所有字符时,尝试左移
while (valid == need.size()) {
// 更新最小窗口
if (right - left < minLen) {
start = left;
minLen = right - left;
}
char d = s.charAt(left);
left++;
if (need.containsKey(d)) {
if (window.get(d).equals(need.get(d))) {
valid--;
}
window.put(d, window.get(d) - 1);
}
}
}
return minLen == Integer.MAX_VALUE ? "" : s.substring(start, start + minLen);
}
// 优化版本:使用数组代替HashMap(假设ASCII字符集)
public static String minWindowOptimized(String s, String t) {
if (s == null || t == null || s.length() < t.length()) return "";
int[] need = new int[128];
int[] window = new int[128];
int required = 0;
for (char c : t.toCharArray()) {
if (need[c] == 0) required++;
need[c]++;
}
int left = 0, right = 0;
int formed = 0;
int start = 0, minLen = Integer.MAX_VALUE;
while (right < s.length()) {
char c = s.charAt(right);
window[c]++;
if (need[c] > 0 && window[c] == need[c]) {
formed++;
}
right++;
while (formed == required) {
if (right - left < minLen) {
start = left;
minLen = right - left;
}
char d = s.charAt(left);
window[d]--;
if (need[d] > 0 && window[d] < need[d]) {
formed--;
}
left++;
}
}
return minLen == Integer.MAX_VALUE ? "" : s.substring(start, start + minLen);
}
public static void main(String[] args) {
System.out.println(minWindow("ADOBECODEBANC", "ABC")); // BANC
System.out.println(minWindow("a", "a")); // a
System.out.println(minWindow("a", "aa")); // ""
System.out.println(minWindow("cabwefgewcwaefgcf", "cae")); // cwae
System.out.println("--- 优化版本 ---");
System.out.println(minWindowOptimized("ADOBECODEBANC", "ABC")); // BANC
}
}
滑动窗口通用模板
最小覆盖子串的解题框架可抽象为以下模板:
// 初始化 need 和 window
int left = 0, right = 0;
while (right < s.length()) {
// 扩张:将 s[right] 纳入窗口
window.add(s[right]);
right++;
// 判断窗口是否需要收缩
while (window needs shrink) {
// 更新结果
// 收缩:将 s[left] 移出窗口
window.remove(s[left]);
left++;
}
}
该模板适用于:
– 找到字符串中所有字母异位词
– 最长无重复字符子串
– 长度最小的子数组(和 ≥ target)
复杂度分析
| 版本 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| HashMap版 | O(|s| + |t|) | O(|字符集|) | 通用,支持Unicode |
| 数组优化版 | O(|s| + |t|) | O(1) | ASCII场景,常数更小 |
左右指针各遍历字符串一次,每个字符最多被加入和移出窗口各一次。
常见变体
变体1:找到所有覆盖子串的起始位置
在收缩条件满足时记录 left 值即可。
变体2:允许字符重复次数不精确匹配
修改 valid/formed 的判断条件,从 == 改为 >=。
变体3:t 中存在重复字符
本文代码已正确处理:通过频次比较而非简单存在性判断。
总结
滑动窗口是处理子串/子数组问题的利器。其核心在于维护窗口的”有效性”条件,通过双指针实现线性扫描。掌握这一框架,可以高效解决大量字符串与数组问题。