news 2026/10/12 5:52:55

滑动窗口算法详解:从暴力解法到O(n)优化,彻底吃透子数组最大平均数

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
滑动窗口算法详解:从暴力解法到O(n)优化,彻底吃透子数组最大平均数

刷到这道题的时候,我的第一反应是:子数组最大平均数 I,这不就是套公式吗?结果往下一看评论区,好家伙,一群人喊着“滑动窗口,我跟你拼了”。这道题确实不复杂,但它是滑动窗口思想最干净、最典型的入门模板之一。如果你能把这道题的来龙去脉彻底吃透,后面遇到“连续子数组”、“定长窗口”、“区间统计”这类问题,会轻松非常多。

这道题本质上就是在问:给定一个整数数组和一个长度 k,找出所有长度为 k 的连续子数组里,平均值最大的那个,返回它的平均值。

暴力解法谁都会写,嵌套两层循环,外层枚举起点,内层从起点往后累加 k 个数,求平均值,更新最大值。这样确实能出正确答案,但一旦数据量上来,比如数组长度到 10 万,k 到几千,O(n*k) 的复杂度会直接让你体验到什么叫超时。而滑动窗口能把复杂度压到 O(n),只用一次遍历就解决问题。这篇文章我会从暴力解法的痛点开始,逐步推到滑窗的优化原理,再给出可直接抄的代码,最后把常考的变种和坑点一起聊透。

1. 内容整体设计与思路拆解

1.1 题目到底在问什么

先把题目的面纱掀开。给一个数组 nums,比如[1, 12, -5, -6, 50, 3],再给一个整数 k,比如 4,要求连续子数组的长度刚好是 4,算出每个长度为 4 的子数组的平均值:

  • [1, 12, -5, -6]的和是 2,平均值 0.5
  • [12, -5, -6, 50]的和是 51,平均值 12.75
  • [-5, -6, 50, 3]的和是 42,平均值 10.5

最大平均值是 12.75,所以答案返回 12.75。

注意两个关键点:第一,子数组必须是连续的,不能跳着选;第二,长度必须是 k,不能多也不能少。这两点直接决定了我们能用什么算法。连续 + 定长,这就是滑动窗口的天然主场。

1.2 暴力解法为什么不行

暴力思路非常简单粗暴:枚举所有长度为 k 的子数组起点 i,从 i 到 i+k-1 累加求和。伪代码如下:

maxAvg = 负无穷 for i in 0..n-k: sum = 0 for j in i..i+k-1: sum += nums[j] maxAvg = max(maxAvg, sum / k) return maxAvg

这种做法的复杂度是多少?外层循环有 n-k+1 个起点,内层循环每次累加 k 个数,总次数约等于 (n-k+1) * k。当 n 和 k 都很接近 n 的一半时,最坏情况大概是 O(n²/4)。如果 n 是 10 万,计算量就是 25 亿次,这在绝大多数在线评测系统里都会超时。

更让人难受的是,这里面有大量重复计算。最典型的例子:起点 0 和起点 1 的两个窗口,有 k-1 个数是重叠的。暴力解法把它们完全重复累加了,相当于每次都从零开始,明明上一次已经算过的数据,白白扔掉,然后又重新捡起来算一遍。这不是笨不笨的问题,这是对算力的浪费。

1.3 滑动窗口为什么能救命

滑动窗口的核心思想,用一句话概括:不要让上一个窗口的计算成果白白浪费,把窗口向右移动一格时,只处理变化的那一小部分。

还是用上面的例子。第一个窗口[1, 12, -5, -6]的和是 2,现在窗口向右挪一格,变成[12, -5, -6, 50]。这时候你仔细看,12, -5, -6这三个数在两个窗口里都出现了,只有最左边的 1 被移出,最右边的 50 被加入。所以新窗口的和 = 旧窗口的和 - 移出的数 + 加入的数,也就是 2 - 1 + 50 = 51。

一步到位,不需要重新累加四个数。窗口每移动一次,只做两次操作:减一个、加一个。时间复杂度从 O(n*k) 降到了 O(n)。

这个思路用生活化的类比来说就是这样:你站在一条传送带旁边,传送带上有好多货物,你负责记录每连续 k 个货物的总重量。最笨的办法是每次传送带动一格,你都从当前这格开始往后数 k 个,一个个称重。滑窗的思路是:你手里有一张写着 k 个货物总重的单据,传送带动一格之后,你只需要把单据上最前面那个货的重量划掉,再在末尾添上新来的那个货的重量,数字就自动更新了。

