目录)
- [LeetCode 643. 子数组最大平均数 I|从暴力枚举到滑动窗口](#LeetCode 643. 子数组最大平均数 I|从暴力枚举到滑动窗口)
-
- [一、最开始的思路:枚举每一个长度为 k 的子数组](#一、最开始的思路:枚举每一个长度为 k 的子数组)
- 二、发现问题:相邻窗口其实有大量重复内容
- 三、什么是滑动窗口?
-
- [1. 定长滑动窗口一般怎么做?](#1. 定长滑动窗口一般怎么做?)
- [2. 怎么判断一道题适不适合滑动窗口?](#2. 怎么判断一道题适不适合滑动窗口?)
- 四、回到这道题:怎么修改原来的代码?
-
- [为什么循环条件是 `i <= n-k`?](#为什么循环条件是
i <= n-k?)
- [为什么循环条件是 `i <= n-k`?](#为什么循环条件是
- 五、还能进一步简化吗?
- 六、复杂度分析
- 七、最后复盘
LeetCode 643. 子数组最大平均数 I|从暴力枚举到滑动窗口
题目链接:643. 子数组最大平均数 I
弄了个网站, 可以看代码是怎么操作达到最终要的结果: 过程可视化
一、最开始的思路:枚举每一个长度为 k 的子数组
题目要求找到一个长度为 k 的连续子数组,使它的平均值最大。
我最开始的想法很直接:既然要求最大平均值,那就把所有长度为 k 的连续子数组都找出来,分别计算平均值,再取最大的那个。
于是就这样写出了第一版:
python
class Solution:
def findMaxAverage(self, nums: list[int], k: int) -> float:
n = len(nums)
max_mean = -float('inf')
i = 0
while i <= n-k:
current = 0
j = i
while j < i+k:
current += nums[j]
j += 1
max_mean = max(current / k, max_mean)
i += 1
return max_mean
这里两个 while 分别负责:
- 外层:枚举每一个合法的子数组起点
i。 - 内层:计算从
i开始、长度为k的子数组总和。
例如:
text
nums = [1, 12, -5, -6, 50, 3]
k = 4
第一个窗口:[1, 12, -5, -6]
第二个窗口:[12, -5, -6, 50]
第三个窗口:[-5, -6, 50, 3]
这个思路没有问题,但每次移动到下一个位置,都需要重新累加 k 个数字。
一共有 n-k+1 个窗口,每个窗口需要 O(k) 的求和时间。
因此时间复杂度是:O((n-k+1) × k),通常记为 O(nk)。
因此,当数组很长、k 也很大时,就容易超出时间限制。。。
那么,有没有办法避免重复计算?
二、发现问题:相邻窗口其实有大量重复内容
观察前两个窗口:
text
第一个:[ 1, 12, -5, -6]
第二个:[12, -5, -6, 50]
它们中间的 12、-5、-6 完全一样。
但我之前的代码每次都会:
python
current = 0
然后重新计算整个窗口的总和。
既然大部分数字没有变化,为什么不直接利用上一个窗口已经计算出来的结果?
例如:
text
第一个窗口总和:1 + 12 - 5 - 6 = 2
向右移动一格以后:
text
移出左边的 1
加入右边的 50
新窗口总和 = 2 - 1 + 50 = 51
这样就不需要重新计算中间的三个数字了。
所以关键的变化是:
不再每次重新计算窗口,而是维护一个可以随着窗口移动而更新的总和。
这就引出了 滑动窗口。
三、什么是滑动窗口?
简单来说:滑动窗口(Sliding Window)是一种处理 数组或字符串中连续区间的常见算法思想。
可以把窗口理解成一个框,框住当前需要处理的一段连续元素。
随着窗口向右移动,我们不必每次重新处理框里的所有内容,而是尽量利用之前已经计算过的信息。
(推荐去我的网站看看, 有过程的可视化, 会更好的理解滑动窗口的窗长什么样,怎么运动的~
例如固定长度为 3:
text
nums = [1, 2, 3, 4, 5]
[1, 2, 3] 4 5
1 [2, 3, 4] 5
1 2 [3, 4, 5]
滑动窗口通常分为两种:
| 类型 | 特点 | 常见问题 |
|---|---|---|
| 定长滑动窗口 | 窗口长度固定,每次整体移动 | 长度为 k 的最大总和、平均值 |
| 不定长滑动窗口 | 窗口长度可以变化,根据条件扩张或收缩 | 满足条件的最短或最长子数组 |
这道 643 题属于定长滑动窗口。
1. 定长滑动窗口一般怎么做?
通常可以分成四步:
第一步:确定窗口长度。
例如题目要求长度为 k,那窗口始终包含 k 个元素。
第二步:初始化第一个窗口。
先计算前 k 个元素的总和:
python
current = sum(nums[:k])
第三步:移动窗口,更新状态。
每次窗口右移一格:
text
旧窗口:[a, b, c]
新窗口:[b, c, d]
移出 a
移入 d
所以:
text
新窗口和 = 旧窗口和 - 移出元素 + 移入元素
第四步:更新答案。
每移动一次,就根据题目要求更新最大值、最小值或其他统计结果。
需要注意的是,滑动窗口不一定维护总和。
根据题目不同,也可能维护字符出现次数、不同元素的数量等状态。
它的核心是:
窗口移动时,只处理发生变化的部分,尽量复用原来的计算结果。
2. 怎么判断一道题适不适合滑动窗口?
可以先观察三个问题:
- 题目是不是在研究数组或字符串中的 连续区间?
- 是不是需要不断考察 相邻的 区间?
- 当区间移动时,能不能通过 加入、移除元素 来高效更新需要的信息?
如果这些条件都满足,就值得考虑滑动窗口。
但不是所有连续区间题都能直接套用同一种滑动窗口写法,尤其是不定长窗口,还需要考虑窗口收缩是否具有正确性依据。
四、回到这道题:怎么修改原来的代码?
我原来使用 i 表示窗口的起始位置。
所以继续保留这个定义。
例如 k = 4:
text
i = 0
窗口下标:[0, 1, 2, 3]
i = 1
窗口下标:[1, 2, 3, 4]
从第一个窗口移动到第二个窗口:
text
移出的下标:0 = i - 1
移入的下标:4 = i + k - 1
因此,只需要更新:
python
current = current - nums[i-1] + nums[k+i-1]
但第一个窗口比较特殊。
因为它前面没有旧窗口可以复用,所以必须先计算一次:
python
if i == 0:
current = sum(nums[:k])
之后的窗口才使用更新公式。
于是得到第二版代码:
python
class Solution:
def findMaxAverage(self, nums: list[int], k: int) -> float:
n = len(nums)
max_mean = -float('inf')
i = 0
while i <= n-k:
if i == 0:
current = sum(nums[:k])
else:
current = current - nums[i-1] + nums[k+i-1]
max_mean = max(current / k, max_mean)
i += 1
return max_mean
这次不再需要内层 while。
因为每次窗口移动,只需要一次减法和一次加法,就能得到新的窗口总和。
为什么循环条件是 i <= n-k?
因为 i 表示窗口起点。
一个长度为 k 的窗口,最右边的下标是:
text
i + k - 1
它不能超过数组最后一个下标 n-1。
因此:
text
i + k - 1 <= n - 1
i <= n - k
五、还能进一步简化吗?
其实可以。
因为所有窗口的长度都是 k,而且 k 是固定的正数。
所以:总和越大,平均值就越大。
我们没有必要每次都除以 k,可以先找到最大的窗口总和,最后再计算平均值。
另外,第一个窗口也可以在循环外初始化,这样就不需要每次判断 i == 0。
python
class Solution:
def findMaxAverage(self, nums: list[int], k: int) -> float:
n = len(nums)
current = sum(nums[:k])
max_sum = current
for i in range(1, n-k+1):
current = current - nums[i-1] + nums[i+k-1]
max_sum = max(max_sum, current)
return max_sum / k
这版和我自己写的第二版,本质上是同一种算法,只是把初始化和循环分开,让代码更加简洁。
六、复杂度分析
最开始的暴力枚举:
- 时间复杂度:
O((n-k+1)k),通常简写为O(nk)。 - 空间复杂度:
O(1)。
优化后的滑动窗口:
- 时间复杂度:
O(n)。初始化需要O(k),之后每次窗口移动只需要O(1)。 - 空间复杂度:
O(1)。只需要维护当前窗口总和和最大值等变量。
七、最后复盘
这道题的思考过程其实很简单:
text
题目要求长度为 k 的最大平均值
↓
先枚举所有长度为 k 的连续子数组
↓
每次重新计算窗口总和
↓
发现相邻窗口有大量重复元素
↓
既然大部分元素没变,能不能复用旧结果?
↓
窗口右移时,只移出一个、加入一个
↓
维护窗口总和
↓
从 O(nk) 优化到 O(n)
可以通过这道题去理解定长滑动窗口的作用:
滑动窗口不是简单地把双层循环改成单层循环,而是通过维护窗口状态,避免对相邻区间进行重复计算。
以后遇到类似题目,可以先问:
当这个连续区间向右移动时,究竟哪些元素发生了变化?之前的计算结果能不能直接利用?
如果能找到高效更新状态的方法,就有机会使用滑动窗口优化。