LeetCode LCR 041. 数据流中的移动平均值
题目描述
题意分析
题目目标:构造时给定一个固定窗口宽度
size,此后每次next(val)把val追加进数据流,并返回数据流末尾最多size个数字的算术平均值,返回类型是浮点数。
核心约束:窗口宽度构造之后不再变化,这说明所需内存是常量级的;next会被反复调用(调用次数远大于size),因此单次调用的代价必须与size无关,否则总代价会退化成两个规模的乘积。
边界处理:调用次数还不足size时,分母是"已经来过的数字个数"而不是size;size等于 1 时每次返回的就是val本身;元素允许为负数,求和不能假设单调;求平均必须走浮点除法,整数除法会截断。
实现取舍:只需要"最近size个"这一段后缀,早于它的数字永远不会再参与任何一次计算,所以数据流可以只保留一个固定长度的缓冲区,而不必留下全部历史。
解法:数组扫描
核心思路
最直接的做法是把所有来过的数字都存进一个可变长数组,每次
next时从末尾往前取size个重新累加再除以个数。这样能过,但每次调用都要重新加一遍窗口里的所有数字,m次调用的总代价是 $O(m \cdot size)$,而且内存随调用次数无限增长。
瓶颈在于重复劳动:第i次和第i+1次调用的窗口高度重合,只在两端各差一个元素——右边进来一个新数字,左边挤出一个最老的数字。既然差异只有两项,和就没有必要重算,用加减修正即可。
由此确定要维护的两条不变量:其一,s恒等于当前窗口内全部元素之和;其二,缓冲区arr的长度恒为size,下标cnt % size处存放的恰好是"下一次将被挤出窗口的那个元素"(窗口尚未填满时该位置是初始值 0,减 0 不改变和,与"没有元素被挤出"语义一致)。
有了这两条,next只做三件事:s += val - arr[idx]完成一进一出的修正,arr[idx] = val让缓冲区继续满足不变量二,cnt自增记录总数。分母取min(cnt, size),因为真实窗口宽度是"已到达个数"与"窗口上限"中的较小者。
解题步骤
- 构造函数里开一个长度为
size的整型数组arr,并把和s、计数cnt初始化为 0。为什么用定长数组而不是链式结构:窗口宽度已知且固定,定长数组配合取模就能天然实现"新值覆盖最老值",省掉了节点分配与指针维护。- 每次
next先算出本次写入位置idx = cnt % arr.length。为什么这个位置正好是最老元素:cnt是此前已写入的总数,写入是按0,1,2,...循环推进的,所以下标cnt % size上残留的是size次之前写下的那个值,也就是当前窗口左端之外的第一个元素。- 用
s += val - arr[idx]一次完成"减去出窗元素、加上入窗元素"。为什么要先减后写:arr[idx]在被覆盖之前才是旧值,顺序颠倒就拿不到需要扣除的那一项了。- 再执行
arr[idx] = val与++cnt。为什么cnt只增不取模:它同时承担"总到达个数"的职责,取模会丢掉窗口未满阶段判断分母所需的信息。- 返回
s * 1.0 / Math.min(cnt, arr.length)。为什么要乘1.0:s与分母都是整型,不做提升就会先执行整数除法把小数部分截掉。- 以
具体用例:size = 3,依次调用next(1)、next(10)、next(3)、next(5)走一遍。初始arr = [0,0,0],s = 0,cnt = 0。第一次val = 1,idx = 0,s = 0 + 1 - 0 = 1,arr = [1,0,0],cnt = 1,分母min(1,3) = 1,返回1.0。第二次val = 10,idx = 1,s = 1 + 10 - 0 = 11,arr = [1,10,0],cnt = 2,分母 2,返回5.5。第三次val = 3,idx = 2,s = 11 + 3 - 0 = 14,arr = [1,10,3],cnt = 3,分母 3,返回14 / 3 ≈ 4.666...。第四次val = 5,idx = 3 % 3 = 0,此时arr[0] = 1正是要挤出窗口的最老元素,s = 14 + 5 - 1 = 18,arr = [5,10,3],cnt = 4,分母min(4,3) = 3,返回6.0,与直接计算(10+3+5)/3完全一致。
代码实现
// 核心实现:数组扫描,维护必要状态并避免重复处理。
class MovingAverage {
private int[] arr;
private int s;
private int cnt;
public MovingAverage(int size) {
arr = new int[size];
}
public double next(int val) {
int idx = cnt % arr.length;
s += val - arr[idx];
arr[idx] = val;
++cnt;
return s * 1.0 / Math.min(cnt, arr.length);
}
}
// 核心实现:数组扫描,维护必要状态并避免重复处理。
type MovingAverage struct {
arr []int
cnt int
s int
}
func Constructor(size int) MovingAverage {
arr := make([]int, size)
return MovingAverage{arr, 0, 0}
}
func (this *MovingAverage) Next(val int) float64 {
idx := this.cnt % len(this.arr)
this.s += val - this.arr[idx]
this.arr[idx] = val
this.cnt++
return float64(this.s) / float64(min(this.cnt, len(this.arr)))
}
复杂度分析
- 时间复杂度:构造 $O(size)$,单次
next为 $O(1)$。凭什么:next内部只有一次取模、一次加减、一次赋值和一次除法,全是常数条指令,与窗口宽度和已到达元素个数都无关。- 空间复杂度:$O(size)$。凭什么:除了固定长度为
size的缓冲区外,只额外保存了和与计数两个标量,历史数据不做任何留存。
关键点总结
- 窗口只在两端变化时,和应当用"加新值、减旧值"增量维护,而不是每次重算,这是所有定宽窗口统计量(和、平均、计数)的通用套路。
- 定长数组加下标取模,就是一个不需要搬移元素的先进先出缓冲区;只要窗口宽度提前已知,它比链式结构更省事也更快。
- 数组初始值 0 在这里不是"脏数据"而是合法状态:窗口未满时减 0 恰好等价于"没有元素出窗",让填充期和稳定期共用同一段代码。
- 计数器同时充当"写入位置来源"和"分母判据"两种角色,所以必须保留原始累计值而不能提前取模。
- 面试视角:面试官问这题时真正想听的是"能不能把单次查询压到 $O(1)$、把内存压到 $O(size)$"。先说暴力的 $O(m \cdot size)$ 与无限增长的内存,再点出"相邻窗口只差两个元素"这一观察,最后主动补一句"如果窗口宽度会动态变化,就换成双端队列存下标",通常就能收住这道题。
易错点总结
- 错误写法:把
arr[idx] = val写在s += val - arr[idx]之前 →size = 3时依次输入1,10,3,5,第四次读到的arr[0]已被改成 5,和变成14 + 5 - 5 = 14,返回4.666而不是正确的6.0。- 错误写法:分母直接写死
arr.length→size = 3只调用一次next(1),返回0.333而不是1.0,窗口未填满阶段的答案全错。- 错误写法:
return s / Math.min(cnt, arr.length)漏掉浮点提升 →size = 3输入1,10,3时整数除法得到 3,返回3.0而不是4.666,小数部分被静默截断。- 错误写法:给
cnt也取模,写成cnt = (cnt + 1) % arr.length→size = 3输入四个数后cnt归零,min(cnt, size)变成 0,直接除零得到Infinity或NaN。- 错误写法:把总和声明为窗口内元素的最大绝对值可能溢出的类型,或者在别的变种里用
int累加size个 $10^9$ 级别的数 → 例如窗口宽 $10^5$、每个值 $10^5$,和达到 $10^{10}$ 超出 32 位范围,平均值变成负数。- 错误写法:改用
List存全部历史再取末尾size个求和 → 调用 $10^5$ 次、size为 $10^4$ 时要做 $10^9$ 次加法,直接超时,同时内存随调用次数线性增长。- 错误写法:构造函数里没有真正分配数组(Go 中写成
var arr []int而不是make([]int, size))→ 第一次next执行cnt % len(arr)就是对 0 取模,运行时 panic。- 错误写法:为了"省事"在
next里重新遍历arr求和 →size = 3且只调用两次时,arr中第三个位置的 0 也被算进去,返回(1+10+0)/2这样的错误值,同时把单次代价拖回 $O(size)$。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 933. 最近的请求次数 | 简单 | 窗口由时间戳而非个数界定,出窗条件变成比较时间差 |
| 362. 敲击计数器 | 中等 | 同一时刻可能多次敲击,需要按秒聚合后再做环形覆盖 |
| 622. 设计循环队列 | 中等 | 同样用定长数组取模,但要额外区分队空与队满 |
| 239. 滑动窗口最大值 | 困难 | 统计量换成最大值后无法增量加减,必须靠单调结构维护 |
| 295. 数据流的中位数 | 困难 | 数据流无窗口上限,中位数要靠两个堆对顶维护 |