你品品,这就是滑动窗口的全部精髓。理解了这一步,后面代码怎么写都是顺理成章的事。

2. 核心细节解析与实操要点

2.1 窗口的初始化与流动方式

实现滑动窗口最关键的三个变量:

  • windowSum:当前窗口内所有元素的和
  • maxSum:到目前为止见过的最大窗口和
  • left和right:窗口的左右边界,或者用i表示窗口起点

初始化阶段,先计算第一个长度为 k 的窗口的和,这个动作是必须的,因为后续的窗口都是基于它推导出来的。代码如下:

int windowSum = 0; for (int i = 0; i < k; i++) { windowSum += nums[i]; } int maxSum = windowSum;

然后从 k 开始向右遍历数组。每次迭代,窗口的右边界是 i,左边界是 i-k。新窗口的和等于上一个窗口的和减去 nums[i-k],再加上 nums[i]。

这里有个初学者容易犯迷糊的地方:为什么右边界从 k 开始?因为我们已经把下标 0 到 k-1 算作第一个窗口了,接下来滑动窗口时,新加入的元素自然从下标 k 开始。而新窗口的起点就是 i-k+1,被移除的元素下标就是 i-k。

2.2 为什么不用求每个窗口的平均值

很多第一次接触这道题的人会问:题目要求返回最大平均值,那我在滑窗过程中直接算平均值不就行了?

可以,但没必要。因为 k 是固定的,所以平均值 = 和 / k,k 不变,比较平均值和比较总和是完全等价的。在整个遍历过程中,我们只需要维护最大和 maxSum,最后返回 maxSum / k 就完事了。

这样做的好处有两个:一是省掉每次除法运算的开销,二是在求和中使用较大的数值进行比较,避免浮点精度带来的误差。虽然除法开销不算大,但在循环里跑几万次几十万次,能省一点是一点。

2.3 边界条件与返回类型

边界条件通常有这几种情况:

  • 数组为空或 k 为 0:题目一般保证 k 合法,但写代码时心里要有数
  • k 等于数组长度:此时只有一种窗口,直接返回总和除以 k
  • k 等于 1:窗口长度为 1,答案就是数组里的最大值
  • 数组里全是负数:maxSum 必须初始化为一个足够小的值,不能初始化为 0

最后这一点特别重要。如果你把 maxSum 初始化为 0,而数组里全是负数,比如[-1, -2, -3],k=2,窗口和最大值是 -3,但你会因为 maxSum 一开始是 0 而永远无法更新它,最后返回 0,直接错误。

这类错误的根源在于混淆了“最大和的初始值”和“窗口和的初始值”。窗口和的初始值是第一个窗口的和,这个可以确定;但最大和的初始值应该设为第一个窗口的和,而不是理论上的最小整数。因为至少有一个窗口存在,第一个窗口的和就是当前已知的最大值。

正确写法:

int maxSum = windowSum;

这样无论数组全是正数、负数还是零,都不会出错。

2.4 数据类型的陷阱

这道题有一个暗坑:int 溢出。

如果数组长度很大,比如 n 是 10 万,而数组元素很大,比如接近 10 万,那一个窗口的和可能达到 10^10 级别,已经超过 int 类型的上限 2,147,483,647。

所以窗口和windowSum和maxSum建议使用long类型,或者在使用过程中注意转换为浮点类型。返回平均值时再用 double 运算,避免整数除法把小数丢掉。

double maxAvg = (double) maxSum / k;

这里有个细节:要先转 double 再做除法,否则整数除以整数得到整数,小数部分全部被截断,答案是错的。

3. 实操过程与核心环节实现

3.1 最标准的滑动窗口写法

我先把最优解写出来,这个版本是我日常刷题最常用的模板,注释也写得比较详细。

