文章目录
题目:
给定两个字符串 s 和 t,长度分别是 m 和 n,返回 s 中的 最短窗口子串 ,使得该子串包含 t 中的每一个字符(包括重复字符 )。如果没有这样的子串,返回空字符串 ""。
测试用例保证答案唯一
示例 1:
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。
示例 2:
输入:s = "a", t = "a"
输出:"a"
解释:整个字符串 s 是最小覆盖子串。
示例 3:
输入: s = "a", t = "aa"
输出: ""
解释: t 中两个字符 'a' 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。
提示:
m == s.lengthn == t.length1 <= m, n <= 10^5s和t由英文字母组成
进阶: 你能设计一个在 O(m + n) 时间内解决此问题的算法吗?
方法一:滑动窗口 + 两个 map
思路
维护窗口 [left, right),用两个 map:
need:记录t中每个字符需要多少个(固定不变)window:记录当前窗口内每个字符实际有多少个
用 valid 记录窗口中"已满足需求"的字符种类数。当 valid == len(need) 时窗口覆盖 t,开始收缩左边界。
关键点 :只有当 window[c] 从 need[c]-1 增到 need[c] 时 valid++,从 need[c] 减到 need[c]-1 时 valid--。其他情况不改变 valid。
代码实现(Go):
go
package main
import "fmt"
// import (
// "fmt"
// "math"
// )
func minWindow(s string, t string) string {
// need:记录t中每个字符需要多少个
// 例如 t="AABC",则 need['A']=2, need['B']=1, need['C']=1
need := make(map[byte]int)
// window:记录当前窗口内每个字符实际有多少个
// 例如窗口内有"AB",则 window['A']=1, window['B']=1
window := make(map[byte]int)
// 遍历t,统计每个字符的需求量
for i := 0; i < len(t); i++ {
need[t[i]]++
}
left, right := 0, 0 // 双指针,维护窗口 [left, right)
// valid:记录窗口中"已满足需求"的字符种类数
// 例如 need有3种字符A、B、C,窗口中A和B已达标,则valid=2
// 当valid==len(need)时,说明所有字符都达标了
valid := 0
start := 0 // 记录最小窗口的起始位置
// 哨兵值:合法子串长度最大为len(s),+1保证比任何合法窗口都大
// 这样第一次找到合法窗口时 right-left < length 必然成立,能正常更新
length := len(s) + 1
// 主循环:right指针不断右移,扩大窗口
for right < len(s) {
// 取出右指针位置的字符,即将加入窗口
c := s[right]
// right右移,窗口扩大(注意:此时right已经指向下一个位置)
right++
// 只关心t中出现的字符,也就是我们需要的字符,不在t中的字符对覆盖无意义
if _, ok := need[c]; ok {
// 字符c进入窗口,计数+1
window[c]++
// 如果窗口内c的数量刚好等于需求量,说明c这个种类达标了
// 例如 need['A']=2,window['A']从1变到2,valid++
if window[c] == need[c] {
valid++
}
// 注意:只有"刚好达到"时才valid++,超过不会重复加
// 例如 window['A']从2变到3,不触发valid++(已经达标过了)
}
// valid等于need的种类数,说明窗口已覆盖t的全部字符
// 此时尝试收缩左边界,看能否在保持覆盖的前提下缩短窗口
for valid == len(need) {
// 当前窗口 [left, right) 是一个合法覆盖,检查是否更小
// 进入 for 循环的那一刻,当前窗口 [left, right) 就是一个合法覆盖,必须先记录下来
// 收缩左边界后窗口可能立即变得不合法。如果不先记录,就可能漏掉这个解。
if right-left < length {
start = left
length = right - left
}
// 准备移出左边界字符,收缩窗口
d := s[left]
left++
if _, ok := need[d]; ok {
// 移出前检查:如果该字符在窗口中刚好满足需求
// 移出后就不再满足了,valid必须减1
// 例如 need['A']=2, window['A']=2,移出一个A后变1,不再达标
if window[d] == need[d] {
valid-- // 再缩就不合法了,停止收缩; 不会再循环去记录这个解了
}
// 然后才真正减少计数(顺序很重要,先判断再减)
window[d]--
}
}
// 收缩结束后回到外层循环,继续右移right扩大窗口
}
// length没被更新过(仍是初始值len(s)+1),说明没找到合法覆盖
if length == len(s)+1 {
return ""
}
// 截取最小窗口对应的子串
return s[start : start+length]
}
func main() {
// 示例1
fmt.Printf("%s\n", minWindow("ADOBECODEBANC", "ABC"))
// 示例2
fmt.Printf("%s\n", minWindow("a", "a"))
// 示例3 %q 会给字符串加上双引号,让输出更清晰地看出边界。
fmt.Printf("%q\n", minWindow("a", "aa"))
}
时空复杂度
| 复杂度 | 说明 | |
|---|---|---|
| 时间 | O(m + n) |
need 初始化 O(n);left、right 各最多遍历 s 一次 O(m) |
| 空间 | O(1) |
s 和 t 只含英文字母,最多 52 种字符,两个 map 最多各存 52 个键值对,是常数级别,不随输入规模增长 |
方法二:滑动窗口 + 单数组(常数优化)
思路
核心问题:方法一用了两张表,方法二只用一张
方法一里你有两个 map:
need['A']=1 window['A']=2 → 窗口比需求多1个A(盈余1)
need['B']=1 window['B']=1 → 刚好满足
要判断"是否满足",得对比 window[c] 和 need[c] 两个值。
方法二的思路 :把两个数相减,存成一个数。
need['A'] = 原始需求 - 窗口计数 = 1 - 2 = -1 (负数=盈余1个)
need['B'] = 原始需求 - 窗口计数 = 1 - 1 = 0 (零=刚好)
一张表就同时表达了"需求"和"窗口计数"的信息。
和方法一算法逻辑完全相同,区别在于数据结构:
用一个 need[128] 数组同时 表示"需求"和"窗口计数",省掉 window map:
- 正数 = 还缺这么多
- 0 = 刚好满足
- 负数 = 窗口中多了(盈余)
用 needCount 记录"还未满足的字符种类数",当 needCount == 0 时窗口覆盖 t。
操作规则:
子串字符 - 窗口字符- 右指针进入字符
c:need[c]--。若减到0,说明该字符刚好凑齐,needCount-- - 左指针移出字符
d:若移出前need[d] == 0(刚好满足),移出后变1(不足),needCount++;然后need[d]++
对于 t 中不存在的字符:初始 need[c] = 0,进入时 need[c]-- 变 -1(不触发 needCount 变化),移出时 need[d]++ 回到 0(也不触发),自动被忽略。
代码实现(Go):
right扩大窗口 → 满足覆盖 → 进入收缩循环,边记录边收缩
→ 收缩到不再满足 → 退出循环 → right继续扩大 → 再次满足 → 再收缩...
go
package main
import (
"fmt"
"math"
)
func minWindow(s string, t string) string {
// need[128]:一个数组同时承担"需求表"和"窗口计数"两个角色
// ASCII字符集范围0~127,共128个字符,用字符ASCII码直接做下标
// 需求 - 窗口
// 值的含义:
// 正数 = 窗口还缺这么多该字符
// 0 = 窗口中该字符数量刚好满足需求
// 负数 = 窗口中该字符有盈余(多了)
var need [128]int
// needCount:记录"还未满足"的字符种类数
// 例如 t="AABC",有3种字符A、B、C,needCount初始=3
// 当needCount减到0,说明所有种类都达标了
needCount := 0
// 遍历t,统计每个字符的需求量
for i := 0; i < len(t); i++ {
c := t[i]
// 第一次遇到该字符(need[c]还是0),说明是一个新种类
// 例如 t="AABC":A第一次出现时needCount++,B、C同理
if need[c] == 0 {
needCount++ // 记录一个待满足的种类
}
need[c]++ // 该字符需求量+1
}
left, right := 0, 0
start := 0
// 哨兵值:比任何合法窗口长度都大,保证第一次找到时一定能更新
length := math.MaxInt32
// 主循环:right指针不断右移,扩大窗口
for right < len(s) {
// 取出右指针位置的字符,即将加入窗口
c := s[right]
// right右移,窗口扩大(窗口是[left, right)左闭右开)
right++
// 字符c进入窗口,该字符的需求减1
// 例如 need['A']=2,进入一个A后变成1(还缺1个)
need[c]--
// 检查该字符是否"恰好凑齐"
// 例如 need['A']从1减到0:窗口里A的数量刚好达标,少一个待满足种类
// 注意:只有从正数减到0才算"刚好凑齐"
// 从0减到-1是盈余,不算(needCount不变)
if need[c] == 0 {
needCount--
}
// needCount==0:所有字符种类都达标了,窗口已覆盖t
// 尝试收缩左边界,看能否在保持覆盖的前提下缩短窗口
// 只有移出一个"刚好达标"的字符才会让收缩结束;盈余字符移多少个都不影响
for needCount == 0 {
// 当前窗口[left, right)是合法覆盖,先记录
// 必须先记录再收缩,否则可能漏掉这个解
if right-left < length {
start = left
length = right - left
}
// 准备移出左边界字符,收缩窗口
d := s[left]
left++
// 移出前检查:该字符是否处于"刚好达标"状态
// 例如 need['A']=0,移出一个A后need['A']变1(不足),needCount++
// 这就是退出信号:再缩就不合法了
// 如果need['A']=-1(盈余),移出后变0,仍满足,needCount不变,继续收缩
if need[d] == 0 {
needCount++
}
// 真正执行移出:该字符需求+1(窗口中少了一个)
need[d]++
}
// 收缩结束后回到外层循环,继续右移right扩大窗口
}
// length没被更新过(仍是math.MaxInt32),说明没找到合法覆盖
if length == math.MaxInt32 {
return ""
}
// 截取最小窗口对应的子串
return s[start : start+length]
}
func main() {
fmt.Printf("%q\n", minWindow("ADOBECODEBANC", "ABC"))
fmt.Printf("%q\n", minWindow("a", "a"))
fmt.Printf("%q\n", minWindow("a", "aa"))
}
时空复杂度
| 复杂度 | 说明 | |
|---|---|---|
| 时间 | O(m + n) |
与方法一相同,但常数更优:数组访问是直接索引,无哈希计算 |
| 空间 | O(1) |
固定大小 int[128],128 个 int = 1024 字节,与输入规模无关 |
对比
| 方法一(两个 map) | 方法二(单数组) | |
|---|---|---|
| 时间复杂度 | O(m + n) |
O(m + n) |
| 空间复杂度 | O(1) |
O(1) |
| 数据结构 | 两个 map[byte]int |
一个 [128]int |
| 访问开销 | 哈希计算 + 查桶 | 直接索引 |
| 内存布局 | 不连续,动态分配 | 连续,栈上 |
| CPU 缓存 | 不友好 | 友好 |
| 实际速度 | 慢一些 | 快一些(常数级提升) |
| 可读性 | 两个 map 语义清晰 | 正负值含义需理解 |
| 通用性 | 任意字符集都适用 | 需要字符集可映射为数组下标 |
结论 :两者大 O 完全一致,都是 O(m+n) 时间、O(1) 空间。
数组版赢在常数因子 ------无哈希开销、连续内存、少维护一个数据结构。这不是渐近级别的优化,但在 10^5 数据规模下实际运行更快。