每年都有大批人卡在华为OD机试的算法题上,明明平时刷题不少,一到考场就翻车。我之前带过几个准备OD机试的朋友,发现他们有个共同问题:题目其实不难,但总在“读题不仔细”“输入输出处理不对”“边界条件没考虑”这些细节上丢分。今天拿一道典型的真题——滑动窗口最大和值(C++),把从读题到AC的全过程拆开讲一遍,包括为什么用滑动窗口、代码怎么写、考场上有哪些坑。
这道题在华为OD机试的新系统、双机位C卷里出现频率相当高,而且它背后覆盖的“固定长度连续子数组”这一类问题,几乎每次考试都会换着花样出现。如果你正在准备OD机试,C++是主语言,那这篇文章值得认真看完。
1. 这道题为什么容易在机试里出现:从题目形态看考点
先还原一下题目长什么样。
给定一个整数数组 nums 和一个整数 k,请找出该数组中所有长度为 k 的连续子数组,返回这些子数组的最大和。
举个例子:
code复制数组:[1, 3, -2, 5, 7, 4]
k = 3
长度为 3 的连续子数组有:
- [1, 3, -2],和 = 2
- [3, -2, 5],和 = 6
- [-2, 5, 7],和 = 10
- [5, 7, 4],和 = 16
最大和就是 16。
题目形态就是这么简单,没有任何花哨包装。但恰恰是这种简单题目,在机试里非常有代表性。
1.1 一眼看去是“求最大连续和”,实际考的是一类区间统计思维
很多第一次做这道题的人,第一反应是枚举所有起点,然后内层循环把 k 个数加起来。这种暴力做法的问题在于,当数组长度 n 很大(比如 10^5 甚至 10^6),k 也很大时,时间复杂度 O(n × k) 会直接超时。
OD机试的判定系统对时间限制通常比较严格,C++ 一般能承受 10^7 到 10^8 次基本运算,超过这个量级就容易超时。如果 n = 10^5、k = 10^4,暴力法要跑 10^9 次累加,这基本稳超时。
所以这道题表面是“求和”,实际考的是你能不能把相邻子数组之间的重复计算消掉。这是滑动窗口这类算法的核心思想,也是机试考官最想看到的。
1.2 考官的隐藏意图:用一道题同时考察三件事
我后来复盘过很多OD真题,发现考官设计题目时很少只考一个点。这道“滑动窗口最大和值”看似孤立,实际上同时覆盖了三个能力维度:
- 模型抽象能力:能不能从“连续子数组”这个描述中识别出这是定长滑动窗口问题,而不是去套动态规划或者前缀和。
- 边界处理能力:数组为空、k 大于数组长度、数组元素全为负数、k 等于数组长度,这些情况怎么处理。
- 代码稳定性:考场上的代码不需要炫技,但要能在一次编译错误或一次运行时错误之后,快速定位问题并重新提交。
所以你会发现,这道题在OD题库里被归为“基础必会题”是有道理的。它不考偏门算法,考的就是你能不能把最基础的算法思想写对、写稳。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解为什么可行但危险:从复杂度一步步看清本质
2.1 暴力解的正确写法
在引入滑动窗口之前,先把暴力解法写清楚,因为只有理解了暴力解的问题,才能理解滑动窗口优化在哪里。
cpp复制#include <iostream>
#include <vector>
#include <climits>
using namespace std;
int maxSumBruteForce(const vector<int>& nums, int k) {
int n = nums.size();
if (n < k) {
return INT_MIN; // 或者按题目要求处理
}
int maxSum = INT_MIN;
for (int i = 0; i <= n - k; i++) {
int sum = 0;
for (int j = i; j < i + k; j++) {
sum += nums[j];
}
maxSum = max(maxSum, sum);
}
return maxSum;
}
这个写法逻辑完全正确,而且对于小规模数据(比如 n <= 1000)跑得很快。问题出在数据规模上。
2.2 计算暴力解到底有多慢
我们用一个具体的例子来感受一下。
假设 n = 100000,k = 50000。外层循环大约有 n - k + 1 = 50001 次,每次内层循环累加 k = 50000 个数。总操作次数是:
code复制50001 × 50000 = 2.5 × 10^9 次累加
也就是 25 亿次整型加法。C++ 每秒大约能执行 10^8 到 10^9 次简单运算(取决于机器和编译器优化),25 亿次累加至少需要 2 到 3 秒。大多数OJ的时限是 1 秒到 2 秒,这个复杂度基本必挂。
就算侥幸没超时,也相当于在悬崖边上走钢丝。机试系统里你的提交记录会显示运行时间,万一判题机负载高一点,同样的代码可能这次能过下次就超时。
2.3 滑动窗口优化的本质:复用上一次的计算结果
再来看一个更直观的例子。
数组:[1, 3, -2, 5, 7, 4],k = 3。
第一个窗口 [1, 3, -2] 的和是 1 + 3 + (-2) = 2。
第二个窗口 [3, -2, 5] 和第一个窗口有什么区别?左边少了 1,右边多了 5。
所以第二个窗口的和 = 第一个窗口的和 - 1 + 5 = 6。
第三个窗口 [ -2, 5, 7] = 第二个窗口的和 - 3 + 7 = 10。
发现没有,每次只需要做“加一个新数、减一个旧数”两次操作,而不是重新加 k 个数。这就是滑动窗口的核心:窗口移动时,数据是连续变化的,我们可以复用上一次的计算结果。
从时间复杂度上看,滑动窗口只需要遍历一次数组,每次操作 O(1),整体 O(n)。这比暴力解的 O(n × k) 优化了不止一个量级。
这里强调一个概念:滑动窗口不一定是两个指针“夹”出来的窗口,更本质的是一种增量计算思想。凡是“连续子数组/子串”且长度固定的问题,都可以优先考虑这个思路。
3. C++ 实现细节与完整代码:把边界和坑一次说清
很多人在leetcode上刷题时,函数签名和输入输出都已经帮你想好了,但在OD机试的OJ系统里,你要自己处理完整的输入输出。这部分对不熟悉竞赛式代码的选手来说,反而比算法本身更容易丢分。
3.1 完整可运行的代码
下面这个版本是我在OD机试场景下推荐的标准写法,注释尽量精简但关键步骤保留:
cpp复制#include <iostream>
#include <vector>
#include <climits>
using namespace std;
int main() {
int n, k;
cin >> n >> k;
vector<int> nums(n);
for (int i = 0; i < n; i++) {
cin >> nums[i];
}
// 如果 k 大于数组长度,直接按规则处理
if (n < k) {
cout << "invalid" << endl;
return 0;
}
// 初始化第一个窗口的和
int windowSum = 0;
for (int i = 0; i < k; i++) {
windowSum += nums[i];
}
int maxSum = windowSum;
// 窗口右移:加入新元素,移出旧元素
for (int i = k; i < n; i++) {
windowSum += nums[i] - nums[i - k];
maxSum = max(maxSum, windowSum);
}
cout << maxSum << endl;
return 0;
}
这段代码的思路很清晰:
- 读入数组和 k。
- 先计算第一个窗口 [0, k-1] 的和。
- 从 i = k 开始遍历,每次窗口右移一位,加上 nums[i],减去 nums[i - k],更新最大值。
- 输出结果。
3.2 循环边界:为什么是 i < n 而不是 i <= n - k
很多人在写第一版滑动窗口时,容易把边界搞错。这里需要仔细推敲一下。
我们维护的窗口索引范围是 [i - k + 1, i],其中 i 是窗口最右边元素的索引。初始时第一个窗口的右边界是 k - 1,所以 i 从 k 开始,表示下一个要加进来的元素索引。
对于数组长度 n,i 的最大值是 n - 1(因为数组最后一个元素的索引是 n - 1)。当 i = n - 1 时,窗口覆盖的是 [n - k, n - 1],这是最后一个完整窗口。
所以循环条件是 for (int i = k; i < n; i++),不是 i <= n - k。这一点可以从代码里窗口更新的逻辑来理解:nums[i] - nums[i - k] 中的 i - k 是窗口左边界移出的元素索引,必须保证 i - k >= 0。当 i = k 时,移出的是 0,正好对应第一个元素,逻辑成立。
如果写成 i <= n - k,会导致最后一个窗口没有被正确更新,结果可能偏小。而且一些判题系统会随机出边界用例,这种隐藏错误不会在样例中暴露,但在大数据测试里一定会挂。
3.3 用 int 还是 long long:一个容易被忽视的坑
这是我在帮人review代码时几乎每次都会提到的问题:数组元素范围。
很多机试题目不会明说数组元素的具体范围,只给一句“整数数组”。但如果数组中包含较大正数,且 k 也很大,那么窗口内 k 个数的累加和完全可能超过 int 的表示范围。
int 的取值范围是 -2,147,483,648 到 2,147,483,647,大约 21 亿。如果数组元素是 10^9 级别的数,k = 10^5,那么窗口和能到 10^14,远远超过 int。此时用 int 会造成溢出,结果变成负数,然后你把负数和当前 maxSum 比较,整个结果全错。
最稳妥的做法是使用 long long,除非题目明确说了数组元素和 k 的乘积在 int 范围内。
cpp复制long long windowSum = 0;
long long maxSum = LLONG_MIN;
同时记得引入 <climits> 或 <cstdlib>,用 LLONG_MIN 而不是 INT_MIN 来初始化最大和。如果你不确定题目数据范围,直接用 long long 是最保险的,内存占用多一点点,但运行速度几乎没有差别。
3.4 负数数组的陷阱:maxSum 初始化不能拍脑袋
很多人习惯把 maxSum 初始化为 0,这在数组全是正数时没问题,但如果数组中包含负数,就会出错。
比如数组 [-3, -2, -5],k = 2,最大和是 -5。如果你把 maxSum 初始化为 0,那么比较时会一直保留 0,最终输出 0,而正确答案是 -5。
正确做法是将 maxSum 初始化为第一个窗口的和:
cpp复制long long maxSum = windowSum;
而不是 0,也不是 INT_MIN。为什么不是 INT_MIN?因为如果数组元素是 long long 范围内的值,INT_MIN 可能不够小,而且第一次更新窗口时就已经覆盖了。这里直接初始化为第一个窗口的和是最自然的,也避免了不必要的边界讨论。
3.5 滑动窗口的常见变体:不只是求和
题目如果变一下,还可以考察滑动窗口的其他形态。比如:
- 滑动窗口最大值:给定数组和 k,求每个长度为 k 的窗口的最大值。这种题需要配合单调队列,时间复杂度仍然是 O(n),但实现复杂度上了一个台阶。
- 滑动窗口平均值:求每个长度为 k 的窗口的平均值,并返回最大值。这种题就是把求和换成平均值,注意用 double 或者浮点比较时的精度问题。
- 至少包含某个目标值的最短连续子数组:这种属于变长滑动窗口,窗口大小不固定,需要用双指针动态调整。
理解固定窗口的滑动本质后,再去看那些变体,会发现都是同一个套路:维护窗口、更新信息、记录最优解。
4. 华为OD机试实际考试中的注意事项:从读题到提交的全流程
知道算法题本身怎么做还不够,你还得知道在OD机试的考场环境里怎么把这个题稳稳拿下。这部分的经验,很多刷题网站不会告诉你,但考场上踩一次坑代价很大。
4.1 机试系统与双机位C卷:熟悉环境比多刷一道题更重要
华为OD机试目前采用双机位监控,也就是你要用一个摄像头对准自己,另一个设备(通常是手机)放在侧后方拍摄考试环境。考试用的系统是专门的上机评测平台,界面和牛客网、力扣都不太一样,提交代码后系统会编译、运行、对比输出。
这里有个容易被忽视的点:机试系统支持的语言版本可能和你本地环境不完全一致。比如你在本地用 C++17 的特性(结构化绑定、if constexpr 等)写得很爽,但评测机如果只支持 C++14,编译就会报错。我在考试前通常会确认一下当前机试平台支持的C++标准,实在不确定就只用 C++11 甚至 C++98 通用的语法写,能避免很多莫名的编译问题。
另外,双机位考试环境要求网络稳定,但这是另一个话题,这里不展开。重点是在考试前一定要去官方提供的模拟环境或过往真题系统里走一遍,熟悉代码编辑器的缩进设置、调试方式、提交流程。不然代码写对了,因为不会操作界面浪费大量时间,那才是最亏的。
4.2 输入输出的处理习惯:不要在简单地方丢分
OD机试的题面和力扣不同,它不会帮你封装好输入输出参数,而是需要你在 C++ 里自己用 cin/cout 或 scanf/printf 读取数据、输出结果。
最常见的输入格式是:
code复制n k
a1 a2 a3 ... an
第一行两个整数,第二行 n 个整数。
但有一种变体你需要警惕:题目可能把数组和 k 放在同一行,比如:
code复制1 3 -2 5 7 4 3
让你自己判断前 n 个是数组、最后一个是 k,或者反过来。这类题目考察的就是你是否能灵活处理输入格式,而不是只会死板的 cin >> n >> k。
我在考场上养成了一个习惯:把输入读取单独写一个小函数,或者至少在主函数开头先集中处理。这样后面逻辑清晰,也不会因为输入没读完整导致死循环。
另外要特别提醒:cin 默认会跳过空白字符(空格、换行、制表符),所以即使第二行数据分成两行、三行输入,cin >> nums[i] 也能正确读取。不要试图自己写字符级解析,除非你确定题目有特殊输入格式。
输出方面,OD机试通常要求只输出结果,不要输出任何提示文字。比如不要写 cout << "maxSum is " << maxSum << endl;,因为评测系统是逐字符比对输出的,多了一个空格或换行都可能导致结果错误。最终提交前把代码里的调试输出全部注释或删除。
4.3 自测用例怎么构造:别只测样例
考试时给你的是题目样例,但样例只能证明你的代码“看起来对”,不能证明所有情况都对。我在准备OD机试时,习惯为每道题准备几类固定的自测用例:
| 用例类型 | 示例 | 验证目的 |
|---|---|---|
| 正数全为负 | [-3, -2, -5], k=2 |
验证最大和不会初始化为0 |
| k = 1 | [5, -1, 9], k=1 |
验证窗口长度为1时的边界 |
| k = n | [2,4,6], k=3 |
验证只有一个窗口的情况 |
| n < k | [1,2], k=5 |
验证非法输入的兜底逻辑 |
| 全为正数大值 | [1000000, 1000000], k=2 |
验证 int 是否溢出 |
每次在本地跑完这些用例,再提交到评测系统,能明显降低 WA(Wrong Answer)的概率。特别是“数组元素全为负数”这个用例,至少能拦住一半的初学者。
4.4 遇到更复杂版本时的应对策略
机试题目不总是原题,很可能在滑动窗口基础上做包装。比如:
- 增加一个“最多可以修改一个元素为任意值”的条件,问最大窗口和。
- 要求返回最大和对应的窗口起始位置。
- 把数组改成循环数组。
面对这些变体,核心策略是先识别出最底层的数据结构需求。只要它说的是“连续子数组/子串”,优先往滑动窗口、前缀和、单调队列这几个方向去靠。
如果考试时真遇到不会的变体,也不要死磕一道题。OD机试通常有多道题,总分是累积的,把简单题全做对、中等题做出一部分,往往也能拿到不错的分数。先把这道题的固定窗口版稳稳AC,已经是很多人的目标了。
5. 从这道题延伸出去的同类题型:一套思路吃透一类题
这道题讲完后,如果只停留在“我会做这一道”,那备考效率还是太低。滑动窗口是一整类题型的底层模板,建议花点时间把下面的变体都过一遍。
5.1 定长窗口系列:求和、求平均、求最大
定长窗口是最好理解的,窗口大小不变,每次移动只更新头和尾的差异。除了“最大和值”,常见的还有:
- 长度为 k 的子数组最大平均值(LeetCode 643)
- 长度为 k 的子数组中和大于等于 target 的个数
- 长度为 k 的连续子数组中,乘积最大(注意负数对乘积的影响)
这类题都只需要维护一个窗口变量,在移动过程中更新结果,代码结构几乎一样。我通常会在准备阶段把三道定长窗口题放一起刷,直到不需要思考就能写出来为止。
5.2 变长窗口系列:最长无重复子串、最短覆盖子串
变长窗口比定长窗口复杂一些,因为窗口的左右边界都要动态变化。核心思路是:右指针不断右移扩大窗口,当窗口内条件不满足时,左指针右移缩小窗口,直到条件重新满足。
这类题典型的有:
- 无重复字符的最长子串
- 包含所有字符的最短子串
- 水果成篮(至多两种水果)
虽然看起来和“滑动窗口最大和值”不太像,但底层的“滑动窗口”思想是一致的。你甚至可以理解为:定长窗口是右指针和左指针固定间隔地一起移动;变长窗口是右指针负责扩展、左指针负责收缩。
5.3 滑动窗口 + 单调队列:滑动窗口最大值
如果你想把这道题的难度再提一档,可以看看滑动窗口最大值(LeetCode 239)。它不在固定窗口中直接求最大值,而是需要维护一个单调递减队列,保证队头始终是当前窗口的最大值。
这个变体和“滑动窗口最大和值”相比,算法复杂度相同,都是 O(n),但实现细节复杂很多。机试如果出到这道题,通常属于中等偏上的难度,建议备考时间充裕的话优先掌握,因为它的代码模板在很多场景下非常有用。
5.4 实际备考中的优先级建议
在有限的备考时间里,我一般建议按以下优先级来:
- 先掌握定长滑动窗口模板(本题)。
- 把前缀和也一起学了,因为很多“连续子数组和”类的题目可以同时用滑窗或前缀和做。
- 再学变长滑动窗口,重点练“无重复字符的最长子串”和“最短覆盖子串”。
- 最后有余力再看单调队列。
不要一上来就抱着难题啃。OD机试的目标是拿分,不是竞技比赛,太偏太难的题出现概率低,把基础题做熟练才是性价比最高的策略。
我个人在准备这类题目时,最大的体会是:写滑动窗口的代码时,永远把“窗口内维护的是什么”写在注释里。求和的窗口维护的是总和,求最大值的窗口维护的是最大值,求最长无重复的窗口维护的是字符频次。把这一点想清楚,代码就不容易写散。
另外,我在前面提到的“数组元素可能为负”“maxSum 初始化为第一个窗口和”这两个细节,是真的会在机试里反复出现的坑。建议你在本地编译器里亲手跑一遍负数数组的用例,看到输出结果和你预期的差距,印象会比看任何文章都深。
备考刷题这件事,没有太多捷径,但掌握一套清晰的模板,绝对比盲目刷三百道题更高效。希望这篇文章能帮你把滑动窗口最大和值这道题彻底拿下,也能让你在考场上多一份从容。