class Solution { public double findMaxAverage(int[] nums, int k) { int n = nums.length; // 1. 计算第一个窗口的和 long windowSum = 0; for (int i = 0; i < k; i++) { windowSum += nums[i]; } // 2. 记录当前最大窗口和 long maxSum = windowSum; // 3. 滑动窗口主体 for (int i = k; i < n; i++) { // 窗口向右移动:移除 nums[i-k],加入 nums[i] windowSum = windowSum - nums[i - k] + nums[i]; if (windowSum > maxSum) { maxSum = windowSum; } } // 4. 返回最大平均值 return (double) maxSum / k; } }

Python 版本同样简洁:

class Solution: def findMaxAverage(self, nums: List[int], k: int) -> float: n = len(nums) # 第一个窗口的和 window_sum = sum(nums[:k]) max_sum = window_sum # 滑动窗口 for i in range(k, n): window_sum = window_sum - nums[i - k] + nums[i] max_sum = max(max_sum, window_sum) return max_sum / k

你观察一下这段代码的结构,其实就三步:初始化窗口、循环滑动、更新答案。很多滑动窗口题目都可以套这个骨架,只是窗口更新的规则和答案的判定方式不同。

3.2 另一种写法:先右边界扩展,再左边界收缩

还有一种写法更贴近“窗口先扩大再缩小”的直觉,也更方便以后过渡到不定长窗口问题:

class Solution { public double findMaxAverage(int[] nums, int k) { long sum = 0; long maxSum = Long.MIN_VALUE; for (int i = 0; i < nums.length; i++) { // 右边界扩展,加入当前元素 sum += nums[i]; // 窗口长度达到 k,开始统计并准备左边界收缩 if (i >= k - 1) { maxSum = Math.max(maxSum, sum); // 左边界收缩,移除最左边的元素 sum -= nums[i - k + 1]; } } return (double) maxSum / k; } }

这种写法的好处在于逻辑上更统一,不需要单独初始化第一个窗口。每加入一个元素,如果窗口长度已经达到 k,就统计一次答案,然后把最左边的元素移出去,为下一轮做准备。

我实际用下来,这种写法对理解“窗口是动态的”非常有帮助,尤其适合作为滑动窗口思想的通用模板。当窗口长度不固定时,只需要把i >= k - 1这个判断条件换成其他条件即可,比如while (sum > target)之类的。

3.3 从暴力到滑窗的递进过程

如果你在学习过程中想验证滑窗的优化效果,可以先自己写一版暴力解,再对比滑窗解。我经常这样干,因为看复杂度分析和亲手跑一遍,体验完全不同。

// 暴力法,用于本地验证小数据 public double bruteForce(int[] nums, int k) { double maxAvg = Double.NEGATIVE_INFINITY; for (int i = 0; i <= nums.length - k; i++) { long sum = 0; for (int j = i; j < i + k; j++) { sum += nums[j]; } maxAvg = Math.max(maxAvg, (double) sum / k); } return maxAvg; }

用一组随机生成的数据,比如n = 50000, k = 10000,分别跑两个版本,你会直观感受到暴力法在滑动窗口面前有多么不堪一击。暴力的总操作次数约 4 亿次,在本地可能要两三秒甚至更久,而滑窗几乎是瞬时完成。有一次我拿真实环境测,暴力解法跑了 3200 毫秒,滑窗只跑了 1 毫秒,这个差距以后慢慢体会。

3.4 为什么这个代码能直接用

这道题的时间复杂度是 O(n),空间复杂度是 O(1)。没有额外数组,没有哈希表,只有几个变量在不停更新。所以无论是在 Java 还是在 Python 里跑,都能轻松通过数据量很大的测试用例。

在实际面试中,滑动窗口属于“高频且必须秒答”的基础算法。如果你能把这道题讲清楚,面试官通常默认你掌握了滑窗的基本原理,后面会继续追问变种题目,比如无重复字符的最长子串、长度最小的子数组这些。所以这道题刷透的意义不只是 AC,而是给后续更复杂的滑窗问题打地基。

4. 常见问题与排查技巧实录

4.1 为什么我的代码返回了错误的平均值

这个问题绝大多数情况下是 int 除法导致的。如果你写的是:

return maxSum / k;

而 maxSum 是 int,k 也是 int,Java 会做整数除法,直接把小数部分截断。比如5 / 2 = 2,不会得到 2.5。

解决办法是先把其中一个操作数转为 double:

return (double) maxSum / k;

Python 3 里5 / 2 = 2.5,没有这个问题,所以 Python 用户不容易踩到这个坑。但 Java/C++ 用户一定要小心。

另一个可能性是 maxSum 的初始值设置有问题。如果你把 maxSum 初始化为 0,而数组全是负数,最大值永远更新不了,就会错误地返回 0。这时候应该把 maxSum 初始化为第一个窗口的和。

4.2 int 溢出应该怎么处理

当数组长度和数组元素数值都较大时,窗口和可能超过 int 上限。比如 n = 100000,nums[i] = 100000,k = 50000,窗口和是 50 亿,远超 int 上限 21 亿。

这个问题的隐蔽之处在于:溢出不会立刻报错,而是会让结果变成一个负数,然后你发现答案突然变成 0 或者负数,代码逻辑看起来又没问题,特别难排查。

最好是一开始就把windowSum和maxSum定义为long。这不算过度设计,算是对边界条件的敬畏。竞赛环境里因为溢出丢分的人,每年都不在少数。

4.3 滑动窗口时数组越界怎么办

标准解法中,主循环从i = k开始,到i = n - 1结束,每次访问nums[i]和nums[i - k],下标范围是0到n-1,不会越界。

如果你用第二种写法(先加右边界再收缩左边界),要注意判断窗口长度达到 k 时,左边界下标是i - k + 1。当 i 等于 k-1 时,左边界是 0,正好是第一个窗口。如果不小心写成i - k,那个时刻左边界就变成 -1 了,直接崩掉。

我自己的经验是:如果不确定边界下标,可以先手动模拟一遍小数组。比如 nums =[1, 2, 3, 4],k = 2,把每一步的 i、windowSum、maxSum 都写出来,对照代码走一遍,思路立刻就清晰了。

4.4 遇到全是重复数字的数组,结果对吗

很多人会担心:如果数组里全是[5, 5, 5, 5],k = 2,窗口的每个和都是 10,最大值是 10,平均值是 5。代码先初始化第一个窗口和为 10,maxSum = 10,滑动时后面的窗口和也是 10,windowSum > maxSum不成立,maxSum 保持 10,最后返回 10 / 2 = 5。没问题。

如果全是负数[-1, -2, -3, -4],k = 2,首先初始化 windowSum = -3,maxSum = -3,然后滑动得到 -5,不更新,-7 也不更新,最后 maxSum 还是 -3,平均值 -1.5,这是正确的结果。

4.5 常见问题速查表

症状可能原因解决方案
结果小数部分丢失整数除法先转 double 再做除法
全负数数组返回 0maxSum 初始化为 0初始化为第一个窗口的和
大数组结果异常int 溢出windowSum 和 maxSum 用 long
下标越界边界计算错误手动模拟一遍小数组
运行超时用了暴力解法改用滑动窗口 O(n)

这张表我建议收藏,刷其他滑动窗口题目时,遇到的坑大体也是这几类,换汤不换药。

5. 从这道题延伸出去的变种与应用

5.1 定长窗口的通用解法

这道题的滑窗写法是定长窗口最基础的模板。总结一下就是:先装第一个窗口,然后右边界逐个加入新元素,左边界逐个移除旧元素,窗口和保持稳定,在移动过程中记录答案。

后面遇到类似的题,比如“长度为 k 的子数组最大和”、“每个长度为 k 的窗口的平均值”、“子数组最大平均数 II”,思路完全一样,只是返回值或统计方式略有不同。比如“子数组最大平均数 II”要求最大平均数,但 k 不固定,这时候就要换思路,用二分答案加前缀和,复杂度比这题高一个量级。建议先把这道题的定长窗口模板吃透,再去碰不定长。

5.2 从定长窗口到不定长窗口

如果你已经掌握了定长窗口,那接下来可以挑战“长度最小的子数组”这类不定长窗口题。这类题里的窗口长度是动态的,右边界一直向前扩展,左边界根据条件收缩,直到不满足约束为止。

但这类题和这题最大的区别是:定长窗口的收缩时机是固定的(每步收缩一格),而不定长窗口的收缩时机取决于条件是否满足,通常需要 while 循环来收缩左边界。理解好这个差异,你就能从定长滑窗丝滑切换到不定长滑窗。

5.3 处理正负数混排数组的注意点

LeetCode 643 这道题的数组里可能出现负数,这和“只有正数”的题目有个显著区别:当窗口向右移动时,窗口和可能变大也可能变小,方向和直觉不完全一致。比如一个很大的负数移出窗口,窗口和会突然变大;一个很大的正数进入窗口,窗口和也会变大。

所以不能简单地判断“当前窗口和比 maxSum 小就不再管了”,必须所有窗口都检查一遍,确保 maxSum 永远是全局最大值。标准代码里if (windowSum > maxSum)这个判断每轮都要执行,不能省略修正条件,或者在窗口和变小的时候跳过更新。

有意思的是,如果题目变成“子数组最大平均数 II”,k 不固定,正负混排会让两个端点问题变得很微妙,核心在于前缀和的最小值需要动态维护。这些都是进阶内容,先把这道题吃透,你再去碰那些更复杂的变种就不会一头雾水。

6. 写在最后的实操体会

我个人在实际刷题中最大的感受是:LeetCode 643 这道题,代码量不大,但信息密度极高,属于典型的“会者不难,难者不会”题。

如果你把滑动窗口理解成“窗口移动时只处理首尾变化”,而不是“每次都重新计算整个窗口”,那你对这道题的理解就到位的。刷题的时候不要急着背答案,先自己推导一遍:从暴力到滑窗,为什么滑窗能少算那么多遍?很多人在初学阶段觉得“暴力也能过,为什么要优化”,但在真实工程场景和数据量大的情况下,O(n*k) 和 O(n) 的差距,可能是用户等 3 秒还是等 0.01 秒的区别。

另外,我强烈建议你用一个在线网站对比执行时间,自己构造边界测试用例,比如 k 等于数组长度、k 等于 1、全负数数组、超大整数数组,亲手验证一遍代码的表现。踩过坑才会长记性,尤其是 int 溢出和 maxSum 初始值这两个点,我见过不少人在面试手写代码时栽在这里,明明思路是对的,就因为这些细节被扣分,非常可惜。

最后再分享一个小技巧:这道题的滑窗思想在真实业务里也很常见,比如监控系统中计算最近 5 分钟的平均响应时间,实现思路就是在时间轴上维护一个定长窗口,每次有新数据进来就更新窗口内的均值,跟这道题的本质完全一致。把一道算法题和实际场景联系起来,你会更容易记住它,也不会觉得刷题是纯应试。祝你在滑动窗口这条路上越走越顺,别再跟它“拼了”,而是跟它“交了朋友”。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 5:52:03

工业PoE交换机选型与部署:从PoE供电原理到远距离传输故障排查

1. 为什么工业现场越来越离不开PoE交换机先讲一个我实际见过的场景&#xff1a;某自动化产线改造&#xff0c;现场要部署一批IP摄像机和无线AP。电工班老张跟我说&#xff0c;新设备装起来不难&#xff0c;难的是布线——摄像头装在钢梁上&#xff0c;附近根本没有电源插座&…

作者头像 李华
网站建设 2026/10/12 5:51:22

非Docker环境下手动安装视频标注工具VATIC全攻略

做视频数据集最大的痛点从来不是算法选型&#xff0c;而是标注。尤其是视频目标检测任务&#xff0c;动辄几千帧画面&#xff0c;一帧一帧画框能把人逼疯。我在2020年初接到一个内部项目&#xff0c;需要快速攒一批标注数据&#xff0c;专门去调研了一圈视频标注工具&#xff0…

作者头像 李华
网站建设 2026/10/12 5:51:04

中国省市区城市列表MySQL数据表:导入、转JSON与查询优化

简介&#xff1a;这是一份面向后端开发、数据分析与地图应用开发者的中国省市区城市列表MySQL数据表&#xff0c;以SQL脚本形式提供&#xff0c;可直接导入数据库&#xff0c;也可转换为JSON数据使用。数据覆盖全国省份、城市与区县&#xff0c;包含行政区划码和经纬度信息&…

作者头像 李华
网站建设 2026/10/12 5:50:43

用Go从零写一个命令行记账工具:SQLite存储与CSV导出

1. 为什么我最终选择用 Go 来写一个记账工具很多人第一次听到“用 Go 写记账工具”都会觉得有点小题大做——记账嘛&#xff0c;Excel 拉个表格&#xff0c;或者手机上下个现成的 App 不就完了&#xff1f;我一开始也是这么想的。但用了几年各种记账软件之后&#xff0c;我发现…

作者头像 李华
网站建设 2026/10/12 5:50:00

throw/throws 关键字

一、throws 作用throws 写在方法声明处&#xff0c;用于声明该方法有可能抛出某种异常&#xff0c;把异常交给调用这个方法的人去处理。一句话区分&#xff1a;throws 是声明异常&#xff0c;自己不捕获&#xff0c;抛给上层调用者。 和 try-catch 的区别&#xff1a;try-catch…

作者头像 李华
网站建设 2026/10/12 5:49:56

微网共享储能利益分配机制:调度权、收益分成与协议设计实战指南

做微网的人这几年大概率听过“共享储能”这个词。园区里建一块电池&#xff0c;几家企业一起用&#xff0c;投资有人分担&#xff0c;利用率也拉得起来&#xff0c;表面上是一个多方共赢的方案。但等到微网运营商和用户聚合商真正坐下来谈合作&#xff0c;事情就没那么舒服了。…

作者头像 李华