如果你最近在刷 LeetCode Hot 100,看到“滑动窗口”这个分类时大概率会有的困惑是:这里面题型不少,有让找最长无重复子串的,有让求满足条件的最短子数组的,还有直接让输出滑动窗口最大值的,看起来好像没有统一套路。但等我把 Hot 100 里的窗口类题目按类型整理完才发现,它们背后就是同一套增量更新的思路,只是在不同约束条件下换了几种写法。
这篇文章不是把每道题答案罗列一遍,而是把“hot100 滑动窗口”相关的底层逻辑、模板推导、边界条件和常见坑一次讲透。核心以 Python 为例,因为目前的题解社区里 Python 实现越来越主流,而且 Python 的 collections.deque、Counter 这类容器特别适合表达窗口结构。适合正在刷 Hot 100 题解、被各种双指针写法绕晕,又不满足于只记答案的读者。
1. 为什么 Hot 100 里的滑动窗口值得单独整理成一个专题
先说实话,LeetCode Hot 100 的题单有不少版本,不同版本收录的题目会有出入,但目前市面上主流题单里,滑动窗口类题目基本都覆盖这几个方向:无重复字符的最长子串、最小覆盖子串、长度最小的子数组、窗口最大值等。它们看起来彼此独立,实际面试里也经常被当成字符串、数组题单独出现,很少会被考官主动提醒“这题用滑动窗口”。所以很多人刷题时只是顺着题号做,每道题都能看懂单篇题解,却始终没有形成一套自己的判断逻辑——这才是最亏的地方。
把滑动窗口单独拎出来集中刷,最大的收益是能强迫自己先做“窗口形态”判断,而不是一上来就套模板。我把这些题按技术驱动因素分成三类,自己在复盘时也一直用这张分类表:
| 题型方向 | 窗口形态 | 常见问题 | 核心技术点 |
|---|---|---|---|
| 找最长 | 窗口始终保持合法,右边界扩张后尝试收缩,答案取最大 | 无重复字符的最长子串 | 哈希集合、重复元素清理 |
| 找最短 | 窗口先构成合法解,然后收缩到即将非法,答案取最小 | 长度最小的子数组、最小覆盖子串 | while 收缩、两类指针更新 |
| 找窗口内的固定统计 | 窗口长度固定或变化规则固定 | 字符串异位词、滑动窗口最大值 | 计数数组、单调双端队列 |
还有一点容易被忽略:滑动窗口经常被划进“双指针”大专题里,但它的核心并不是指针本身,而是“增量更新”。普通双指针有时可以无脑操作数组,滑动窗口则必须保证你从左侧移除一个元素时,窗口状态能正确回滚。弄懂这个差别,题目再变化你也能很快定位问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从暴力枚举到滑动窗口:为什么它能从 O(n²) 降到 O(n)
很多刚接触滑动窗口的人,第一反应是拿暴力循环从头到尾试一遍:外层枚举左边界,内层枚举右边界,然后对每个子区间做一次统计或求和。假设数组长度是 n,暴力枚举所有子区间本身就是 O(n²),如果每次还重新计算子区间的和或字符频率,复杂度还会再乘上区间长度。这显然不是面试官想看到的。
滑动窗口的思路则完全换了一个角度:窗口本质就是数组上的一段连续区间,用 left 和 right 两个下标来表示左右边界。right 每次向右扩展一个新元素,left 只在窗口不满足约束时才向右移动,且左指针永远不会往回退。窗口里的数据也不需要每次重新算,而是每当 right 挪动一格就把右边新元素加入状态,每当 left 挪动一格就把左边被踢出的元素从状态里减去。这种更新方式是 O(1) 或 O(字符集大小) 的,整体上每个元素最多进窗口一次、出窗口一次,所以总复杂度是 O(n)。
可以类比成地铁闸机通道:一件行李从右侧闸口进站,从左侧闸口出站,同一时刻只关心通道里那一段行李。无论进站出站多少次,通道长度如何伸缩,每件行李都只被扫描一次。这里的关键是“窗口状态”要能经受住删除操作。很多人在暴力解法里用 sum 函数或者重新构造哈希表,是因为他们没有意识到滑动窗口可以用“增量更新”来维护一个始终可靠的临时状态。
当然,滑动窗口也必须是单向流动的。如果题目要求的不是连续子数组,而是可以任意排列的子序列,或者允许你把数组元素重新排序,那窗口就没有意义了——因为 left 到 right 之间必须始终代表物理上连续的一段。这个前提条件会在后面的章节里再展开。
3. 手写可变窗口模板之前,先想清楚答案更新位置
可变窗口是所有滑动窗口题目里最容易被写错的类型,原因不是代码语法难,而是很多人分不清“什么时候更新答案”。同样是可变窗口,求最长子串和求最短子数组的写法完全不一样,如果你只背一个模板去套,很容易在边界条件上挂掉。
3.1 最长类:先让窗口合法,再尝试更新答案
求最长类问题时,窗口约束条件通常是“窗口内不能出现重复字符”“窗口内某种字符数量不能超过阈值”这类。以右指针 right 每轮扩张为起点,我们先添加新元素,如果新元素导致窗口不合法,就循环收缩左指针,直到窗口重新合法。关键是:答案应该在窗口合法之后更新,用 right - left + 1 去刷新最大值。
模板可以概括成:
python复制left = 0
ans = 0
for right in range(n):
add(nums[right]) # 右边界扩张
while not is_valid_window(): # 窗口不满足约束
remove(nums[left]) # 左边界收缩
left += 1
ans = max(ans, right - left + 1) # 窗口合法后更新答案
这个顺序不能变。如果你把更新放在 while 收缩之前,窗口可能带着非法状态参与计算,结果就会虚大。还有一个容易被忽略的细节:收缩是用 while 而不是 if。因为加入一个新元素后,可能需要连续弹出多个旧元素才能让窗口重新合法,只弹一次肯定不够。
3.2 最短类:窗口先构成合法解,在恢复非法之前更新答案
求最短类问题反过来。比如“长度最小的子数组”要求窗口内元素和至少为 target,我们要对每个右边界,不断尝试右移 left,让窗口在保持合法(和仍然不小于 target)的前提下尽量短,然后在窗口即将变为非法之前记录答案。对应模板为:
python复制left = 0
ans = float('inf')
for right in range(n):
add(nums[right]) # 右边界扩张
while window_sum >= target: # 窗口构成合法解
ans = min(ans, right - left + 1) # 先记录当前合法窗口
remove(nums[left]) # 再尝试收缩
left += 1
很多人会把更新和收缩的顺序搞反,导致少算一个合法解。正确逻辑是:当 while 条件进入循环时,窗口已经合法,这时应该先记录答案,然后把左边界往右挪,看挪了以后是否依然合法。如果依然合法,就继续挪,直到不合法为止。每次退出 while 时,窗口必然处于“差一点就不满足条件”的状态,而这个状态本身也是 next 轮右边界扩张的起点。这样一轮下来,每个 left 位置最多被处理一次,整体仍然是线性的。
4. Hot 100 无重复字符最长子串:从哈希集合到最后一次位置优化
无重复字符的最长子串应该是 Hot 100 里出场率最高的滑动窗口题之一,但它其实非常能够检验一个人对窗口内重复元素处理的理解程度。以 s = "abcabcbb" 为例,题目要求找最长的连续子串,且子串中所有字符都不重复。
最容易理解的写法是哈希集合版本。right 从 0 开始依次向右走,每遇到一个新字符就把它加入集合。如果发现字符已经存在于集合,说明窗口不合法,此时要循环从左边移除字符,直到窗口里的重复字符被清除干净。代码可以这样写:
python复制def lengthOfLongestSubstring(s: str) -> int:
seen = set()
left = 0
ans = 0
for right, ch in enumerate(s):
while ch in seen:
seen.remove(s[left])
left += 1
seen.add(ch)
ans = max(ans, right - left + 1)
return ans
逐行看这个过程:right 每加一个字符,先检查它是否和窗口里的字符冲突;冲突就移动 left 并不断移除左边字符,直到冲突解除。注意这里一定要先 remove 再 left += 1,顺序反了会移除错字符。这种实现虽然简洁,背后的实际移动次数最坏可以达到 O(2n),因为 left 和 right 各自都可能会移动整个字符串长度。不过站在面试角度,这个版本已经足以证明思路。
如果还想更进一步,可以使用哈希表记录每个字符最后一次出现的下标。此时 left 不需要一步一步挪,遇到重复字符后可以一次性跳到目标位置。这里有一个非常经典的陷阱,我第一次写时就踩了:更新 left 之前必须先判断“重复字符是否真的在当前窗口内”。比如字符串遍历到很后面的位置时,哈希表里那个旧下标可能已经小于 left,说明这个字符已经不在当前窗口里,不能把它当作重复。
python复制def lengthOfLongestSubstring(s: str) -> int:
last = {}
left = 0
ans = 0
for right, ch in enumerate(s):
if ch in last and last[ch] >= left:
left = last[ch] + 1
last[ch] = right
ans = max(ans, right - left + 1)
return ans
last[ch] >= left 这个判断就保证了重复字符来自当前窗口。网上很多题解直接写 left = max(left, last[ch] + 1),在这个用例下也能得到正确答案,但对初学者来说,显式的条件判断更能帮助理解。用表格演示一次 s = "abcabcbb" 的优化版本,过程会很清楚:
| right | 当前字符 | left | last | ans |
|---|---|---|---|---|
| 0 | a | 0 | 1 | |
| 1 | b | 0 | 2 | |
| 2 | c | 0 | 3 | |
| 3 | a | 1(因为 last[a]=0 >= left=0) | 3 | |
| 4 | b | 2(因为 last[b]=1 >= left=1) | 3 | |
| 5 | c | 3(因为 last[c]=2 >= left=2) | 3 | |
| 6 | b | 4(因为 last[b]=4 >= left=3) | 3 | |
| 7 | b | 5(因为 last[b]=6 >= left=4) | 3 |
从这组过程能看出来,left 的跳转不是无脑跳跃,而是严格受窗口当前左边界约束。只要能把这题的优化版本讲明白,可变窗口的“收缩逻辑”基本就没有死角了。
5. “hot100 滑动窗口最大值”不是堆排序能解决的问题:重点在单调队列维护什么
如果你搜索过相关热词,大概率会看到“Python 滑动窗口最大值图解”和“hot100 题解 pdf”这两类内容被大量讨论。滑动窗口最大值为什么看起来比普通窗口题难一个档次?因为前面讲的窗口题,更新状态时只需要增加或删除一个元素,但是窗口每移动一次,都要立刻知道当前 k 个元素里的最大值。朴素做法是每轮对窗口内元素重新取 max,复杂度 O(nk)。用最大堆也可以做,但堆的删除操作比较麻烦,而且 Python 的 heapq 没有内置的按值删除,需要懒删除标记,代码写起来不够干净。
Hot 100 要求的线性解,核心是把窗口最大值问题转换成“维护一个单调递减的候选序列”。我们要用双端队列 deque,队列里存的不是最大值本身,而是数组元素的下标。为什么存下标而不存值?因为窗口移动时,我们需要知道一个备选元素是否已经滑出窗口边界,只有下标才能精确判断过期。
单调队列的维护规则可以拆成两条:
- 每次新元素 nums[i] 进入窗口前,先从队尾弹出所有不大于 nums[i] 的旧元素的下标。因为这些旧元素在窗口后续移动中,一旦和 nums[i] 处于同一个窗口,最大值一定会优先选择 nums[i],旧元素永远不可能翻身。
- 如果队头下标已经小于当前窗口左边界 i - k + 1,就从队头弹出。这说明队头元素已经滑出窗口,不再是合法候选。
这两条做完之后,deque 的队头一定是当前窗口的最大值下标。完整代码如下:
python复制from collections import deque
def maxSlidingWindow(nums, k):
dq = deque()
ans = []
for i, x in enumerate(nums):
while dq and nums[dq[-1]] <= x:
dq.pop()
dq.append(i)
if dq[0] < i - k + 1:
dq.popleft()
if i >= k - 1:
ans.append(nums[dq[0]])
return ans
这套代码从头到尾每个元素都只会进入队列一次、离开队列一次,整体时间复杂度是 O(n),空间复杂度是 O(k)。
不少人会疑惑:为什么在“寻找最小值”时也可以套同一套逻辑?因为稍微把 while 里的条件改一下就可以。求最大值时维护单调递减队列,队头是最大值;求最小值时维护单调递增队列,队头是最小值。其他部分包括下标过期判断一概不变。
为了验证自己对单调队列的理解,建议用 nums = [1,3,-1,-3,5,3,6,7],k = 3 手动推一遍。前两个元素不足窗口长度,但队列已经初具雏形:
- i=0,x=1,队列为空,直接把下标 0 放进 deque,此时队列是 [0]。
- i=1,x=3,队尾是 1,3 比 1 大,弹出下标 0,放入下标 1,队列变成 [1],最大值是 3。
- i=2,x=-1,队尾 3 比 -1 大,不需要弹出,放入下标 2,队列是 [1,2],当前窗口 [1,3,-1] 的最大值在队头,是 3。
- i=3,x=-3,队尾是 -1,-1 比 -3 大,直接放入下标 3,队列是 [1,2,3]。
- i=4,x=5,5 比队尾 -3、-1、3 都大,所以把三个旧下标全部弹出,只留下下标 4,此时窗口 [ -3,5,3] 中 5 确实是最大值。
- i=5,x=3,队内下标 4 对应值 5,3 不大于 5,所以放入队列,得到 [4,5],窗口 [5,3,6] 还没形成,但下轮会看到真实含义。
- i=6,x=6,6 比队尾 3 大,弹出下标 5,6 也比队头 5 大,弹出下标 4,队列只剩 [6],因此后续窗口最大值是 6。
- i=7,x=7,同理,7 把 6 弹出,队列是 [7]。
手动推完一遍,会发现单调队列其实并不神秘:它通过“队尾淘汰弱者”“队头淘汰过期者”两条原则,让每个可能成为答案的下标最多在队列里存活一次,所以总操作数是线性的。这也是为什么它能替代堆来在线性时间内处理这个问题。
6. 固定窗口和覆盖型窗口:用计数来理解窗口状态
可变窗口讲完以后,Hot 100 里还有一类容易混进来的题目,比如给一个字符串,让你找其中所有“由指定字符组成的固定长度排列”的下标。这类题表面上和上面不一样,实际上也是滑动窗口,只不过窗口长度固定,或者要求窗口内容必须覆盖给定字符集。
固定窗口的例子可以看作窗口长度就是 k,right 每向右走一步,left 也会被迫向右走一步,从而保持窗口长度不变。这时你真正要维护的并不是“窗口是否合法”这种定性状态,而是窗口里各种字符的出现次数。典型场景是“字符串的排列”和“找到字符串中所有字母异位词”,目标是判断窗口内字符频率是否与目标字符串完全相同。
一种常见的低效做法是:每移动一次窗口就重新构造一次 Counter,然后和目标 Counter 比较。Counter 可以通过 Python 的字典比较直接判断相等,代码看起来很简洁:
python复制from collections import Counter
def findAnagrams(s, p):
target = Counter(p)
window = Counter()
res = []
k = len(p)
for i in range(len(s)):
window[s[i]] += 1
if i >= k:
window[s[i - k]] -= 1
if window[s[i - k]] == 0:
del window[s[i - k]]
if window == target:
res.append(i - k + 1)
return res
这种写法在笔试里能通过,但每次比较 Counter 时,最坏情况下要遍历所有不存在的字符,省不了多少事。更好的做法是维护两个计数数组或两个 Counter,再用一个变量 diff 记录当前窗口和目标状态有多少个字符频次不一致。每轮只更新被加入和被移出的字符,操作固定为 O(1),判断窗口是否成立时只看 diff 是否为 0。这样代码没有明显速度压力,也更容易解释给面试官。
覆盖型窗口的典型代表是最小覆盖子串。这个题的特点是需要一个“欠账”计数:先在哈希表里统计 t 中每个字符还需要多少次,然后用窗口去匹配。当某个字符的欠账数量归零时,说明这个字符已经覆盖够了,用一个变量 covered 记录已经覆盖的字符种类数。只有当 covered 等于 t 的总字符种类数时,窗口才算形成了完整覆盖。之后的工作就是不断尝试收缩左边界,找出最短覆盖。
这种题真正困难的部分不是窗口移动,而是记录状态变化时的语义。比如在移除左端字符时,必须先判断这个字符是否影响窗口的覆盖状态,再决定是否减少 covered。这里常见的错误是把覆盖的字符总数和覆盖的字符种类数混为一谈,导致同一字符出现多次时重复计数。我的建议是遇到这类问题先在草稿纸上画出字符计数表,把所有欠账数量写出来,再开始写 while 收缩,会少走很多弯路。
7. 哪些情况不适合用滑动窗口:不要盲目套模板
滑动窗口虽然高效,但滥用它也很容易翻车。使用滑动窗口至少需要满足几个条件:
第一,子结构必须是连续的。如果题目允许你从数组中挑出任意元素组成目标,而不是要连续一段,那窗口的“区间流动”概念就失效了。比如经典的两数之和或三数之和题,它们的核心是组合选择,不是连续区间,不该用滑动窗口。
第二,窗口状态必须能通过“添加新元素 + 移除旧元素”来高效更新。如果移除一个元素后无法快速更新出新的统计结果,滑动窗口的意义就不大。例如某些需要频繁求区间中位数的问题,移除元素后中位数不能简单回滚,单靠滑动窗口无法维持线性复杂度,这时候反而要借助双堆或平衡树等结构。
第三,窗口收敛的单调性必须成立。长度最小的子数组题之所以能用 while 收缩,是因为数组元素全是正数,窗口的和随 right 增加而单调增加,随 left 增加而单调减少。如果数组包含负数,“窗口和满足条件”就不再具有单调性,一个看似不满足条件的窗口可能在加入负数后又变得满足,暴力收缩就会错误地丢掉潜在解。遇到这类题要格外小心,即使题目允许负数出现,也需要重新审视窗口收缩的触发条件。
还有一个容易被误导的地方:不要把“固定长度区间求最值”和“所有子数组求最值”混为一谈。滑动窗口能在线性时间内解决前一个问题,不代表能解决后一个问题,因为后者的子区间数量本身是 O(n²),不可能在线性时间内全部枚举完。做这类题时先确认是不是真的只有连续区间参与运算,再决定要不要用窗口模型。
8. 自己复盘这组题时,我保留的几个实用习惯
最后分享几个我在刷 Hot 100 滑动窗口题时养成的习惯,实用性很强。
第一,每道题写完后,我会在代码里故意留一个 debug 输出:每次窗口变化后打印 left、right 和窗口内部状态。通过打印结果去对照手动推演的结果,能最快发现更新位置错在哪。比如最长无重复子串的 set 版本,如果输出发现 left 跳过了字符,基本就是 while 和 if 用错了。
第二,我会把“滑动窗口最大值”和“滑动窗口最小值”两份代码并排放在一起,只比较 while 条件的方向。这样能强化记忆,避免面试时现场分析单调队列的方向。最大值对应 while 里是 <=,最小值对应 >=,但如果只背这个结论,换一个新题就会忘。更稳妥的办法是现场用一个小例子推一遍,3、-1、5 这组数足够测试方向是否正确。
第三,区分窗口左右边界是左闭右闭还是左闭右开。我的习惯是统一用左闭右闭区间,这样窗口长度是 right - left + 1,退出 while 时 left 指向第一个不满足条件的位置,或者最后一个已经移除的元素位置。刷 LeetCode 的时候,右边界通常用 for 循环的 i 隐含表示,左边界则需要手动维护,保持接口一致能减少大量边界错误。
第四,做题时准备一个窗口题复查清单:更新答案是在扩张前还是收缩后,收缩用的是 while 还是 if,离开窗口的元素是否正确回滚,下标是否可能跳到 left 之前,窗口长度是否可能为负数。每完成一道新题就对这五个问题过一遍,比单纯刷十道题更有用。
滑动窗口看起来套路简单,真正的难点其实全在对“状态”的理解上。把状态更新的时机和边界条件摸透之后,Hot 100 里的窗口题就再也没有黑盒了。
