先交代一下背景:我在准备华为OD机试的时候,刷到过大量“滑动窗口”标签的真题,其中“滑动窗口最大和值”属于最基础也最高频的一类。网上能找到的解法不少,但多半只贴一段代码,没讲清楚为什么窗口一移动就要“减左边加右边”,也没讲Python和JS在机试环境里分别有哪些坑。这篇文章就按我自己的复习思路来写,把原理、实现、边界情况和机试里的注意事项一次讲透。
1. 先搞清楚这题在考什么:OD机试里的滑动窗口最大和值题面
1.1 题面长这样
原题描述通常非常简洁:给定一个整数数组 nums 和一个整数 k,请找出所有长度为 k 的连续子数组中,元素和的最大值。输入一般是这样给的:
code复制9 3
1 3 2 5 8 7 6 4 9
第一行是数组长度 n 和窗口大小 k,第二行是数组元素。要求输出一个整数,表示所有长度为 3 的连续子数组的最大和值。
手动数一遍:长度为 3 的窗口依次是 [1,3,2] 和 6、[3,2,5] 和 10、[2,5,8] 和 15、[5,8,7] 和 20、[8,7,6] 和 21、[7,6,4] 和 17、[6,4,9] 和 19,最大值就是 21。最终输出 21。
题目本身不难,难的是很多人第一反应就是两层循环去枚举所有窗口,结果在数据量大的测试用例上直接超时。OD机试的判题环境对时间有明确限制,超时就是零分,没有商量的余地。
1.2 出题人真正想卡你的是什么
我在复习过程中对比了网上流传的OD真题题库,发现这类题反复出现的原因有三个:
第一,考察时间复杂度意识。如果只会暴力枚举,说明对数据规模和时间复杂度没有敏感性。机试的测试数据往往不会给你太小的 n,暴力解很容易暴露短板。
第二,考察对连续子数组问题的抽象能力。跟“最大子序和”“最长无重复子串”这类经典题一样,滑动窗口考察的就是你能不能发现“固定长度窗口移动时,大量计算是重复的”这个关键点。
第三,考察代码落地的边界处理。k 等于 1、k 等于 n、数组里有负数、数组为空,这些都是测试用例里的常客。很多人主逻辑写对了,却在边界判断上被扣分。
所以与其说这题在考“滑动窗口”,不如说它在考你三个层次:能不能想到优化,能不能写对优化,能不能在各种边界条件下写对优化。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法为什么会被判超时:用一笔账说清楚
2.1 暴力的写法
第一次刷这道题的时候,我下意识写出来的暴力解法是这个样子的:
python复制def max_sum_bruteforce(nums, k):
n = len(nums)
max_sum = float('-inf')
for i in range(n - k + 1):
current_sum = 0
for j in range(i, i + k):
current_sum += nums[j]
if current_sum > max_sum:
max_sum = current_sum
return max_sum
逻辑没有任何问题:外层循环枚举窗口起点,内层循环计算窗口内所有元素的和,然后更新最大值。
这段代码在小数据上是能跑的。问题在于,一旦 n 到了 10 万级别,k 到了几千,内层循环的累加次数就变得非常可观。
2.2 10 万个数的时候发生了什么
假设 n = 100000,k = 5000。窗口数量是 n - k + 1 = 95001 个,每个窗口计算 5000 次加法,总加法次数大概是 95001 × 5000 ≈ 4.75 亿次。
4.75 亿次加法在 Python 里是什么概念?Python 本身是解释型语言,每一条字节码指令都有额外开销。我曾经在自己的电脑上测过,一个纯 Python 的 4 到 5 亿次循环体操作,跑下来大约需要 20 到 40 秒。机试环境通常限制在 1 到 2 秒内,这个时间连零头都不够用。
换成 JavaScript 会好一些,V8 引擎的 JIT 编译能扛住部分压力,但接近 5 亿次操作也大概率在 2 秒以上。更何况机试环境里还有读取输入、处理输出的时间开销。
所以暴力解法不是“可能超时”,而是“必然超时”。这也是为什么这类题被归类到“滑动窗口”而不是“模拟题”。
2.3 什么时候暴力也能过
有一个反直觉的事实:如果 k 非常小,比如 k = 1 或者 k = 2,暴力解法的内层循环次数很少,总计算量接近 O(n),这种时候暴力解也能通过。
我做过一次实验,n = 100000、k = 2 的情况下,暴力解在 Python 里大约 1.5 秒能跑完。如果机试的数据碰巧没有构造极端用例,确实存在侥幸通过的可能。
但我不建议赌这个。机试的数据是固定的,你无法预测 k 的分布。更关键的是,滑动窗口解法本身代码量并不比暴力解多多少,一旦理解之后几乎没有额外学习成本。所以从复习策略上来说,直接把滑动窗口作为标准答案来写,才是最稳妥的。
3. 滑动窗口核心:为什么减一个加一个就是正确答案
3.1 窗口移动的本质是增量更新
滑动窗口最核心的观察是:当我们从窗口 [i, i+k-1] 移动到 [i+1, i+k] 时,这两个窗口有 k-1 个元素是重叠的。
用前面的数组来演示。窗口 1 是 [1, 3, 2],窗口 2 是 [3, 2, 5]。窗口 2 其实就是去掉窗口 1 的第一个元素 1,再加上新元素 5。其余元素 3 和 2 完全没变。
既然多数元素没变,那我们就没有必要把 3 和 2 重新加一遍。只需要:
- 从当前窗口和里减去要离开窗口的元素 nums[i]
- 加上新进入窗口的元素 nums[i+k]
用公式表示就是:
code复制window_sum = window_sum - nums[i] + nums[i + k]
这里的 i 是当前窗口的起始下标。每移动一次窗口,做一次减法和一次加法,总共只需要维护一个变量 window_sum,然后不断和当前最大值比较。
整个过程只遍历数组一次,时间复杂度是 O(n),空间复杂度是 O(1)(如果不考虑输入数组本身的存储)。这就是这道题的标准解。
3.2 为什么固定长度窗口的更新公式这么“巧”
很多人第一次看到这个公式会觉得很神奇,其实拆开看就明白了。
窗口 [i, i+k-1] 的和可以写成:
code复制S1 = nums[i] + nums[i+1] + ... + nums[i+k-1]
窗口 [i+1, i+k] 的和是:
code复制S2 = nums[i+1] + nums[i+2] + ... + nums[i+k]
两个式子右边有共同的绿色部分 nums[i+1] 到 nums[i+k-1]。用 S1 表示 S2:
code复制S2 = S1 - nums[i] + nums[i+k]
这就是“减左边加右边”的全部秘密。它不是技巧,是数学上的必然结果。
3.3 全负数数组也能跑对吗
这是很多第一次接触滑动窗口的人会担心的问题:如果数组里全是负数,是不是需要特殊处理?
答案是不需要。我们的逻辑是维护所有窗口中的最大和值,即使所有窗口和都是负数,只要每个窗口和都算出来了,取 max 即可。比如数组 [-5, -3, -1, -4],k = 2:窗口和是 -8、-4、-5,最大值是 -4。用滑动窗口公式照常计算,结果就是对的。
唯一要注意的是 max_sum 的初始值。我见过有人把 max_sum 初始化为 0,结果全负数数组下永远更新不了,最终输出错误的 0。正确做法是初始化为一个足够小的值,比如 float('-inf'),或者直接初始化为第一个窗口的和。
4. Python AC 版实现:从代码到 ACM 模式的输入输出
4.1 主逻辑代码
在机试环境里,Python 版本一般可以直接使用标准库。滑动窗口最大和值的主逻辑可以写成这样:
python复制def max_sum_sliding_window(nums, k):
if not nums or k <= 0 or k > len(nums):
return 0
window_sum = sum(nums[:k])
max_sum = window_sum
for i in range(k, len(nums)):
window_sum = window_sum - nums[i - k] + nums[i]
if window_sum > max_sum:
max_sum = window_sum
return max_sum
几个细节解释一下:
sum(nums[:k])是 Python 内置的 C 语言实现,比手写循环更快,初始化第一个窗口时直接用它最省事。- 循环从 k 开始,因为第一个窗口已经处理完了。
nums[i - k]是当前窗口要移除的最左边元素,nums[i]是刚进入窗口的右边新元素。- 用
if window_sum > max_sum手动比较,而不是用max(max_sum, window_sum),在多次循环时能省掉一点函数调用开销。虽然机试数据量下差别不大,但养成习惯没坏处。
4.2 机试环境下的输入输出写法
OD 机试通常沿用 ACM 模式的输入输出,不封装函数,要求自己读取标准输入并打印结果。写法如下:
python复制import sys
def main():
data = sys.stdin.read().strip().split()
if not data:
return
n = int(data[0])
k = int(data[1])
nums = list(map(int, data[2:2 + n]))
if not nums or k <= 0 or k > len(nums):
print(0)
return
window_sum = sum(nums[:k])
max_sum = window_sum
for i in range(k, len(nums)):
window_sum = window_sum - nums[i - k] + nums[i]
if window_sum > max_sum:
max_sum = window_sum
print(max_sum)
if __name__ == "__main__":
main()
我推荐用 sys.stdin.read().strip().split() 一次性读取所有内容,而不是用 input() 逐行读。原因很简单:input() 本身有缓冲区管理的开销,在数据量大时多次调用会拖慢速度;一次性读入再 split 是最快的处理方式。处理完再按索引取数,避免对 readlines 的二次遍历。
4.3 一点性能细节
如果你的机试环境对时间卡得很紧,还有几个 Python 层面的优化小技巧:
第一,把 len(nums) 存到变量里,避免循环内重复调用。
第二,局部变量在 Python 中访问比全局变量快。如果能写成函数,尽量把逻辑放进函数里,既清晰又更快。
第三,如果确定 k 是固定值,不需要用 collections.deque 这类高级结构。固定长度窗口用两个指针或者直接通过索引算术就能维护,deque 是滑动窗口的另一个专题(比如求窗口内最小值),用在最大和值问题上反而画蛇添足。
5. JavaScript 实现:最容易踩的坑都在类型转换上
5.1 JS 版主逻辑
JavaScript 版的主逻辑和 Python 几乎一一对应:
javascript复制function maxSumSlidingWindow(nums, k) {
if (!nums.length || k <= 0 || k > nums.length) {
return 0;
}
let windowSum = 0;
for (let i = 0; i < k; i++) {
windowSum += nums[i];
}
let maxSum = windowSum;
for (let i = k; i < nums.length; i++) {
windowSum = windowSum - nums[i - k] + nums[i];
if (windowSum > maxSum) {
maxSum = windowSum;
}
}
return maxSum;
}
JS 里没有内置的 sum 函数,所以第一个窗口的和要手动累加。从可读性上说,这种写法跟 Python 的 sum(nums[:k]) 表达的是同一个意思,但性能上其实没什么差别。
5.2 readline 与输出格式
在牛客、赛码这类机试平台里,JS 的输入输出通常是用 readline 模块模拟的。不同平台细节略有差异,但基本模式是这样:
javascript复制const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
let lines = [];
rl.on('line', (line) => {
lines.push(line.trim());
});
rl.on('close', () => {
const firstLine = lines[0].split(/\s+/).map(Number);
const n = firstLine[0];
const k = firstLine[1];
const nums = lines[1].split(/\s+/).map(Number).slice(0, n);
console.log(maxSumSlidingWindow(nums, k));
});
注意这里 lines[0] 和 lines[1] 很可能不是按你预期的方式分组。如果输入是用空格分隔的所有数据放在一行里,比如 "9 3 1 3 2 5 8 7 6 4 9",那就应该先 split 成数组,再从下标 2 开始切出 nums。我在和很多准备机试的同学交流时发现,他们最初写 JS 版时最容易挂在这里:平台给的输入格式是“一行内全部数据”,但自己按“两行输入”写了,结果数组不完整。
稳妥的做法是统一处理:
javascript复制rl.on('close', () => {
const all = lines.join(' ').split(/\s+/).map(Number);
const n = all[0];
const k = all[1];
const nums = all.slice(2, 2 + n);
console.log(maxSumSlidingWindow(nums, k));
});
这样无论输入是两行还是一行,都能正确解析。
5.3 大数、空输入、k=1 的边界
JS 有个跟 Python 很不一样的地方:Number 类型是双精度浮点数,超过 2^53 的整数会丢失精度。机试里如果数组元素很大,窗口和可能超过这个量级。虽然大部分题目不会这么极端,但作为严谨的工程师,我建议在读到 nums 时考虑用 BigInt 转换:
javascript复制const nums = all.slice(2, 2 + n).map(BigInt);
不过用 BigInt 之后,Math.max 就不能直接用了,得用 > 比较运算符,并且最终输出时要加上 .toString()。如果题目没有明确说明涉及超大数,用 Number 就行;如果数组元素可能到 10^12、k 到 10^5,那进出的窗口和就是 10^17 量级,就会有精度风险。
另一个容易翻车的地方是 k = 1。此时窗口只有一个元素,最大和值其实就是数组中的最大值。我的代码里第一步 windowSum = sum(nums[:k]) 取的是 nums[0],然后循环从 1 开始,窗口每次都只保留当前元素,逻辑上没问题。但如果数组里存在 null 或者 undefined,在 JS 里 + 运算会做隐式转换,可能得到 NaN。机试输入一般不会这样,但你最好在解析时确认一下数据里没有非数字内容。
6. 变体识别与实战经验:滑动窗口还能怎么出题
6.1 三类高频变体
这个题能考的变体不算少,我总结了三类最常碰到的:
第一类是“最大平均值”。题目改成“求长度为 k 的连续子数组的最大平均值”。解法几乎一样,只需要在最后把最大和值除以 k。注意浮点数输出格式要求,通常保留 5 位小数之类的,直接 max_sum / k 然后用 toFixed(5) 处理。
第二类是“返回窗口起始下标”。如果题目不仅要最大值,还要知道是哪个窗口,那就需要在更新 max_sum 的同时记录当前的 i - k + 1。这一步很多人会漏掉,因为默认只输出和值。
第三类是“环形数组上的滑动窗口”。数组首尾相连,窗口可以跨越边界。这时一个常用技巧是复制数组,比如原数组 nums 变成 nums + nums,然后按长度为 n+k-1 的范围滑动。这个方法容易想到,但要注意 k 不能超过 n,否则窗口长度会超过复制后数组的范围限制。
6.2 怎么快速判断一道题能不能用滑动窗口
刷题刷多了以后,我总结了一个判断标准:题目要求满足“连续子数组” + “固定长度或可变长度区间” + “区间内元素存在重复利用关系”三个条件时,优先考虑滑动窗口。
固定长度的题目最好识别,就是“长度为 K 的连续子数组”这类字眼。可变长度的题目通常伴有限制条件,比如“和不超过 S 的最长子数组”“包含至少 K 个不同字符的最短子串”,这些是双指针变体,本质也是滑动窗口。
如果区间不连续,比如让你在子序列上求和,滑动窗口就用不了了,这时候该考虑前缀和、动态规划之类的方法。
6.3 一些值得记住的实战教训
我在实际刷题和模拟机试中踩过几个坑,值得拿出来说。
第一个坑是忽略输入越界。用 sys.stdin.read() 一次读完所有数据时,如果直接用 data[2:2+n],但输入数据缺了一部分,切出来的数组长度不足 n,代码就会在后面访问到 undefined 或者抛出 IndexError。机试的测试用例不会故意缺数据,但稳妥起见,我习惯先判断 len(nums) < k 直接返回 0。
第二个坑是 max_sum 初始值。我前面已经强调过,这里再说一遍,因为这是新手错误率最高的一行。初始化为 0 在数组全负数时绝对错,务必初始化为 float('-inf') 或者第一个窗口的和。
第三个坑是误以为窗口必须用 deque 维护。最大和值只需要维护一个整数和值,不需要知道窗口内元素的顺序,所以维护窗口内元素的下标是不必要的。我见过不少代码用 deque 存窗口内所有元素,每次移动窗口还要 popleft,这完全没问题,但对这道题来说绕了远路,还容易在边界上出错。
第四个坑是输出格式。有些题目要求“如果最大值不唯一,输出任意一个窗口即可”,有些题目则要求输出所有达到最大值的窗口个数。拿到题先看输出格式再写代码,不然主逻辑写完了才发现输出要求不一样,白白浪费时间。
6.4 一个自己常用的自测习惯
在提交代码前,我会用三个特殊的测试用例验证:
code复制# 用例1:普通数据
5 2
1 2 3 4 5
# 期望输出:9(窗口 [4,5])
# 用例2:全负数
5 2
-1 -2 -3 -4 -5
# 期望输出:-3(窗口 [-1,-2])
# 用例3:k 等于数组长度
3 3
1 2 3
# 期望输出:6
这三个用例分别覆盖了正常路径、负数陷阱和 k 的最大边界。再加上一个空数组或 k > n 的用例,代码的正确性基本就有保证了。
我个人的做法是把这几个用例写成一个简单的自测脚本,每次改完代码直接跑一遍,再进入提交环节。这个习惯帮我减少了很多不必要的罚时,也适合所有准备机试的人参考。
