本篇题目
LeetCode 76 · 最小覆盖子串
思路
找 s 中包含 t 所有字符的最短子串。暴力枚举所有子串是 O(n²),太慢。
核心观察:如果窗口 [left, right] 已经涵盖 t,再往右扩展没有意义,应该收缩左端点找更短的。这种”一端扩、一端缩”就是可变滑动窗口。
步骤:
- 右端点不断右移,把字符纳入窗口(
count_s[char] += 1) - 一旦
count_s >= count_t(窗口涵盖 t),进入 while 循环:- 记录当前窗口长度,若更短则更新答案
- 移出左端点字符,左端点右移,继续收缩
- 直到不再涵盖,继续移动右端点
代码
class Solution: def minWindow(self, s: str, t: str) -> str:
count_s = Counter() # 当前窗口内各字符的出现次数(动态更新) count_t = Counter(t) # t 中各字符的出现次数(固定不变)
final_right = len(s) # 最短合法窗口的右端点,初始设为 len(s) final_left = -1 # 最短合法窗口的左端点,初始设为 -1(表示还没找到) left = 0 # 滑动窗口的左端点
for right, char in enumerate(s): # 右端点从左到右遍历 s count_s[char] += 1 # 右端点字符移入窗口
while count_s >= count_t: # 当前窗口已涵盖 t(每种字符数量都够) if right - left + 1 < final_right - final_left + 1: # 找到更短的合法窗口 final_left = left # 更新最短窗口的左端点 final_right = right # 更新最短窗口的右端点 count_s[s[left]] -= 1 # 左端点字符移出窗口 left += 1 # 左端点右移,收缩窗口
if final_left < 0: # final_left 仍为 -1,说明从未找到合法窗口 return "" else: return s[final_left:final_right + 1] # 返回最短合法子串关键细节
count_s >= count_t 是什么意思?
Counter 支持 >= 比较:对 count_t 中每个键,count_s 对应的值都 ≥ count_t 的值。即窗口内每种字符数量都够用了。
初始值的设计:final_left=-1, final_right=len(s),使得 final_right - final_left = len(s)+1,比任何合法子串都长,第一次找到合法窗口必然更新。最后用 final_left < 0 判断是否找到过答案。
为什么不需要删 0 值键? 因为用的是 >= 比较,count_s['A']=0 不影响判断(0 < 1,涵盖失败)。
总结
- 核心技巧:可变滑动窗口,右扩左缩,while 循环收缩
- 复杂度:时间 O(|s| + |t|),空间 O(字符集大小)
LeetCode 438 · 找到字符串中所有字母异位词
思路
找 s 中所有 p 的字母异位词的起始索引。异位词 = 字符种类和数量完全一致,顺序无所谓。
暴力做法:对每个子串排序后比较,O(n·m·logm),会超时。
核心观察:异位词的长度固定为 len(p),所以窗口大小固定,用固定滑动窗口。
步骤:
- 右端点右移,字符移入窗口
- 当窗口长度 ==
len(p)时,判断count_s == count_p,匹配则记录左端点 - 移出左端点字符(注意删掉值为 0 的键),左端点右移
代码
class Solution: def findAnagrams(self, s: str, p: str) -> List[int]:
count_s = Counter() # 当前窗口内各字符的出现次数 count_p = Counter(p) # p 中各字符的出现次数(固定不变)
ans = [] left = 0 # 窗口左端点
for right, char in enumerate(s): # 右端点从左到右遍历 s count_s[char] += 1 # 右端点字符移入窗口
if right - left + 1 == len(p): # 窗口长度恰好等于 p 的长度 if count_s == count_p: # 窗口内字符与 p 完全一致,是异位词 ans.append(left) # 记录左端点(异位词起始索引)
count_s[s[left]] -= 1 # 左端点字符移出窗口
if count_s[s[left]] == 0: # 如果该字符数量降为 0 del count_s[s[left]] # 必须删掉,否则 == 比较会误判 # 例:{'A':0,'B':1} != {'B':1} left += 1 # 左端点右移
return ans关键细节
为什么必须删 0 值键? 这题用 == 比较,{'A':0, 'B':1} != {'B':1},不删会误判为不匹配。上道题用 >= 不需要删。
为什么用 if 而不是 while? 窗口大小固定,每次右端点移入一个,左端点也移出一个,不需要循环收缩。
总结
- 核心技巧:固定滑动窗口,窗口满了直接判断,然后移出左端点
- 复杂度:时间 O(|s| + |p|),空间 O(字符集大小)
两题横向对比
| 题目 | 窗口大小 | 收缩方式 | 判断条件 | 删 0 值键 |
|---|---|---|---|---|
| 76 最小覆盖子串 | 可变 | while 循环主动收缩 | >= (涵盖即可) | 不需要 |
| 438 字母异位词 | 固定为 len(p) | 每步固定移出最左字符 | == (完全一致) | 必须删 |
滑动窗口模板总结
固定窗口(窗口大小固定为 n)
count_p = Counter(p)count_s = Counter()left = 0
for right, char in enumerate(s): count_s[char] += 1
if right - left + 1 == n: # 窗口够长了 if count_s == count_p: # 判断是否匹配 # 记录答案
left_char = s[left] count_s[left_char] -= 1 if count_s[left_char] == 0: del count_s[left_char] # 固定窗口必须删 0 值键(因为用 ==) left += 1可变窗口(窗口大小不固定)
count_t = Counter(t)count_s = Counter()left = 0
for right, char in enumerate(s): count_s[char] += 1
while count_s >= count_t: # 满足条件时收缩 # 记录答案
count_s[s[left]] -= 1 # 可变窗口不需要删 0 值键(因为用 >=) left += 1LeetCode 111 · 二叉树的最小深度
思路
找二叉树从根节点到最近叶子节点的最短路径层数。
为什么用 BFS 而不是 DFS? BFS 按层遍历,第一次遇到叶子节点时,当前层数就是最小深度,可以直接返回,不需要遍历整棵树。DFS 需要遍历所有路径再取最小值。
步骤:
- 根节点入队,
depth = 0 - 每进入新一层,
depth += 1 - 用
for _ in range(len(queue))处理当前层所有节点 - 遇到叶子节点(左右子节点都为 None)直接返回
depth
代码
from collections import deque
class Solution: def minDepth(self, root: Optional[TreeNode]) -> int: if not root: # 空树,深度为 0 return 0
queue = deque([root]) # 队列初始化,根节点入队 depth = 0 # 当前层数(深度)
while queue: # 队列不为空,说明还有节点未处理 depth += 1 # 进入新的一层,深度 +1
for _ in range(len(queue)): # 只处理当前层的节点(len 在进入循环时固定) node = queue.popleft() # 取出当前层的一个节点
if node.left: # 左子节点存在,加入下一层队列 queue.append(node.left) if node.right: # 右子节点存在,加入下一层队列 queue.append(node.right)
if not node.left and not node.right: # 遇到叶子节点 return depth # 当前层数就是最小深度,直接返回
return depth关键细节
为什么用 for _ in range(len(queue)) 而不是直接 while queue?
len(queue) 在进入 for 循环时就固定了,正好是当前层的节点数。下一层节点虽然已经入队,但不会被这次 for 处理,保证了按层遍历。
为什么判断叶子节点在 popleft 之后?
入队时只知道节点存在,不知道是不是叶子。要判断叶子必须拿到节点本身,看左右子节点是否都为 None。
总结
- 核心技巧:BFS 层序遍历,第一个叶子节点所在层即为最小深度
- 复杂度:时间 O(n),空间 O(n)
BFS 心法与模板总结
核心心法
BFS 的本质是按层扩散——从起点出发,先把距离为 1 的全处理完,再处理距离为 2 的,以此类推。
所以 BFS 天然适合解决最短/最少类问题,因为第一次到达终点时,走的路径一定是最短的。
标准模板
from collections import deque
queue = deque([起点])visited = set([起点]) # 防止重复访问(图题必须,树可省略)depth = 0 # 记录层数,不需要层数时可省略
while queue: depth += 1
for _ in range(len(queue)): # 关键:固定当前层的节点数 node = queue.popleft()
if node == 终点: # 找到答案,直接返回 return depth
for neighbor in 邻居(node): # 扩展下一层 if neighbor not in visited: visited.add(neighbor) queue.append(neighbor)两个关键点
为什么用 for _ in range(len(queue)) 而不是直接 while queue?
len(queue) 在进入 for 循环时固定,正好是当前层的节点数。下一层节点虽然已经入队,但不会被这次 for 处理到,保证了严格的按层遍历。
为什么需要 visited?
图中可能有环,不记录已访问节点会导致死循环。树没有环所以可以省略,但图题必须加。
适用场景
- 求最短路径/最少步数(第一次到达即最优)
- 按层处理节点(层序遍历、每层统计)
- 多源 BFS(多个起点同时扩散,比如「腐烂的橘子」)