如果说算法题里有什么是“背过模板还得跪”的,滑动窗口绝对算一个。很多朋友刷题的时候都遇到过这种情况:明明把模板抄下来了,也知道left和right两个指针怎么挪,但题目稍微一变就晕——比如窗口什么时候收缩,收缩到什么时候停,窗口内的数据怎么维护,一旦这几个问题想不清楚,代码就写成死循环或者答案差一位。这篇文章我就以实际做题和工程里的使用经验为背景,把“优选算法——滑动窗口”这件事彻底拆开,从最朴素的暴力枚举讲起,到固定窗口、可变窗口、单调队列优化,再看它在真实场景里的影子,最后把实践中踩过的坑和排查思路一并整理出来。
1. 滑动窗口的本质:先搞懂“窗口”和“滑动”这两个动作
1.1 从暴力枚举开始:为什么要“框”出一段连续区间
滑动窗口解决的问题几乎都有一个共同特征:操作对象是数组或字符串上的一段连续区间。比如“长度为 k 的子数组的最大和”“包含某些字符的最短子串”“乘积小于 k 的连续子数组个数”,这些问题的关键词是“连续”,而不是任意组合。
我最早学这类题的时候,第一反应就是穷举。两层循环,外层定起点,内层定终点,把所有连续子数组全部扫一遍,求最大、求最小、计数。这种做法本身没错,但它最大的问题在于:当数组长度为n时,连续子数组的数量是n(n+1)/2,而每个子数组可能还要再去算一次和、算一次乘积、数一次字符频次,整体复杂度往往到O(n^2)甚至O(n^3)。数据量小的时候无所谓,一到10^5级别的输入就跑不动了。
那“窗口”是怎么来的?你可以把数组想象成一条放在传送带上的长纸带,用一个固定宽度的取景框去看纸带上的内容。取景框从头移动到尾,每挪动一次,只取走最前面的一个元素、加入一个新元素,框内其余部分完全不动。这样一来,每一帧的画面(也就是窗口的内容)不需要重新计算全部,只需要在上一次的基础上做一个“加新减旧”的微调。这就是窗口思想的核心收益:用增量计算替代重复计算。
1.2 复杂度如何从 O(n*k) 降到 O(n)
很多教程直接告诉你“滑动窗口是 O(n)”,但不解释为什么,导致初学者只记住了结论,遇到具体问题还是不会分析。我换一个更直观的角度来解释。
假设数组长度是n=1000000,窗口长度k=1000。暴力方法对每个可能起点都重新扫一遍后 1000 个元素,总共大概要扫描1000000*1000=10^9次,这个量级用普通电脑跑起来一秒内基本没戏。滑动窗口的思路是:窗口从数组头部开始,每次右边界向右移动一格,左边也跟着向右移动一格,每次移动后窗口内只新增一个元素、移除一个元素,求“窗口和”时就只需要做一次加法和一次减法,全程扫描一遍数组就结束了,总运算量大约就是n次级别的常数倍,也就是10^6这个量级。
有人可能会问:窗口每次移动时,内部的“状态”(比如和、哈希计数、最大值)都不需要重新计算吗?答案是:如果这个状态满足“可增量维护”的条件——即新状态可以由旧状态加上新元素、去掉旧元素得到——那就不需要重新计算。这也是为什么滑动窗口题里最常用到的数据结构不外乎整数类型的累加值、哈希表/数组频次、双端队列等等。搞明白这个原理,再看后面那些模板和代码,你就知道每一行到底在干嘛了。
在实际做题和面试中,我认为“复杂度”这件事从来不是背公式,而是能说清楚“为什么少了那么多重复计算”。当你把这层逻辑想透,后面再怎么变形都不慌。
2. 固定窗口模板:最简单也最容易抄错的形态
2.1 一段可以直接落地的固定窗口框架
固定窗口是所有滑动窗口里最友好的入门形态。所谓“固定”,就是窗口长度始终等于k,每次右边界扩展一格,左边界也跟进一步,始终保持窗口大小为k。典型题目就是“长度为 k 的连续子数组最大平均值”。
我下面给出一段 Python 参考实现,注释写得很细,可以当成一个通用的起步模板:
def find_max_average(nums, k): n = len(nums) if n < k: return 0.0 # 先统计第一个窗口的和 current_sum = sum(nums[0:k]) max_sum = current_sum # 窗口向右滑动 for i in range(k, n): # 加入新元素,移除最左边元素 current_sum += nums[i] - nums[i - k] # 记录最大值 if current_sum > max_sum: max_sum = current_sum return max_sum / k这段代码的关键操作只有一行:current_sum += nums[i] - nums[i - k]。nums[i]是窗口右边界新纳入的元素,nums[i - k]是窗口左边界正要被挤出去的元素。窗口每移动一步,不需要再重新加一遍 k 个数字,这就是上一节说的增量维护。
固定窗口的适用面其实比很多人想的宽,凡是“找一段长度固定的连续区间满足某个条件”“求所有长度恰好为 k 的窗口内的某种统计量”都能用。比如求长度为 k 的子数组最大平均数、子数组最大和、每个滑动窗口的平均值,甚至某些“窗口内不同字符个数”的问题,只要统计量能快速更新,都可以套这个骨架。
2.2 固定窗口的三个隐蔽细节
写模板容易,写对不容易。我总结三个新手特别容易翻车的地方。
第一个是第一个窗口的初始化。很多人直接让左右指针从 0 开始,然后一边扩展一边判断长度,结果发现循环逻辑特别别扭,边界条件反复出错。更省心的方法是先单独算第一个窗口,就像上面的代码里先用sum(nums[0:k])做一个初始化,然后再写滑动循环。这样主循环里就只需要处理“窗口已经成形之后的移动”,清晰很多。
第二个是先加还是先减的顺序问题。在固定窗口里,因为窗口长度不变,所以current_sum += nums[i]和current_sum -= nums[i-k]的顺序其实不影响最终结果,但如果你把窗口状态更新放在“记录结果”之后,就会错过第一个窗口或者最后一个窗口的结果。建议形成一个固定习惯:先更新窗口状态,再记录/更新答案。顺序一致了,调试的时候就不容易乱。
第三个是窗口长度不足 k 的情况。比如数组长度小于 k,理论上根本不存在一个合法的固定窗口,这时候直接返回什么值得看题目约定。有些题要求返回-1,有些返回0,最怕的是你没做这个判断,然后去访问nums[k]导致数组越界。我见过不少人因为这个问题在面试里栽跟头,说到底就是“先想清楚边界,再写代码”。
还有一个容易被忽略的点:如果固定窗口总长度很大,用
sum(nums[0:k])每次都对前 k 个数求和,在极端情况下(比如 k 接近 n)倒也不是不行,但更好的做法是先用一个for循环把前 k 项累加出来,甚至在读入数据时同步维护前缀和,后续所有窗口的和都可以用前缀和直接相减得到。
3. 可变窗口:真正的难点是“什么时候缩”
3.1 “右扩左缩”的基本套路:一个模板应对八成题目
固定窗口练熟之后,真正的分水岭是可变窗口。所谓可变,就是窗口长度不固定,右指针不断向前走,左指针在特定条件下才向前收缩,从而得到“某个约束条件下最优的连续子数组/子串”。
这类题最典型的场景是求“最长/最短满足条件的子数组/子串”。比如“无重复字符的最长子串”“和小于 target 的最长子数组”“包含所有目标字符的最短子串”。它们的通用解法可以浓缩成一个套路:
- 初始化
left = 0,right = 0,以及一个用于表示窗口状态的变量(如哈希表、计数器、累加和)。 - 外层循环
right从 0 到 n-1 一直向右扩展,不断把nums[right]纳入窗口状态。 - 内层循环在“窗口状态不满足约束”或者“已经满足要求需要寻找更优解”时,移动
left收缩窗口,同时把移出的元素从状态里剔除。 - 每次窗口状态合法时,尝试更新答案。
这个套路看起来简单,但真正容易出问题的是第 3 步的“收缩条件”。我把它分为两类来理解。
第一类是超过约束就收缩,比如“找到数组和小于 target 的最长子数组”。当窗口内的和大于等于 target 时,说明当前窗口不满足要求,必须把左边界往右移,同时减去对应的值,直到重新满足“和小于 target”。这种收缩是为了让窗口回到合法状态,然后继续尝试找更长的窗口。
第二类是已达目标后尝试更优解,比如“包含所有目标字符的最短子串”。当窗口已经包含了全部所需字符时,题目要求最短,所以这时候记下当前长度,然后主动从左边缩小窗口,看看缩小之后还满不满足“包含全部目标字符”。如果缩小后依然满足,说明找到了更短的答案;如果不满足,就继续扩展右边界。这样一来,每一个合法窗口都不会被漏掉,而且能在 O(n) 时间内覆盖所有可能的边界组合。
区别清楚了再写代码,思路会顺很多。很多人卡在“什么时候更新答案”上——其实答案只应该在窗口处于合法状态时更新。如果你在窗口不合法时也去更新,结果就是答案被污染。
3.2 用“无重复字符的最长子串”完整拆解一次过程
空谈模板有点抽象,我们选一道最经典的题目“无重复字符的最长子串”(LeetCode 3)来逐步拆解。原始问题描述很简单:给定一个字符串,请找出其中不含有重复字符的最长子串的长度。
先想清楚状态怎么维护。我们要知道窗口内有哪些字符、以及它们各自出现了几次。这里用哈希表freq记录每个字符在当前窗口中的出现频次,每次右指针扩展时把新字符计数加一,如果发现某个字符计数大于 1,就说明窗口里有重复字符,需要收缩左边界。
具体过程是这样的:
s = "abcabcbb" left = 0 right = 0 freq = {} max_len = 0 right = 0, 字符 'a', freq['a'] = 1, 窗口合法, max_len = 1 right = 1, 字符 'b', freq['b'] = 1, 窗口合法, max_len = 2 right = 2, 字符 'c', freq['c'] = 1, 窗口合法, max_len = 3 right = 3, 字符 'a', freq['a'] = 2, 发现重复 -> 循环收缩 left: 移除 s[left]='a', freq['a'] = 1, left = 1, 此时窗口内字符是 "abc",窗口内 'a' 的频次为1,合法 -> max_len = max(3, right-left+1=3) = 3 right = 4, 字符 'b', freq['b'] = 2, 发现重复 -> 收缩 left: 移除 s[left]='b', freq['b']=1, left=2, 窗口内字符 "abc",合法 -> max_len = 3 right = 5, 字符 'c', freq['c'] = 2, 发现重复 -> 收缩 left: 移除 s[left]='c', freq['c']=1, left=3, 窗口内字符 "bc",合法 -> max_len = 3 right = 6, 字符 'b', freq['b']=2, 发现重复 -> 收缩 left: 移除 s[left]='b', freq['b']=1, left=4, 窗口内字符 "cb",合法 -> max_len = 3 right = 7, 字符 'b', freq['b']=2, 发现重复 -> 收缩 left: 移除 s[left]='c', freq['c']=0, left=5, 窗口内字符 "b",合法 -> 收缩 left: 移除 s[left]='b', freq['b']=1, left=6, 窗口内字符 "b",合法 -> max_len = 3最终答案是3。这一步一步推下来,你会发现窗口的收缩并不是随便乱缩的,而是要缩到“当前窗口状态重新满足条件”为止。注意在right=7时,我们连续收缩了两次左边界,说明内层循环必须是while而不是if——这是可变窗口里一个极其容易忽略的细节。
代码可以写成这样:
def length_of_longest_substring(s: str) -> int: freq = {} left = 0 max_len = 0 for right, ch in enumerate(s): freq[ch] = freq.get(ch, 0) + 1 # 只要窗口内有重复字符,就不断收缩左边界 while freq[ch] > 1: left_char = s[left] freq[left_char] -= 1 left += 1 max_len = max(max_len, right - left + 1) return max_len这里用freq[ch] > 1作为收缩条件,其实隐含了一个事实:引起重复的必然是刚加入的ch。所以收缩时可以只盯着ch的频次直到它恢复为 1,而不用每次检查整个哈希表。这也是实际做这类题时一个很实用的优化点。
3.3 再拆一个“最小覆盖子串”问题加深理解
“最小覆盖子串”(LeetCode 76)是最能检验可变窗口掌握程度的题。题目要求:在字符串s中找出包含字符串t所有字符的最短子串。注意这里说的是“包含所有字符且数量不少于 t 中的数量”,不要求顺序一致。
我习惯用两个计数来维护状态:一个是need,记录 t 中每个字符还需要多少个;另一个是formed,表示当前窗口中“已经达到 t 要求”的字符种类数。当formed等于 t 中不同字符种类数时,说明当前窗口已经是一个合法覆盖子串,接下来就尽量收缩左边界来找更短答案。
核心代码如下:
def min_window(s: str, t: str) -> str: from collections import Counter need = Counter(t) # required = 多少种字符需要被覆盖 required = len(need) formed = 0 left = 0 ans = (float("inf"), 0, 0) window = {} for right, ch in enumerate(s): window[ch] = window.get(ch, 0) + 1 if ch in need and window[ch] == need[ch]: formed += 1 # 当窗口已经覆盖 t 中全部字符,尝试收缩 while formed == required: if right - left + 1 < ans[0]: ans = (right - left + 1, left, right) # 从窗口移除最左边字符 left_char = s[left] window[left_char] -= 1 if left_char in need and window[left_char] < need[left_char]: formed -= 1 left += 1 return s[ans[1]: ans[2] + 1] if ans[0] != float("inf") else ""这里最关键的点在于:formed只在“某个字符的计数从不足变成满足”时加一,在“从满足变成不足”时减一。因为每类字符只会被封顶一次,所以formed不会无限增长,算法才能在 O(n) 内完成。我在面试中见过不少人用朴素方法,每次检查窗口是否覆盖 t 时都重新扫描一遍 t,然后整体复杂度就退化成了 O(n*|t|),这在大数据量下基本就是送命。
用可变窗口时一定要祈祷一件事:窗口状态的维护是单调且有界的。所谓“单调”,是指
formed这类指标只会因窗口伸缩而产生有限的增减;所谓“有界”,是指哈希表的大小不会无限制膨胀。只要这两点成立,整个算法的时间复杂度就是 O(n) 级别,这也是滑动窗口能作为“优选算法”存在的根基。
4. 单调队列优化:滑动窗口最大值的正解
4.1 为什么普通队列和优先队列都不够好
固定窗口里有一个非常经典的问题:给定数组nums和窗口大小k,求每个长度为 k 的窗口内的最大值。很多人一上来就用大顶堆(优先队列),每次窗口移动时把新元素入堆,把出窗口的元素做标记,然后取堆顶最大值。这个思路可行,但有个隐患:当你从堆顶弹出最大值之后,如果这个最大值其实已经不属于当前窗口了,你还得继续找到下一个仍在窗口内的最大值,这就导致堆中会残留大量过期元素。每次取最大值的代价虽然是 O(log k),但堆里累计的过期元素多了以后,内存和实际运行时间都会变差。
有人可能会说:那我直接用普通队列,每次窗口滑动时遍历一遍窗口内元素找最大值,不就没有过期问题了吗?没错,但每次找最大值要 O(k),整体复杂度会退化到 O(n*k),窗口稍微大一点就完全跑不动。
真正高效且优雅的做法是单调队列。它本质上是一个双端队列,但队列里的元素保持严格单调递减(或递增)的顺序。窗口每移动一次,我们做两件事:
- 从队尾入队新元素,入队前把所有比新元素小的队尾元素全部弹出。
- 从队头弹出已经不在当前窗口内的元素。
这样队头永远都是当前窗口的最大值。整个过程的均摊复杂度是 O(1),整体算法是 O(n)。
4.2 单调队列的维护规则,一步一步演示
我拿一个具体例子走一遍。假设nums = [1,3,-1,-3,5,3,6,7],k = 3。我们用一个双端队列q存的是数组下标,而不是元素值,因为只有保存下标,才能判断某个元素是否还在窗口内。
- 初始 i=0,元素 1。队列为空,直接把 0 入队。队列:
[0],对应值[1]。 - i=1,元素 3。队尾元素是
nums[0]=1,1 小于 3,将 0 弹出,然后 1 入队。队列:[1],对应值[3]。此时 i=1 还没到窗口长度 3,不输出。 - i=2,元素 -1。队尾元素是
nums[1]=3,3 不小于 -1,所以 -1 可以直接入队。队列:[1,2],对应值[3,-1]。当前窗口[1,3,-1]最大值是 3,输出nums[q[0]]=3。 - i=3,元素 -3。队尾
nums[2]=-1,-3 不比 -1 大,直接入队。队列:[1,2,3],对应值[3,-1,-3]。但注意队头下标 1 已经小于 i-k+1=1?i-k+1 = 1,所以队头还在窗口内。当前窗口[3,-1,-3]最大值 3,输出 3。 - i=4,元素 5。队尾
nums[3]=-3,弹出;再往前nums[2]=-1,弹出;再往前nums[1]=3,3 小于 5,也弹出。队列空了,4 入队。队列:[4],对应值[5]。当前窗口[-3,5,3]最大值 5,输出 5。 - i=5,元素 3。队尾
nums[4]=5,5 不小于 3,3 入队。队列:[4,5],对应值[5,3]。队头下标 4 仍在窗口内。当前窗口[5,3,6]?等等,这里要小心,窗口是 i-k+1 = 3 到 5,也就是原数组下标 3、4、5 对应[-3,5,3]。但我上面写串了。你只需要知道结论:输出 5。 - i=6,元素 6。队尾
nums[5]=3弹出,队尾nums[4]=5弹出,6 入队。队列:[6],输出 6。 - i=7,元素 7。队尾 6 弹出,7 入队。队列:
[7],输出 7。
最终输出的结果是[3,3,5,5,6,7]。对应每次窗口的最大值,大家可以自己拿原数组验证一下,完全正确。
这里有一个关键操作顺序:先清理队头过期元素,再清理队尾的小元素,最后入队新元素,并输出当前窗口答案。顺序如果反了,可能导致元素还没入队就去判断队头过期,或者把新元素弹掉了,逻辑就会出错。
完整代码:
from collections import deque def max_sliding_window(nums, k): q = deque() res = [] for i, v in enumerate(nums): # 移除已经离开窗口的队头下标 if q and q[0] <= i - k: q.popleft() # 移除队尾所有小于当前值的元素,维持单调递减 while q and nums[q[-1]] <= v: q.pop() q.append(i) # 窗口成型后开始记录 if i >= k - 1: res.append(nums[q[0]]) return res单调队列这道题的代码虽然短,但它是“滑动窗口 + 数据结构”的组合题里最常考的一种。如果你能在白板上讲清楚“为什么队尾要弹出比它小的元素”,基本上就能证明你真的理解了这层优化,而不是背模板。
4.3 单调队列之外的变式和延伸
理解了单调队列求最大值,求最小值就是把条件反过来,维护一个单调递增队列,队头永远是当前窗口的最小值。两者都理解了以后,可以试着做一些组合题——比如“滑动窗口中位数”“滑动窗口众数”“滑动窗口内不同元素的个数”,这些题会和堆、哈希表、平衡树等其他数据结构交叉,但底层的窗口滑动思路始终不变。
我也顺便提一句单调栈。单调栈处理的是“找左边/右边第一个比当前元素大/小的元素”这类问题,它和单调队列的核心思想高度一致,都是通过去掉无用的中间状态来维护某种单调性。区别在于一个作用于栈,一个作用于双端队列。把这两个放在一起对比学习,效果会好很多。
5. 滑动窗口在真实工程里的样子
5.1 网络传输与流量控制中的窗口思想
滑动窗口这个词其实不只是在算法题里用,它在计算机网络的传输控制里同样是个核心概念。传输层协议里经常提到的“窗口”表示发送方在不等待确认的情况下,最多还能发送多少数据。窗口越大,单位时间内能传的数据就越多,但网络拥堵时窗口会被调小,避免丢包和重传。虽然网络协议里的窗口尺寸调整逻辑比刷题复杂得多,但它们的基本思路是完全一致的:维护一个连续的范围,按条件前后滑动,以此控制一段数据的处理量。
理解这一点对工程师来说挺重要,因为很多底层网络和分布式系统里的“限流”策略也是这个思路。比如固定窗口计数,在时间轴上划分出一段段等长的窗口,每段窗口内允许一定数量的请求通过;时间走到下一个窗口,计数器清零,重新计数。这种策略实现简单,但缺点是可能存在窗口交接处的双倍流量峰值,所以后来又有了滑动窗口日志、滑动窗口计数器等更平滑的方案。
5.2 信号滤波与传感器数据处理:滑窗均值
另一个非常贴近日常的场景是信号滤波,尤其是传感器数据的平滑处理。如果你做过姿态解算、温度采集、声音强度检测之类的项目,一定遇到过“数据毛刺太大,直接读数根本没法用”的问题。最简单的处理办法就是“滑动窗口均值滤波”:维护最近 N 个采样值,每次新数据到来时,去掉最旧的一个、加入最新的一个,然后求窗口内的平均值,用这个平均值作为当前输出。
对应热词里的“滑动窗口滤波模型”和“滑动窗口滤波器延迟”,我再展开说一句。窗口越大,平滑效果越好,但延迟也越大——因为输出是对过去 N 个时刻的平均,N 越大,系统对信号的响应越迟钝。这个“延迟”是滑窗滤波天生的特性,没有任何参数能完全消除。工程上一般会在“平滑度”和“响应速度”之间取一个平衡,比如温度传感器用 5 到 10 个点的窗口,姿态解算里的互补滤波或 Kalman 滤波本质上也借鉴了类似的“历史状态加权”思想。
写一个非常简化的滑窗平均滤波示例:
from collections import deque class SlidingWindowAverage: def __init__(self, window_size): self.window_size = window_size self.window = deque(maxlen=window_size) self.total = 0 def update(self, value): if len(self.window) == self.window_size: self.total -= self.window[0] self.window.append(value) self.total += value return self.total / len(self.window)这个类里面其实就蕴含了滑动窗口最核心的“加新减旧”逻辑,和刷题时的窗口求和几乎一模一样。区别只在于数据来源是实时流,而不是静态数组。
5.3 日志聚合、实时统计与数据库分页
滑动窗口在工程里还有一个常见形态是基于时间窗口的实时统计。比如“最近 5 分钟的错误日志数量”,你自然不能每次都从全量日志里重新扫一遍,而是维护一个 FIFO 队列,新日志进来,超过 5 分钟的老日志就移出去,队列长度随时反映最近 5 分钟的总量。这也是滑动窗口在“流式数据”场景下的直接应用。
包括数据库里的分页查询,如果你把“LIMIT offset, size”看作一个固定窗口,每次翻页就相当于窗口整体往后移动 size 行——虽然它的实现和内存中的滑动窗口不是一回事,但那种“框住一段连续数据,按顺序推进”的直觉是一脉相承的。这也是为什么我认为滑动窗口不仅是一种刷题技巧,更是一种通用的“增量维护连续区间状态”的工程思维。
在工程里用滑动窗口的时候,我不太建议为了省内存把窗口压缩成几个聚合值。因为一旦后面有“窗口回溯”的需求(比如要看某个时刻窗口内的完整数据),聚合值就救不回来了。该维护队列就维护队列,只有在能明确接受信息丢失的前提下,才考虑只存统计量。
6. 常见问题与排查心得实录
6.1 死循环与超时:while 条件的三种典型错误
可变窗口最常见的问题就是死循环。我总结了三种几乎每个人都犯过的错误。
第一种是收缩循环没有正确终止。比如在“无重复字符最长子串”里,如果用if freq[ch] > 1而不是while,那一次只能移出一个字符,当窗口里有两个重复字符时根本清不干净,于是外层循环继续跑但状态一直不合法,最终结果错误或者卡死。解决办法很简单:凡是收缩操作,先问自己一句“收缩一次够吗”,不够就上 while。
第二种是左右指针相遇后还在收缩。当left已经大于right时,窗口已经没有意义了,此时再去访问s[left]就会越界。保险做法是内层循环加left <= right的边界条件,或者在收缩前判断left < right。这看起来是个很小的问题,但当window_size为 0 或者输入为空字符串的时候,就是个大坑。
第三种是状态更新顺序反了。比如先移动了left,再减去s[left],结果减掉的不是打算移除的那个字符。我在实际调代码时发现,最好的习惯是“先根据当前 left 位置更新状态,再 left += 1”,顺序固定,能少一大半低级错误。
6.2 越界与结果遗漏:边界值处理的三个经验
边界问题在滑动窗口题里非常普遍,尤其是那些从 1 开始计数的场景。我分享三个高频经验。
第一,循环里right从 0 到 n-1 跑完整个数组后,最后一个窗口的状态往往没有被记录。比如固定窗口题里,有人只在循环体内更新结果,但因为初始化时没有预处理第一个窗口,导致第一个窗口的结果被漏掉;或者可变窗口里,所有合法窗口也只会在 for 循环体内被记录,如果最后一次right移动后窗口完全合法但循环刚好结束,就可能漏答案。解决思路是:固定窗口先预处理第一个窗口,可变窗口在循环体内每步移动都尝试更新答案。
第二,数组为空、窗口长度比数组大、目标字符串为空这几种特殊输入,在代码里最好用一两个 if 直接挡掉,而不是塞进通用逻辑里去硬算。比如“最短覆盖子串”里,如果t为空字符串,答案应该直接返回空串,根本不用进循环。
第三,哈希表里的频次可以出现负数吗?在“最小覆盖子串”这类题中,左边界收缩时会不断减少字符频次,某个字符可能在 t 中出现过但已经被窗口完全移出,此时频次变成 0 甚至负数。这不一定是坏事,但如果你用“频次是否大于 0”来判断字符是否在窗口里,就会出错。建议统一约定:频次只表示“窗口与目标的差值”,不做额外语义判断。
6.3 如何一眼识别滑动窗口题:五个判断信号
最后我把“什么题适合用滑动窗口”总结成几个判断信号,这些经验主要来自 LeetCode 刷题和面试复盘,对我个人来说准确率很高。
第一,题目描述里出现“连续子数组”“连续子串”字样,并且要求在连续区间上找最大值、最小值、长度、计数。第二,数据规模在10^5左右,暴力的 O(n^2) 必然超时,而 O(n) 的滑动窗口正好合适。第三,要求的最优解与某个“可变区间长度”相关,而不是固定在某一个坐标点上。第四,窗口内状态能通过增删元素快速更新。第五,往往会有两个指针同时移动的直觉,也就是常说的“双指针”。
当然也有不适合滑动窗口的情况。比如子序列问题(不要求连续),滑动窗口就无法直接派上用场,得改用动态规划;再比如窗口内的状态没法增量维护,必须重新全量计算,那滑动窗口的优势就不存在了。遇到这些题,硬套模板只会越写越乱。
我个人做这类题的经验是:先别急着写代码,拿一支笔在纸上把
left和right的移动轨迹画出来,标出每一轮窗口的合法/不合法状态,再写代码就是水到渠成的事。很多人写不出来,不是因为不会语法,而是因为脑子里根本没有“某一轮窗口长什么样”的画面。
滑动窗口作为一类经典的优选算法,它真正教给我们的不是一个模板,而是一种思维方式:面对大量连续区间的计算,如何通过“加新减旧”的方式避免重复劳动。这层思考无论在刷题、面试还是工程实现里都极其受用。希望这篇从原理到实践、再到避坑心得的拆解,能帮你真正拿下这个看似简单、实则暗藏玄机的算法。