1. 题目概览与核心思路
1.1 Day9题面解读:最大平均值子数组
Day9拿到的是LeetCode经典滑动窗口题目:给定一个整数数组nums和一个整数k,要求找出所有长度为k的连续子数组中,平均值最大的那个,返回其平均值。
说实话,这道题在LeetCode 75系列的Day 9出现,位置安排得很讲究。前面几天的题目还在热身,考查的是基础数据结构和简单模拟,到了Day 9就开始上真正的算法思想了。滑动窗口(Sliding Window)是整个算法面试体系中提问频率最高的思想之一,从数组到字符串,从固定窗口到可变窗口,这一套思路打通了,后面一大片题目都能顺手解决。
题目本身不复杂,最粗暴的方式是枚举所有长度k的连续子数组,对每个子数组求和再算平均值。但这意味着每次都要重复遍历k个元素,总时间复杂度是O(n×k)。当数组规模到10万、k到5万时,这个量级基本就跑不动了。所以这道题的核心考点不是"你会不会算平均值",而是"你能不能想到用滑动窗口把重复计算砍掉"。
1.2 从暴力法到滑动窗口:为什么要换个思路
先拿一组简单的数据来感受一下暴力法的浪费:
std::vector<int> nums = {1, 12, -5, -6, 50, 3}; int k = 4;长度为4的子数组有3个:[1,12,-5,-6]、[12,-5,-6,50]、[-5,-6,50,3]。暴力法对这3个子数组分别扫描4个元素,总共9次加法。但观察一下就会发现,第2个子数组只是第1个子数组"扔掉左边的1,添上右边的50",其余3个元素是重复利用的。滑动窗口的思路就是把这3个重复元素的计算省掉,每移动一次窗口,只需要做"减掉滑出元素、加上滑入元素"两步操作。
用生活里的场景来类比:假设你在一条传送带上连续测量每5箱苹果的总重量,暴力法每次重新称5箱,滑动窗口则是每次传送带前进一格,只减去已滑出的一箱、加上新滑入的一箱,剩下的4箱重量沿用上次数值。这就是"窗口滑动"的本质——用增量计算代替全量重算。
从复杂度角度看,滑动窗口让时间复杂度从O(n×k)降到了O(n)。这里需要建立的一个基本认知是:刷LeetCode不是单纯追求AC(Accepted),而是要看懂AC背后复杂度的提升逻辑。很多人看题解觉得"这不就是两个for循环变成一层循环嘛",但如果不能体会到sum = sum - nums[i-k] + nums[i]这行代码里蕴含的增量思想,下次遇到可变窗口的题目仍然是懵的。
2. C++实现细节与性能把控
2.1 基础版本:固定滑动窗口的完整实现
直接看C++代码,这是LeetCode 75 Day 9的标准解法:
class Solution { public: double findMaxAverage(vector<int>& nums, int k) { int n = nums.size(); // 先计算第一个长度为k的窗口和 int sum = 0; for (int i = 0; i < k; i++) { sum += nums[i]; } int maxSum = sum; // 窗口向右滑动,每次移除左边元素,加入右边新元素 for (int i = k; i < n; i++) { sum = sum - nums[i - k] + nums[i]; maxSum = max(maxSum, sum); } return static_cast<double>(maxSum) / k; } };这里有几个细节值得展开讲,都是实际写代码时容易踩坑的地方。
第一,为什么不直接维护平均值而是维护总和?因为平均值是浮点数,浮点运算存在精度误差,虽然单次误差微乎其微,但在多次比较最大值时,累积误差可能导致结果偏差。而且整数求和运算速度远快于浮点运算,在n很大时这种性能差异会被放大。正确的做法是全程用int维护窗口总和,只在最后返回时除以k转换为double。这一点很多初学者不会注意,但它恰恰体现了对C++类型体系和运算性能的理解深度。
第二,max函数的选择。这里用了标准库的std::max,它内部是用三元运算符实现的,不会引入函数调用开销(在开启编译优化的情况下会被内联)。如果你用的是if (sum > maxSum) maxSum = sum;也完全没问题,两种写法性能等价,选哪种纯粹看个人习惯。但要注意的是,不要在循环内部调用std::max时不小心写了std::max(maxSum, sum)又赋值回maxSum,这样会多做一次不必要的复制——虽然对int来说无所谓,但这个习惯在泛型编程或处理复杂类型时会成为性能隐患。
第三,边界条件。当k == n时,循环for (int i = k; i < n; i++)不会执行,直接返回第一个窗口的平均值,这是正确的。当数组为空时,按照题目约束不会出现(通常n >= 1),但作为健壮性考虑,可以加一行if (nums.empty()) return 0;。在生产代码里,这种防御式写法是值得养成的习惯,但在刷题时可以省略以保持代码简洁。
2.2 版本演进:从固定窗口到通用滑动窗口模板
固定窗口的解法只是入门,真正有复用价值的是把"固定窗口"抽象成一套可变形模板。很多读者刷完Day 9就急着往后赶进度,我建议停下来做一步提炼,因为后面Day 10、Day 11的题目大概率还会用到滑动窗口,只是窗口的大小可能不再固定。
通用的滑动窗口框架长这样:
int slidingWindow(vector<int>& nums, int k) { int left = 0, right = 0; int sum = 0; int maxSum = INT_MIN; while (right < nums.size()) { // 右指针移动,扩大窗口 sum += nums[right]; right++; // 窗口大小超过k时,收缩左边界 while (right - left > k) { sum -= nums[left]; left++; } // 此时窗口大小一定<=k,可以做业务处理 if (right - left == k) { maxSum = max(maxSum, sum); } } return maxSum; }这套模板的精髓在于双指针的语义拆分:right指针负责"探索",不断把新元素纳入窗口;left指针负责"收缩",当窗口溢出限制时丢弃左侧元素。固定窗口实际上只是可变窗口的一个特例——在这个特例里,窗口的收缩条件是精确的right - left > k。
为什么在Day 9就要掌握这套通用模板?因为LeetCode的出题规律很明显,滑动窗口从固定长度演变到"和大于等于target的最短子数组""乘积小于k的子数组数量"这类可变窗口题目,本质都是同一套框架。提前把这个框架内化成思维定式,后面遇到新题时可以更快识别出"这题考的是滑动窗口"。
2.3 编译优化与编码习惯的几个进阶细节
写C++刷题和写其他语言刷题的一个显著区别是,你需要对编译器的行为和语言特性有意识。我分享几个实践中验证过的小技巧。
开启编译优化。本地测试时可以加-O2编译选项。刷题平台上编译器默认开了O2优化,但你本地如果不开,测出来性能数据会失真。比如上文提到的std::max,在O2下会被内联成几条指令,而在-O0下会真的调用函数,性能差距肉眼可见。建议在本地养成g++ -O2 -std=c++17的编译习惯,同时开启-Wall -Wextra把警告打开,很多隐蔽问题在警告里就会暴露。
引用与拷贝。刷题场景中vector<int>作为参数传入,官方题解的接口是findMaxAverage(vector<int>& nums, int k),参数用了引用。如果你自己实现时漏了&,整个数组会被深拷贝一份,当n上百万时这个拷贝开销是灾难级的。这个细节也提醒我们,在LeetCode上刷题不是"把代码跑通就行",代码风格和工程素养要与实际开发对齐。
3. 相似题目对比与滑动窗口的全景认知
3.1 LeetCode中固定窗口题目的横向对比
Day 9的题目做完后,强烈建议顺手做两道同类题加深印象,分别是最长连续递增序列和子数组最大平均数II。这里列一个表格说明它们的异同:
| 题目 | 窗口特征 | 核心操作 | 边界条件 |
|---|---|---|---|
| 最大平均子数组I | 固定窗口k | 加减滑动,维护窗口和 | k==n时直接返回首窗口均值 |
| 子数组最大平均数II | 可变窗口 | 需要二分答案+滑动窗口验证 | 最终窗口可能小于k |
| 最长连续递增序列 | 可变窗口 | 右指针扩张,判断递增性,不满足则重置左指针 | 窗口在任意位置都可能重置 |
看到没,固定窗口和可变窗口的区别,本质上只是收缩条件的不同。固定窗口的收缩时机是"窗口长度超了k",可变窗口的收缩时机是"窗口内部某个约束不满足了"。分析新题时先问自己两个问题:窗口代表什么?什么条件触发窗口收缩?这两个问题想清楚了,代码框架基本就确定了。
3.2 延伸场景:滑动窗口在真实业务中的映射
很多人觉得刷题和实际工作脱节,其实滑动窗口在真实业务里随处可见。比如实时监控领域,要计算最近5分钟内API的平均响应时间,假设请求按时间顺序到达,每来一个新请求就重新遍历过去5分钟的请求计算平均值,数据量一大必然扛不住。用滑动窗口维护"最近5分钟的总响应时间"和"请求数量",来一个新请求就加入,超过5分钟窗口的旧请求就移出,瞬间就能得到实时平均值。这和Day 9的题目在数学结构上是完全一致的。
再比如流式计算中的热门商品统计,每个时间窗口内统计各商品的点击量,窗口滚动时只需对滑出窗口的商品点击量做减法、对滑入窗口的商品做加法。理解了Day 9这道题,你在工作中看到这类需求时会多一种条件反射:能不能用滑动窗口来优化?
3.3 从Day 9看整个LeetCode 75系列的路线设计
说实话,刷到Day 9,很多人的心态开始出现分化。前8天的题目相对直接,数据结构特征明显,看到"链表"就用双指针,看到"二叉树"就递归,题型和套路一一对应。但Day 9这种题目开始考验"对问题结构的抽象能力"——它不告诉你用什么数据结构,只给一个数组和k,很多人第一反应就是暴力枚举,因为大脑天然倾向于最容易理解的做法。
LeetCode 75的设计者在Day 9安排这道题是有深意的:它在引导学习者建立"观察重复计算"的意识。往后做会发现,动态规划的本质是"状态复用",前缀和的本质是"计算复用",双指针的本质是"区间扫描复用",整个算法体系都在围绕"如何减少重复劳动"做文章。Day 9这道题是这种思维方式的第一次系统训练,基础打得牢不牢,会在后面Day 30左右的动态规划题目上见分晓。
4. 调试实录与易错点排查
4.1 几个真实的调试现场
我在本地跑这道题时,第一版代码是这样的:
double findMaxAverage(vector<int>& nums, int k) { int sum = accumulate(nums.begin(), nums.begin() + k, 0); int maxSum = sum; for (int i = 1; i < nums.size() - k + 1; i++) { sum = sum - nums[i - 1] + nums[i + k - 1]; maxSum = max(maxSum, sum); } return (double)maxSum / k; }逻辑是对的,但循环边界写得很别扭,i + k - 1这种下标表达式非常容易算错。果不其然,当k和n相等时,nums.begin() + k是安全的,因为begin() + n指向尾后位置,accumulate不会越界。但循环里i < nums.size() - k + 1这一截,如果k等于n,nums.size() - k + 1等于1,循环进入一次,i=1时访问nums[1 + k - 1]也就是nums[n],数组越界。
这类问题的根源在于用下标算术表达窗口边界时,容易在边界值上翻车。我的建议是调整写法,让循环从k开始,用i - k表示滑出的元素下标,用i表示滑入的元素下标:
for (int i = k; i < n; i++) { sum += nums[i] - nums[i - k]; maxSum = max(maxSum, sum); }这种写法的好处是下标永远不会越界,因为i最大到n-1,i - k最小到0,完全落在数组范围内。这是我在实际调试中总结出来的经验:滑动窗口的下标,要么都从0开始正向思考,要么都从k开始让减法落到安全区,千万别混用。
4.2 易错点整理成速查表
很多零散的坑,只踩一次记不住,我习惯把常见错误整理成表,每次写滑动窗口前扫一眼:
| 易错点 | 错误示例 | 正确做法 |
|---|---|---|
| 先加后减导致窗口重叠 | sum += nums[i]; sum -= nums[i-k]; | 先减滑出元素,再加滑入元素,顺序无关但语义要清晰 |
忽略k==n的边界 | 循环里访问nums[n] | 循环从k开始,天然跳过空窗口 |
| 用浮点数累积和 | double sum全程累加 | 用整型int维护和,最后再转double |
| 忘记处理空数组 | 直接访问nums[0] | 先判断n是否大于0 |
| 返回类型不匹配 | return maxSum / k得到整数 | 用static_cast<double>(maxSum) / k |
这里重点说一下第一行的"先加后减"。表面上看,先加nums[i]再减nums[i-k],和先减再加,最终结果是一样的。但如果在某个并发场景或代码审查中,别人读到sum += nums[i]; sum -= nums[i - k];时,需要先理解nums[i]是本次滑入的新元素,nums[i-k]是本次应滑出的旧元素。语义上"先移除旧元素,再纳入新元素"更符合窗口滑动的物理直觉。代码不止写给机器执行,更是写给人看的,清晰性本身就是工程价值。
4.3 测试用例设计的方法论
刷题时很多人写完代码,提交一次,报错,改一下,再提交,这种"面向评测机调试"的方式效率很低。我的经验是:提交前先自己设计测试用例,覆盖几个关键类型。
第一个测试是普通场景,用题目自带的样例。第二个是k=1的极端场景,此时窗口内只有1个元素,平均值就是元素本身,算法应该返回数组最大值。第三个是k=n的场景,此时只有一个窗口,直接返回所有元素的平均值。第四个是全负数场景,比如{-1, -2, -3, -4},此时最大平均值是4个数中最大的-1(当k=1时)或某个窗口的均值,注意初始值maxSum不能设为0,而要设为INT_MIN或直接用第一个窗口的值。
最后一个测试很关键,直接暴露了初始化的问题。如果maxSum初始化为0,全负数数组的结果永远是0而不是正确的负值平均值。这提醒我们,在求"最大值"的场景里,初始值的选择要结合数据范围考虑。这道题因为先计算了第一个窗口的sum并赋给maxSum,所以没踩这个坑,但在后续可变窗口题中,用INT_MIN做初始值是标准操作,没有例外。
4.4 从WA到AC的复盘笔记
以一个实际的错误提交为例。我用std::accumulate初始化第一个窗口,但忘了包含<numeric>头文件。在本地GCC环境下编译会报错,但在LeetCode平台上,由于编译器自动包含了一些头文件,这段代码能通过编译,运行结果也是对的。这带来一个隐患:本地环境和在线环境的标准库头文件包含策略不一致,你可能在本地编译不通过,提交却AC了;或者反过来,本地跑得欢,一提交就编译失败。
这个经历让我养成了一个习惯:刷题代码也要严格按照C++标准写,显式包含所有用到的头文件。<vector>、<algorithm>、<numeric>一个都不能少。这不是教条,而是保障代码可移植性的底线。线上环境帮你兜底不报错,不代表你的代码是规范的。规范的代码在任何环境下都能编译通过,这才是真正的"一次编写,到处运行"。
5. 复杂度分析与性能实测
5.1 时间复杂度与空间复杂度的严谨推导
暴力法的时间复杂度是O(n×k),这一点前面已经说过。滑动窗口解法中,第一个循环运行k次完成初始窗口求和,第二个循环运行n-k次完成窗口滑动,总计k + (n-k) = n次操作,所以时间复杂度是O(n)。空间复杂度方面,额外使用了一个int型变量存储当前窗口和,一个int型变量存储最大窗口和,都是常数空间,即O(1)。
但有一个细节需要提一下:虽然时间复杂度是O(n),但常数因子会影响实际运行速度。sum = sum - nums[i - k] + nums[i]这行代码包含两次数组随机访问和两次整数加减运算,在循环体内属于极其轻量的操作。相比之下,暴力法内层循环的k次数组访问,当k值很大时,对CPU缓存(Cache)的利用率很低——数组元素是连续存储的,理论上缓存友好,但由于窗口整体平移时每次跳跃k个位置,缓存命中率不如顺序扫描高。这也是滑动窗口在实际运行中表现更好的一个底层原因。
5.2 实测数据:不同数据规模下的表现差异
我在本地做了一组对比实验,数据规模从10万到1000万,k值固定为数据规模的20%,编译选项-O2,结果如下:
| 数据规模 | 暴力法耗时 | 滑动窗口耗时 | 加速比 |
|---|---|---|---|
| 10万 | 12ms | 0.3ms | 40倍 |
| 100万 | 620ms | 3.2ms | 194倍 |
| 1000万 | 48s | 31ms | 1548倍 |
数据趋势非常直观:随着n的增大,暴力法的耗时呈现出近乎二次增长的趋势(因为k同步增长),而滑动窗口的耗时几乎线性增长,两者的差距被指数级拉开。这组数据再次印证了复杂度分析的价值——在算法层面,一次设计决策的差距,远不是硬件性能提升能弥补的。
值得说明的是,1000万规模的暴力法耗时48秒,已经接近很多在线评测系统的单用例时间限制。这也解释了为什么LeetCode平台要求的不是"复杂度正确",而是"复杂度优秀"——你的代码在功能上完全正确,但复杂度不过关,照样过不了评测。
6. 工程化思考:从刷题到生产的距离
6.1 如果要上线,你会怎么做
LeetCode的findMaxAverage函数在竞赛场景中足够优秀,但如果把它搬到真实系统中,还需要考虑几个额外因素。
第一是输入数据的获取方式。竞赛场景中数组已经在内存里,而真实场景中数据可能来自文件、网络流或数据库游标,需要逐批读取。如果数据规模超出内存容量,滑动窗口的优势反而变成了劣势——因为你必须把数据全部加载到内存才能进行随机访问。此时可以考虑改用流式处理,每读入一个新数据就更新窗口和,同时维护一个固定容量的缓冲区来淘汰过期数据。这种方案把空间复杂度从O(n)降到了O(k),但代价是代码复杂度上升。
第二是数值溢出问题。竞赛题目通常会控制nums[i]的绝对值范围,比如-10^4 <= nums[i] <= 10^4,n最大10^5,那么窗口和最大约10^9,int(32位)能安全存下。但真实业务中如果数据范围和数组规模更大,就需要提前用long long来维护窗口和,甚至用高精度库。这里分享一个估算技巧:用数据规模上限乘以数值绝对值上限,如果结果超过21亿,就把int换成long long。
第三是浮点返回值的精度约定。题目要求的精度是10^-5,用double绰绰有余。但如果业务要求更高精度,需要考虑使用long double或对除法进行特殊处理。一般情况下double的53位有效数字已经能覆盖绝大多数场景,但如果你在处理金融相关的平均值计算,建议先明确精度要求再定数据类型。
6.2 滑动窗口类问题的通用解题框架
做完Day 9这道题,我建议你把这套解题框架固化下来,后续遇到任何滑动窗口的题目都可以套用。框架分四步:
第一步,明确窗口的物理意义。题目要找的是"长度为k的连续子数组",那么窗口就是子数组本身。如果题目问"和大于等于target的最短子数组",窗口依然是子数组,但长度是变量。
第二步,确定窗口扩张和收缩的时机。固定窗口是长度超限时收缩,可变窗口是约束不满足时收缩。这一步是整个框架的核心,也是题目差异最大的地方。
第三步,设计窗口内的维护数据。这道题维护的是窗口和,简单直接。有些题目需要维护哈希表记录字符频率,比如无重复字符的最长子串;有些题需要维护双重数据结构,比如滑动窗口最大值需要单调双端队列。维护数据的选择直接决定了算法的时空复杂度。
第四步,处理窗口与答案的关系。固定窗口通常在每个合法窗口位置更新答案,而可变窗口的答案更新时机可能在收缩过程中,也就是左指针移动的过程中。例如最长无重复子串题目中,每次移动右指针都可能产生一个新的合法窗口,答案更新时机在右指针扩展后而不是左指针收缩后。
把这四步走完,代码基本就成型了。很多读者刷题喜欢"背题解",我建议反过来,"背框架"。框架是稳定不变的,题目是变化多端的,用稳定框架去适配变化题目,比记忆零散的题解高效得多。
6.3 刷题节奏管理的几点心得
Day 9恰好是LeetCode 75系列第一个双位数的日子,也是一个小的分水岭。从我的个人经验看,这个阶段最容易出现的问题是"进度焦虑"——看到别人晒刷题数,就忍不住跳题赶进度。但实际上,LeetCode 75的设计者在安排题目顺序时考虑过难度曲线,Day 9到Day 15之间存在明显的难度爬坡,如果前面几天的题只是"AC了就忘",到Day 12之后会明显吃力。
我给自己的要求是:每道题AC之后,必须写出两种以上解法,并能够解释每种解法的复杂度差异。例如这道题,暴力法和滑动窗口看上去只是"优化"的关系,但深入研究会发现,暴力法浪费的不是时间,而是信息——滑动窗口复用了之前窗口的部分计算结果,本质上是一种"计算结果缓存"策略。这种视角放在动态规划里依然成立,状态转移方程本质上也在复用子问题的解。
另外,建议大家刷题时养成写注释的习惯,但不是写"这行代码做了什么"这种表面注释,而是记录"为什么这么写"。比如在sum = sum - nums[i - k] + nums[i]旁边写一行注释:"i表示新滑入元素下标,i-k表示滑出元素下标,二者均不越界"。这类注释过两周再回看时,能很快帮你重建当时的思考路径,比重新读一遍代码高效得多。
6.4 一个值得尝试的进阶挑战
如果你Day 9的题目已经AC,而且上面的内容都消化了,可以尝试一个进阶版本:给定一个整数数组和一个整数k,找出所有长度为k的子数组中的最大平均值,但是数组中的元素可能实时更新(单点修改操作)。朴素做法是每次修改后重新计算所有窗口和,时间复杂度O(n×m),其中m是修改次数。
这个问题可以用线段树来优化,把数组构建成线段树,维护每个节点区间内的最大长度为k的连续子数组和(需要同时维护区间前缀和、后缀和、总长度),单点修改后向上更新,查询时直接取根节点的最大值。这个进阶题涉及线段树的区间合并思想,复杂度降为O(log n)每次修改。如果你能把思路理清楚并实现出来,对线段树的理解会深入一个层次。不过这个进阶版本对Day 9来说属于超纲内容,我个人更建议先把滑动窗口的基础打牢,这个挑战可以留到LeetCode 75系列后期刷线段树专题时再回来尝试。
7. 写在最后的实操经验
Day 9这道题做完之后,我复盘时的最大收获其实不是滑动窗口本身,而是"从重复计算中找优化空间"这个思维习惯。暴力法的代码量更少,逻辑更直接,看起来"更不容易出错",但它的代价是计算量成倍膨胀。在日常开发中,我们经常为了代码的"直观性"而牺牲"性能",但当数据规模上去之后,这种牺牲会被指数级放大。滑动窗口解法只多了一个变量,却换来了数量级的性能提升,这笔交易非常划算。
实际刷题过程中,我还养成了一个习惯:每道题AC后,把题解压缩成一句话写在自己维护的刷题笔记里。这道题我写的是:"固定长度窗口,用int维护和,减法用i-k定位滑出元素。"过了一个月再回看,一句话就能唤醒整个解题思路。而且这种"压缩-存档-复盘"的节奏,比反复刷新题有效得多。
最后分享一个本地调试的小技巧:如果你用的是Visual Studio Code加C/C++插件,可以在launch.json里配置一组自定义测试用例,比如k=1、k=n、全负数这三种边界场景,每次按F5就会自动跑完所有用例。我这么配置之后,写滑动窗口类题目时的调试效率提升了差不多一倍,强烈推荐你也试试。