LeetCode Hot 100|滑动窗口专题零基础学习笔记
学习范围:LeetCode 3《无重复字符的最长子串》 、LeetCode 438《找到字符串中所有字母异位词》 、LeetCode 76《最小覆盖子串》
学习阶段:从暴力枚举过渡到滑动窗口;既理解原理,也能独立手写 Python 代码。
适用人群:刚学 Python、已经接触过
for/while/set/list/ord()的学习者。
目录
- 学习成果与总体路线
- 滑动窗口到底是什么
- [LeetCode 3|无重复字符的最长子串](#LeetCode 3|无重复字符的最长子串)
- [LeetCode 438|找到字符串中所有字母异位词](#LeetCode 438|找到字符串中所有字母异位词)
- [LeetCode 76|最小覆盖子串](#LeetCode 76|最小覆盖子串)
- 三道题的核心区别与通用模板
- [Python 基础与常见错误汇总](#Python 基础与常见错误汇总)
- 自测练习与答案
- 复习清单与后续学习建议
1. 学习成果与总体路线
本专题已完成三道 Hot 100 题:
| 题目 | 类型 | 暴力方法 | 优化方法 | 优化时间 |
|---|---|---|---|---|
| 3. 无重复字符的最长子串 | 可变长度窗口 | 枚举起点,向右逐个试 | set + 左右指针 |
平均 O(n) |
| 438. 找到字符串中所有字母异位词 | 固定长度窗口 | 截取每个子串后排序比较 | 两个长度 26 的计数数组 | O(n) |
| 76. 最小覆盖子串 | 覆盖型可变长度窗口 | 枚举起点并逐个扩张终点 | need / window / formed |
平均 O(n+m) |
共同思想: 不要为每个起点重新做一遍相同的工作,尽可能复用"上一个窗口"已经获得的信息。
专题进度: 三道滑动窗口核心题均已学习完成。第 76 题属于困难题,重点复习合法条件与 formed 的精确增减。
2. 滑动窗口到底是什么
滑动窗口就是用两个边界,描述字符串或数组中的一段连续区间。
text
s = "a b c a b c b b"
0 1 2 3 4 5 6 7
窗口 [left, right] = [1, 3]
当前连续子串 = "bca"
窗口长度 = right - left + 1 = 3
left:左边界。right:右边界。- 向右扩张:新的字符进入窗口。
- 左边收缩:旧的字符离开窗口。
- 根据题目要求,维护窗口中的辅助信息,例如字符集合、频率数组、和、最大值等。
2.1 可变长度窗口
适用例子:无重复字符的最长子串。
窗口不是固定大小;右指针不断向右,遇到不合法的情况(出现重复)就移动左指针,直到重新合法。
text
右指针扩张 → 发现重复 → 左指针收缩直到不重复 → 更新最优答案
2.2 固定长度窗口
适用例子:找到字符串中所有字母异位词。
如果 p 有 m 个字符,那么候选子串必须恰好有 m 个字符。先建立第一个长度为 m 的窗口;之后每一步都让一个字符进入、另一个字符离开。
text
初始化前 m 个字符 → 比较一次
右侧进 1 个字符 → 左侧出 1 个字符 → 比较一次 → 重复
2.3 覆盖型可变长度窗口
适用例子:最小覆盖子串。
右边界扩张直到窗口覆盖目标的字符次数;合法之后连续缩小左边界,每次记录更短的窗口;一旦缩小导致缺少必需字符,就重新扩大右边界。
text
右指针扩张,补齐需求 → 合法时记录答案并连续收缩 → 不合法时停止收缩 → 重复
先判断题目要求的区间长度是否固定,再决定使用哪种窗口模式;可变长度窗口又要根据题目目标确定什么时候收缩。
3. LeetCode 3|无重复字符的最长子串
- 题目链接:https://leetcode.cn/problems/longest-substring-without-repeating-characters/
- 难度:中等
- 输入:字符串
s - 输出:没有重复字符的最长连续子串长度(整数)
3.1 题意理解
python
s = "abcabcbb"
# 最长无重复连续子串之一是 "abc"
# 答案:3
s = "pwwkew"
# "wke" 与 "kew" 均合法,长度为 3
# 答案:3
子串必须连续。 例如 "pwwkew" 中依次选出的 "pwke" 虽然没有重复字符,却不是连续子串,不能算。
再区分两件事:
- 返回的是最长长度,不是子串内容,也不是起点。
- "没有重复字符"表示窗口中的同一个字符最多出现一次。
3.2 暴力解法:枚举起点 + set 判断重复
思路
- 用外层
for i枚举子串起点。 - 每换一个起点,就创建一个空集合
seen。 - 内层
for j从这个起点往右扫描。 - 遇到重复字符,当前起点后面的更长子串一定也重复,因此可以
break。 - 若不重复,就加入集合,并更新最长长度。
暴力解法完整 Python 源码
python
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
max_length = 0
for i in range(len(s)):
seen = set()
for j in range(i, len(s)):
if s[j] in seen:
break
seen.add(s[j])
length = j - i + 1
max_length = max(max_length, length)
return max_length
逐行理解
| 代码 | 作用 |
|---|---|
max_length = 0 |
没找到合法非空子串时,答案为 0 |
for i in range(len(s)): |
枚举所有起点 |
seen = set() |
每个新起点都从空集合开始 |
for j in range(i, len(s)): |
从起点一直向右试 |
if s[j] in seen: |
当前字符是否已经出现 |
break |
一旦重复,停止当前起点的扫描 |
seen.add(s[j]) |
记录没有重复的新字符 |
j - i + 1 |
闭区间 [i, j] 的长度 |
max(max_length, length) |
保存历史最长长度 |
手算:s = "abba"
起点 i=0:
j |
新字符 | seen(加入成功后) |
是否可继续 | 当前/最长长度 |
|---|---|---|---|---|
| 0 | a | {a} |
是 | 1 / 1 |
| 1 | b | {a, b} |
是 | 2 / 2 |
| 2 | b | 不添加 | 否,遇重复并 break |
--- / 2 |
起点 i=1 时,"b" 合法、继续遇到 "b" 重复;起点 i=2 可以取 "ba",最长仍是 2。所以最终输出 2。
暴力复杂度
- 时间:最坏
O(n²)。例如字符互不重复,每个起点都会往后扫描较长一段。 - 额外空间:
O(min(n, k)),其中k是字符种类上限;不限制字符集时可写为O(n)。
问题在哪里? 例如检查完 "abcde",换起点后又从 "bcde" 重新检查。大量字符被重复处理。
3.3 优化思路:可变长度滑动窗口
我们不再为每个起点创建一个新的集合,而是只维护一个窗口 和一个集合。
关键不变量(必须始终成立):
seen中的字符,恰好是当前窗口[left, right]内的所有字符,而且互不重复。
当右指针想加入 s[right] 时:
- 如果
s[right]不在集合里:直接加入,窗口增长。 - 如果已经在集合里:反复删除
s[left]并移动left,直到重复消除。 - 然后加入新字符,更新窗口长度。
为什么必须用 while 而不是 if?
python
s = "abba"
# 当前窗口是 "ab",left = 0
# right = 2,准备加入第二个 b
此时 seen = {'a', 'b'}。
第一次删除 a,集合中仍然有 b ;必须再删除原来的 b。如果用 if 只删一次,新窗口就可能保留两个 b,违反条件。使用 while 才能持续收缩到合法。
3.4 最优解:set + 双指针
完整 Python 源码(与本次练习写法一致)
python
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
left = 0
ar = set()
max_length = 0
for right in range(len(s)):
while s[right] in ar:
ar.remove(s[left])
left += 1
ar.add(s[right])
length = right - left + 1
max_length = max(max_length, length)
return max_length
ar是你自己起的变量名。完全正确。也可以把它改名为seen或window_chars,只要前后一致即可。
从上到下解释
left = 0:从字符串最左边开始。ar = set():集合只保存当前窗口里已有的字符。for right in range(len(s)):右指针每轮向右处理一个新字符。while s[right] in ar:新字符在窗口中已经存在,说明发生重复。ar.remove(s[left]):先删除原左端字符。left += 1:再向右移动左边界;顺序不能交换,否则可能删错字符。ar.add(s[right]):窗口再次合法后,才加入新字符。right - left + 1:计算当前闭区间长度。max(...):维护所有合法窗口中出现过的最大长度。
逐轮手算:s = "pwwkew"
right |
当前新字符 | 收缩动作 | 最终 left |
最终合法窗口 | max_length |
|---|---|---|---|---|---|
| 0 | p | 无 | 0 | p |
1 |
| 1 | w | 无 | 0 | pw |
2 |
| 2 | w | 移出 p、旧 w |
2 | w |
2 |
| 3 | k | 无 | 2 | wk |
2 |
| 4 | e | 无 | 2 | wke |
3 |
| 5 | w | 移出旧 w |
3 | kew |
3 |
最终输出:3。
为什么两层循环仍是 O(n)?
虽然代码包含一个 for 和一个 while,但两个指针都只向右移动,从不回退:
right遍历n个字符。left在整个算法中最多累计移动n次。
所以总移动次数不超过线性数量,集合操作平均 O(1):
- 平均时间复杂度:
O(n)。 - 额外空间:
O(min(n, k));不限制字符集规模时是O(n)。
边界测试
s |
结果 | 说明 |
|---|---|---|
"abcabcbb" |
3 | 最长 abc 等 |
"bbbbb" |
1 | 每个字符都是 b |
"pwwkew" |
3 | wke 或 kew |
"abba" |
2 | 要用 while 连续移除 |
"" |
0 | 循环不执行 |
"abcdef" |
6 | 全部不重复 |
"dvdf" |
3 | 最长 vdf |
3.5 本题常见错误
错误 A:用 if 代替 while。 只删一次不保证新字符不重复;考虑 "abba"。
错误 B:在循环里写 seen = set()。 这样每轮都清空窗口信息,丧失滑动窗口的意义。
错误 C:先移动 left,再删旧字符。 正确顺序是先 remove(s[left]),再 left += 1。
错误 D:用 right - left 求长度。 因为左右端点都包含,必须 + 1。
错误 E:把 range(s) 当作遍历下标。 range() 需要整数;本题应写 range(len(s))。
4. LeetCode 438|找到字符串中所有字母异位词
- 题目链接:https://leetcode.cn/problems/find-all-anagrams-in-a-string/
- 难度:中等
- 输入:两个仅包含小写英文字母的字符串
s和p;题目中p非空。 - 输出:
s中所有与p互为字母异位词的连续子串起始下标。
4.1 题意理解
python
s = "cbaebabacd"
p = "abc"
# "cba" 和 "bac" 都与 "abc" 互为异位词
# 输出:[0, 6]
字母异位词的三个条件:
- 长度相同。
- 字符种类相同。
- 每种字符出现的次数相同;顺序可以不同。
例如 "aab" 与 "aba" 是异位词,但 "aab" 与 "abb" 不是。
因为长度必须与 p 一致,所以窗口大小一开始就能确定:m = len(p)。
4.2 暴力解法:枚举固定长度子串 + 排序
思路
- 提前算出
target = sorted(p)。 - 枚举
s中每个足够容纳m个字符的起点i。 - 截取
window = s[i:i+m]。 - 若
sorted(window) == target,就记录i。
暴力解法完整 Python 源码
python
class Solution:
def findAnagrams(self, s: str, p: str) -> list[int]:
result = []
m = len(p)
target = sorted(p)
for i in range(len(s) - m + 1):
window = s[i:i + m]
if sorted(window) == target:
result.append(i)
return result
逐行解释与关键下标
sorted(p):将p的字符排序后保留作参照。range(len(s) - m + 1):枚举所有合法窗口起点,防止子串长度不足。s[i:i+m]:截取从i开始的m个字符;切片不包含右端下标i+m。sorted(window) == target:排序后完全一样,才是异位词。result.append(i):添加的是起始下标,不是当前子串或长度。
举例:s = "abab",p = "ab"。
i |
s[i:i+2] |
排序结果 | 是否匹配 |
|---|---|---|---|
| 0 | "ab" |
['a','b'] |
是 |
| 1 | "ba" |
['a','b'] |
是 |
| 2 | "ab" |
['a','b'] |
是 |
返回 [0, 1, 2]。
暴力复杂度
设 n = len(s)、m = len(p),并假设 n >= m:
sorted(p):O(m log m)。- 最多有
n-m+1个窗口,每次切片O(m)、排序O(m log m)。 - 总时间:
O(m log m + (n-m+1) · m log m),通常简写为O((n-m+1) · m log m)(当m ≥ 2)。 - 额外空间:
O(m),用于切片、排序和目标排序表;不计返回答案。
问题: 相邻窗口大量字符相同,但我们仍然从头排序。
4.3 优化思路:固定长度窗口 + 字符频次
题目规定只有 a~z 26 个小写英文字母,因此可以用一个长度为 26 的列表计数。
python
count = [0] * 26
如何将字母转换为下标?
python
ord('a') - ord('a') # 0
ord('b') - ord('a') # 1
ord('c') - ord('a') # 2
# ...
ord('z') - ord('a') # 25
建立两个频次数组:
python
m_count = [0] * 26 # p 的字符频次
windows_count = [0] * 26 # s 当前窗口的字符频次
判断异位词,无须排序:
python
if windows_count == m_count:
# 当前窗口与 p 的字符次数完全一致
列表 == 会逐个比较对应位置;两个列表都只含 26 个数字,因此一次比较是 O(26),可以视为常数时间。
4.4 为什么分两个 for?------最适合当前学习阶段的写法
你提出过一个很重要的问题:为什么初始化窗口和后续滑动要分开写?
答案:这是为了区分建立窗口 和移动已建立好的窗口两个阶段,逻辑更直观。不是语法强制要求,也可以合并;但我们选你理解更顺手的分开写法。
第一阶段:建立第一个窗口
python
for i in range(m):
m_index = ord(p[i]) - ord('a')
windows_index = ord(s[i]) - ord('a')
m_count[m_index] += 1
windows_count[windows_index] += 1
这个循环结束时,窗口恰好容纳了 s 的前 m 个字符。此时需要单独检查一次下标 0:
python
if windows_count == m_count:
result.append(0)
第二阶段:从第 m 个下标开始滑动
python
for i in range(m, n):
in_index = ord(s[i]) - ord('a')
windows_count[in_index] += 1
out_index = ord(s[i - m]) - ord('a')
windows_count[out_index] -= 1
if windows_count == m_count:
result.append(i - m + 1)
每轮固定做三件事:进一个 → 出一个 → 比较一次。
为什么移出的是 i-m?
假设 m=3、当前 i=5:
text
移动之前:窗口覆盖下标 2、3、4
移动之后:窗口覆盖下标 3、4、5
新进入:s[5]
旧离开:s[2] = s[i-m]
新窗口起点:3 = i-m+1
别混淆三个下标公式:
| 操作 | 对应表达式 |
|---|---|
| 进入窗口的字符 | s[i] |
| 离开窗口的字符 | s[i-m] |
| 当前窗口起始下标 | i-m+1 |
特别注意: 如果窗口还未建立,就从 i=0 执行 s[i-m],Python 会把负数下标当成"从末尾索引",导致错误地移除字符,而不一定报错。因此分开初始化也能规避这类失误。
4.5 最优解:两个计数数组 + 两个 for
完整 Python 源码(与这次独立练习写法一致)
python
class Solution:
def findAnagrams(self, s: str, p: str) -> list[int]:
result = []
m = len(p)
n = len(s)
m_count = [0] * 26
windows_count = [0] * 26
if m > n:
return result
# 第一个 for:初始化目标词和第一个窗口
for i in range(m):
m_index = ord(p[i]) - ord('a')
windows_index = ord(s[i]) - ord('a')
m_count[m_index] += 1
windows_count[windows_index] += 1
# 别漏掉第一个窗口
if windows_count == m_count:
result.append(0)
# 第二个 for:窗口每次右移一格
for i in range(m, n):
# 右边新字符进入
in_index = ord(s[i]) - ord('a')
windows_count[in_index] += 1
# 左边旧字符离开
out_index = ord(s[i - m]) - ord('a')
windows_count[out_index] -= 1
# 当前窗口匹配,则记录起点
if windows_count == m_count:
result.append(i - m + 1)
return result
这就是你已经独立写对的算法,只调整了空格和注释以方便复习。变量名 m_count、windows_count 都可以继续沿用。
注:这里用的是 Python 3.9+ 的
list[int]类型标注。若使用旧版 Python 的List[int],在本地通常需要from typing import List。
逐行逻辑总览
| 关键语句 | 含义 |
|---|---|
m = len(p) |
固定窗口长度 |
if m > n: return result |
原字符串太短,直接返回 [] |
m_count = [0] * 26 |
保存 p 的 26 字母频次 |
windows_count = [0] * 26 |
保存窗口的 26 字母频次 |
for i in range(m) |
初始化 p 和 s 的首个窗口 |
result.append(0) |
首个窗口匹配时,起点是 0 |
for i in range(m, n) |
后续每次让一个新字符进入 |
windows_count[in_index] += 1 |
进入字符计数增加 |
windows_count[out_index] -= 1 |
离开字符计数减少 |
result.append(i-m+1) |
记录当前窗口的起始下标 |
手算:s = "cbaebabacd",p = "abc"
m=3,目标频次为 a:1, b:1, c:1。
| 起点 | 当前长度 3 的子串 | 匹配? | result |
|---|---|---|---|
| 0 | cba |
是 | [0] |
| 1 | bae |
否 | [0] |
| 2 | aeb |
否 | [0] |
| 3 | eba |
否 | [0] |
| 4 | bab |
否 | [0] |
| 5 | aba |
否 | [0] |
| 6 | bac |
是 | [0, 6] |
| 7 | acd |
否 | [0, 6] |
最终返回 [0, 6]。
优化后复杂度
- 初始化频次:
O(m)。 - 每个后续字符只进入一次、移出一个;两份长度 26 的列表比较最多 26 次:
O(26(n-m))。 - 因为
26是常数,n >= m时总时间为O(n)。 - 两个固定长度 26 的计数数组:额外空间
O(1)(不计输出result)。
4.6 可选思考:一个循环也能实现,但无需强行改写
下面只给出核心处理 s 的逻辑,不是要求现在背的模板:
python
for right in range(n):
# 先让新字符进入
windows_count[ord(s[right]) - ord('a')] += 1
# 当加入第 m+1 个字符时,开始移出最左侧字符
if right >= m:
windows_count[ord(s[right-m]) - ord('a')] -= 1
# 窗口至少有 m 个字符时,才能检查是否匹配
if right >= m - 1 and windows_count == m_count:
result.append(right-m+1)
使用这一写法时,m_count 要先单独统计完毕。这只说明"两段式"并非唯一方法;当前优先掌握上面的两个 for 版本即可。
4.7 本题常见错误
错误 A:漏掉第一个窗口。 后续 for i in range(m,n) 从第 m 个下标开始,因此必须提前比较一次下标 0 的窗口。
错误 B:窗口进一个字符后忘了出一个。 窗口长度会不断增长,无法匹配固定长度 m。
错误 C:写反 +=1 与 -=1。 新进入必须 +1,旧离开必须 -1。
错误 D:把旧字符下标写成 i-m+1。 旧字符在 i-m,而 i-m+1 才是新窗口起点。
错误 E:只比较字符是否出现,不比较出现次数。 "aab" 与 "abb" 有相同字符种类,但不是异位词。
错误 F:直接 for i in range(m) 前不检查 m>n。 原字符串太短时,访问 s[i] 会越界;需要提前返回。
边界测试
s |
p |
结果 |
|---|---|---|
"cbaebabacd" |
"abc" |
[0, 6] |
"abab" |
"ab" |
[0, 1, 2] |
"baa" |
"aa" |
[1] |
"abc" |
"abcd" |
[] |
"aaaa" |
"aa" |
[0, 1, 2] |
"a" |
"a" |
[0] |
"a" |
"b" |
[] |
5. LeetCode 76|最小覆盖子串
5.1 题目到底要什么
给定字符串 s 和目标字符串 t,在 s 中寻找最短的连续子串 ,要求这个子串包含 t 中每一种字符以及对应的出现次数 。找不到就返回空字符串 ""。
python
s = "ADOBECODEBANC"
t = "ABC"
# 答案:"BANC"
"ADOBEC" 也包含 A、B、C,长度为 6;"BANC" 更短,长度为 4,因此选择后者。
三个不能混淆的条件:
- 子串必须连续,不能隔几个字符随意挑选。
- 覆盖意味着次数至少达到要求,允许多出来的字符。
- 返回的是字符串,不是起点,也不是最短长度。
举个更容易忽略的例子:
python
t = "AABC"
# 需要至少 2 个 A、1 个 B、1 个 C
# "ABC" 不合法:少 1 个 A
# "BAAC" 合法:次数恰好满足
# "AAABC" 合法:多 1 个 A 也没关系
本题区分大小写:"A" 和 "a" 是不同字符。
5.1.1 你学过的两道题如何帮助理解?
| 题目 | 你已经会的能力 | 在本题中的用途 |
|---|---|---|
| 3. 无重复字符的最长子串 | left、right 构成可变窗口,while 连续收缩 |
窗口合法后,尽可能缩短 |
| 438. 找到所有字母异位词 | 字符计数、需求与当前窗口比较 | 确认每种目标字符出现次数足够 |
| 76. 最小覆盖子串 | 把以上两个知识点组合 | 频次达标后缩小窗口,记录最短字符串 |
5.2 从暴力解法开始
5.2.1 最直观的思路
假设:
python
s = "ADOBECODEBANC"
t = "ABC"
- 固定起点
left = 0,让终点right从左向右移动。 - 维护当前窗口里每个字符的出现次数。
- 一旦当前窗口覆盖
t,记录合法窗口长度;对这个固定起点而言,再向右扩大只会更长,所以停止这一轮内层搜索。 - 将
left改成 1、2、3......,重复上述过程。 - 所有起点检查完后,返回找到的最短子串。
为了表示目标需求,先创建字典:
python
need = {}
for ch in t:
need[ch] = need.get(ch, 0) + 1
# 如果 t = "AABC",则 need = {'A': 2, 'B': 1, 'C': 1}
其中 need.get(ch, 0) 表示:ch 不在字典里时使用默认值 0。
5.2.2 暴力解法完整 Python 源码
python
class Solution:
def minWindow(self, s: str, t: str) -> str:
if not s or not t or len(t) > len(s):
return ""
need = {}
for ch in t:
need[ch] = need.get(ch, 0) + 1
min_length = float('inf')
answer = ""
# 枚举每个子串的起点
for left in range(len(s)):
window = {}
# 从当前起点向右枚举终点
for right in range(left, len(s)):
ch = s[right]
window[ch] = window.get(ch, 0) + 1
# 判断是否覆盖 t 的所有字符和对应次数
valid = True
for key in need:
if window.get(key, 0) < need[key]:
valid = False
break
if valid:
length = right - left + 1
if length < min_length:
min_length = length
answer = s[left:right + 1]
# 起点固定,继续扩大只会更长
break
return answer
为什么 break 正确?
break 只结束当前的内层 for right。对于固定的 left,第一个合法的 right 已经是这个起点能得到的最短合法窗口。后面扩大窗口,不可能更短。但我们仍然继续尝试新的左边界,因为新的起点可能得到更好的答案。
为什么这里 window 每次都要重新建?
暴力解法对每个新起点都从头统计。这正是它效率不高的原因,也是滑动窗口要优化的地方。
5.2.3 暴力复杂度
设 n = len(s),m = len(t),k 为 t 中不同字符的种类数。
- 建立
need:O(m)。 - 枚举起点和终点:最坏
O(n²)次。 - 每个候选窗口检查最多
k种必需字符:O(k)。 - 因此最坏时间:
O(m + n²k);字符种类上限固定时可以写作O(m+n²)。 - 额外空间:需求字典和窗口字典最多存储
O(k+n)种字符(暴力代码会统计所有进入窗口的字符)。在固定英文字符集下为O(1);若不固定字符集,可界定为O(m+n),不计答案字符串。
暴力解法的重要性:明确"我要维护什么"和"哪里在重复计算",再谈优化。
5.3 为什么要改用滑动窗口
对于窗口 [left, right],如果我们已经统计了它的字符次数:
- 扩大窗口 :
right前进一步,只需要统计刚进入的s[right]。 - 缩小窗口 :
left前进一步,只需要扣掉刚离开的s[left]。 - 不再 因为起点变化就清空
window并把中间字符重新统计一次。
目标不同,收缩规则也不同:
text
无重复字符的最长子串(3):
右侧加入后发生重复 → 不合法 → 收缩至合法 → 更新最大长度
最小覆盖子串(76):
右侧加入直到覆盖全部需求 → 已经合法 → 尽可能收缩并更新最小长度
不要死记"所有可变窗口都是窗口不合法才收缩"。 不同题目的合法性与最优化目标不同。本题恰好在合法时持续收缩。
5.4 真正的难点:formed 是什么
只使用 need 与 window 两个字典,当然也能每次逐个检查目标字符是否足够。但这会反复扫字典。
我们新建两个变量:
python
required = len(need) # 一共有多少种不同的目标字符
formed = 0 # 已经有多少种目标字符的数量达标
当且仅当:
python
formed == required
当前窗口才覆盖了目标 t。
最重要的提醒:
formed统计"满足需求的字符种类数",不是窗口里字符的数量,也不是出现次数的总和。
5.4.1 用 t = "AABC" 手算
目标:
python
need = {'A': 2, 'B': 1, 'C': 1}
required = 3
| 当前窗口 | A 达标? | B 达标? | C 达标? | formed |
|---|---|---|---|---|
"A" |
否(仅 1 个) | 否 | 否 | 0 |
"AA" |
是 | 否 | 否 | 1 |
"AAB" |
是 | 是 | 否 | 2 |
"AABC" |
是 | 是 | 是 | 3 |
"AAABC" |
是(多一个无妨) | 是 | 是 | 3 |
"A" 虽然出现了,但目标要求两个 A,所以它还不能贡献一个 formed。
5.4.2 右边进入一个字符:什么时候 formed += 1?
python
ch = s[right]
if ch in need:
window[ch] = window.get(ch, 0) + 1
if window[ch] == need[ch]:
formed += 1
必须刚好达到目标次数,才把这种字符计为达标。
例如 need['A'] = 2:
- 第一个 A:
window['A'] = 1,未达标,formed不动。 - 第二个 A:
window['A'] = 2,刚刚达标 ,formed += 1。 - 第三个 A:
window['A'] = 3,早已达标,不得再次加 1。
为什么不能写 >=?因为第三个、第四个 A 都会再次增加 formed,使"已满足种类数"失真。
5.4.3 左边移出一个字符:什么时候 formed -= 1?
python
left_ch = s[left]
if left_ch in need:
window[left_ch] -= 1
if window[left_ch] < need[left_ch]:
formed -= 1
left += 1
只有某种字符的次数从"足够"降到"不足",formed 才减 1。
例如 need['A'] = 2、当前有 3 个 A:
- 删除一个 A → 还剩 2 个,仍达标,
formed不变。 - 再删除一个 A → 只剩 1 个,不达标,
formed -= 1。
这里先减少 window[left_ch],再判断是否 < need[left_ch],最后才移动 left;顺序很重要。
5.4.4 为什么 while formed == required?
假设:
python
s = "XAABCA"
t = "AABC"
从左往右扩张,首次覆盖需求的窗口是 "XAABC":
text
X A A B C A
[-------] XAABC:合法,长度 5
[-----] AABC:移除 X 后仍合法,长度 4(更优)
[---] ABC:再移除 A 后不合法,缺一个 A
因此,每当窗口合法,就用 while 尽量缩小,并且在删除左字符之前记录当前合法答案。一旦缩小到不合法,就继续让右指针扩张。
5.5 滑动窗口最优解完整源码
这是你通过练习并修正两个小错误之后应当能够独立写出的版本。保持熟悉的 for right / while formed 结构。
python
class Solution:
def minWindow(self, s: str, t: str) -> str:
if not s or not t or len(t) > len(s):
return ""
# 阶段 1:统计目标字符串每种字符需要多少个
need = {}
for ch in t:
need[ch] = need.get(ch, 0) + 1
# 阶段 2:初始化窗口状态
window = {}
required = len(need)
formed = 0
left = 0
start = 0
min_length = float('inf')
# 阶段 3:右指针向右,逐个加入新字符
for right in range(len(s)):
ch = s[right]
if ch in need:
window[ch] = window.get(ch, 0) + 1
# 这一种字符刚好达到要求
if window[ch] == need[ch]:
formed += 1
# 阶段 4:窗口合法,尽可能收缩左边
while formed == required:
length = right - left + 1
if length < min_length:
min_length = length
start = left
left_ch = s[left]
if left_ch in need:
window[left_ch] -= 1
# 这一种字符从达标变为不足
if window[left_ch] < need[left_ch]:
formed -= 1
left += 1
# 阶段 5:返回最短子串,而不是它的长度
if min_length == float('inf'):
return ""
return s[start:start + min_length]
5.5.1 分段解释
| 代码 | 具体作用 |
|---|---|
if not s or not t or len(t) > len(s) |
空输入或 t 更长时,不可能有合法窗口 |
need[ch] = need.get(ch, 0) + 1 |
统计所需字符的次数;重复字符不能丢 |
window = {} |
只记录当前窗口里出现过的目标字符的次数 |
required = len(need) |
目标中不同字符的种类数 |
formed = 0 |
当前已满足次数要求的种类数 |
min_length = float('inf') |
用无穷大表示"尚未找到答案" |
for right in range(len(s)) |
右指针一步步向右扩张 |
if ch in need |
非目标字符无需维护计数,但仍占据子串长度 |
if window[ch] == need[ch] |
新进入字符刚刚达标,增加 formed |
while formed == required |
当前覆盖需求了,尽可能缩小左边界 |
length = right - left + 1 |
当前窗口包含左右两个端点,所以要加 1 |
start = left |
保存目前最优窗口的起点 |
window[left_ch] -= 1 |
旧字符离开窗口 |
if window[left_ch] < need[left_ch] |
移出导致该字符不再达标,减少 formed |
left += 1 |
收缩完成后继续向右移动左边界 |
s[start:start + min_length] |
切片返回真正的字符串答案;右端点不包含 |
5.5.2 算法运行时的两个关键不变量
- 每轮
for执行完右侧加入操作时,window中记录的是当前窗口[left, right]的目标字符频次;窗口收缩时每移走左端字符,也会同步维护频次。 formed始终等于当前频次已经达到need要求的字符种类数 ;所以formed == required等价于"当前窗口合法"。
只要这两个不变量不被破坏,算法就能在窗口合法时收缩寻找最短答案。
5.6 逐步手算与正确性理解
5.6.1 官方示例 s = "ADOBECODEBANC"、t = "ABC"
目标需要 A、B、C 各一个:required = 3。
观察关键时刻(省略不影响结论的中间扩张步骤):
| 时刻 | 当前合法窗口或当前局面 | 操作结果 | 最优答案 |
|---|---|---|---|
right=5(字符 C) |
"ADOBEC",formed=3 |
首次合法,记录长度 6 | "ADOBEC" |
| 随后左侧收缩 | 删除最左侧 A 后不足 | formed 变 2,停止收缩 |
"ADOBEC" |
right=10(字符 A) |
重新拥有 A、B、C | 又可以连续收缩 | 仍 "ADOBEC" |
| 收缩到左边界为 5 | "CODEBA",长度 6 |
已合法,但没更短 | 仍 "ADOBEC" |
| 继续删除 C | 失去必需的 C | formed 变 2,停止收缩 |
仍 "ADOBEC" |
right=12(字符 C) |
再次满足 A、B、C | 不断删除左端多余字符 | 逐渐缩短 |
| 收缩到左边界为 9 | "BANC",长度 4 |
更新最短窗口 | "BANC" |
| 再删除 B | 不再有 B,窗口失效 | 结束当前收缩 | "BANC" |
最终:
python
"BANC"
5.6.2 为什么这个算法不会漏掉更短答案?
- 对每个
right,算法只在右端加入一个新字符后检查当前窗口是否合法。 - 一旦合法,就不断右移
left,把不必要的左端字符尽量去掉,期间的所有合法候选窗口都与历史最短比较。 - 当移走某种必需字符造成数量不足时,继续缩小只会更不满足需求;此时正确操作是扩大右端,等待补足缺失字符。
- 两指针不会回退,但也不必回退:已经扔掉的较左起点,不会比本轮已经检查的合法窗口更短。
5.6.3 为什么最优答案只保存起点与长度?
python
min_length = float('inf')
start = 0
当发现更优窗口时只更新这两个数字,最后才截取:
python
return s[start:start + min_length]
这样避免每次窗口一变短就复制一段字符串,也使最优解时间复杂度的论证更清晰。
5.7 复杂度与两种窗口对比
设 n = len(s)、m = len(t)、k = t 中不同字符的种类数。
5.7.1 最优解复杂度
- 建立
need:O(m)。 - 右指针
right最多处理n个字符:O(n)。 - 左指针
left总共最多向右移动n次,不会回头:所有内层while的总次数也是O(n)。 - 字典查询、插入、更新在通常情况下平均
O(1)。 - 最后只截取一次答案,最多复制
O(n)个字符。
因此:平均时间 O(n+m)。
空间:need 和 window 只为目标字符分配字典键,最多 k 个键,额外空间 O(k) ,不计返回的答案字符串。在题目规定的固定英文字符集里,k 有常数上界,也可写作 O(1)。
5.7.2 三道题比较
| 题目 | 窗口什么时候收缩 | 保存什么状态 | 求什么答案 |
|---|---|---|---|
| 3. 无重复字符的最长子串 | 右侧新字符造成重复时 | set,当前窗口有哪些字符 |
最长长度 |
| 438. 找到所有字母异位词 | 每轮滑动移出一个旧字符 | 两个频次数组 | 所有合法起点 |
| 76. 最小覆盖子串 | 窗口已覆盖目标时,持续收缩 | need、window、formed |
最短子串本身 |
5.8 你亲自遇到的两个错误
你在独立写代码时,主体结构、缩进、右指针与左指针移动都写对了,只剩下两个非常典型的逻辑错误。一定要把它们当成复习重点。
5.8.1 错误一:用整个字典比较来更新 formed
你第一次写的是:
python
if window == need:
formed += 1
为什么不对?
formed 记录的是"多少种目标字符已经达标",不是"整个字典有没有恰好一样"。
例如:
python
need = {'A': 2, 'B': 1, 'C': 1}
window = {'A': 2}
A 已经达标,正确的 formed 是 1,但 window == need 是 False。另外,窗口里允许出现某些目标字符超过所需次数,整个字典也可能不相等。
正确写法:
python
if window[ch] == need[ch]:
formed += 1
只关注刚加入的那个字符是否"恰好跨过达标线"。
5.8.2 错误二:返回了最短长度,不是最短子串
你第一次写的是:
python
return min_length
但题目的函数声明是:
python
def minWindow(self, s: str, t: str) -> str:
题目要求返回字符串。比如 "BANC",而不是整数 4。
正确写法:
python
return s[start:start + min_length]
切片左闭右开 ,比如 s[9:13] 得到 "BANC"。如果整个过程中从未找到合法窗口,那么 min_length 保持 float('inf'),必须在切片前先返回 ""。
5.8.3 再补充四个容易误写的地方
window[ch] >= need[ch]就每次formed += 1。 错!第 3、4 个 A 不应重复贡献种类数;加入时只有==才加。- 左侧每删一个目标字符,就直接
formed -= 1。 错!只有删除后< need[left_ch]才说明刚刚从达标变为不达标。 - 忘记
left += 1。while可能永远停在同一个左端点,发生死循环。 - 把
length = right - left + 1写成right - left。 闭区间长度必须加 1。
5.9 边界用例、练习与默写
5.9.1 必测用例
s |
t |
预期结果 | 检查什么 |
|---|---|---|---|
"ADOBECODEBANC" |
"ABC" |
"BANC" |
官方标准例子 |
"a" |
"a" |
"a" |
单字符正好匹配 |
"a" |
"aa" |
"" |
需求次数多于原串 |
"aa" |
"aa" |
"aa" |
同一个字符需要多次 |
"XAABCA" |
"AABC" |
"AABC" |
重复需求、收缩窗口 |
"abc" |
"d" |
"" |
根本不存在目标字符 |
"AAABC" |
"AABC" |
"AABC" |
多余字符可以被收缩 |
"aAbBc" |
"ABC" |
"" |
大小写有区别 |
"" |
"A" |
"" |
空原字符串(鲁棒性测试) |
"ABC" |
"" |
"" |
空目标(鲁棒性测试) |
注:最后两项是为了练习鲁棒性,题目原始约束通常限定字符串非空。
5.9.2 口头练习
t="AABC"时,当前窗口只有一个 A,formed能增加吗?为什么?- 如果目标需要 2 个 A,窗口有 3 个 A,移除一个后
formed要不要减 1? - 本题为什么合法时用
while收缩,而第 3 题通常是重复时收缩? start=9、min_length=4,返回的切片怎么写?- 为什么
for right嵌套while仍然是线性级别? - 目标字符之外的字符(如目标
ABC,窗口中有 D)是否需要统计?它是否占据窗口长度?
展开口头练习答案
- 不能,A 尚未满足需要的 2 次。
- 不减,移除后剩 2 个,仍然达标。
- 第 3 题要保证"不重复",重复出现使窗口非法;本题要尽可能短,只有合法后才可以放心尝试缩小。
s[start:start+min_length],即s[9:13]。right最多走n步,left在所有while中累计最多走n步,互不回退。- 可以不计入
window字典,但它仍在[left,right]内,计算长度时不能假装它不存在。
5.9.3 默写框架(不要先看标准答案)
python
class Solution:
def minWindow(self, s: str, t: str) -> str:
if not s or not t or len(t) > len(s):
return ""
need = {}
for ch in t:
need[ch] = TODO_1
window = {}
required = len(need)
formed = 0
left = 0
start = 0
min_length = float('inf')
for right in range(len(s)):
ch = s[right]
if ch in need:
window[ch] = window.get(ch, 0) + 1
if TODO_2:
formed += 1
while TODO_3:
length = right - left + 1
if length < min_length:
min_length = length
start = TODO_4
left_ch = s[left]
if left_ch in need:
window[left_ch] -= 1
if TODO_5:
formed -= 1
TODO_6
if min_length == float('inf'):
return ""
return TODO_7
展开填空答案
TODO_1→need.get(ch, 0) + 1TODO_2→window[ch] == need[ch]TODO_3→formed == requiredTODO_4→leftTODO_5→window[left_ch] < need[left_ch]TODO_6→left += 1TODO_7→s[start:start + min_length]
5.10 一分钟速记卡片
需求字典 :
need[ch] = need.get(ch, 0) + 1。目标种类数 :
required = len(need)。已达标种类数 :
formed。右侧进入 :
window[ch] += 1;只有刚好等于need[ch]才formed += 1。合法条件 :
formed == required。合法后收缩 :先记录更短答案;左侧退出,只有低于
need[left_ch]才formed -= 1;再left += 1。返回 :若无合法窗口返回
"";否则返回s[start:start+min_length]。
最后一句:第 76 题不是死记 formed,而是理解它代表"已经满足数量要求的字符种类数"。
6. 三道题的核心区别与通用模板
6.1 对照表
| 对比维度 | LeetCode 3 | LeetCode 438 | LeetCode 76 |
|---|---|---|---|
| 需要找什么 | 最长无重复子串的长度 | 所有异位词子串的起点 | 最短覆盖子串的内容 |
| 窗口类型 | 可变长度 | 固定长度 | 可变长度(覆盖型) |
| 窗口合法条件 | 任意字符最多出现一次 | 与目标 p 的每种字符次数一致 |
每种所需字符次数至少达到目标要求 |
| 维护信息 | 一个 set |
两个 26 长度计数数组 | need、window、formed |
| 左边何时移动 | 发现重复时,可能连续多次 | 每加入一个字符,就移出一个旧字符 | 已覆盖所有需求时尽量收缩 |
| 更新答案 | 每次窗口合法后更新最大长度 | 两份频次数组相等时记录起点 | 每次窗口合法时更新最短长度与起点 |
| 优化后时间 | 平均 O(n) |
O(n) |
平均 O(n+m) |
| 优化后额外空间 | 最坏 O(n) |
O(1)(26 个小写字母) |
O(k),固定英文字符集下 O(1) |
6.2 模板一:可变长度窗口(理解型伪代码)
python
left = 0
# 创建窗口维护数据
for right in range(n):
# 检查/考虑右侧新元素
while 当前窗口不合法:
# 移除 s[left] 对窗口的影响
left += 1
# 把新元素纳入合法窗口(视题目维护方式而定)
# 更新答案
这是理解型框架 ,不是可以直接运行的 Python;不同题目中"先加入新元素"还是"先检查重复后加入",可能不同。LeetCode 3 采用先检查重复、后加入。
6.3 模板二:固定长度窗口(你喜欢的分开写法)
python
m = len(p)
n = len(s)
if n < m:
return []
# 1. 初始化第一个长度为 m 的窗口
for i in range(m):
# 将 s[i] 纳入窗口,同时初始化目标信息
pass
# 2. 检查第一个窗口
# 如果符合条件,记录起点 0
# 3. 后续窗口每轮向右移动一步
for i in range(m, n):
# s[i] 进入
# s[i-m] 离开
# 判断窗口,若符合条件则记录 i-m+1
pass
同样,这是帮助回忆的伪代码骨架;要真正提交,需要填入具体的计数、判定及返回逻辑。
6.4 模板三:覆盖型可变长度窗口(LeetCode 76)
python
# 这段是帮助回忆的模板,不是完整可运行代码
need = ...
window = {}
required = len(need)
formed = 0
left = 0
for right in range(len(s)):
# 让右边新字符进入,并同步维护 window、formed
while formed == required:
# 记录当前合法区间:争取更短
# 让最左侧字符离开,并同步维护 window、formed
left += 1
牢记:formed 不是已加入字符数,而是已达标字符的种类数。
6.5 何时使用 set,何时使用计数数组?
- 只关心某字符是否存在 ,且窗口要求互不重复:优先考虑
set。 - 关心每种字符具体出现多少次 :考虑
dict、Counter或计数数组。 - 字符集明确、很小且固定,例如只有
a-z:长度 26 的数组通常简单高效。 - 字符集可能任意(例如 Unicode 字符),不能想当然用 26 长度的计数数组。
- 需要判断"至少有多少个"的覆盖需求:字典 +
formed更适合灵活维护。
7. Python 基础与常见错误汇总
这一节尤其重要:你前面已经能够理解算法,接下来要提高独立敲代码的准确率。
7.1 set() 不是 {}
python
seen = set() # 空集合
other = {} # 空字典,不是集合
7.2 set.add() 和 set.remove()
python
seen = set()
seen.add('a') # 添加 a
seen.remove('a') # 删除已存在的 a
注意 remove() 删除一个不存在的元素会抛出 KeyError。LeetCode 3 的代码保证了当前 s[left] 确实在合法窗口集合中,所以不会误删。
7.3 range() 与 len()
python
s = "abc"
for i in range(len(s)):
print(i) # 0、1、2
错误示范:range(s),因为 s 是字符串而不是整数。
7.4 break、continue 和 while
break:退出当前这一层循环。continue:跳过当前轮剩余代码,进入下一轮。while 条件::条件成立就一直重复;LeetCode 3 用它持续清除重复字符。
7.5 字符串切片与窗口长度
python
s = "abcde"
print(s[1:4]) # "bcd",右端下标 4 不包含
left = 1
right = 3
length = right - left + 1 # 3
切片区间 [起点, 终点) 与滑动窗口常用闭区间 [left, right] 不要混淆。
7.6 ord() 转换字母下标
python
index = ord('c') - ord('a') # 2
count = [0] * 26
count[index] += 1
7.7 Python 的下划线是变量名的一部分
python
left = 0
_left = 0
left____ = 0
它们是三个不同的变量。做代码填空时,替换整个 TODO 或 _____ 占位符,不要把答案嵌入下划线中间。
7.8 List[int] 与 list[int]
python
# Python 3.9+
def f(nums: list[int]) -> list[int]:
return nums
# 需要 typing.List 的写法
from typing import List
def g(nums: List[int]) -> List[int]:
return nums
7.9 dict.get() 与 formed 的达标判断
python
need = {}
for ch in "AABC":
need[ch] = need.get(ch, 0) + 1
# {'A': 2, 'B': 1, 'C': 1}
# 右边进入时:次数刚好达标,才 formed += 1
if window[ch] == need[ch]:
formed += 1
# 左边退出时:次数刚好跌破目标,才 formed -= 1
if window[left_ch] < need[left_ch]:
formed -= 1
7.10 缩进决定逻辑归属
python
for right in range(len(s)):
while s[right] in seen:
seen.remove(s[left])
left += 1
seen.add(s[right]) # 与 while 平级:去重结束后才执行
如果 seen.add(s[right]) 错误地缩进进 while,程序逻辑就会被破坏。
8. 自测练习与答案
建议先只看题目,尝试手算或在纸上写代码,再展开后面的答案。
练习 A:判断子串与滑动窗口(LeetCode 3)
s = "abba",最长无重复连续子串长度是多少?s = "dvdf",最长无重复连续子串长度是多少?- 在
s = "abba"中,right=2遇到第二个b。收缩窗口结束后left等于多少? - 为什么 LeetCode 3 的去重语句使用
while而不是if?
练习 B:固定长度滑动窗口(LeetCode 438)
s = "abab",p = "ab",返回哪些起始下标?s = "aaba",p = "aa",返回哪些起始下标?m=3,i=5,进入和离开的字符下标分别是多少?新窗口起点是多少?- 为什么分两个
for的写法,需要单独检查第一个窗口? s长度比p短时,应该返回什么?- 为什么频次数组只有 26 个元素时,比较两份数组在复杂度分析中算
O(1)?
练习 C:最小覆盖子串(LeetCode 76)
t="AABC",当前窗口"AA":required和formed分别是多少?t="AABC",窗口从"AAABC"移除一个 A,formed应不应该减 1?- 为什么 LeetCode 76 使用
while formed == required? - 目标需要 2 个 A,第三个 A 进入时,
formed应不应该再加 1? s="ADOBECODEBANC"、t="ABC",最终返回"BANC"还是整数4?
练习 D:默写挑战
不看答案,独立写出:
- LeetCode 3:
set()+for right+while收缩 + 更新max_length。 - LeetCode 438:
m_count/windows_count+ 初始化首窗口 + 检查起点 0 + 后续进一出一 +i-m+1。 - LeetCode 76:
need/window/formed+ 右扩张 + 合法时while收缩 + 返回切片。
写完后分别测试空/短字符串、重复字符、大量相同字母和一般样例。
点击展开:自测参考答案
2("ab"或"ba")。3("vdf")。2;需要依次移出a和原来的b。- 一次移除不一定消除重复,因此需要持续收缩。
[0, 1, 2]。[0]。- 进入
s[5],离开s[2],窗口起点3。 - 第二个循环从
i=m开始,首次窗口对应的下标 0 不会在第二轮被检查。 []。- 要检查的元素个数固定为 26,不随
n增长。 required=3(A、B、C 三种),formed=1(只有 A 已达标)。- 不减;从 3 个 A 变为 2 个,仍满足需要 2 个的要求。
- 为了在仍然合法的前提下尽可能缩短窗口,并检查所有可能的更短候选。
- 不加;只有从 1 个 A 增加到 2 个 A 时才刚好达标。
- 返回字符串
"BANC"。
9. 复习清单与后续学习建议
9.1 完成打卡
- 理解连续子串与子序列的区别。
- 能解释 LeetCode 3 的暴力两层循环思路。
- 理解滑动窗口如何复用上一个窗口的信息。
- 能用
set、while实现可变长度窗口。 - 能解释为什么嵌套
while仍然是平均O(n)。 - 理解字母异位词必须满足字符频次相同。
- 能用
ord()和长度 26 的列表统计字符频次。 - 能使用两个
for完成先初始化、再移动的固定长度窗口。 - 能解释
i、i-m和i-m+1的不同用途。 - 理解覆盖题中"至少满足需要的次数"和异位词的"次数完全相等"的区别。
- 能解释
formed的含义,以及为什么进入用==、退出用<。 - 能使用合法窗口的
while收缩,维护start、min_length。 - 知道最终返回
s[start:start+min_length],而不是整数长度。 - 已完成三道滑动窗口题的代码练习和纠错。
9.2 复习时的三个层次
第一遍:只看思路。 能否说清楚窗口何时扩张、何时收缩、如何更新答案?
第二遍:手算。 手动写出 left、right、集合或频次数组在几轮关键迭代中的状态。
第三遍:独立写代码。 合上笔记,从 class Solution 开始完整默写,再对照样例与边界情况检查。
9.3 下一步推荐
本专题三道题已完成。 下一步建议进入 Hot 100「子串」专题,先学习 LeetCode 560《和为 K 的子数组》,逐步掌握前缀和与哈希表的结合。
最后一页:考试前速记
无重复字符的最长子串(3) :
set记录窗口字符,right向右,重复时用while移除s[left]并left += 1,最后更新right-left+1。找到所有字母异位词(438) :窗口长度固定为
m=len(p),先建立首窗口、检查下标 0,再从i=m开始循环;s[i]进入、s[i-m]离开,频次相同时记录i-m+1。最小覆盖子串(76) :
need保存需求,window计数,formed记录已达标的字符种类;右扩张至合法、左收缩找最短;最后返回s[start:start+min_length]。滑动窗口的本质:复用已知窗口的信息,而不是每换一个起点就从头计算。
本笔记整合了已完成的三道滑动窗口题;代码采用 Python 3,其中第 438 题使用题目限定的非空 p 和小写英文字母条件。第 76 题附完整暴力解法、优化解法,以及独立练习时的两处典型纠错。