我平时刷题或者做工程优化的时候,只要看到“子数组”、“子串”、“连续区间”这几个词,脑子里第一反应就是滑动窗口。这名字听起来有点玄乎,剥开来看其实就是一个非常朴素的区间维护手段:在数组上保持一个动态的前后边界,像一条毛毛虫一样往前爬,窗口里的内容就是当前关心的那一段。这个思路在算法题里出现的频率极高,从“最长不重复子串”到“滑动窗口最大值”,再到工业界的限流、滤波、流量统计,背后全是它。这篇文章我打算直接把滑动窗口从原理到模板、从代码到工程场景一次讲透,重点包含三种最常用的窗口形式、最大最小值的单调队列解法,以及它和滤波模型之间的关联,顺便放几个我自己调试时踩过的坑。
1. 滑动窗口的核心思路与适用场景
1.1 暴力解法为什么慢,问题到底出在哪
先理解滑动窗口解决的是什么问题。假设有一个长度为 n 的数组,要找出所有长度为 k 的连续子数组里的某个统计量,最简单的做法当然是两层循环:外层枚举起点,内层枚举终点,把每一段都求一遍。以“长度为 k 的子数组最大和”为例,暴力代码长这样:
python复制def max_sum_bruteforce(nums, k):
n = len(nums)
ans = float('-inf')
for i in range(n - k + 1):
total = 0
for j in range(i, i + k):
total += nums[j]
ans = max(ans, total)
return ans
这个时间复杂度是 O(n*k)。当 n 是十万、k 是一半长度的时候,计算量就是十亿级别,基本没法用。问题的根源在于:相邻两个窗口之间有 k-1 个元素是重叠的,暴力做法把这些重叠部分一遍又一遍重复计算了。说得直白一点,你在重复做大量无用功,每移动一个位置,明明只是最左边出去一个数、最右边进来一个数,却把整个窗口里所有数重新加了一遍。
1.2 滑动窗口如何消除重复计算
滑动窗口的改进思路特别简单:既然大部分窗口内容没变,就别全部重算,只处理边界变化。窗口向右移动一格,就把左边的元素移出去、右边的元素加进来,统计量在这个基础上做增量更新。用这个思路实现的“长度为 k 的子数组最大和”长这样:
python复制def max_sum_sliding(nums, k):
n = len(nums)
if n < k:
return None
total = sum(nums[:k])
ans = total
for i in range(k, n):
total += nums[i] # 右边新滑入的元素
total -= nums[i - k] # 左边滑出的元素
ans = max(ans, total)
return ans
这样每一轮只做两次加减操作,整体复杂度降到 O(n)。这正是滑动窗口最大的价值:通过复用已有的计算结果,把原本需要重复遍历整个窗口的工作量,压缩成对每个元素只处理常数次。理解了这一点,再看后面所有变种都会觉得顺理成章。
1.3 适合用滑动窗口的问题有什么特征
不是所有数组题都适合滑动窗口。我自己总结了一套判断标准,碰到新题先拿这几条去套:
- 对象是连续的线性结构,也就是数组、字符串、链表这类。
- 需要研究的是连续子区间,题目里会说子数组、子串、连续序列。
- 窗口移动时,统计量的更新可以只依赖移出元素和移入元素,不必重新遍历整个窗口。
- 目标通常是要找一个满足某种约束的最长、最短或固定长度的窗口。
如果题目是求“不连续子序列”“全排列”“任意组合”,那滑动窗口就不是主方案,大概率应该考虑动态规划或者回溯。判断错了方向比不会做更浪费时间,这条经验我反复在面试和实际代码 review 中验证过。
1.4 窗口形态的两种基本分类
滑动窗口按形态基本分成两类:一类是窗口长度固定的固定窗口,一类是窗口左边界有条件地向右收缩的动态窗口,也叫双指针变长窗口。固定窗口最常见的使用场景是“连续 k 个元素的最大/最小/平均值”,而动态窗口更多用于处理“满足某个条件的最长/最短子串”这类问题。前者的核心在于增量更新的写法,后者的核心则在于什么时候收缩左边界、什么时候扩大右边界。
固定窗口就像一条定长的传送带,一步一步匀速往前走,每一步吐出一个旧元素、吞入一个新元素。动态窗口则更像一把可以伸缩的卡尺,右边界不断向前探索,一旦发现当前窗口不满足题目约束,左边界就跟着向前挪,直到重新满足约束。两种形态的代码模板不同,调 bug 的逻辑也不同,下面我分别展开说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 两种窗口实现模式:从模板到细节
2.1 固定长度窗口的代码模板
固定窗口几乎可以套同一个模板,不同题目只改更新统计量的那两行。我写的模板是这样:
python复制def fixed_window(nums, k):
n = len(nums)
if n < k:
return []
# 初始化第一个窗口
results = []
state = init_state(nums[:k])
results.append(generate_result(state))
# 滑动
for right in range(k, n):
state = update_add(state, nums[right]) # 新元素加入
state = update_remove(state, nums[right - k]) # 旧元素移出
results.append(generate_result(state))
return results
这里的 init_state、update_add、update_remove、generate_result 都是抽象动作,具体到不同题目再填充。这样分的最大好处是,代码逻辑和窗口滑动机制被解耦了,出 bug 时容易定位。实际写题时,不必真的每个题目都建一个类,但脑子里要能分清哪一步是“加新元素”,哪一步是“删旧元素”,顺序很重要。如果顺序写反了,比如先删后加和先加后删,在某些统计量上结果可能碰巧一样,但在求最大值最小值这类场景下会出错,而且错误还很隐蔽。
2.2 变长窗口的代码模板
变长窗口的通用结构是一个外层 for 循环移动右指针,内层 while 循环在条件不满足时收缩左指针。下面是最常见的写法框架:
python复制def variable_window(nums):
n = len(nums)
left = 0
state = init_state()
ans = 0
for right in range(n):
state = update_add(state, nums[right])
while not condition_satisfied(state):
state = update_remove(state, nums[left])
left += 1
ans = best(ans, candidate(state, left, right))
return ans
核心思想一句话:右指针势如破竹地往前扩,它负责“探索”;左指针在条件不满足的时候往前缩,它负责“约束”。这个模板在“无重复字符的最长子串”“最小覆盖子串”中非常管用。你可能注意到,内层 while 的总执行次数并不会超过 n,因为 left 只会从左往右移动,永远不会回头。所以整体时间复杂度仍然是 O(n),而不是 O(n^2)。
2.3 动态窗口的收缩时机到底怎么判断
我见过很多新手在写变长窗口时最大的困惑,就是不知道 left 到底该什么时候移动、移动多少。其实原则非常统一:当窗口当前的内容不满足题目约束时,就不断地收缩左边界,直到重新满足。举个例子,找“不含重复字符的最长子串”,约束是窗口内不能出现重复字符。右指针每加入一个新字符,如果发现这个字符上次出现的位置还在窗口内,说明出现重复了,那左边界就必须缩到重复位置之后。
这种收缩逻辑要写成通用伪码,关键在于抽象出两个函数:一个是判断窗口是否有效的 valid,一个是当窗口无效时逐步收缩 left。至于是 while 还是 if,取决于收缩一次之后是否可能仍然不满足条件。如果不确定,写 while 更安全,只要保证 left 不会越过 right 就可以。
2.4 为什么两种模板的时间复杂度都是 O(n)
不管你用固定窗口还是变长窗口,每个元素最多被右指针扫到一次、被左指针移出一次,所以总操作次数是线性的。这一点经常被面试官追问,需要讲清楚复杂度是怎样摊还的。变长窗口里,内层 while 看起来是嵌套循环,但每次 while 执行都伴随 left 的自增,left 在整个算法中绝不会超过 n,所以左指针移动的总次数最多是 n,所有 while 迭代合在一起是 O(n)。很多初学者看到 for 里面套 while 就以为一定是 O(n^2),其实那是没有分析出内层循环整体只执行 n 次。面试时如果能主动讲清这个摊还分析,会显得基本功很扎实。
3. 滑动窗口的经典算法题精讲
3.1 滑动窗口最大值:单调队列才是主角
窗口内的最大值是滑动窗口算法里有代表性的难题,因为单纯用增量更新求均值很容易,窗口往右挪一格,总和加一个数减一个数就得到新均值,但最大值不行——如果移除的那个数刚好是当前最大值,你没法直接知道第二大的数是多少,传统的堆虽然能维护最大值,但延时删除处理起来也比较麻烦。
这时候要用到一个重要的辅助结构:单调双端队列。队列里保存的是数组的下标,并且保证下标对应的元素值从队头到队尾是单调递减的。也就是说,队头永远是目前窗口内最大元素的下标。处理过程分三步:
- 移除队头中已经滑出窗口的下标。
- 从队尾弹出所有小于当前新元素的值的下标,因为它们在未来不可能成为窗口最大值了。
- 把新元素下标加入队尾。
为什么可以放心弹出那些较小元素,因为它们比当前新元素更早离开窗口,而且值还更小,永远没有机会翻身做最大值。这个“晚到且更大,就把早到且更小的全淘汰”的思路,本质上是一个单调栈思想的扩展。代码实现如下:
python复制from collections import deque
def maxSlidingWindow(nums, k):
dq = deque()
result = []
for i in range(len(nums)):
# 1. 移除已经离开窗口的下标
if dq and dq[0] <= i - k:
dq.popleft()
# 2. 移除队尾所有小于当前元素的下标
while dq and nums[dq[-1]] <= nums[i]:
dq.pop()
# 3. 加入当前下标
dq.append(i)
# 记录窗口最大值
if i >= k - 1:
result.append(nums[dq[0]])
return result
这个写法不仅简洁,而且每个下标最多入队一次、出队一次,整体是 O(n) 的时间复杂度,空间复杂度是 O(k)。我最初自己写的时候很容易漏掉第一步的过期判定,导致窗口已经划走之后,最大值还是旧窗口里的陈旧值。写这类题,队列里存下标而不是存值,也是一个容易踩坑的点。存下标的好处很明显:既能拿到值用于比较,也能判断队头是否还在当前窗口范围内,两者缺一不可。
3.2 滑动窗口的最小值:镜像对称的套路
有朋友觉得最大值会用单调递减队列,最小值那不得重新想一套逻辑。其实完全不用,思路是镜像对称的:维护一个单调递增的队列,队头始终是窗口内最小值的下标。每次处理新元素时,将队尾所有大于等于当前元素的值弹出,因为它们不可能再做最小值了。代码几乎和最大值版本一一对应,只需把比较符号和注释反过来:
python复制def minSlidingWindow(nums, k):
dq = deque()
result = []
for i in range(len(nums)):
if dq and dq[0] <= i - k:
dq.popleft()
while dq and nums[dq[-1]] >= nums[i]:
dq.pop()
dq.append(i)
if i >= k - 1:
result.append(nums[dq[0]])
return result
如果你掌握了两者的对应关系,刷题时可以少记一半代码。面试中考“滑动窗口最大值”的频率远高于最小值,但理解了最大值,最小值自然也能写出来。这个镜像对称特性也再次印证了一个观点:单调队列解决的是“窗口内第任意方向极值”的问题,区分只在弹出条件上。
3.3 最长无重复子串:变长窗口的入门代表
这个题在 LeetCode 上编号是第 3 题,几乎每个刷题的人都会碰到。在一个字符串中找出最长的不含重复字符的子串长度。用变长窗口的思路来解,非常自然。用一个字典记住每个字符最后一次出现的下标,右指针向右遍历,如果当前字符之前出现过,就把左边界直接挪到那个旧位置加一再进行比较,确保窗口内没有重复字符。
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 更新,永远要小心“过期位置”这个概念。
3.4 最小覆盖子串:哈希表与窗口check的配合
另一个变长窗口的典型是 LeetCode 第 76 题“最小覆盖子串”:在字符串 S 中找到包含字符串 T 全部字符的最短子串。做法是维护两个哈希表,一个记录 T 中每个字符的需求次数,另一个记录当前窗口中各字符的统计。用一个变量 matched 来表示当前窗口已经满足了多少个不同字符的需求。右指针扩展窗口时,如果某个字符出现次数刚好达到需求,matched 就加一;当 matched 等于 T 的字符种类数时,窗口已经包含了 T 的全部字符,此时尝试收缩左边界,并不断更新最短子串的起点和长度。
这个题的复杂度也是 O(n),但实现细节比前两个题多。遇到过好几次的问题是一个字符在 T 中出现多次时,统计是否达到需求的判断必须用“小于”,否则会把多次出现的字符提前计数。这类题多写几遍能极大提升对哈希表计数与窗口约束之间的控制力。
4. 从力扣走向工程:滑动窗口滤波的落地细节
4.1 你刷的算法题,其实就在你的传感器数据里
很多人在力扣上刷完滑动窗口后,觉得这只是应付面试的套路,直到某一天做传感器数据处理时才发现,原来生产环境里到处是滑动窗口。最常见的例子就是滑动窗口滤波模型:对时序数据,每隔固定时间取最近 N 个采样点,计算平均值作为当前滤波输出,比如一个温度传感器每 100ms 采集一次数据,如果直接用原始值展示,曲线会非常毛躁,而取最近 10 个点的均值,曲线会平滑很多,这就是滑动窗口滤波。
和算法题里的固定窗口求和其实一模一样:每来一个新采样值,窗口向右移动一格,加入新值、弹出旧值,平均值用窗口内的累加和除以窗口长度即可。用代码表示就是一个移动平均滤波器:
python复制class SlidingWindowFilter:
def __init__(self, window_size):
self.window_size = window_size
self.window = []
self.sum = 0.0
def update(self, new_value):
if len(self.window) == self.window_size:
self.sum -= self.window.pop(0)
self.window.append(new_value)
self.sum += new_value
return self.sum / len(self.window)
但这段代码有个性能隐患:pop(0) 的时间复杂度是 O(n),因为它要移动后续所有元素。真正常用的实现应该用 collections.deque,或者更进一步的环形缓冲区,让两端的操作都变成 O(1)。工业级实现还有一个更高级的变体叫“指数滑动平均”,不用维护整个窗口,只用一个变量保存上次平均值,新均值等于 alpha 乘新值加上 (1-alpha) 乘旧均值。 这对存储极度受限的嵌入式场景非常友好,缺点是窗口没有真实边界,早期数据的影响会衰减但不会彻底消失。
4.2 滑动窗口滤波器的延迟问题分析
热词里出现过“滑动窗口滤波器延迟”,这个问题在工程中确实常被忽略。滑动窗口滤波器的输出用的是当前窗口内所有数据的平均,所以它本质上是一个因果系统,输出一定滞后于信号的实时变化。滞后量大约是多少呢?对一个窗口大小为 N 的均值滤波器,在信号发生阶跃变化时,输出需要经过约 N 个采样点才能基本到达新的稳定值,可以近似认为它引入了 (N-1)/2 个采样周期的群延迟。
拿真实场景算一笔账:传感器采样率是 100Hz,也就是一个周期 10ms,如果用窗口长度为 10 的滑动平均滤波,延迟大约就是 5 个周期即 50ms。在一些电机转速闭环控制系统里,50ms 的延迟已经足够让控制环路的相位裕度明显下降,可能导致系统振荡。所以滤波器窗口不是越大越好,平滑度和实时性天生是一对矛盾。我在调嵌入式滤波参数时,一贯的做法是先确定系统能容忍的最大延迟,再反推窗口长度,绝不为了好看而盲目加大窗口。
如果既要平滑又要低延迟,有几种替代方案:使用加权滑动平均,给靠近当前时刻的采样点更高的权重;或者使用更高级的滤波器设计,例如一阶低通滤波器,它只需要两个参数,延迟特性往往比纯滑动平均更灵活。以下是一个一阶低通滤波的简易实现:
python复制class LowPassFilter:
def __init__(self, alpha):
self.alpha = alpha
self.y = None
def update(self, x):
if self.y is None:
self.y = x
else:
self.y = self.alpha * x + (1 - self.alpha) * self.y
return self.y
alpha 越接近 1,滤波越轻、响应越快;alpha 越小,滤波越重、延迟越大。实际调试alpha取多少往往不是算出来的,而是看实时曲线效果试出来的。
4.3 硬件描述语言里的滑动窗口:verilog 实现思路
滑动窗口滤波还有一个经常被搜到的应用场景是 FPGA 上的信号处理,热词里就有“滑动窗口滤波verilog”。硬件工程师处理传感器数据时,同样会用到滑动窗口均值滤波。verilog 实现滑动窗口滤波的常见办法是移位寄存器:在每个时钟上升沿,把新数据移位进入寄存器链,同时每个寄存器存着历史 N 个周期的采样值,然后再用加法树把所有寄存器里的值累加。为了提高吞吐率,累加不是在一个周期里全部完成,而是采用多级流水线,把大位宽加法拆成多个周期完成。
用移位寄存器直接实现的一个细节是,每个时钟周期都要把所有寄存器的值加一遍,当窗口长度变大时,加法树的面积和功耗增长很快。工程实践中更常见的做法是只保存窗口总和,每个周期加新值、减最老的值,这和在 Python 里维护窗口总和思路完全一样。这就需要额外设计一个 FIFO 或者环形缓冲,在 verilog 里维护一个指针指向最老数据的位置。另外还要小心定点数溢出,采样的位宽是 12 位,窗口长度是 1024,那么累加器最大可能需要 22 位甚至更宽,才能保证不溢出。
4.4 从算法题到限流算法:另一个常见的工业场景
滑动窗口另一个高频工业应用是限流。比如一个 API 网关限制每秒最多处理 100 个请求,最简单的计数器法是用一个变量记录当前时间窗口内的请求数,但固定窗口在两个窗口交界处有突刺问题,比如 0.9 秒时已经 100 个请求,1.1 秒时又是 100 个请求,实际两秒内过去了 200 个请求,这显然不够平滑。滑动窗口限流把时间切成更小的格子,例如把 1 秒分成 10 个 100ms 的格子,计算当前时间往前推 1 秒内所有格子的请求总数,超过阈值就拒绝新请求。这种思路在数据上仍然是一个滑动求和,和算法题里“长度为 k 的子数组最大和”结构完全同源。工程上和纯算法有一点不同,算法题的数组已经存在,可以等窗口整体构造好再开始移动;而限流是实时数据流,每条请求到达时窗口右端自然推进,核心要点是及时清理过期格子的计数,不然内存会随着时间不断膨胀。
5. 常见问题与排查技巧实录
5.1 写滑动窗口最容易出的几个错
最大滑动窗口题里老丢最大值,排查后发现队头虽然存的是最大下标,但该下标已经滑出窗口范围,这一步的过期移除如果放在最后做,就会在窗口收缩后继续引用旧值。所以单调队列解法里,过期移除必须放在每次循环的最前面。
变长窗口求最小覆盖子串时,左边界总是收缩不够,right 明明一直在往前走,left 却几乎没有动。调试后发现窗口收缩的 while 条件写成了检查子串是否“刚好等于”,而不是“覆盖”,导致不满足条件时没有完整收缩。这类题要记住,left 收缩的终止条件是窗口依然满足约束,不是刚好满足,只要满足约束就别缩,因为题目要求最短,缩到“刚好满足”已经是极限。
代码一跑就数组越界,排查发现初始窗口长度大于数组长度,这种情况应该提前返回。很多模板会忽略这个边界分支,实际写代码时一定要在函数开头加一句 n < k 的判断。还有一个隐蔽的越界场景是变长窗口 left 不断右移时,如果 left 不慎越过了 right,下一次访问 nums[left] 就会越界。收缩条件最好加上 left <= right 的约束。
我还遇到过统计结果对不上,排查发现是窗口更新顺序乱了。求窗口和时,先减滑出的元素再加新滑入的元素,结果没错;但如果在某些代码里先加了新元素、后减滑出的元素,遇到窗口恰好是新旧元素交替时就会出错。固定顺序永远是先加右边的,再减左边的,或者先减再加,但要在整个项目中保持同一个约定,不要混着写。
5.2 调试滑动窗口代码的通用技巧
调试这类代码,我自己的经验是用小规模用例把窗口每一步状态都列出来,人肉比对。比如数组 [1,3,-1,-3,5,3,6,7],k=3,求最大值,我会把每一步的队列状态和窗口内容写下来,一旦某一步队列内容和手推不一致,立刻就能发现问题。手推步骤看起来笨,但往往比单步断点更快让我理解到位,因为核心逻辑是数据结构状态的变化,不是某一行算术的错误。
还有一个方法是构造边界用例,先空数组、单元素数组、k 等于数组长度、数组全是相等元素、数组严格递增、严格递减。很多 bug 只有在这些极端用例下才会暴露。顺便说一句,如果是在 LeetCode 上题解报错,千万不要只盯着错的那个用例,用错例的输入反推是哪个边界条件没处理,往往一眼就能看出问题所在。
5.3 滑动窗口的性能优化进阶
有时候窗口逻辑写对了,但实际数据量一大还是慢。常见原因有三个:一是用了 O(n) 的列表删除操作,比如 Python 的 pop(0),改成 collections.deque 或自己维护左右指针后效率高几个量级;二是用 object 作为哈希键,导致哈希效率下降,在 Python 里字符或整数作为键通常没问题,但如果是自定义对象要确认哈希方法合理;三是在窗口内使用额外的排序或堆操作,如果统计量不需要全序,就尽量别引入 O(log k) 甚至 O(k log k) 的额外成本。
真正的工业级滑动窗口还会考虑内存分配问题。每次循环都新建一个列表或字典存放中间状态,会频繁触发垃圾回收,变得很慢。窗口状态尽量复用同一个数据结构,循环里只做增量修改,不做整体拷贝。对 C++ 来说,就是要留意 vector 扩容带来的拷贝开销,尽量用 reserve 预分配。
5.4 问题排查速查表
下面这张表是我在实际写代码和答疑中总结出来的高频问题清单,每次代码行为异常时,我都会按表里的顺序排查一遍。大部分 bug 都能在这个阶段被定位,剩下的才是真正需要系统性调试的疑难杂症。
| 症状 | 常见原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 窗口最大值偶尔偏大 | 过期下标没及时出队 | 检查队列头部下标与 i-k 的比较 | 循环一开始就做过期剔除 |
| 滑动窗口平均值偏低 | 窗口未满就参与统计 | 将窗口长度和算法题要求的对齐,确认初始条件 | 未满之前单独处理或跳过 |
| 变长窗口结果偏小 | 误用 if 代替 while 收缩 | 检查收缩条件是否可能连续触发 | 用 while 直到满足约束 |
| 死循环或者超时 | left 未递增或收缩逻辑不完整 | 打印 left、right 的状态变化 | 在每次收缩后自增 left |
| 全相等数组中最大值错误 | 弹出条件用了严格小于还是小于等于,边界处理不一致 | 和手推结果对齐 | 弹出条件可统一为小于等于 |
| 内存占用持续增大 | 窗口历史数据未清理 | 检查数据结构是否有删除过期元素 | 用双端队列或环形缓冲区 |
6. 写在最后的实操心得
滑动窗口算法刷到最后,你会发现题型再多,本质上也就两个问题要回答:什么时候窗口不满足条件,以及用什么数据结构高效维护窗口内的统计量。前一个问题的答案是“和题目约束比对”,后一个问题的答案,可能是一个普通变量、一个哈希表,或者一个单调队列。把这套判断框架刻在脑子里,远远比背诵十几个题型模板更重要。
另外分享一个我自己的小习惯:每次写完滑动窗口代码,我会顺手在草稿纸上画一遍窗口移动的过程,把每个元素进出窗口的时机标在时间轴上。这件事看起来慢,但能极大程度减少因索引错位、边界漏判带来的返工,特别是遇到题目同时考察窗口内最大值和最小值时,画图几乎是唯一能让我保持清醒的办法。
如果你正在准备面试或者刚开始接触算法,可以按这个顺序练:先做固定窗口求最大和,再做变长窗口求最长无重复子串,最后啃滑动窗口最大值。前两个帮你建立窗口状态量维护的直觉,最后一个帮你理解为什么单调队列是窗口问题里最锋利的工具。等这三关过了,再遇到滑动窗口系列的衍生题,不管包装成什么样,你都能从窗口和有边界的状态迁移角度去拆解它。
