LeetCode LCR 017. 最小覆盖子串
题目描述
题意分析
在
s中找一段连续的子串,使它「覆盖」t,并且长度最短;若不存在这样的子串就返回空字符串。答案要的是子串本身,所以除了长度还得记住起点。「覆盖」的判定标准是按重数而不是按种类:
t中某个字符出现k次,窗口里就必须出现至少k次,多出来不要紧。这是本题最容易读错的一处——t = "AABC"时只含一个A的窗口不算覆盖。「连续」意味着答案对应原串上的一个区间
[left, right],可以用两个下标刻画,而不必枚举字符组合。更重要的是覆盖性对区间单调:若[left, right]已经覆盖t,那么把右端点继续往右扩张仍然覆盖;反之若当前区间没覆盖,缩短它只会更差。这条单调性说明左右端点都只需要单向移动。另一个可以利用的信号是字符集有限:题面给的是英文字母大小写,全部落在 ASCII 范围内,因此计数容器可以用一个固定长度的数组,判断是否覆盖不需要遍历哈希表。
边界情形:
t比s长时必然返回空串;s中根本没有某个必需字符时返回空串;答案可能就是整个s(如s = "ab"、t = "ba"),所以最优长度的初值必须比s的长度还大;s与t都只有一个字符且相等时答案是长度 1 的窗口,收缩逻辑不能把长度 1 的窗口排除掉。
解法:滑动窗口维护字符欠账
核心思路
暴力做法枚举所有 $O( s ^2)$ 个子串,每个再花 $O( s + t )$ 判断是否覆盖,总代价 $O( s ^3)$。瓶颈很清楚:相邻的两个候选区间只差一个字符,判覆盖却每次都从零重算。 于是改成增量维护。核心是两个量:数组
need[ch]表示窗口还欠多少个字符ch,整数missing表示窗口一共还欠多少个字符。初始化时need就是t的频次表,missing = t.length()。字符入窗时need[ch]--,出窗时need[ch]++,所以need[ch]允许是负数,负值表示窗口里这个字符多余了几个。关键在于
missing的更新只在「欠账真的发生变化」时才动。入窗时先看need[ch] > 0是否成立:成立说明这个字符本来还欠着,这次入窗填上了一格真实缺口,missing--;不成立说明窗口里这个字符已经够用(甚至过剩),这次入窗只是让它更过剩,missing不动。出窗时对称:先need[ch]++,若增后need[ch] > 0,说明刚移出的那一个是必需的,窗口从此欠账,missing++;若增后仍≤ 0,移走的只是多余副本,窗口依然覆盖。由此得到循环不变量:
missing恒等于need中所有正值之和,也就是当前窗口与覆盖t之间的真实差距。所以missing == 0就是「窗口覆盖t」的充要条件,判定是 $O(1)$ 的,不必扫描整张计数表。有了 $O(1)$ 判定,算法形状就是标准的可变窗口:右端点逐个右移扩张,一旦
missing == 0就进入内层循环,先记录当前窗口长度,再从左端移出字符尝试继续缩短,直到窗口重新欠账为止。内层用while而不是if很关键——missing归零的那一刻,左边可能积压了一串多余字符,必须一路挤干净,否则记录到的不是以当前right结尾的最短合法窗口。正确性论证:对每个右端点
right,内层循环退出前的最后一次记录,恰好对应「使[left, right]覆盖t的最大left」,即以right结尾的最短合法窗口。所有右端点的最短窗口取最小值,就是全局答案;而任何合法窗口都有唯一的右端点,不会被漏掉。左右端点都只增不减:
right由外层循环推进,left只在内层循环里++,两者的总位移都不超过|s|,这就是线性复杂度的来源。
解题步骤
- 建欠账表:开一个长度 128 的
need数组,遍历t把每个字符的需求数加进去。用固定数组而不是哈希表,是为了让入窗、出窗和判覆盖都是纯数组下标操作。- 初始化状态:
missing = t.length(),因为初始窗口为空,欠的正好是t的全部字符(含重复);left = 0;bestLen取一个不可能达到的大值(Java 用Integer.MAX_VALUE,Go 用len(s) + 1),bestStart = 0。bestLen的初值必须严格大于|s|,否则整串就是答案时会被漏掉。- 右端点扩张:
right从 0 扫到末尾,取in = s[right]。先判断need[in] > 0再执行need[in]--,顺序颠倒就会把「填补缺口」误判成「制造过剩」。- 判覆盖并收缩:
while (missing == 0)时,先用right - left + 1尝试更新bestLen和bestStart——此刻窗口一定是合法的,是唯一能安全记录答案的时机。- 移出左端字符:取
out = s[left],执行need[out]++;若增后need[out] > 0,说明移走的是必需字符,missing++让内层循环退出。最后left++。这一步的need[out]++绝不能省,否则missing永远为 0,内层循环出不来。- 收尾:外层结束后若
bestLen仍是初值,说明从未出现合法窗口,返回空字符串;否则返回[bestStart, bestStart + bestLen)这一段子串。以
s = "ADOBECODEBANC"、t = "ABC"走一遍。初始need[A] = need[B] = need[C] = 1,missing = 3,left = 0。右端点推进到下标 0 的
A,need[A]由 1 变 0,missing = 2;下标 1 的D、下标 2 的O都不是必需字符,need变成-1,missing保持 2;下标 3 的B使missing = 1;下标 4 的E无影响;下标 5 的C使missing = 0。此时窗口是[0, 5]即"ADOBEC",长度 6,记为当前最优。开始收缩:移出下标 0 的A,need[A]回到 1,为正,missing = 1,left = 1,内层退出。继续推进:下标 9 的
B入窗时need[B]已经是 0(下标 3 的B还在窗口里),所以missing不变、need[B]变-1;下标 10 的A入窗使missing = 0。窗口[1, 10]长度 10,不优于 6。收缩阶段依次移出D、O、下标 3 的B(need[B]由-1变 0,不为正,窗口仍合法)、E,left走到 5,窗口[5, 10]长度 6,与最优并列不更新;再移出下标 5 的C,need[C]变 1 为正,missing = 1,left = 6,退出。最后推进到下标 12 的
C,missing再次归零。收缩阶段:移出下标 6 的O(need[O]由-1变 0),left = 7,窗口长度 6;移出下标 7 的D,left = 8,窗口[8, 12]长度 5,更新最优;移出下标 8 的E(need[E]由-1变 0,仍合法),left = 9,窗口[9, 12]长度 4,更新最优为bestStart = 9、bestLen = 4;再移出下标 9 的B,need[B]变 1 为正,missing = 1,left = 10,退出。外层结束,返回s[9..12]即"BANC"。再看
s = "a"、t = "aa":missing初值为 2,唯一一次入窗只能把它降到 1,while从未进入,bestLen保持初值,返回""。
代码实现
class Solution {
public String minWindow(String s, String t) {
int[] need = new int[128];
for (int i = 0; i < t.length(); i++) {
need[t.charAt(i)]++;
}
int missing = t.length();
int left = 0;
int bestStart = 0;
int bestLen = Integer.MAX_VALUE;
for (int right = 0; right < s.length(); right++) {
char in = s.charAt(right);
if (need[in] > 0) {
missing--;
}
need[in]--;
// missing 为 0 时窗口已覆盖 t,开始尽量左收缩。
while (missing == 0) {
int len = right - left + 1;
if (len < bestLen) {
bestLen = len;
bestStart = left;
}
char out = s.charAt(left);
need[out]++;
if (need[out] > 0) {
missing++;
}
left++;
}
}
if (bestLen == Integer.MAX_VALUE) {
return "";
}
return s.substring(bestStart, bestStart + bestLen);
}
}
func minWindow(s string, t string) string {
need := make([]int, 128)
for i := 0; i < len(t); i++ {
need[t[i]]++
}
missing := len(t)
left := 0
bestStart := 0
bestLen := len(s) + 1
for right := 0; right < len(s); right++ {
in := s[right]
if need[in] > 0 {
missing--
}
need[in]--
// missing 为 0 时窗口已覆盖 t,开始尽量左收缩。
for missing == 0 {
length := right - left + 1
if length < bestLen {
bestLen = length
bestStart = left
}
out := s[left]
need[out]++
if need[out] > 0 {
missing++
}
left++
}
}
if bestLen == len(s)+1 {
return ""
}
return s[bestStart : bestStart+bestLen]
}
复杂度分析
时间复杂度:$O( s + t )$。建欠账表扫 t一遍是 $O(t )$;主循环中 right走|s|步,内层while每次执行都让left前进一格,而left单调不减、总位移不超过|s|,因此内层循环的总次数摊还后是 $O(s )$ 而非每轮 $O( s )$;每步只做常数次数组读写, missing == 0的判覆盖也是 $O(1)$,不含隐藏的字符集遍历。主导项是|s|。- 空间复杂度:$O(C)$,其中
C = 128是 ASCII 字符集大小,need数组的规模与输入长度无关,可视为常数;其余只有若干下标与计数变量,返回值不计入额外空间。
关键点总结
- 把「是否满足约束」压缩成一个 $O(1)$ 可查的标量(这里是
missing),是所有可变窗口题的通用手法;一旦判定要遍历计数表,复杂度就会多乘一个字符集大小。- 计数值允许为负是这套写法的精髓:负值携带了「过剩多少」的信息,正是它让出窗时能区分「移走了必需字符」与「移走了多余副本」。
- 「先判断再修改」与「先修改再判断」的次序,取决于修改的方向:入窗前
need的正负才代表旧的欠账,出窗后need的正负才代表新的欠账,两处顺序天然相反,不能凭手感统一。- 内层收缩必须是
while而不是if:missing归零的时刻左侧可能积压多个无用字符,只挤一格就无法保证得到以当前右端点结尾的最短窗口。- 求最短区间时,最优长度的初值要取一个不可达的上界(
|s| + 1或整型最大值),并用它兼作「无解」的标记,能省掉一个布尔变量。
面试视角:先点明「按重数覆盖」和「原串连续」这两个读题结论,再讲清 missing的不变量以及为什么左右指针单向移动就够,这两点讲透了这道困难题就稳了。常见追问有三个:need为什么允许负数、内层为什么用while、以及若把「子串」改成「子序列」会怎样——后者顺序有额外约束,窗口法不再成立,需要转成区间型动态规划。用双层循环枚举子串再逐个判覆盖不能当主答案,即使用哈希表加速判定也只是把 $O(s ^3)$ 降到 $O( s ^2)$。
易错点总结
- 入窗时先做
need[in]--再判断need[in] > 0:填补最后一个缺口时计数已经被减成 0,判断失败、missing不减。用s = "a"、t = "a"测试,missing永远是 1,while不会进入,返回""而正确答案是"a"。- 入窗判断写成
need[in] >= 0,把「计数恰好相等、已经够用」也当成填补缺口:重复字符入窗会重复扣减missing,窗口尚未覆盖t就被判定合法而提前收缩。用s = "AAB"、t = "AB"测试,第二个A入窗时need[A] = 0也触发missing--,missing提前归零,"AA"乃至"A"被当成合法窗口记录,最终返回"A",正确答案是"AB"。- 内层收缩用
if代替while:每个右端点只挤掉一格左侧字符。用s = "AAB"、t = "AB"测试,missing在下标 2 归零时记下长度 3 的"AAB",随后只移出一个A就退出,最终返回"AAB",而正确答案是"AB"。- 忘记
need[out]++,只写left++:missing再也不会变回正数,while (missing == 0)成为死循环,left冲出字符串范围,s.charAt(left)抛StringIndexOutOfBoundsException(Go 中是切片下标 panic)。- 把更新答案的代码挪到
left++之后:记录的是已经收缩过头、不再覆盖t的窗口。用s = "a"、t = "a"测试,记下的长度是0 - 1 + 1 = 0,bestLen变成 0,最终返回""。bestLen初值取s.length():整个s恰好是唯一答案时长度不小于初值、不会被记录。用s = "ab"、t = "ba"测试会返回"",正确答案是"ab"。- 只统计字符种类、不看出现次数:用
s = "ABC"、t = "AABC"测试会返回"ABC",但s里只有一个A,正确答案是""。- 计数数组开成
new int[26]并用ch - 'a'索引:题面的t = "ABC"是大写字母,'A' - 'a'等于-32,直接抛出数组越界异常。字符集含大小写时必须用 128 长度的表或哈希表。- 收缩条件写成
while (missing == 0 && left < right):多加的条件把长度为 1 的窗口排除在外。用s = "a"、t = "a"测试,left < right为假,答案从未被记录,返回""。- 返回时写成
s.substring(bestStart, bestLen):把长度当成了结束下标。用s = "ADOBECODEBANC"、t = "ABC"测试,bestStart = 9、bestLen = 4,substring(9, 4)因为起点大于终点而抛出异常。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 面试题 17.18. 最短超串 | 中等 | 对象是整数数组且待覆盖元素互不重复,返回下标区间而非子串 |
| 3. 无重复字符的最长子串 | 中等 | 求最长而非最短,约束是「窗口内不重复」,扩张与收缩的触发条件正好相反 |
| 209. 长度最小的子数组 | 中等 | 同样求最短窗口,但合法性判据是数值和 ≥ target,只需一个前缀和变量 |
| 438. 找到字符串中所有字母异位词 | 中等 | 窗口长度固定为模式串长度,要求恰好相等而非覆盖,收缩退化为定长滑动 |
| 567. 字符串的排列 | 中等 | 定长窗口的判定版,只需返回是否存在,找到一个即可提前退出 |
| 30. 串联所有单词的子串 | 困难 | 覆盖单位从字符变成等长单词,需要按单词长度分组做多条独立的滑动窗口 |
| 424. 替换后的最长重复字符 | 中等 | 合法条件变成「窗口长度减最高频次 ≤ k」,需要额外维护窗口内的众数频次 |
| 1004. 最大连续1的个数 III | 中等 | 只对 0 计数,欠账表退化成一个整数,是本题写法的最简特例 |
| 727. 最小窗口子序列 | 困难 | 要求 t 按顺序作为子序列出现,覆盖性不再对区间单调,滑动窗口整体失效 |