小时候玩丢手绢,最紧张的时刻不是手绢落地,而是你要绕大半圈跑回自己位置时,全场都在喊“跑快点”。现在回头看,这个游戏的数学本质一点也不幼稚:一圈孩子就是环形数组,追与被追的最短路径就是两点在环上的最短距离,而“手绢现在可能在谁身后、转一圈后落到哪”这类判断,本质上就是对长度为 N 的环做定长扫描。把它映射到算法题里,正好是标题里那两件事的组合:滑动窗口 + 环形最优距离。这篇文章就以丢手绢为引子,把环形数组上的滑动窗口问题从暴力解法一路讲到单调队列优化,最后顺着热搜词把滑动窗口滤波、重传协议、verilog 实现一起串一遍,目标是让你彻底搞懂这套思路,遇到同类题能直接动手写代码。
我用的是最朴素的方式理解它:所有环形问题,都是“直线问题 + 一个环回头的边界条件”。只要把这个边界处理对了,剩下的滑动窗口模板可以原封不动地复用。下面按我的拆解顺序来。
1. 为什么“丢手绢”是个值得认真拆解的算法题
1.1 从游戏规则到环形数组:定义清楚再动手
丢手绢的规则很简单:N 个人围成一个圈,一个人拿着手绢在圈外跑,趁某人不注意把手绢丢在他身后,然后继续跑;被丢中的人要捡起手绢追,追到之前丢手绢的人要抢占被丢中者的空位。
如果把每个人抽象成一个下标位置,这个圈就是一个环形数组,长度为 N,下标 0 到 N-1,下标 N 又回到 0。这样看,丢手绢的人围着圈跑,本质上就是沿着环形数组做遍历;他跑到某个位置 i,对应数组下标 i % N。
这个抽象不是咬文嚼字。环形数组是很多算法题的高频底层结构,它同时带出了两个经典子问题:一个是两点之间的环形最优距离,另一个是在环上连续扫描一段区间,也就是滑动窗口。标题里的“丢手绢问题”恰恰把这两者揉在一起,所以拿它当引子非常合适。
在动手设计算法前,一定要把问题定义清楚。我给自己定的问题是:
给定一个环形数组 nums,长度为 N,以及一个窗口大小 K。求所有长度为 K 的环形连续窗口中,窗口内最小值为全局最小的那个窗口,并返回该窗口中心位置距离下标 0 的环形最优距离。
这个问题可以拆成三层:第一层是“怎么处理环”,第二层是“怎么高效求每个定长窗口的最小值”,第三层是“怎么把窗口位置转成环上的最短距离”。后面几节就是按这个顺序展开的。
1.2 环形最优距离的两副面孔
“环形最优距离”这个词在不同的书里长得不一样,但本质只有两副面孔。
第一副面孔是两点之间的最短环距。环形数组下标 0 到 N-1 首尾相连,位置 i 和位置 j 之间有两条路:一条沿下标递增方向走,距离是 |i - j|;另一条反过来走,距离是 N - |i - j|。真正的最短距离要取两者中的较小值:
dist(i, j) = min(|i - j|, N - |i - j|)丢手绢里,追的人从 B 的位置出发,跑的方向和丢手绢的人一致;丢手绢的人已经跑出半圈。谁赢取决于路程长短,这个“路程”就是上面的公式。
第二副面孔是沿环连续推进的总长度。假设手绢每轮向后传 K 个位置,传了 t 轮,最终位置是 (start + K * t) % N。如果想回到起点附近,本质上是在研究 K 和 N 的最大公约数,这是另一个经典话题。回到本文,我们关心的是定长 K 的窗口在环上滑动,窗口起点从 0 到 N-1 连续移动,正好就是滑动窗口的标准使用场景。
所以当别人说起“环形最优距离”,不要一头雾水。先问一句:是两点在环上的最短路径,还是沿环扫描覆盖的距离?本文两种都会用到,但在代码里,主导的是第二种场景,第一种作为结果计算。
2. 破环为链:处理环形数组的两种主流套路
环形数组最别扭的地方是下标到头之后要跳回 0。滑动窗口要求“连续推进”,所以第一件事就是把环展开成直线。常见的做法有两种:下标取模和双倍数组拼接。
2.1 下标取模:最省内存但要注意索引漂移
第一种做法是保留原始数组,所有下标访问都用i % N来转。写起来是这个味道:
def get_value(nums, i): return nums[i % len(nums)]好处是零额外空间,坏处是每次访问都带一次取模运算,而且窗口边界判断容易出错。比如窗口左边界 left 和右边界 right 都可能在环形语义下出现“left 大于 right”的情况,处理起来非常绕。
我早年写环形队列相关代码时,有一段时间坚持用取模法,结果在“窗口终点跨过 N 的边界”上反复踩坑。比如一个窗口从下标 7 开始,长度为 3,N = 7,正常展开后覆盖的是下标 7、8、9,取模后是 0、1、2。看起来没问题,但如果要做“右指针 - 左指针 + 1 == K”这类长度判断,指针本身已经越过数组末尾,不能直接拿原始下标算。每次都要额外判断,代码可读性很差。
取模法适合那些“遍历整圈”而不是“维护连续窗口”的场景,比如单纯求环形数组里的下一个更大元素,或者判断会不会绕回原点。一旦窗口要频繁移动,我建议优先考虑拼接法。
2.2 双倍数组拼接:用空间换代码简洁
第二种做法是把数组复制一份接在后面,形成长度为 2N 的新数组:
arr = nums + nums这样原本的环形连续区间,比如起点 6、长度 4 的窗口,在展开数组里就是下标 6 到 9 的普通连续区间,完全不需要考虑取模。滑动窗口的所有逻辑都和普通数组一模一样,定位结果时再“映射回原下标”即可。
代价是空间翻倍。N 是 10 万级时完全无所谓,N 上亿时才需要考虑内存。实际刷题和业务开发里,99% 的场景双倍数组拼接都够用,而且代码更不容易写错。
还有一个细节:双倍数组里最多只需要枚举 N 个起点,也就是窗口起点从 0 到 N-1。窗口终点会落在 N 到 2N-2 之间,所以 arr 长度至少是 N + K - 1。为了省事,直接nums + nums最稳妥,长度 2N 必然覆盖。如果 K 小于 N,只开 N + K 的长度也够,但这样做需要多写一个min,我不推荐,除非内存真的紧张。
2.3 两种套路怎么选:一张表说清楚
我自己选型时参考这个标准:
| 维度 | 下标取模 | 双倍数组拼接 |
|---|---|---|
| 额外空间 | O(1) | O(N) |
| 代码可读性 | 边界判断多,易错 | 线性逻辑,清晰 |
| 访问速度 | 每次带取模运算 | 直接数组索引 |
| 适合场景 | 只遍历一圈、不维护窗口 | 滑动窗口、单调栈、区间统计 |
| 窗口长度 K > N | 需要额外取模处理 K | 同样需要处理 K,但展开后逻辑不变 |
如果只是“从某个起点开始走 K 步看看结果”,用取模。如果要做“滑动窗口维护最值/和/频率”,用拼接。丢手绢问题属于后者,所以我下面的所有代码都默认走拼接路线。
3. 从暴力到滑动:环形最优距离的求解推演
3.1 暴力解法:每个窗口从头扫一遍有多痛
最直接的想法是:枚举每个窗口起点 i,从 i 到 i + K - 1 扫一遍,找出这个窗口的最小值,再和全局最优比较。
def brute_force(nums, k): n = len(nums) best_val = float("inf") best_center = -1 for start in range(n): cur_min = float("inf") for j in range(k): idx = (start + j) % n cur_min = min(cur_min, nums[idx]) if cur_min < best_val: best_val = cur_min center = (start + k // 2) % n best_center = center return best_val, best_center每个窗口花费 O(K) 时间,一共 N 个窗口,整体 O(N*K)。当 K 接近 N/2 时,复杂度接近 O(N^2/2),数据量一大就崩。
暴力解的意义是帮我们验证正确性。我写算法的习惯是先写一个绝对不可能错的暴力版本,再用优化版本去对拍。环形问题尤其需要,因为边界条件太多,直接上优化代码很容易“感觉对了但结果差一位”。
3.2 滑动窗口的“复用”思想:移一位,只动两头
观察暴力解可以发现,窗口从起点 i 移到起点 i+1 时,新增的元素只有一个(下标 i+K),移除的元素也只有一个(下标 i)。窗口内的其他 K-1 个元素完全没变。暴力解法无视这个事实,每次都重新扫描全部 K 个元素,白白浪费了大量重复计算。
滑动窗口的核心思想就是复用:先把上一个窗口的统计结果“带”过来,然后做一次“减去旧元素 + 加入新元素”的增量更新。
这个思想在生活中特别常见。比如你在窗口前看 10 个人的队伍,想知道现在最矮的是谁;队伍只往前走了一个人,你只需要对比新来的那个人和之前最矮的人,不可能回头把所有 10 个人再量一遍。滑动窗口就是这么干的。
但这里有个微妙的问题:如果窗口维护的是“和”,增量更新很简单,减去旧值、加上新值就行。如果维护的是“最小值”,就没法用同样的方式,因为你不知道被移除的那个元素到底是不是当前最小值。如果它刚好是最小值,减掉它之后,第二小的值是谁?你并没有维护这个信息。这时就需要下一节的单调队列登场。
3.3 固定窗口模板:可直接抄的代码
在引入单调队列之前,先看一个“维护窗口和”的标准固定窗口模板,感受一下基础结构。给定环形数组和窗口大小 K,求所有环形窗口的和:
def circular_window_sum(nums, k): n = len(nums) k = k % n or n # 关键:窗口超过一圈时收缩 arr = nums + nums window_sum = sum(arr[:k]) res = [window_sum] for i in range(1, n): window_sum += arr[i + k - 1] - arr[i - 1] res.append(window_sum) return res注意这里先做了k = k % n or n,也就是如果 K 能整除 N,整个环都是窗口,直接取全环和。这一步放在展开数组之前,避免无意义的超大窗口。后面的for i in range(1, n)只枚举 N 个起点,因为环形窗口的起点集合就是 0 到 N-1。
这个模板值得背下来。它把三个容易出错的位置都固定好了:
- 窗口更新公式
arr[i + k - 1] - arr[i - 1]里的下标偏移; - 只枚举前 N 个起点;
- K 超过一圈时先取模。
对“和”适用,对“最值”不直接适用,接下来就是单调队列的活儿。
4. 单调队列才是真正的抓手:窗口最值的 O(N) 解法
4.1 为什么不用堆:删除过期元素的代价
看到“动态维护窗口最值”,很多人第一反应是优先队列(堆)。堆的插入和取最值都是 O(log K),看起来可以接受。但问题在于窗口移动时,被移出窗口的元素可能不在堆顶,你想删它,标准堆做不到“定点删除”,只能懒标记删除:元素过期先不管,等到它成为堆顶时再弹出。
懒标记的思路能过,代码却容易绕。因为你不仅要记录值,还要记录下标,弹出时还得判断这个下标是否在窗口范围内。写多了就会发现,这相当于手动实现了一个带过期时间的优先队列,坑并不少。
单调队列换了个角度:它不维护所有 K 个元素,而是只维护“可能成为窗口最小值”的候选元素。这个集合远比 K 小,而且天然有序,队头就是当前窗口的最小值,过期元素直接从队头弹出,一切都顺理成章。
4.2 单调队列的维护逻辑:核心就两条规则
用双端队列 deque 存下标。以“求窗口最小值”为例,两条规则:
- 新元素入队时,把队尾所有“值不小于新元素”的下标全部弹出,再把新下标压入队尾。这样队头到队尾的值是严格单调递增的,队头就是当前窗口最小值。
- 每次窗口右移后,检查队头下标是否已经滑出窗口左边界,如果是,从队头弹出。
第二条规则保证了队列里的元素都属于当前窗口;第一条规则保证了队列里的元素“一个比一个更有资格当最小值”。
仔细想想第二条规则和第一条规则的关系:队头的值最小,但如果队头过期了,它必须走;队头走后,新队头就是剩下的候选中最小的。由于每个下标最多入队一次、出队一次,总操作次数是 O(N),均摊到每次窗口移动只有 O(1)。这就是单调队列比暴力快得多的根本原因。
丢掉手绢里,就好比你只记住全场当前最矮的人,以及“如果最矮的人走了,谁可能是下一个最矮的”。你不需要记住所有人,只需要记住一条潜在的“接替链”。
4.3 完整实现与复杂度分析
下面是我的完整解法,直接解决第一节定义的问题:求环形数组中所有长度为 K 的窗口的最小值,并返回全局最小窗口的中心到下标 0 的最短环距。
from collections import deque def circular_sliding_window_min(nums, k): n = len(nums) k = k % n if k == 0: k = n # 窗口等于整环 arr = nums + nums q = deque() best_val = float("inf") best_center = -1 for i in range(n + k - 1): # 只需扫到最后一个窗口终点 # 规则1:保持队尾到队头单调递增 while q and arr[q[-1]] >= arr[i]: q.pop() q.append(i) # 规则2:弹出过期下标,窗口范围是 [i-k+1, i] while q and q[0] < i - k + 1: q.popleft() # 当窗口已经完整时开始统计 if i >= k - 1: cur_min = arr[q[0]] center_raw = i - k + 1 + k // 2 # 窗口起点的中心位置(展开数组中) center = center_raw % n if cur_min < best_val: best_val = cur_min best_center = center # 计算到下标0的最短环距 dist = min(best_center, n - best_center) return best_val, best_center, dist nums = [2, 5, -1, 4, 3, 6, 0] k = 3 print(circular_sliding_window_min(nums, k)) # 输出:(-1, 1, 1)结果解释:所有长度为 3 的环形窗口中,包含 -1 的窗口最小值都是 -1,第一个达到该值的窗口中心是下标 1,中心到下标 0 的最短环距是 1。
这里有一个细节容易搞混:center_raw = i - k + 1 + k // 2中,i - k + 1是当前窗口的起点,加k // 2得到窗口中心。因为是“环形中心”,所以直接对 N 取模映射回原数组。如果只要“第一个最优窗口”,那取模后的 center 可能有多个候选,比如窗口起点 0 和起点 1 的中心可能都是同一个。实际按需调整即可。
C++ 版骨架也顺手贴一下,面试手写时节奏更快:
#include <deque> #include <vector> #include <algorithm> using namespace std; pair<int, int> circularWindowMin(vector<int>& nums, int k) { int n = nums.size(); k %= n; if (k == 0) k = n; vector<int> arr = nums; arr.insert(arr.end(), nums.begin(), nums.end()); deque<int> q; int best = INT_MAX, center = -1; for (int i = 0; i < n + k - 1; i++) { while (!q.empty() && arr[q.back()] >= arr[i]) q.pop_back(); q.push_back(i); while (!q.empty() && q.front() < i - k + 1) q.pop_front(); if (i >= k - 1) { int val = arr[q.front()]; int rawCenter = (i - k + 1 + k / 2) % n; if (val < best) { best = val; center = rawCenter; } } } return {best, center}; }复杂度方面:每个下标最多入队一次、出队一次,总时间 O(N),空间 O(N)(展开数组)+ O(K)(队列)。相比暴力 O(N*K),当 N 和 K 都大时差距是数量级的。
4.4 队列里存下标,而不是存值
这是新手最容易踩的坑,单独提出来说。单调队列里应该存“下标”,不是“值”。
如果只存值,队列弹出过期元素时根本不知道这个值对应哪个位置,你无法判断它还在不在窗口里。如果存下标,取最小值时用arr[q[0]]获取值,判断过期时用q[0] < i - k + 1,一个下标同时解决了“值”和“位置”两个需求。
可以这么理解:你在队伍里记住的不是“这个人身高 160”,而是“这个位置的人身高 160”。前面的人走了,你要知道该看下一个位置,而不是盯着一个已经离开的人的身高。
5. 顺着热词走一圈:滑动窗口在滤波、重传、Verilog 里的真面目
“滑动窗口”这四个字在不同圈子里指的东西不太一样,但底层结构都是“一个固定长度的数据集合,随着时间向前移动,旧数据离开、新数据进入”。这也是为什么很多人搜“滑动窗口最小值”之后,会连着搜出一堆滤波、重传协议、verilog 的内容。它们真的是同一个思想的工程化变种。
5.1 滑动窗口滤波:窗口越大越平滑,延迟也越大
滑动窗口滤波最常见的形态是滑动均值滤波。假设传感器采集到一串数据 x[0], x[1], ...,要对第 n 个点做平滑,取它前面 M 个点的平均值:
y[n] = (x[n - M + 1] + x[n - M + 2] + ... + x[n]) / M这个 M 就是窗口长度。在代码里,用累加和的方式维护窗口,每次前进一个点,只需要:
window_sum += new_value - old_value smoothed = window_sum / M这就和环形数组滑动窗口求和完全一样了。我之前在嵌入式项目里对温湿度传感器的原始读数做这种滤波,M 取 5 和取 20 的差别肉眼可见:M 小,曲线跟手但毛刺多;M 大,曲线顺滑但滞后明显。这里的“滞后”就是热搜词里说的“滑动窗口滤波器延迟”。
延迟怎么算?滑动均值滤波的输出实际上是窗口内 M 个点的平均,如果拿输出序列和原始序列做对齐,等效延迟是 (M - 1) / 2 个采样周期。M 越大,平滑力度越强,延迟越大。所以选 M 本质是在“平滑效果”和“实时性”之间做权衡,这个权衡思路和算法题里 K 值的选择一模一样。
如果窗口中混入了脉冲噪声,比如传感器突然跳变一个尖峰,均值滤波会把尖峰均摊到整个窗口,效果一般。这时改用滑动中值滤波更稳:窗口内排序取中间值。中值滤波的窗口最值维护,同样可以用滑动窗口 + 有序结构来实现,只不过比“最值”又复杂了一步。
5.2 滑动窗口重传协议:窗口是可靠性和吞吐率的平衡旋钮
网络传输里的滑动窗口是另一种形态。发送端不用发一条等一条,而是可以连续发出多个报文,这些“已发送但尚未确认”的报文共同构成一个发送窗口。随着确认报文不断返回,窗口整体前移,就像滑动窗口算法里的左右指针一起向右移动。
窗口大小直接决定吞吐率:窗口越大,同一时刻在途的数据越多,链路利用率越高;但窗口越大,一旦出错需要重传的数据也越多,接收端缓冲压力也越大。TCP 的流量控制、拥塞控制,本质上都是在动态调节这个窗口。
从数据结构角度,发送窗口就是一对左右指针维护的区间,每次收到一个 ACK,左指针右移;发送新数据时,右指针右移。判断窗口满不满,就是看右指针减左指针是否达到窗口上限。这和数组上的滑动窗口窗口大小判断完全同构,只是窗口的左边界由“外部确认事件”驱动,而不是由固定步长驱动。
5.3 FPGA 里的滑动窗口:verilog 实现的核心与坑
FPGA 上做滑动窗口滤波,常见的做法是用移位寄存器链实现窗口缓存。M 个寄存器排成一排,每个时钟上升沿,新数据从最左边进入,所有数据向右移一位,最右边的旧数据被移出。这样任意时刻,M 个寄存器里正好存着最近 M 个采样点,这就是一个硬件滑动窗口。
求和部分可以用加法树并行计算,如果窗口长度是 2 的幂,比如 8、16、32,除 M 的操作可以直接用右移,节省逻辑资源。延迟计算也清晰:从数据输入到输出滤波结果,大约是 M 个时钟周期加上求和流水线的延迟。
这里有个工程坑:寄存器链搬移数据会导致功耗和布线压力成倍增长,窗口一大就很吃力。更优雅的做法是用 FIFO 或双口 RAM 模拟滑动窗口,读指针和写指针配合,不搬移数据,只更新读地址。我见过不少同学第一次写 verilog 滑动均值滤波,直接把 M 个数据接成一长串加法器,综合后时序根本收敛不了。用流水的加法树,或者直接例化 DSP 单元,才是实际可落地的方案。
换句话说,你在算法题里写的单调队列、双端队列,落到 FPGA 上对应的是“如何高效维护窗口内最值/和”的硬件结构;移位寄存器、FIFO 是不同成本下的工程取舍。理解了这层对应关系,热词里的那些看似天差地别的词就串成一条线了。
6. 环形窗口的三大坑与我的实战心得
6.1 窗口大小超过一圈:先取模再滑动
环形数组上,如果 K 大于 N,窗口滑动起来会重复覆盖同一批元素。比如 N=7、K=10,长度为 10 的窗口在环上扫,实际内容等于长度为 10 % 7 = 3 的窗口再叠加若干整环。整环部分对极值没有影响,因为一个环包含全部数组元素。
所以处理方式很简单:k = k % n,如果取模结果是 0,说明 K 是 N 的整数倍,窗口等于整个环,直接返回全局最值即可。上面的代码里已经处理了这个逻辑。
这个坑很容易被忽略,因为小规模测试数据一般不会特意构造“窗口比环还长”的用例。我建议把k = k % n or n这一行固定在模板里,不要在业务代码里临时判断。
6.2 下标映射:展开位置和原位置之间的换算
双倍数组拼接后,所有窗口逻辑都是线性的,但最终结果要映射回原环形数组。最容易出错的地方是“窗口中心位置”的映射。
展开数组里的下标 raw_pos 可能是 N 或 2N 范围内的任意值,映射回原下标只需要raw_pos % n。但要注意,窗口中心本身有两种定义:如果 K 是奇数,中心唯一;如果 K 是偶数,中心有两个位置,比如长度为 4 的窗口,中心可以是下标偏左的第二个位置,也可以是偏右的第三个位置。
我在题目里取了k // 2作为中心偏移,这是约定,不是绝对答案。实际做题时一定要先确认题意:要的是“窗口起点距离目标的距离”,还是“窗口中心距离目标的距离”。同一个窗口,起点、中心、终点的环距完全不同,计算前先把定义写下来,比什么都管用。
6.3 调试环形滑窗的土办法
环形问题为什么总写错?因为大脑很难同时跟踪“虚拟下标”和“真实下标”。我的土办法是先在草稿纸上把展开数组完整写出来,再手动推一遍前几个窗口的队列变化,最后用暴力版本对拍。
比如 nums = [2, 5, -1, 4, 3, 6, 0], K = 3,展开数组是:
index: 0 1 2 3 4 5 6 7 8 9 value: 2 5 -1 4 3 6 0 2 5 -1窗口 [0, 1, 2] 最小值是 -1;窗口 [1, 2, 3] 最小值是 -1;窗口 [2, 3, 4] 最小值是 -1;窗口 [3, 4, 5] 最小值是 3;窗口 [4, 5, 6] 最小值是 0……手推一遍后,再让代码打印q的内容核对,很快就能定位是“过期弹出写错”还是“入队条件写错”。
还一个小技巧:代码里临时加点调试输出。比如每次窗口完整时打印i, q, arr[q[0]],和手推结果对照。修完再删掉,不要留着。
就我自己这段时间的经验来说,环形滑动窗口题想写对,真正重要的不是背模板,而是把三个边界刻在脑子里:K 和 N 的关系、展开下标与原始下标的换算、窗口中心定义的确认。这三件事在每一步代码里都可能出错,但只要把暴力版本放在旁边当参照物,耐心对拍一轮,基本都能平稳落地。以后看到“环形 + 定长连续区间 + 最值/和”这类组合,我不会再慌着套模板,而是先在草稿纸上画一圈位置,再决定取模还是拼接、用不用单调队列。思路清晰了,代码自然就顺了。