如果你在刷题列表里同时看到“滑动窗口最大值”和“最小覆盖子串”这两道题,第一反应大概率是:都叫滑动窗口,解法套路应该差不多吧?结果一上手你会发现,一个要用单调队列,另一个要用双指针加哈希表,代码写起来完全是两套东西。这正是这两道题常被放在一起当“压轴题”考的原因——它们从两个方向逼你把“滑动窗口”这个抽象概念彻底理解透。
这篇文章我会把这两道题放在一起拆开讲:它们各自的核心原理、完整推导、可复现代码,以及我在刷题和面试中反复踩过的坑。同时会聊聊这两类解法背后的统一思维,以及一些高频变种题,适合准备算法面试、正在刷 LeetCode 的读者,也适合想理解“窗口思想”在工程中如何落地的朋友。
1. 为什么“滑动窗口”这两个字会误导你:定长与变长的本质分野
“滑动窗口”这四个字听起来很具体,好像就是在数组上滑过一个固定大小的区间。但实际刷题时,你会遇到两种完全不同的窗口,如果没先分清这一点,后面很容易把两个题的做法搞混。
第一种是定长窗口。 窗口大小是固定的,比如每次看 3 个元素,然后整体向右移动一位。LeetCode 239“滑动窗口最大值”就是典型:给定数组和一个固定长度 k,让你输出每个窗口内的最大值。这种场景下,窗口的右边界和左边界是同步移动的,左边界每步都走,右边界也每步都走,窗口宽度永远不变。它有点像在地铁站台里,门的位置固定、宽度固定,你要观察每一批进站乘客里谁最“扎眼”。
第二种是变长窗口。 窗口的左右边界各自独立移动,宽度可以不断伸缩。LeetCode 76“最小覆盖子串”就是典型:在字符串 s 里找一段连续子串,让它能覆盖字符串 t 的全部字符,并且要求这段子串长度最短。这种场景下,右边界负责“扩张”,直到窗口满足覆盖条件;左边界再负责“收缩”,在仍满足条件的前提下尽量把窗口压短。它更像是你在地图上用两只手比划一个区域,左手不动,右手往外扩,扩到覆盖三个目标点后,左手再往回收,看能不能把区域压得更小。
这两个题都叫“滑动窗口”,但一个窗口宽度固定,一个宽度可变;一个关注窗口内元素的“最值”,一个关注窗口内容是否满足“覆盖条件”。所以它们采用的数据结构也完全不同:定长窗口最值问题,需要一种能快速淘汰过期元素、同时能取到当前最大值的结构,于是单调队列上场了;变长窗口覆盖问题,需要知道当前窗口里每种字符还缺几个,于是哈希表加计数上场了。
很多人在这个环节卡住,不是题目本身多难,而是思维上没有把“定长最值”和“变长覆盖”分开看待。后面两节我会分别把两个题的完整推理过程走一遍,你会发现一旦结构选对,代码其实是水到渠成的事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 滑动窗口最大值:单调队列为什么是唯一解
先看题目要求。给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到最右侧,你只能看到窗口里的 k 个数字,每次窗口向右移动一位,返回滑动窗口中的最大值。
示例:nums = [1,3,-1,-3,5,3,6,7],k = 3,输出 [3,3,5,5,6,7]。
最朴素的办法,每次窗口移动后遍历一遍窗口内元素找最大值,时间复杂度是 O(n*k)。当 n 和 k 都到 10^5 量级时,这代码基本跑不动。优化的关键点在于:每一次窗口向右移动,只是从左边移除一个元素,从右边加入一个元素,上一次窗口的最大值信息是可以复用的。
但这里有个麻烦:如果当前最大值恰好是那个被移除的元素,下一次窗口的最大值就变成了“第二大的元素”,你怎么快速找到它?如果再遍历一遍,又退回了暴力法。所以需要一种结构,既能维护当前窗口的最大值,又能在最大值过期后,自动暴露“下一代最大值”。
2.1 单调递减队列的维护规则
单调队列解决的就是这件事。我维护一个双端队列,这个队列里存的是 nums 的下标,并且保证队列里的元素按值递减。也就是说,队头元素永远是当前窗口里的最大值。
入队新元素时,执行两条规则:
- 队尾弹出:只要队列不为空,且队尾元素对应的值小于等于当前值,就把队尾弹出,直到队尾值大于当前值或队列为空。这一步保证队列始终单调递减。
- 队头过期:如果队头元素的下标已经不在当前窗口范围内,也就是队头下标小于
i - k + 1,就弹出队头。
然后当前元素入队,队头就是答案。
我最早看这个算法时,最不理解的是为什么要从队尾弹出小元素。后来想通了:因为一个小元素夹在两个大元素中间,它永远不可能成为“未来窗口”的最大值,留着它只会拖慢队列操作,不如直接丢弃。这个思路和单调栈的“移出无关元素”是一脉相承的。
2.2 手动模拟一遍,把细节看穿
我拿示例手动模拟一遍,你感受一下:
nums = [1,3,-1,-3,5,3,6,7], k = 3,队列里存下标。
- i=0,值1。队列空,直接入队。队列:
[0]。 - i=1,值3。队尾0对应值1 <= 3,弹出0,入队1。队列:
[1]。 - i=2,值-1。队尾1对应值3 > -1,入队2。队列:
[1,2]。窗口已满,队头1对应值3,结果:[3]。 - i=3,值-3。入队3,队列:
[1,2,3]。此时窗口是[1,3],队头1还在窗口内,不弹,结果:[3,3]。 - i=4,值5。队尾3对应-3 <= 5,弹出;队尾2对应-1 <= 5,弹出;队尾1对应3 <= 5,弹出。入队4,队列:
[4]。结果:[3,3,5]。 - i=5,值3。入队5,队列:
[4,5]。结果:[3,3,5,5]。 - i=6,值6。弹出队尾5和4,入队6,队列:
[6]。结果:[3,3,5,5,6]。 - i=7,值7。弹出队尾6,入队7,队列:
[7]。结果:[3,3,5,5,6,7]。
注意 i=3 那一步,虽然已经形成了四个窗口,但队头元素的下标1仍然在当前窗口 [1,3] 范围内,所以不弹出。这个“下标是否过期”的判断非常关键,也是我之前容易写错的地方。
2.3 代码实现与两个容易忽略的细节
python复制from collections import deque
def maxSlidingWindow(nums, k):
n = len(nums)
if n == 0 or k == 0:
return []
q = deque()
ans = []
for i in range(n):
# 队头元素过期
if q and q[0] < i - k + 1:
q.popleft()
# 维护单调递减
while q and nums[q[-1]] <= nums[i]:
q.pop()
q.append(i)
# 窗口形成了,记录队头
if i >= k - 1:
ans.append(nums[q[0]])
return ans
两个细节,面试时一定会被追问。
第一个:队列里存的是下标,不是值。 存值的话,你只知道队头是几,但不知道它在数组里的位置,无法判断它是否已经离开窗口。比如两个窗口的队头都是 3,旧的那个 3 已经过期了,但新的 3 还活着,如果只存值,你怎么区分?存下标就一清二楚。
第二个:弹出条件用 <= 而不是 <。 如果队列里已有两个相同值,用 < 意味着旧值保留,新值排后面;用 <= 则旧值被弹出,新值顶上。两种情况队头最大值没区别,但用 <= 可以让队列里始终保留“更新鲜”的下标,避免过期元素在队头堆积,减少后续判断。窗口较大的时候,这个差异会体现出来。
时间复杂度上,每个元素最多入队一次、出队一次,整体 O(n);空间上队列最长不超过 k,O(k)。
2.4 为什么说单调队列几乎是唯一解
有人可能会想,用大顶堆行不行?大顶堆取最大值确实是 O(1),但堆无法高效地删除“不在当前窗口内的元素”。你只能懒删除:把过期的元素留在堆里,取堆顶时发现过期再弹出。这个方案复杂度也是 O(n log k),能过题,但丑,而且面试官大概率会追问“堆里堆积的元素什么时候清理”。相比之下,单调队列每个元素进出一次,保证 O(n),又彻底避免了堆里的过期堆积问题,这才是这道题最优雅的解法。
有朋友还会问:窗口是定长的,为什么不用前缀和或者 RMQ 之类的结构?前缀和擅长区间求和,区间最值需要 ST 表或线段树。但这里窗口是“滑动”的,每次只移动一步,用动态维护的单调队列显然最贴合题意。
3. 最小覆盖子串:双指针伸缩窗口的边界博弈
再看第二个题。给你一个字符串 s、一个字符串 t,返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串。
示例:s = "ADOBECODEBANC", t = "ABC",答案 "BANC"。
这道题的难点在于,“覆盖”不是一个简单的是非判断。t 里的字符可能重复,比如 t = "AABC",那子串里至少要有两个 A、一个 B、一个 C,多出来的 A 可以但不算数。
我最初的想法是枚举所有子串,一个个检查是否覆盖 t,复杂度能到 O(n^2 * m),不用想也知道不可行。后来意识到,这道题的本质是在变长窗口内维护一个字符需求的动态状态。
3.1 用哈希表和计数状态判断“覆盖”
用一个哈希表 need 记录 t 中每个字符还缺多少个。初始时,need[ch] 等于 ch 在 t 中出现的次数。
右指针每扫过一个字符 c:
- 如果
c在need中,need[c] -= 1,表示这个需求被消耗了一个。 - 当某个字符的需求量减到 0,说明这个字符当前已经“覆盖完成”。
- 我用一个变量
valid记录有多少个字符种类达到了“覆盖完成”状态。当valid == len(need)时,当前窗口就完整覆盖了t。
这里有一个很多人会问的点:为什么不用 Counter(s[left:right]) 和 Counter(t) 直接比较?因为每次比较都是 O(|字符集|),在长字符串上会拖慢整体复杂度。valid 计数法让“是否覆盖”的判断降到了 O(1),整个算法的均摊复杂度才可能是 O(n)。
need[ch] 的值可以是负数,表示当前窗口里 ch 的数量已经超过了需求量。负值不影响覆盖判断,但它会在左指针收缩时发挥作用:只有当 need[d] == 0 时移出才需要减少 valid;如果 need[d] < 0,移出后 valid 不变,因为窗口里还有多余的同类字符。
3.2 手动模拟一遍,看清楚收缩循环的每一轮
我用 s = "ADOBECODEBANC", t = "ABC" 走一遍。初始 need = {A:1, B:1, C:1},valid = 0,左右指针都在 0。
右指针扩张到 C(下标5)时,窗口为 "ADOBEC",三个字符需求都清零,valid = 3。记录答案长度 6,左指针开始收缩。移除 A 后 need[A] 变回 1,valid 降到 2,收缩停止。
右指针继续扫,扫到下标 10 的 A 时,need[A] 从 1 变 0,valid 再次到 3。此时窗口是 "DOBECODEBA",虽然覆盖但长度 10,不值得更新答案。收缩开始,移除 D、O、B、E、C,直到 valid 跌破 3。
右指针最后扫到下标 12 的 C,need[C] 从 1 变 0,valid 到 3。窗口 "ODEBANC" 长度 7,还是不更新。收缩时刻来了:
- 移除 O,
valid不变; - 移除 D,
valid不变; - 移除 E,
valid不变; - 此时窗口变成
"BANC",长度 4,比之前的 6 短,更新答案。
注意,这里的关键是收缩循环里每一轮都要判断一次答案,而不是只在进入收缩时判断一次。如果你把答案更新写在 while 循环外,你就会漏掉 "BANC"。这是我第一次写这道题时踩的坑,后面会单独讲。
3.3 代码实现与 while 循环里的顺序问题
python复制from collections import defaultdict
def minWindow(s, t):
if len(s) < len(t):
return ""
need = defaultdict(int)
for ch in t:
need[ch] += 1
left, right = 0, 0
valid = 0
start, length = 0, float('inf')
while right < len(s):
# 右指针扩张
c = s[right]
right += 1
if c in need:
need[c] -= 1
if need[c] == 0:
valid += 1
# 左指针收缩
while valid == len(need):
if right - left < length:
start = left
length = right - left
d = s[left]
left += 1
if d in need:
if need[d] == 0:
valid -= 1
need[d] += 1
return s[start:start + length] if length != float('inf') else ""
很多人写这类代码时,习惯先移动 left 再更新答案,顺序一错结果就错。在这个模板里,答案更新必须在移除 s[left] 之前,因为此时窗口仍然是合法覆盖状态。一旦 left 移走,窗口可能就不合法了,这个候选答案就再也访问不到。
另外要注意,if c in need 这个判断不能省。对 t 中不存在的字符,它们只是在窗口里“路过”,不改变需求状态,也不需要更新 valid。
时间复杂度 O(n),因为左右指针各自最多移动 n 次。空间复杂度 O(|Σ|),|Σ| 是字符集大小。
3.4 越界和空值:边界条件一网打尽
- 如果
s比t短,直接返回空串。 - 如果
t为空,严格来说空串覆盖空串,但 LeetCode 一般要求返回空串。 - 如果最终
length仍为无穷大,说明没找到覆盖子串,返回空串。 - 如果
s中有大量t不存在的字符,右指针扩张时它们被跳过,左指针收缩时也会逐个跳过,不会影响valid数量,算法依然线性。
4. 两个题解背后的统一思维:一次遍历,两类数据结构
很多刷题攻略会把这两个题归入“滑动窗口”专题,却很少说清楚它们到底是怎么统一起来的。我个人理解是:滑动窗口的本质是维护一个连续区间,在遍历过程中动态调整区间边界,从而把“重复子问题”的计算结果复用到下一个状态。
这两个题的遍历都是 O(n),思路都是“窗口在移动”,但核心数据结构和推动方式不同。我用一张表总结一下:
| 维度 | 滑动窗口最大值 | 最小覆盖子串 |
|---|---|---|
| 窗口形态 | 定长(固定 k) | 变长(宽度动态调整) |
| 核心数据结构 | 单调递减队列 | 哈希表 + 计数状态 |
| 窗口内关注信息 | 当前最大值 | 每种字符还缺几个 |
| 左边界移动方式 | 每次固定右移一位 | 满足覆盖条件后,尽量收缩 |
| 右边界移动方式 | 每次固定右移一位 | 一直向右扩张,直到覆盖 |
| 答案生成时机 | 窗口每次成形时记录队头 | 每次窗口合法时更新最短长度 |
| 时间复杂度 | O(n) | O(n) |
从更深一层看,两个题分别对应两类经典问题:
定长窗口最值问题。 核心难点是“如何快速获取窗口最值 + 如何淘汰过期元素”。单调队列用“单调性 + 下标过期检查”同时解决了这两个问题。类似的题目还有“滑动窗口中位数”、“滑动窗口的最大值 II”等。
变长窗口满足条件的最优连续子数组/子串问题。 核心难点是“如何快速判断当前窗口满足条件”和“如何收缩窗口而不丢解”。哈希表 + valid 计数是标准答案。类似的题目还有“无重复字符的最长子串”、“长度最小的子数组”、“最大连续1的个数 III”。
两个题放在一起,恰恰覆盖了滑动窗口的两个大的解题方向。你如果把这两个方向都练熟,遇到其他带“窗口”字眼的题,就能先问自己两个问题:窗口是定长还是变长?窗口内要维护的是“最值”还是“条件满足状态”?答案是前者,往单调队列上想;答案是后者,往双指针加哈希表上想。思路会清晰很多。
举一个工程上的类比。网络里的 TCP 流量控制也用“滑动窗口”,发送窗口大小可以动态调整,这本质上是一个变长窗口问题;而工业界的滑动窗口滤波,比如对传感器数据做平滑,窗口长度固定,每个窗口取平均或取最值,那是定长窗口问题。算法题和工程问题在抽象层面是相通的。
5. 面试与实战复盘:我踩过的三个坑和应对方法
这两个题在面试中出现频率极高,而且面试官很喜欢追问细节。我把自己的踩坑经历整理成三个点,希望你不用再掉进去。
5.1 坑一:单调队列里存值而不是存下标
我第一次写滑动窗口最大值时,队列里直接存了元素值。结果跑示例能过,一提交就错。原因很简单:当最大值过期时,你无法知道它到底是不是当前窗口内的那个最大值,尤其数组里有重复元素时,存值完全无法判断“过期”。
后来我养成了一个习惯:队列里存下标,取值时再套 nums[q[0]]。 所有需要“判断元素是否还在当前范围内”的题,都推荐存下标。这不是风格问题,是正确性问题。
5.2 坑二:最小覆盖子串的答案更新位置写错
前面提到过,我在收缩循环里只更新了一次答案,就是把答案更新写在 while 循环之前,然后一路 left++。这样 "BANC" 这种在收缩中途出现的更优解就漏掉了。
正确的做法是把答案更新放在 while 循环体的最前面,然后再移动 left。因为每一轮 while 的开始,窗口都处于合法覆盖状态,此时窗口长度就是候选答案。移动 left 之后窗口未必还合法,所以必须先记录再移动。
5.3 坑三:忽略重复字符和字符集大小
最小覆盖子串的 t 可以有重复字符,比如 "AABC",所以 need 计数必须能记录需求数量,不能只用一个 set。另外,如果你用数组模拟哈希表,要注意字符集的范围,如果输入只是小写字母,开 26 长度数组没问题;如果包含大小写字母和数字,建议用 128 长度的数组或直接 defaultdict(int),避免越界。
5.4 面试时如何讲这两个题,通过率更高
我自己的讲题顺序是:先讲暴力解,再讲优化动机,再讲数据结构,再给代码。比如滑动窗口最大值,先承认暴力 O(n*k),然后指出“每次移动只有一个元素离开、一个元素进入”,引出“为什么需要维护一个单调递减的候选序列”。这一步很关键,因为面试官想看的不是你会不会背代码,而是你能不能从暴力解出发,一步步推导出单调队列。
对于最小覆盖子串,我会先说“判断覆盖需要知道每个字符的需求量”,然后引出哈希表计数,再说“窗口合法后要收缩找最短”,引出双指针模板。回答追问时,把 valid 的含义和重复字符的处理讲清楚,基本就能过关。
6. 从这两道题延伸出去的高频变种题,直接照搬框架
练完这两个题之后,建议顺手刷几个变种题,一方面巩固模板,另一方面为面试做广度储备。
定长窗口相关:
- 无重复字符的最长子串(LeetCode 3):变长窗口,窗口内不能有重复字符,用 set 或 dict 维护窗口内字符索引。
- 长度最小的子数组(LeetCode 209):变长窗口,窗口和大于等于 target 时收缩,更新最短长度。
- 字符串的排列(LeetCode 567):定长窗口,判断窗口中字符计数和
s2的某个排列一致,本质是覆盖问题的变体。
变长窗口相关:
- 最大连续1的个数 III(LeetCode 1004):变长窗口,窗口内 0 的个数不超过 k,维护窗口内 0 的计数。
- 删掉一个元素以后全为 1 的最长子数组(LeetCode 1493):也是变长窗口,思路几乎一致。
- 找到字符串中所有字母异位词(LeetCode 438):定长窗口 + 哈希表计数,输出所有满足条件的起始索引。
这些题我建议你用同一个模板去套,先写一个可以复用的框架,比如右指针扩张、条件判断、左指针收缩,再针对题目微调。模板熟练之后,从看到题目到想出解法的时间会明显缩短。
个人习惯是:把这两个题的代码背到“条件反射”的程度。因为滑动窗口题在面试里往往不是最难的,但它经常作为后续更复杂题目的基础模块出现。如果你连基础的模板都要现场推导,后面再叠加状态压缩、双指针、二分反而容易崩。先把这两道硬骨头啃下来,滑动窗口这一整个专题,你就算站稳了。
