今天是我力扣打卡的第六天,刷的是滑动窗口专题里两道非常有代表性的题:239. 滑动窗口最大值和76. 最小覆盖子串。这两道题在力扣热题100里都属于高频题,而且都很适合作为“滑动窗口到底在考什么”的试金石。如果只做其中一题,很多人容易把滑动窗口理解成“两根指针走来走去”,等把两题放一起对比做完了才会发现,滑动窗口真正的难度不在于移动窗口这件事本身,而在于窗口变化后,你怎么用较低的成本维护你想要的“答案状态”。
我准备把这两道题的完整思考过程、代码拆解、以及刷题时容易卡住的几个点都写下来。适合正在刷热题100的人、准备面试时想系统过一遍滑动窗口模型的人、以及之前做过但没完全想明白“为什么要用单调队列/为什么另一个要用哈希表”的人。
1. 为什么day06把这两题放在一起刷:同样叫滑动窗口,其实是两种骨架
很多人第一次接触滑动窗口,都是从“无重复字符的最长子串”或者“长度最小的子数组”这类题目入门的。这类题有个共同点:右指针不断向右扩展,一旦窗口内的状态不满足题目的限制条件,左指针就跟着收缩,直到窗口重新变得合法。整个过程窗口长度一直在动态变化,所以我们一般叫它“可变长度滑动窗口”。
但239这道题完全不是这个路数。它的窗口长度固定是k,右指针每走一格,左指针也必须跟着走一格,窗口不会因为“满足条件”就停下来。你要维护的是一个固定长度窗口内的最大值。这里没有“扩大—收缩—判断”的逻辑,只有“移入一个新元素、移出一个旧元素、回答当前窗口的最大值是多少”这三件事。
76这道题则又回到了最长子串那类模型:给两个字符串s和t,在s里找最短的一段连续子串,让这段子串覆盖t中的所有字符。窗口可以随意伸缩,目标是找到一个最短的合法窗口。
| 对比项 | 239. 滑动窗口最大值 | 76. 最小覆盖子串 |
|---|---|---|
| 窗口长度 | 固定为k | 动态变化,求最短 |
| 左指针的移动时机 | 每轮固定向右移动一格 | 窗口已经覆盖t时,尝试收缩 |
| 需要维护的状态 | 当前窗口最大值 | t中所有字符还缺多少 |
| 额外数据结构 | 双端队列 | 哈希表/计数器 |
| 核心难点 | 旧元素过期淘汰 | 如何判断覆盖、如何找最短 |
我建议你把这两题放在同一天做,不是因为它们都叫滑动窗口,而是因为它们恰好代表了滑动窗口需要掌握的两项不同能力:一类题考察如何高效维护窗口内的“统计结果”,另一类题考察如何精确控制窗口的“伸缩时机”。前者典型代表是239,后者典型代表是76。这个区分想明白了,之后再遇到滑动窗口的变种题,至少能先判断题目考的是哪半边。
1.1 固定窗口和可变窗口,差的不只是长度
固定窗口套路的典型流程大概是:初始化left = 0,right从0开始走,当窗口大小达到k后,每次右指针往前走一步,左指针也跟着走一步,保证窗口长度始终是k。这种结构看起来简单,但是窗口内的最大值变化并不简单。普通数组维护最值需要遍历,可窗口每移动一格就遍历一次k个元素,代价是O(nk)。当k和n都很大时,超时没商量。
可变窗口的典型流程则是:右指针不断向右扩展,直到当前窗口已经满足题目的条件;满足条件后,尝试把左指针向右移动,每移动一下都试图得到一个更优的答案,直到窗口不再满足条件。这个循环的优点在于,左右指针各自最多移动n次,整体复杂度能做到O(n),前提是“判断窗口是否满足条件”这一步不能花太高的代价。
76题最难的地方也在这:t可能包含重复字符,比如t = "AABC",那么你找的子串里至少要包含两个A、一个B、一个C。判断覆盖就不是简单地在窗口里查一下“某个字符在不在”,而是要精确统计每个字符的剩余欠账。这就需要设计一个低成本更新的账本状态,算法主体反而不是难点。
1.2 先用暴力法跑出来,能帮你定位性能瓶颈在哪
我自己刷题的习惯是,如果思路卡住,先把暴力解写出来,然后再去思考暴力解慢在哪里。239的暴力方式很直接:遍历整个数组,对每个窗口调用max(nums[i:i+k])。这个做法在k很小的时候其实没什么问题,但一旦k接近n,比如一个长度10万的数组配一个长度5万的窗口,每次都扫一遍窗口就非常痛苦,理论上限很不可控。
76的暴力方式通常是枚举子串起点和终点:起点i可选n种,终点j从i开始往后找,直到窗口覆盖t,复杂度O(n²)。在这个暴力过程中你会发现两个明显的浪费点:第一,窗口统计被反复重建;第二,很多子串根本没有必要重新判断从头统计。滑动窗口优化套路恰恰就是针对这两个浪费点做文章:窗口滑动时只更新新进入的字符和一个离开的字符,同时把覆盖状态用一个计数器维护好,于是从O(n²)降到O(n)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 滑动窗口最大值:单调队列解决的不是求值,而是“过期淘汰”
初看239题目,最容易想到的数据结构是堆。把窗口里的元素都放到最大堆里,堆顶就是最大值。窗口每次右移时,把新元素加入堆,把离开窗口的元素标记为“已过期”,下次取堆顶时如果发现堆顶过期就弹出。这个思路能跑通,但有两个问题:一是需要额外维护一套过期标记,二是堆里会积压很多过期元素,实际复杂度可能会接近O(nlogn)。
用单调队列可以做到真正的O(n)。这里的核心思想是:窗口向右滑动时,如果一个元素比它左边的某个元素更新,而且值还更大,那么左边那个旧元素就再也不可能成为当前窗口以及未来某一个窗口的最大值了,因为它既没有更大的数值优势,还更容易先滑出窗口。换句话说,一个元素如果已经“打不过”右边的新元素,留下它只是浪费空间。
2.1 单调递减队列维护的到底是什么
我们维护一个下标队列,队列里所有下标对应的nums值是严格递减的,或者说非递增的。我写代码的时候习惯用非递增,也就是允许相等的情况出现在队列里,但实际为了让元素尽量新,通常在遇到相等值时会直接弹出旧值,再放入新值。
队列里只存下标,不存值,这是很多初学者容易忽略的细节。为什么非存下标不可?因为窗口移动时,我们需要知道哪些元素已经滑出窗口,只有下标能精确判断“这个元素是否还在当前窗口里”。如果只存值,当窗口左端滑过一个等于某个值的元素时,你根本不知道应该删除哪一个。
具体维护逻辑如下:
- 队头到队尾,下标对应值从大到小。
- 每次处理新元素x时,先把队头所有已经滑出窗口的下标弹出。判断条件就是q[0] <= i - k,等于i - k的下标已经不在窗口里了。
- 接着把队尾那些对应值小于等于x的下标全部弹出。因为有了x之后,这些较旧且更小的元素不会再成为最大值。
- 把当前下标i加入队尾。
- 当窗口已经形成,也就是当前下标i大于等于k - 1时,队头下标对应的值就是答案。
这里要注意第二步和第三步的顺序。第二步是过期元素的清理,必须在取结果前完成,这样队头才保证有效;而第三步对队尾的清理,本质上是为了维护单调性,不是处理过期,所以放在入队前就行。两步谁先谁后其实都不会影响最终正确性,但先处理过期、再维护单调性,逻辑上更顺,不容易漏掉边界。
2.2 Python代码与运行过程拆解
这是我的Python实现:
python复制class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
from collections import deque
q = deque() # 队列存的是下标,不是值
res = []
for i, x in enumerate(nums):
# 1. 队头下标如果不在当前窗口内,弹出去
while q and q[0] <= i - k:
q.popleft()
# 2. 队尾对应值如果小于等于当前值,它以后不可能成为最大值了
while q and nums[q[-1]] <= x:
q.pop()
# 3. 当前下标入队
q.append(i)
# 4. 窗口形成后,每次取队头作为当前窗口最大值
if i >= k - 1:
res.append(nums[q[0]])
return res
用leetcode示例跑一下:nums = [1,3,-1,-3,5,3,6,7],k = 3。
- i=0,x=1,队列空,直接入队0,此时i<2不输出,队列:[0]。
- i=1,x=3,队尾nums[0]=1 <= 3,弹出0,队列空,入队1,队列:[1]。i<2不输出。
- i=2,x=-1,队头1没有过期(1 > -1),队尾nums[1]=3 > -1,不需要弹,入队2,队列:[1,2]。窗口形成,输出nums[1]=3。
- i=3,x=-3,处理过期:队头1 <= 0?否。队尾nums[2]=-1 > -3,不用弹。入队3,队列:[1,2,3]。输出nums[1]=3。
- i=4,x=5,队头1没有过期(1 <= 1?1 <= 4-3=1,成立),所以队头下标1已经离开窗口,弹出。接着队尾nums[3]=-3 <= 5弹出,nums[2]=-1 <= 5弹出,nums[1]已经弹了,队列空,入队4,队列:[4]。输出5。
- i=5,x=3,队头4还在窗口;队尾nums[4]=5 > 3,入队5,队列:[4,5]。输出5。
- i=6,x=6,队头4没有过期;队尾nums[5]=3 <= 6弹出,nums[4]=5 <= 6弹出,入队6,队列:[6]。输出6。
- i=7,x=7,队头6没有过期;队尾nums[6]=6 <= 7弹出,入队7,队列:[7]。输出7。
整个结果就是[3,3,5,5,6,7],没有问题。
2.3 为什么每个元素只会入队一次、出队一次
连续做完上面几次模拟,你大概能感受到一个很强的性质:每个元素最多只会被入队一次,也最多只会被弹出一次。这个特性是单调队列能达到O(n)的关键。虽然在处理新元素时,内层while可能连续弹出很多元素,但那些元素都是从队列里被彻底移除了,下次不会再被处理。如果把整个算法的总操作次数摊到n个元素上,每个元素平均只贡献一次“入队”和一次“出队”,所以总均摊复杂度是O(n)。
空间复杂度方面,队列里同时存在的下标最多不会超过k个,所以是O(k)。严格来说甚至可以认为不会超过窗口大小加1,因为每次在加入新元素前,已经确保了队列中不存在离开窗口的旧下标。这样一个既能从两端弹出、又能从尾部压入的结构,显然就是双端队列。用Python的list和pop(0)也行,但pop(0)需要把后面所有元素往前移动,最坏情况会退化到O(n),所以一个正经的deque在这里是必须的,不是锦上添花。
如果你用Java,ArrayDeque也有对应的pollFirst、pollLast、offerLast这些方法,逻辑完全通用。如果你用C++,直接std::deque就行,存迭代器或下标都可以。语言实现不重要,重要的是记住“下标才是队列里真正的有效信息”。
2.4 几个我在力扣评论区里经常看到的错误版本
第一个错误是队列里直接存值。表面上跑测试用例时没问题,可一旦窗口滑出,你无法确定该移除哪个值。比如窗口里有两个相同的最大值,如果只存值,你无法知道队头是否已经过期。正确的做法是存下标,用nums[q[0]]来取值,所有过期判断都基于下标。
第二个错误是忘记处理队头过期。有些写法把最大值通过堆维护,能通过一部分用例,但漏掉过期逻辑后,遇到窗口长度小于数组长度且最大值越出窗口的情况就会出错。队列的过期检查不只是“提高性能”,它直接关系到答案正确性:如果队头下标已经小于等于i-k,那它已经不在当前窗口,如果不弹出,输出就是一个已经滑出窗口的元素。
第三个错误是队尾清理时用了小于号而不是小于等于号。遇到连续两个相同的最大值,比如窗口依次进入两个5,旧5本来应该被新5替代,因为新5更不容易过期。如果只弹出小于当前值的旧值,队列里就会同时保留两个5,队头是旧的5。虽然短时内看不出错,但等到窗口移动,旧5先过期时,队头弹出的不是能提供最大值的元素,而另一个5还在队列中;即使结果一样,队列里就多了冗余元素,增加了不必要处理量。用<=来清理能让队列里的下标始终尽量新,干净清爽。
3. 最小覆盖子串:把“覆盖”翻译成一张可以实时更新的欠账表
76题的要求用大白话说:给你两个字符串s和t,找出s中包含t所有字符的最短连续子串。这里的“包含”不是简单的子序列匹配,而是字符数量都要满足,并且允许窗口里含有t之外的字符。比如s = "ADOBECODEBANC",t = "ABC",结果是"BANC",而不是"ADOBEC"。
这题之所以是“滑动窗口”的代表题,因为它展示了一个非常典型的套路:与其反复从零判断一个区间是否覆盖t,不如在窗口移动的过程中实时更新“还缺哪些字符、还缺多少个”,一旦缺的总额变成0,就说明当前窗口已经是一个合法覆盖,可以停下来尝试收缩。
3.1 从统计“窗口里有什么”到统计“t还欠什么”
最容易想到的思路是:每移动一次窗口,就重新统计窗口里各个字符的数量,然后和t的Counter对比。这样做的开销太高,因为每次都要遍历整个窗口或t。我们真正需要的其实是一个能随着窗口移动而增量更新的量。
我建议用一个“欠账表”来理解。假设t = "AABC",那么一开始你欠两个A、一个B、一个C。遍历s时,窗口每加入一个字符,就相当于还掉了一笔账:如果这个字符是t需要的,你就把对应欠账减1;如果不是t需要的,它在账本里的值会变成负数,表示窗口里这个字符已经有了富余。欠账总额missing用来记录你还欠多少个字符。当missing == 0时,说明t的每一个字符都被覆盖了,窗口合法。
这里为什么不统计每个字符的“窗口内正数出现次数”,而是可以用负数记账?因为负数正好记录了“这个字符多出来的数量”。当窗口左端收缩时,把字符从窗口里移出去,账本上这个字符的欠账就要加1。如果加完之后它还是负数,说明即使移走一个,这类字符仍然够用,不会破坏覆盖;如果加完之后变成正数,说明移走这个字符后,你又欠了一个当前种类的字符,窗口不再合法,于是停止收缩。这个逻辑全部落在need这个计数器上,不需要额外再开一个窗口计数器。
3.2 代码实现:右指针负责“还款”,左指针负责“讨债”
直接看我写的版本:
python复制class Solution:
def minWindow(self, s: str, t: str) -> str:
from collections import Counter
need = Counter(t) # 账本:还需要哪些字符、各缺几个
missing = len(t) # 总欠账数,即还差多少个字符
left = 0
ans = (0, float('inf')) # 记录最短窗口的左右端点
for right, ch in enumerate(s):
# 右指针进入一个字符,相当于还掉一笔记账
if need[ch] > 0:
missing -= 1
need[ch] -= 1
# 当欠账还清,说明当前窗口已经覆盖t,尝试收缩左指针
while missing == 0:
if right - left < ans[1] - ans[0]:
ans = (left, right)
left_ch = s[left]
need[left_ch] += 1
if need[left_ch] > 0:
missing += 1
left += 1
return "" if ans[1] == float('inf') else s[ans[0]:ans[1] + 1]
我在ans里存的是左右端点,而不是直接存字符串切片。原因是每次收缩可能发生很多次,频繁切片s[left:right+1]会产生大量临时字符串,内存和耗时都不如存端点划算。最终只切一次,干净利落。
需要注意这里判断need[ch] > 0的含义。如果ch不是t中的字符,它不在need里,need[ch]默认是0,不会走进if分支,也就不会减少missing。但接下来need[ch] -= 1会让这个字符在账本里变成-1,表示窗口里多出了一个无关字符。当左指针收缩经过它时,need[left_ch] += 1加回了0,if判断0 > 0不成立,所以不会增加missing,窗口依然合法。这个设计非常巧妙,本质上是把“多余出来的抵消量”也记录在need里,因此省掉了一个遍历统计窗口字符的哈希表。
3.3 “为什么答案取最小长度还要一直收缩左指针”的现场模拟
以s = "ADOBECODEBANC",t = "ABC"为例。我们的整体思路是:先让右指针走到第一个刚好覆盖t的位置,此时left=0, right=5,窗口是"ADOBEC",覆盖ABC。记录这个长度6。接下来尝试把left向右移动,因为左边字符A离开窗口后,need['A']从0变成1,missing从0变成1,窗口不再覆盖t,收缩停止。循环回到外层,right继续右移,继续“还款”,当移动到某个位置欠账再次清0时,再收缩左边。
真正得到"BANC"的过程发生在right走到末尾附近。当右指针遍历完整个s,最后一次覆盖t时,left会连续收缩多次,从窗口左边不断弹出B、A等字符,直到移走A导致缺A才停下,此时left刚好停在“B”所在的下标。最短窗口就是这段。
每轮收缩,我都先记录一次当前窗口长度,再去移动left。这样能保证记录到的每一步都对应一个合法窗口。如果你在收缩完成之后才记录,就错过了收缩过程中可能产生的更短合法窗口,答案就会偏大。这个顺序是很多初学者容易写反的坑。
3.4 边界条件和容易翻车的细节
第一,t为空字符串时,最短覆盖子串应为空串。不过力扣的输入通常是t非空,但养成先判空的好习惯总没错。如果t为空,直接返回""。
第二,s中根本找不到覆盖t的子串,返回""而不是返回一个莫名其妙的切片。代码里ans[1]保持为float('inf'),所以用ans[1] == float('inf')作为未找到的标记。这个写法在常规字符串题里很常见,不会出现越界问题。
第三,更新最短窗口时,注意ans[1] - ans[0]在初始化时是正无穷,第一次比较一定会成立。但这里有个容易混淆的点:滑动窗口右端是right,左端是left,窗口长度是right - left + 1,而我们比较时用的是right - left。因为比较两个候选答案谁更短,其实比较长度,right-left与right-left+1相差1,对相对大小没有影响。你为了可读性写成right - left + 1 < ans[1] - ans[0] + 1也可以,更直观,也不影响正确性。
第四,关于Counter的自动补默认值。Python里遍历到t之外的字符,need[ch]会创建这个键并标记为-1,而在左指针收缩时left_ch也可能是t里没有的字符,need[left_ch]同样会做增减。用collections.Counter的好处就是缺失键默认为0,增加和减少都很自由,不用每次判断key是否存在。如果用普通字典,就得写get和setdefault来手动初始化,代码会臃肿很多。
整体复杂度非常可观:need这个计数器大小最多是t里的字符种类数,右指针遍历n次,左指针最多也遍历n次,总复杂度O(n),空间复杂度O(字符集大小)。无论n多大,算法都只跑了一遍s,是这类题能拿到的最优级别复杂度。
4. 刷完之后的小结:三个容易混淆的知识点与个人刷题建议
把这239和76放在同一天做完,除了解题本身,我还有一个明显感受:这两道题几乎覆盖了滑动窗口里最常见的三个易混点,梳理清楚它们,比多刷十道类似题更有用。
4.1 什么时候收缩左指针该用while,什么时候用if
这要回到窗口的性质去判断。76这类覆盖子串题,窗口收缩是一个“可能连续发生多次”的过程。因为窗口右端新增一个字符后,可能让整个窗口有非常多的富余,比如t = "ABC",当前窗口是"AAABBC",那么左边连续三个A都是可移出的,移出一个A窗口可能依然覆盖ABC,必须继续尝试移出第二个A,一直到移出某个字符后窗口不再合法。所以要用while来不断尝试收缩。
反过来,239这种固定窗口题根本不存在“尝试收缩”这一说。right每走一步,窗口长度如果超过k,left就一定需要前进一位,但只前进一位就够了,因为每次循环只新增了一个元素。这种天然每次只动一次的场景,用if判断长度等于k后同时移动两边指针即可。
还有一个容易混淆的点:有些长度最小的子数组题也会在收缩时用while,因为窗口去掉左边的值之后,可能还是满足“sum >= target”的条件,要一直移到不满足为止才能保证当前right对应一堆候选里最小的窗口。概括起来,只要收缩目标是“尽量小,直到条件被破坏”,就用while;如果收缩只是一次普通的位置平移,用if就够了。
4.2 为什么滑动窗口最大值里存下标,而覆盖子串里存数量
239的窗口是固定宽度,队列要判断的不只是当前值大小,还包括在一个个滑动的窗口里这个值还有没有效。如果不存下标,一个值到底是属于当前窗口还是已经滑走了,你根本没法判断。这是“存活时间”带来的信息需求。所以单调队列里存储的是“带时间戳的候选者”,下标就是那个时间戳。
76的窗口内,字符之间没有绝对的位置优先级,同一个字符在窗口里出现几次也不影响它能否继续和其他字符组合成覆盖。唯一有用的统计量是每个字符的数量,所以Counter存的是“计数”,而不是单个字符的位置。如果题目改成“求包含所有目标字符且每个字符出现间隔不超过k的最短子串”,那你就需要把下标也纳入考虑,又得换数据结构。
这个现象很有意思:同样是滑动窗口题目,数据结构的选择实际上是跟着你要维护的“状态类型”走的。如果状态只有数量变化,哈希表通常够用;如果状态包含“过期淘汰”语义,就要在下标、时间戳这类额外维度上做文章。
4.3 遇到滑动窗口最值题,先想单调队列还是堆
如果不考虑过期的写法,堆能很自然地给出当前窗口最大值。但堆的问题在于它只保证堆顶点全局最值,一旦顶部元素过期,你需要有办法判断并弹出,同时还可能有一堆过期元素藏在堆中无法及时清理。这样堆的大小会缓慢增长,最坏情况每个元素都在堆里很久才被弹出,复杂度不稳定。
单调队列的优势在于,它把元素的“大小排名”和“年龄”两个维度都照顾到了。队头永远是最值,队头下标又能告诉我们它是否还在窗口里。每个元素只会进出一次,操作均摊O(1)。如果面试官追问可不可以不用单调队列,我通常会说可以用堆加懒删除,代码复杂度和稳定性都更差,不建议作为首选。
另外还有一个很常见的思路是用“大顶堆+下标延迟删除”,这个方法本身没有错,比赛的时候如果一下没想出单调队列,用堆先求对也能接受。但既然学滑动窗口最大值,最好还是掌握单调队列,因为它是这类题目出题人真正想考察的知识点。你已经理解了239后,很多其他变种,比如滑窗中位数、滑窗平均值、滑窗第k大,也能顺着同一套思路延伸出去。
4.4 这两题在热题100里的位置,以及我推荐的刷题顺序
在力扣热题100里,滑动窗口相关的题目数量并不算多,但每一道都是面试高频。最常见的是“无重复字符的最长子串”“长度最小的子数组”“最小覆盖子串”“滑动窗口最大值”这几位。我的个人建议是,如果你刚开始系统刷滑动窗口,先用3. 无重复字符的最长子串练手,这道题只需要一个哈希集合加双指针,对“可变长度滑动窗口”的收缩逻辑形成肌肉记忆。
然后刷76. 最小覆盖子串。它的覆盖判断逻辑比无重复字符稍微复杂,但主体思路依然是“右指针扩大窗口,左指针收缩窗口”,只要把欠账表的missing维护清楚,代码上的难度并不算高。刷完76后,你已经掌握了可变窗口的完整套路。
最后再碰239. 滑动窗口最大值。我不是说239比76难很多,而是239的本质是“数据结构设计”,如果你前面没有建立足够的滑动窗口直觉,很容易被“窗口到底怎么移动”干扰,忽视了单调队列的真正价值。先理解动态窗口的收缩时机,再理解静态窗口的最值淘汰,逻辑上更自然。
我自己的体会是,滑动窗口这类题,盲目追求“今天多刷几题”意义不大,真正值得记住的是每个模型背后的触发条件。239教会我:一个旧元素如果既没有数值优势,又没有年龄优势,那它就没有必要继续活在队列里。76教会我:一个统计状态并不需要反复从头计算,只要维护好“距离目标还差多少”这个量,窗口的合法与否一眼就能判断。两道题做完回头看,很多曾经觉得玄乎的变形题,其实都只是在这两种模型之间做加法而已。
