【Day 53】76. 最小覆盖子串

文章目录

题目:

给定两个字符串 st,长度分别是 mn,返回 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.length
  • n == t.length
  • 1 <= m, n <= 10^5
  • st 由英文字母组成

进阶: 你能设计一个在 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]-1valid--。其他情况不改变 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)leftright 各最多遍历 s 一次 O(m)
空间 O(1) st 只含英文字母,最多 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

操作规则:

  • 子串字符 - 窗口字符
  • 右指针进入字符 cneed[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 数据规模下实际运行更快。


相关推荐
虚无的纽扣3 小时前
【力扣刷题】第二天:无重复字符的最长字串、移动零问题
算法·leetcode·排序算法
传奇开心果编程6 小时前
【Go入门练中学】第2课:条件与循环
后端·学习·golang
圣保罗的大教堂7 小时前
leetcode 3742. 网格中得分最大的路径 中等
leetcode
我不会起名字3228 小时前
一天一道算法题(35):电话号码的字母组合
java·数据结构·后端·python·leetcode·go·回溯
圣保罗的大教堂9 小时前
leetcode 2033. 获取单值网格的最小操作数 中等
leetcode
6Hzlia9 小时前
【Classic 150 刷题计划】 LeetCode 242. 有效的字母异位词 | C++ 哈希计数与严密防线
c++·算法·leetcode
wabs6669 小时前
关于二叉树【力扣101.对称二叉树的思考】
数据结构·c++·算法·leetcode·二叉树
6Hzlia10 小时前
【Classic 150 刷题计划】 LeetCode 228. 汇总区间 | C++ 双游标区间扫描与 to_string 规范
c++·算法·leetcode
传奇开心果编程10 小时前
【Go入门练中学】第1课:从零开始
后端·学习·golang