一、题目
一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 “” 。
注意:
对于 t 中重复字符,寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
如果 s 中存在这样的子串,我们保证它是唯一的答案。
示例 1:
输入:s = “ADOBECODEBANC”, t = “ABC”
输出:“BANC”
解释:最小覆盖子串 “BANC” 包含来自字符串 t 的 ‘A’、‘B’ 和 ‘C’
示例 3:
输入: s = “a”, t = “aa”
输出: “”
解释: t 中两个字符 ‘a’ 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。
二、语法知识
2.1 字典
2.1.1 字典 (dict) 概述
字典是 Python 中一种内置的核心数据结构,属于映射类型。它存储的是键值对的集合。字典的主要特点包括:
- 无序性:在 Python 3.6 及更早版本中,字典的键值对存储顺序是未定义的(不保证插入顺序)。但在 Python 3.7+ 中,字典会保留键值对的插入顺序(作为语言规范的一部分)。
- 可变性:字典的内容(键值对)可以动态地添加、修改或删除。
- 键的唯一性:字典的键必须是唯一的。如果添加时使用了已存在的键,则会覆盖该键对应的旧值。
- 键的类型要求:字典的键必须是可哈希的数据类型。通常,不可变类型(如整数、浮点数、字符串、元组)可以用作键。可变类型(如列表、字典、集合)不能用作键。
- 值的类型自由:字典的值可以是任何 Python 对象(包括数字、字符串、列表、元组、其他字典等)。
字典使用花括号 {} 创建,键值对之间用冒号 : 分隔,不同的键值对用逗号 , 分隔。
# 创建一个空字典
empty_dict = {}
# 创建包含键值对的字典
student = {
"name": "Alice",
"age": 20,
"courses": ["Math", "Physics"],
1: "ID Number" # 整数也可以作为键
}
2.1.2 常用字典方法
字典对象提供了多种方法来操作和访问其中的数据。以下是一些最常用的方法:
1. 访问元素
dict[key]: 通过键访问对应的值。如果键不存在,会引发KeyError。name = student["name"] # 获取 "name" 对应的值 "Alice"dict.get(key[, default]): 通过键获取对应的值。如果键不存在,返回default(默认为None),而不是引发错误。age = student.get("age") # 20 grade = student.get("grade", "N/A") # 键不存在,返回 "N/A"
2. 添加/修改元素
dict[key] = value: 如果键key不存在,则添加新的键值对{key: value};如果键key已存在,则更新其对应的值为value。student["grade"] = "A" # 添加新键值对 "grade": "A" student["age"] = 21 # 更新键 "age" 的值为 21dict.setdefault(key[, default]): 如果键key存在于字典中,则返回其对应的值。如果键不存在,则插入{key: default}(default默认为None),并返回default。# 键 "email" 不存在,插入 {"email": None} 并返回 None email = student.setdefault("email") # 键 "phone" 不存在,插入 {"phone": "123-4567"} 并返回 "123-4567" phone = student.setdefault("phone", "123-4567")
3. 删除元素
del dict[key]: 删除字典中键为key的项。如果键不存在,引发KeyError。del student["courses"] # 删除键 "courses" 及其值dict.pop(key[, default]): 删除字典中键为key的项,并返回其对应的值。如果键不存在,且提供了default,则返回default;否则引发KeyError。removed_age = student.pop("age") # 删除并返回 "age" 的值 removed_grade = student.pop("grade", "N/A") # 键不存在,返回 "N/A"dict.popitem(): 删除并返回字典中的最后一个键值对(遵循 LIFO 原则)。返回形式为(key, value)。如果字典为空,引发KeyError。last_item = student.popitem() # 删除并返回最后插入的键值对,如 (1, 'ID Number')dict.clear(): 清空字典,移除所有键值对。student.clear() # student 变为空字典 {}
4. 获取视图对象
这些方法返回字典的视图对象,它们是动态的,会反映字典的更改。
dict.keys(): 返回一个视图对象,包含字典中所有的键。keys_view = student.keys() # 如 dict_keys(['name', 'age', 'courses'])dict.values(): 返回一个视图对象,包含字典中所有的值。values_view = student.values() # 如 dict_values(['Alice', 20, ['Math', 'Physics']])dict.items(): 返回一个视图对象,包含字典中所有的键值对(以(key, value)元组形式)。items_view = student.items() # 如 dict_items([('name', 'Alice'), ('age', 20), ...])
5. 更新字典
dict.update([other]): 使用另一个字典other或可迭代的键值对(如元组列表)来更新当前字典。对于other中的键值对:- 如果键在当前字典中不存在,则添加。
- 如果键已存在,则更新其对应的值。
new_info = {"age": 21, "city": "New York"} student.update(new_info) # 更新 age 为 21, 添加 city: "New York"
6. 其他方法
dict.copy(): 返回字典的一个浅拷贝。student_copy = student.copy()len(dict): 返回字典中键值对的数量(不是方法,是内置函数)。num_items = len(student) # 如 3key in dict: 检查键key是否存在于字典中(不是方法,是成员运算符)。has_name = "name" in student # True
2.1.3 总结
字典是 Python 中功能强大且高效的数据结构,特别适合需要通过唯一键快速查找、添加或删除数据的场景。其核心是基于哈希表实现,使得这些操作的平均时间复杂度接近 O ( 1 ) O(1) O(1)。
以下表格总结了上述常用方法:
| 方法/操作 | 描述 | 示例 |
|---|---|---|
dict[key] | 通过键访问值(不存在则 KeyError) | value = my_dict["key"] |
dict.get(key[, default]) | 通过键获取值(不存在则返回 default) | value = my_dict.get("key", "default") |
dict[key] = value | 设置/更新键值对 | my_dict["new_key"] = 42 |
dict.setdefault(key[, default]) | 键存在返回值,不存在则插入 {key: default} 并返回 default | val = my_dict.setdefault("key", []) |
del dict[key] | 删除指定键的项(不存在则 KeyError) | del my_dict["key"] |
dict.pop(key[, default]) | 删除指定键的项并返回其值(不存在则返回 default 或 KeyError) | val = my_dict.pop("key") |
dict.popitem() | 删除并返回最后一个键值对(空字典则 KeyError) | key, value = my_dict.popitem() |
dict.clear() | 清空字典 | my_dict.clear() |
dict.keys() | 返回键的视图 | keys = my_dict.keys() |
dict.values() | 返回值的视图 | values = my_dict.values() |
dict.items() | 返回键值对元组的视图 | items = my_dict.items() |
dict.update([other]) | 用其他字典或键值对迭代器更新字典 | my_dict.update({"a": 1, "b": 2}) |
dict.copy() | 返回字典的浅拷贝 | new_dict = my_dict.copy() |
len(dict) | 返回键值对数量 | count = len(my_dict) |
key in dict | 检查键是否存在 | if "key" in my_dict: |
三、算法知识
3.2 滑动窗口
3.2.1 滑动窗口算法概述
滑动窗口算法是一种用于处理数组或字符串等序列数据的有效技巧。它的核心思想是维护一个大小可变的窗口(通常由两个指针 left 和 right 定义),这个窗口在序列上“滑动”,并根据问题要求动态调整窗口的大小或位置。这种方法通常能将原本需要嵌套循环的问题优化到线性时间复杂度
O
(
n
)
O(n)
O(n)。
3.2.2 主要类型
-
固定大小的窗口
- 特点:窗口的长度在滑动过程中保持不变。
- 应用场景:计算数组每个大小为
k的连续子数组的平均值、最大值、最小值等。 - 操作:
- 初始化
left=0,right=k-1。 - 计算初始窗口的值。
- 每次将窗口向右滑动一步(
left++,right++),移除窗口最左端元素的影响,加入新进入窗口的元素的影响,更新窗口值。
- 初始化
-
可变大小的窗口
- 特点:窗口的大小会根据某些条件(如窗口内元素满足特定性质)动态变化。
- 应用场景:寻找满足特定条件(如和小于等于
target)的最长子数组/子串,或者寻找包含所有特定元素的最短连续子数组/子串(如最小覆盖子串)。 - 操作:
- 初始化
left=0,right=0。 - 不断将
right指针向右移动,扩大窗口,直到窗口内的状态满足(或不再满足)某个条件。 - 当满足条件时,可能需要更新结果(如记录当前窗口长度)。
- 然后开始移动
left指针向右,收缩窗口(通常是为了寻找更优解或破坏当前条件),并不断更新窗口状态。 - 重复上述过程直到
right到达序列末端。
- 初始化
3.2.3 关键点
- 窗口表示:通常由两个指针
left(左边界)和right(右边界)表示。窗口范围是[left, right]。 - 窗口滑动:通过移动
left和right指针实现窗口的扩张或收缩。 - 窗口状态维护:在移动指针时,需要高效地更新窗口的状态信息(如窗口内元素的和、最大值、元素出现频率等)。使用哈希表(用于计数)、队列(用于维护最大值/最小值)等数据结构可以高效地实现这一点。
3.2.4 应用示例
- 固定大小:计算大小为
k的滑动窗口的最大值(可使用双端队列优化)。 - 可变大小:
- 最长无重复字符子串:给定字符串,找到不含重复字符的最长子串。
- 最小覆盖子串:给定字符串
s和t,在s中找到包含t所有字符的最短连续子串。 - 和为
target的最长子数组:给定整数数组和target,找到和大于等于target的最短连续子数组(或小于等于target的最长子数组)。
3.2.5 时间复杂度
- 固定大小窗口:通常为 O ( n ) O(n) O(n),因为每个元素最多被进入和离开窗口各一次。
- 可变大小窗口:通常也为
O
(
n
)
O(n)
O(n) 或
O
(
2
n
)
O(2n)
O(2n)。虽然
left和right都可能移动 n n n 次,但每个元素最多被left和right各访问一次。
3.2.6 代码示例(可变窗口:最长无重复字符子串)
def lengthOfLongestSubstring(s: str) -> int:
if not s:
return 0
char_index = {} # 记录字符最近一次出现的位置
left = 0
max_length = 0
for right in range(len(s)):
if s[right] in char_index and char_index[s[right]] >= left:
# 当前字符在窗口内重复出现,左边界跳到重复字符上次出现位置的下一位
left = char_index[s[right]] + 1
char_index[s[right]] = right # 更新字符位置
max_length = max(max_length, right - left + 1) # 更新最大长度
return max_length
3.2.7 总结
滑动窗口算法是一种高效处理连续子数组/子串问题的技巧,特别适合需要满足特定条件的连续序列查询。通过维护窗口的状态并利用指针移动,它能显著降低时间复杂度。理解窗口如何根据条件扩张和收缩是掌握该算法的关键。
四、题解
4.1 题解1
思路:哈希表+滑动窗口(双指针)
核心思想:滑动窗口 + 字符计数器(高效判断窗口是否覆盖目标字符串)
4.1.1 问题本质
- 目标:在字符串
s中找到最短子串,使其包含字符串t的所有字符(允许额外字符) - 关键挑战:
- 暴力解法(检查所有子串):O(n³) → 超时
- 需设计 O(n) 解法,通过滑动窗口动态维护覆盖状态
4.1.2 核心思想:覆盖条件 ≠ 精确匹配
| 条件 | 是否满足题目要求 | 示例(t="AAB") |
|---|---|---|
window_count == target_count | ❌ 错误 | 窗口 "AAB" ✅,但 "AAAB" ❌ |
window_count ≥ target_count | ✅ 正确 | 窗口 "AAB" ✅,"AAAB" ✅ |
💡 核心洞察:
窗口只需满足window_count[c] ≥ target_count[c](允许更多字符/更高频次),
而非精确相等(window_count == target_count)。
4.1.3 算法步骤详解
1. 初始化阶段
# 构建目标字符计数器
target_count = {}
for char in t:
target_count[char] = target_count.get(char, 0) + 1
- 作用:记录
t中每个字符的目标出现次数 - 示例:
t="AAB"→{'A':2, 'B':1}
2. 滑动窗口维护
| 指针 | 操作 | 目的 |
|---|---|---|
| 右指针 | 扩展窗口直到覆盖所有目标字符 | 找到第一个有效窗口 |
| 左指针 | 收缩窗口尝试找到最小有效窗口 | 优化当前窗口长度 |
3. 关键状态跟踪(核心创新点)
matched_chars = 0 # 已满足条件的字符种类数
- 定义:当前窗口中**
window_count[c] ≥ target_count[c]的字符种类数** - 判断覆盖条件:
matched_chars == len(target_count)
(当所有目标字符种类均满足要求时,窗口有效)
✅ 为什么需要 matched_chars?
| 方法 | 时间复杂度 | 原因 |
|---|---|---|
| 直接比较字典 | O(nk) | 每次循环需 O(k) 遍历字典检查 |
matched_chars 跟踪 | O(n) | 每次更新 O(1),判断覆盖 O(1) |
⚠️ k 的影响:当字符集很大时(如 Unicode),字典比较的 O(k) 会显著拖慢性能
4.1.4 关键操作逻辑
1. 右指针移动(扩展窗口)
char = s[right]
if char in target_count:
window_count[char] = window_count.get(char, 0) + 1
# 仅当达到目标次数时增加 matched_chars
if window_count[char] == target_count[char]:
matched_chars += 1
- 关键点:
- 仅当
window_count[char] == target_count[char]时增加matched_chars - 避免重复计数(例如
t="AA"时,第二个A不会再次增加matched_chars)
- 仅当
2. 左指针移动(收缩窗口)
left_char = s[left]
if left_char in target_count:
# 仅当低于目标次数时减少 matched_chars
if window_count[left_char] == target_count[left_char]:
matched_chars -= 1
window_count[left_char] -= 1
- 关键点:
- 仅当
window_count[left_char] == target_count[left_char]时减少matched_chars - 确保精确反映覆盖状态(例如
t="AA"时,移除第一个A不会破坏匹配)
- 仅当
4.1.5 手动模拟验证(s="ADOBECODEBANC", t="ABC")
| 步骤 | 操作 | window_count | matched_chars | 说明 |
|---|---|---|---|---|
| 1 | 初始化 | {} | 0 | target_count = {'A':1, 'B':1, 'C':1} |
| 2 | 右指针到 'A' | {'A':1} | 1 | A:1 ≥ 1 → 但需检查是否首次达到 |
| 3 | 右指针到 'B' | {'A':1, 'B':1} | 2 | B:1 ≥ 1 → matched_chars += 1 |
| 4 | 右指针到 'C' | {'A':1, 'B':1, 'C':1} | 3 | C:1 ≥ 1 → matched_chars += 1 → 窗口有效 |
| 5 | 左指针收缩到 'D' | {'A':0, 'B':1, 'C':1} | 2 | A:0 < 1 → matched_chars -= 1 → 窗口失效 |
✅ 验证结果:
- 首个有效窗口:
"ADOBEC"(长度 6)- 最小有效窗口:
"BANC"(长度 4)
4.1.6 时间复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
遍历 t 初始化 | O(m) | m = len(t) |
遍历 s 滑动窗口 | O(n) | n = len(s) |
| 总时间复杂度 | O(n+m) | 线性时间,高效 |
| 空间复杂度 | O(k) | k = 字符集大小(通常为常数) |
💡 对比:
- 暴力解法:O(n³) → n=100 时已超时
- 滑动窗口:O(n) → n=10⁵ 时仅需 0.1 秒
4.1.7 常见误区与避坑指南
| 误区 | 后果 | 修正方法 |
|---|---|---|
用 window_count == target_count | 永远无法检测到有效窗口 | 用 matched_chars 跟踪 |
忘记检查 char in target_count | 额外字符干扰计数 | 只处理目标字符 |
更新 matched_chars 时用 ≥ | 重复计数导致逻辑错误 | 严格用 == 判断 |
忽略 window_count 初始化 | 字典操作报错 | 用 get(char, 0) 安全访问 |
4.1.8 完整代码模板
class Solution:
def minWindow(self, s: str, t: str) -> str:
m = len(s)
result = ""
# 1. 初始化 target_count
target_count = {}
for char in t:
target_count[char] = target_count.get(char, 0) + 1
# 2. 滑动窗口变量
matched_chars = 0
window_count = {}
left=0
min_len = inf
# 3. 移动右指针
for right in range(m):
char = s[right]
if char in target_count:
window_count[char] = window_count.get(char, 0) + 1
# 关键:检查当前字符是否满足目标次数
if window_count[char] == target_count[char]:
matched_chars += 1
# 4. 当覆盖所有字符时,尝试收缩左指针
while matched_chars == len(target_count):
# 5. 更新最短子串
if right-left+1 < min_len:
min_len = right-left+1
result=s[left:right+1]
# 6. 收缩左指针
left_char = s[left]
if left_char in target_count:
window_count[left_char]-=1
# 关键:检查收缩后是否破坏匹配
if window_count[left_char]<target_count[left_char]:
matched_chars-=1
left+=1
return result
4.1.9 关键总结口诀
“目标计数先建好,
窗口扩展看匹配;
达标计数加一种,
收缩窗口减一种。
左移更新最小子,
覆盖判断看种类。”
4.1.10 扩展思考
- 变体问题:
- 求最长不重复子串→ 反向应用此逻辑
- 求包含所有元音字母的最短子串 → 修改
target_count内容
- 优化方向:
- 用数组代替字典(当字符集固定时,如 ASCII)→ 常数级优化
- 提前终止条件(当
min_len == len(t)时)→ 小幅优化
- 面试高频考点:
- 为什么不能直接比较字典?
matched_chars的更新时机为什么用==而不是≥?- 如何处理
t包含重复字符的情况?
💡 终极思考:
如果t="AAA",当窗口中出现"AA"时,matched_chars是多少?为什么?
(答案:0,因为window_count['A']=2 < target_count['A']=3,未达到目标次数)
4.1.11 优化
4.1.11.1 优化 1:提前终止(理论最短窗口)
原理:当找到长度等于 len(t) 的窗口时,不可能有更短的有效窗口(因为必须包含 t 的所有字符)。
# 在更新最短子串后添加
if min_len == n: # n = len(t)
return result # 立即返回,无需继续
4.1.11.2 优化 2:数组代替字典(ASCII 优化)
原理:当字符集有限(如 ASCII 128 字符),数组访问比字典快 3-5 倍(O(1) 常数更小)。
class Solution:
def minWindow(self, s: str, t: str) -> str:
m = len(s)
n = len(t)
result = ""
# 1. 初始化 target_count
# 替换原字典初始化
target_count = [0] * 128
in_t = [False] * 128 # 快速判断字符是否在 t 中
required_chars = 0
for char in t:
ascii_val = ord(char)
if target_count[ascii_val] == 0: # 首次出现该字符
required_chars += 1
target_count[ascii_val] += 1
in_t[ascii_val] = True
# 2. 滑动窗口变量
matched_chars = 0
# 窗口计数也改用数组
window_count = [0] * 128
left=0
min_len = inf
# 3. 移动右指针
for right in range(m):
char = s[right]
ascii_val = ord(char)
if in_t[ascii_val]: # O(1) 检查
window_count[ascii_val] += 1
# 关键:检查当前字符是否满足目标次数
if window_count[ascii_val] == target_count[ascii_val]:
matched_chars += 1
# 4. 当覆盖所有字符时,尝试收缩左指针
while matched_chars == required_chars:
# 5. 更新最短子串
if right-left+1 < min_len:
min_len = right-left+1
result=s[left:right+1]
if min_len==n:return result
# 6. 收缩左指针
left_char = ord(s[left])
if in_t[left_char]:
window_count[left_char]-=1
# 关键:检查收缩后是否破坏匹配
if window_count[left_char]<target_count[left_char]:
matched_chars-=1
left+=1
return result

7423

被折叠的 条评论
为什么被折叠?



