ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

定长滑动窗口:算法、通信与信号处理中的统一模型

定长滑动窗口:算法、通信与信号处理中的统一模型 你可能在算法题里见过“滑动窗口最大值”在计算机网络教材里见过“滑动窗口重传协议”在传感器数据处理中见过“滑动平均滤波”。这三样东西看起来毫不相关但骨子里其实是同一个模型定长滑动窗口——一段长度固定、不断向前平移的观察区间。这篇文章就把这三条线串起来讲透。既然是基础篇我不打算堆一堆高级技巧只做一件事让你以后再看到“定长滑动窗口”这六个字能立刻知道它到底在说什么、能做什么、坑在哪里。1. “定长”两个字是理解滑动窗口的第一道门槛1.1 为什么窗口长度必须固定很多人刚接触这个概念时最容易忽略的就是“定长”这两个字。窗口长度一旦确定问题就会变得非常清爽每个时刻窗口里的数据量是固定的旧数据什么时候离开、新数据什么时候进入全部有章可循。这个特性在算法、通信、信号处理里都极其重要。举个例子你在传感器数据里取最近 100 个采样点做平均那 100 就是一个定长窗口。每来一个新采样点就踢掉最旧的那个点窗口里永远只有最近 100 个点。你把窗口长度改成 200平滑效果会更好但响应速度会更慢。这就是定长窗口的第一个核心意义用“长度”换来“行为可预期”。反过来看如果窗口长度不固定问题就完全变了。在算法题里可变窗口通常意味着双指针或二分查找在通信协议里可变窗口意味着拥塞控制和动态调速在信号处理里可变窗口意味着自适应滤波。它们虽然也叫“滑动窗口”但复杂度不在一个量级。所以基础篇必须先强调定长是所有后续讨论的地基。1.2 队列视角滑动窗口本质上是一个固定容量的队列如果你写过一点代码会很快意识到定长滑动窗口本质上就是“容量为 K 的队列”。每来一个新元素从队尾入队如果队里元素超过 K 个就把队头元素出队。整个过程像一个排队买票的队伍窗口长度就是队伍人数人往前走一步队伍尾部就多一个人头部就少一个人。但这个视角有一个容易误导人的地方裸队列只能告诉你“窗口里现在有哪些元素”却回答不了“窗口里的最大值是多少”“窗口里的平均值是多少”。后者需要额外的辅助结构。所以你在实际应用中看到的定长滑动窗口往往是“队列 某种附加处理”的组合而不是一个孤零零的队列。我见过不少初学者一上来就用 Python 的list.pop(0)模拟出队然后发现在大数据量下慢得离谱。原因很简单pop(0)会让后面所有元素前移单次操作就是 O(N)整个滑动过程变成了 O(N*K)数据稍微一大就爆炸。记住滑动窗口的“滑动”是两个动作右边进来一个左边出去一个。这个动作必须做到 O(1)否则你的窗口再标准也只是玩具。1.3 一个最朴素、但功能完整的定长窗口骨架我们先用最直白的方式写一个定长窗口的处理框架不追求性能只追求把“滑动”这件事说明白。def fixed_window(seq, k): n len(seq) window [] for i in range(n): window.append(seq[i]) # 右侧进入 if len(window) k: window.pop(0) # 左侧离开 if len(window) k: # 在这里处理当前窗口的内容 print(window)这段代码的流程非常直观遍历输入序列每次先加入一个新元素如果窗口超长就移除最左边元素窗口长度正好为 K 时执行我们需要的计算。这个骨架适合理解但不适合直接用于生产。原因有两点一是pop(0)是 O(N) 操作二是如果你每次都对整个窗口重新求和、重新求最大值那总复杂度还是 O(N*K)。真正的优化思路是不要让窗口里的数据“重复计算”。求和时维护一个累加值最大值/最小值时维护一个单调队列统计类问题时维护一个哈希表。这些优化后面会逐个展开。你现在只需要记住定长窗口的底层动作永远只有两个——入队和出队所有复杂逻辑都建立在“保持窗口长度恒定”这个前提之上。2. 算法题里的定长窗口从暴力解到单调队列2.1 三类最高频的定长窗口问题刷题时碰到的定长窗口问题翻来覆去其实就三类窗口求和/求均值、窗口最大值/最小值、窗口内某种元素的统计计数。很多题目看起来花里胡哨剥掉外壳都能归到这三类里。问题类型暴力做法定长窗口优化常用辅助结构窗口和 / 均值每个窗口重新累加 O(N*K)增量更新加右减左 O(N)一个累加变量窗口最大值 / 最小值每个窗口重新扫描 O(N*K)单调队列 O(N)双端队列 deque窗口内某种计数每个窗口重新统计 O(N*K)哈希表 增量更新 O(N)哈希表 / 计数器这张表的核心信息是定长窗口能把大多数“连续 K 个元素”的问题从 O(N*K) 降到 O(N)。代价是你要额外维护一个辅助结构。不同问题对应不同结构选错结构复杂度就会退回去这也是很多人明明用了“滑动窗口”却还是超时的原因。2.2 单调队列求窗口最大值为什么队头一定是答案窗口最大值是个很经典的问题经典到很多面试官会直接问“滑动窗口最大值”。我先说暴力思路枚举每个窗口的开始位置再遍历窗口内 K 个元素找最大值总复杂度 O(N*K)。数据量小没问题数据量一大就完蛋。优化思路是单调队列。你维护一个双端队列里面存的是数组下标并且要保证这些下标对应的元素值从队头到队尾严格递减。换句话说队列里装的永远是“到目前为止有可能成为窗口最大值的候选元素”。具体维护规则有三条新元素入队前从队尾开始把所有小于等于新元素的值全部弹出因为它们在新元素存在期间不可能再成为最大值。新元素的下标放入队尾。如果队头下标已经滑出当前窗口即下标小于窗口左边界把队头弹出。为什么队头一定是当前窗口最大值因为队列里的元素是递减排列的队头最大而且我们会在它滑出窗口时第一时间移除它所以队头永远不会是一个已经离开窗口的过期元素。这就是单调队列的核心原理用“淘汰掉不可能再成为答案的旧值”来保证效率。我用 Python 给你演示一个完整实现。这里的关键点是队列里存下标而不是存值因为只有下标才能判断元素是否已经滑出窗口。from collections import deque def maxSlidingWindow(nums, k): q deque() res [] for i, x in enumerate(nums): # 1. 保持队列从队头到队尾递减 while q and nums[q[-1]] x: q.pop() q.append(i) # 2. 移除已经滑出窗口的队头下标 if q[0] i - k: q.popleft() # 3. 当窗口长度达到 k 时记录结果 if i k - 1: res.append(nums[q[0]]) return res求窗口最小值只需把判断条件反过来保持队列单调递增其他逻辑完全一致。你可能会问为什么不直接用堆因为堆没法高效处理“过期元素”的删除问题你虽然可以用懒删除但代码复杂度会明显上升。单调队列最优雅的地方在于每个元素最多入队一次、出队一次整体 O(N)而且代码很短。我自己在面试中比较偏爱讲单调队列因为它能顺带考察你“为什么用下标而不是值”这一层理解是否到位。2.3 窗口求和前缀和与增量更新到底怎么选窗口求和可能是定长窗口里最简单的一类简单到有些人会误以为所有定长窗口问题都这么简单。求和有两种常见写开法。第一种是增量更新维护一个total每来一个新元素就total x如果窗口满了就total - nums[i - k]。这个做法直观而且不占额外空间。def fixedWindowSum(nums, k): n len(nums) if n k: return [] total sum(nums[:k]) res [total] for i in range(k, n): total nums[i] # 右边界加入 total - nums[i - k] # 左边界离开 res.append(total) return res第二种是前缀和预处理出prefix[i]表示前 i 个元素之和任意区间[l, r]的和直接用prefix[r1] - prefix[l]得到。这个做法能处理“任意区间求和”并不局限于定长窗口。那什么时候用哪个如果你只需要一个窗口从头滑到尾增量更新更简洁如果你需要在同一个数组上反复查询不同长度的窗口和前缀和一劳永逸。两者的复杂度都是 O(N)真正的区别在于“窗口是否固定”。这次是固定窗口下次可能就变成不定长的所以两种方法都得会。2.4 边界条件与索引陷阱K 和 i 的关系是最容易出错的地方定长窗口问题的代码通常很短但边界错起来很隐蔽。常见的有这么几类第一窗口还没充满时就开始记录结果。比如求长度为 K 的窗口平均值你应该在i K-1时才记录因为下标从 0 开始第 K 个元素的下标是 K-1。很多初学者用i % K 0判断结果在窗口中间位置就输出了错误答案。第二循环终止条件的理解。如果你采用“先加右边界再移左边界”的写法那i的终点是n-1没问题但如果你写成两步式很容易在最后一轮忘记把最后一个窗口的结果写进数组。建议自己拿几个小例子逐步推导一遍。第三空数组和K n的边界情况。窗口比整个数组还长这时候严格来说没有“完整窗口”有些题目要求直接返回空数组有些题目要求返回所有可能的前缀。这个必须看题目约定不能想当然。第四也是我踩过最多的坑单调队列的过期判断。请不要用q[0] i - k下标从 0 开始算的话一旦q[0] i - k就说明它已经不在当前窗口内必须弹出。差一个等于号结果就完全不对。这个细节我建议你写在笔记里面试前翻一遍。3. 通信协议里的窗口滑动重传、流量控制与序号空间的数学约束3.1 窗口等于 1停等协议为什么是慢速基线聊完算法我们把视线转到计算机网络的滑动窗口重传协议。这里的“窗口”含义和算法题里略有不同但底层逻辑一致它也是“一段固定长度、不断向前平移的范围”只不过里面装的不是数据元素而是“还没收到确认的帧序号”。先看最简单的窗口长度等于 1 的情况。发送方发一个帧然后必须停下来等接收方返回 ACK收到确认之后才能发下一个帧。这个协议学名叫“停等协议”它能正常工作但效率很低。低在哪里我们来算一笔账。假设信道往返时间 RTT 是 100 毫秒发送一个帧用了 1 毫秒那么发完一个帧之后你要干等 99 毫秒才能发下一个。换句话说链路上 99% 的时间是闲置的。这就是窗口等于 1 的天然缺陷它把“连续发送”的能力直接锁死了。所以滑动窗口协议的核心目的就是允许发送方在窗口长度 W 的范围内发出去多个还没收到确认的帧而不是一个个傻等。窗口越宽管道利用率越高。但窗口不是想开多大就开多大它受到接收方缓存能力和序号空间的双重约束。3.2 发送窗口和接收窗口如何配合滑动在标准的滑动窗口重传协议里有两个窗口发送窗口和接收窗口。发送窗口规定“发送方最多可以连续发送多少个未被确认的帧”接收窗口规定“接收方愿意接收哪些序号范围内的帧”。发送方的滑动过程有三个关键事件发送一个帧窗口右边界不动但窗口里的“已发未确认”计数增加。收到确认窗口左边界向右移动确认过的帧就被移出窗口。超时重传如果某个帧超时没收到 ACK发送方要根据协议选择重传。接收方同样维护一个窗口。它只接受落在当前接收窗口范围内的帧如果收到窗口左侧的重复帧直接丢弃但可能补发 ACK如果收到窗口右侧的帧在大多数基础协议中会直接丢弃因为那说明接收方可能漏掉了一部分帧。我把这个过程想象成一个闸门发送窗口的左边是已经完成确认的“过去”右边是尚未发送的“未来”窗口内就是“正在空中飞的包”。接收窗口的左边是已经上交的“过去”右边是还不允许接收的“未来”窗口内就是“允许到达的包”。两边窗口各自滑动靠 ACK 同步。3.3 定长窗口与序号空间的数学关系为什么“够用”不等于“随便选”这里有一个基础但非常容易忽略的数学关系序号空间必须足够大否则接收方无法区分“新帧”和“重传的旧帧”。假如序号只有 2 位那序号只能是 0、1、2、3绕一圈就回到 0。发送窗口长度也是 4那么发送方发出 0、1、2、3 四个帧。如果这四个帧全部丢失超时后重新发送 0、1、2、3接收方刚才已经把 0、1、2、3 全部接收并确认了现在又收到一个 0它无法判断这是“新的第 5 个帧”还是“重传的第 1 个帧”。因为序号一样数据却可能不一样。为了避免这种歧义窗口长度 W 和序号空间 M 之间必须满足约束条件W 不能超过 M 的一半。也就是说W ≤ 2^(n-1)其中 n 是序号位数。最经典的例子是 3 位序号序号空间是 0 到 7窗口最大只能是 4。如果再大接收方的“上一次收到的最大序号”和“当前收到的序号”之间就可能出现无法判断的重叠区间。这个约束背后的直觉是接收方必须能区分“窗口绕了一圈”和“窗口还没绕完”这两种情况。窗口超过序号空间一半时接收方会同时看到落在自己窗口左侧和右侧的相似序号它根本没法确定对方到底走到哪里了。基础篇里我不展开证明但记住这个结论非常有用因为它直接决定了为什么协议设计不能只考虑带宽还得考虑序号位数的开销。3.4 真实 TCP 的窗口可变别把模型和协议画等号你可能会疑惑我明明听 TCP 也有滑动窗口它的窗口长度不是固定值啊。这个观察非常敏锐。教材里讲的“定长滑动窗口重传协议”是一个理想化的教学模型它固定窗口长度是为了让你集中理解“滑动”和“确认”的核心机制。真实 TCP 使用的是“可变滑动窗口”窗口大小会随接收缓冲区空间、网络拥塞程度动态调整。所以学习时我建议你把定长滑动窗口当成一个底层原语把 TCP 当成这个原语的复杂超集。TCP 里有两种窗口接收方通告的窗口rwnd用于流量控制拥塞窗口cwnd用于拥塞控制实际发送上限取两者较小值。这两个窗口都会变化但它们滑动的基本动作仍然是“ACK 到达后左边界右移”。理解了固定窗口的一切你再看可变窗口时就只是多了“窗口大小为什么要变”这一个问题。4. 信号处理与硬件实现滑动窗口滤波、Verilog实现与延迟真相4.1 滑动平均滤波的离散模型从通信协议回到手边的工程问题传感器数据噪声很大怎么办第一个想到的算法往往是滑动平均滤波。它的模型极其简单输出值等于最近 K 个输入值的算术平均。用公式表示就是y[n] (x[n] x[n-1] ... x[n-K1]) / K这个滤波器在嵌入式、自动化、音视频处理里都极其常见。因为它的计算量小、实现简单、对随机噪声有天然的抑制效果。所谓“随机噪声”平均之后会被部分抵消K 越大抵消效果越好。我记得自己第一次做传感器数据采集时直接把 ADC 采到的原始值拿去做控制结果抖动很厉害。加了一个 K8 的滑动平均之后曲线瞬间平稳。那是我第一次直观感受到“滑动窗口滤波模型”的威力。但后来我也发现它不能解决所有问题因为它本质上是低通滤波会把高频的有用信号一起平滑掉。4.2 窗口长度与相位延迟为什么滤波输出总是慢半拍很多人用滑动平均后发现一个现象滤波后的数据和原始数据对比总是“慢半拍”。这不是错觉而是一个物理性的延迟。定长窗口平均本质上是在用“当前时刻及之前 K-1 个时刻”的数据来估计当前输出。因为数据总是滞后于真实时间的输出自然比输入滞后。这个滞后在数学上有明确表达滑动平均滤波的群延迟约为 (K-1)/2 个采样周期。也就是说K 越大延迟越大。例如 K8 时输出大约滞后 3.5 个采样周期K64 时滞后 31.5 个采样周期。如果系统是每秒采样 1000 次31.5 个周期大约是 31.5 毫秒这已经相当可观了。在纯监控场景里无所谓但在实时控制回路里延迟过大可能直接导致系统振荡或响应迟钝。所以选择 K 时一定要想清楚你到底要的是平滑性还是实时性。平滑性要求窗口更长实时性要求窗口更短。两者天然矛盾。一个更聪明的做法是采用加权滑动平均即对最近的数据给更大权重、更远的数据给更小权重可以在同样窗口长度下降低延迟但这就超出了基础篇的范围了。4.3 硬件实现Verilog里如何落一个定长窗口如果要把滑动平均滤波写进 FPGA 或 ASIC窗口的实现方式和软件完全不同。软件里用的是数组硬件里最自然的对应物是移位寄存器。先说最简单的思路把 K 个样本存到一条 K 级移位寄存器链里每个时钟周期到来时新样本 x[n] 写入最前面的寄存器最后一个寄存器的值 x[n-K1] 被推出。然后你需要一个加法器树把 K 个寄存器的值全加到一起最后除以 K。K 如果选成 2 的幂除法直接变成右移硬件成本非常低。这种“真实移位寄存器”实现有一个麻烦K 个数的并行求和需要用到一个包含 K-1 个加法器的加法器树面积和布线压力都不小。工程上更常见的优化是“分布式累加”结构维护一个总数寄存器sum每个时钟周期执行sum sum x[n] - x[n-K1]。这样你实际上只需要一个减法器、一个加法器以及一个用来存储历史样本的环形缓冲区或移位寄存器。这个方法在算法里叫“增量更新”在硬件里叫“滑窗累加器”。给你一个概念性 Verilog 片段展示增量更新的关键逻辑// 伪代码级示例重点展示滑窗累加思路 reg [WIDTH-1:0] sum; reg [WIDTH-1:0] history [0:K-1]; integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sum 0; for (i 0; i K; i i 1) history[i] 0; end else begin // 新样本进入最旧样本退出 sum sum x_new - history[0]; // 移位history[1] - history[0], ..., x_new - history[K-1] for (i 0; i K-1; i i 1) history[i] history[i1]; history[K-1] x_new; end end这里有几个硬件工程师都懂的坑一是数据位宽必须考虑累加溢出K 个 W 位数值相加理论位宽至少是 W log2(K)二是有符号数处理ADC 采样通常是补码形式无符号和有符号的加减法扩展规则不同直接套移位除法可能会出错三是异步复位和时钟域问题在多时钟域设计里尤其要警惕防止 history 里的数据在跨时钟域时出现亚稳态。4.4 平滑效果与实时性的真实取舍我们用一组直观数据感受一下 K 的选择。假设输入信号在第 100 个采样点突然从 0 跳变到 1分别取 K4、K16、K64。K4 的输出大概在第 102 个点就能明显上升到第 104 个点左右跟到接近 1响应快但噪声抑制弱输出仍然比较毛糙。K16 的输出要更平缓但跳到接近 1 的时间会推到第 108 个采样点以后且上升沿拉长表现成一段“斜坡”。K64 的输出最平滑但大概要到第 130 个点以后才走到接近 1延迟非常明显。如果你拿这个输出去控制电机电机会明显“犹豫”。所以我的经验是在不知道选多少合适时先选一个令你满意的平滑度再测量延迟是否可接受。如果一个 K 不能满足两个指标那就说明单一固定窗口已经到极限了你需要级联滤波、加权滤波或自适应窗口。这也是“基础篇”之后要讨论的进阶话题。5. 基础篇的最后一课识别“定长窗口问题”的统一特征5.1 三个场景放到同一张表里我把算法、通信、信号处理三个场景拉到一张表里你看它们结构上有多相似。场景数据单元窗口位置窗口长度滑动触发条件核心操作算法题求窗口最大值数组元素数组下标区间定长 K每次右移一个元素维护单调队列滑动窗口重传协议帧/序号序号区间定长 W收到 ACK 后右移缓存未确认帧、超时重传滑动平均滤波采样点采样序列定长 K每个采样时钟右移累加新样本、减去旧样本这三者的共同点一眼就能看出来都有一个线性有序的数据流都有一个长度固定的“观察区间”都通过“进来一个、出去一个”的方式向前平移。区别只在于数据单元是什么、窗口移动的触发条件是什么、出窗口后怎么处理旧元素。5.2 三个快速判断标准怎么判断一个任务能不能用定长滑动窗口解决我建议你问自己三个问题数据是不是按顺序到达的算法题里是数组下标顺序通信里是帧序号顺序滤波里是采样时间顺序。如果数据本身无序滑动窗口没有意义因为你无法定义“窗口”的范围。你要关心的是不是“最近连续 K 个元素”的某种状态最大值、平均值、未确认帧数、接收缓存占用本质都是“最近 K 个单元的状态”。旧元素的影响是不是应该在它滑出窗口的那一刻彻底消失定长窗口要求旧元素一旦离开窗口就不再参与任何计算。协议里的 ACK、滤波里的旧样本都必须在这个时刻被干净地移除。如果这三个问题的答案都是“是”那你就可以放心地使用定长滑动窗口模型只要有一个“否”就要考虑其他方案比如可变窗口、指数加权或者全量数据重新计算。5.3 我踩过的几个真实的坑第一个坑是在算法题里用 Python 的list.pop(0)。看起来功能完全正确但遇到 10 万级数据直接超时。换成collections.deque之后瞬间通过。这个教训告诉我定长窗口的“高效”必须建立在 O(1) 入队和出队之上否则模型再对落地也翻车。第二个坑是滤波里把 K 当成“时间长度”而不是“采样点数”。有一段时间我负责的传感器每秒采样 1000 次我希望做 200 毫秒的平滑就顺手填了 K200。后来校准时间戳时才发现K200 意味着 0.2 秒的窗口但系统时间同步用的是毫秒级两者对不上导致数据序列对不齐。代码本身没写错是 K 的单位理解错了。第三个坑是在通信协议仿真里把窗口长度设计得比序号空间的一半还大。跑仿真时表现很正常直到把超时重传打开接收方开始频繁出现“疑惑”它不知道到达的包到底是新包还是旧包重传。后来查教材里的约束条件才发现问题出在窗口长度和序号空间的数学关系上。这个坑我印象极深因为它不是代码 bug而是模型 bug排查难度高很多。如果让我给基础篇做一个落脚点我不会说“记住这些规则就行”。我更想说定长滑动窗口不是一道题、一个协议、一个滤波器它是一种“用固定范围切割连续数据流”的思维方式。你越是能在一堆看似无关的问题里看到这个共性就越能把算法、通信、信号处理的知识点连成一张网。我在实际项目中体会到这套思维一旦建立起来以后遇到“最近 N 个”“连续 K 个”“窗口大小为 M”这类描述时第一反应就会是先画出一个定长窗口再想滑动条件最后选择辅助结构。这个顺序走顺了基础篇的任务就算真正完成了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进