1. 先看清题目:这题不是普通“子数组最大和”
LeetCode 2461 这道题,我第一次刷的时候,并没有觉得它难:无非找一个长度为 K 的连续子数组,求最大和。可写完两个版本都错,才意识到真正的题眼不是“最大”,而是“子数组内所有元素互不相同”。这题的常规解法骨架是定长滑动窗口 + 哈希表;窗口负责把“连续子数组的和”从每次重算变成增量更新,哈希表负责回答“当前窗口内到底有没有重复元素”。这篇文章就用这一道题,把这种组合套路拆开讲清楚,顺便记录几个我实际提交时踩过的坑。
题面很短:给你一个整数数组 nums 和一个整数 k,找一个长度为 k 的连续子数组,并且这个子数组内部的所有数字都不同。返回所有满足条件的子数组中,元素和的最大值。如果没有这样的子数组,返回 0。
1.1 限制条件里藏着两个“与”关系
第一眼看过去很容易只盯着“最大和”,于是脑子里马上跳出最大子段和、前缀和、贪心之类的方案。但仔细拆解,条件其实是:
- 长度必须正好是
k; - 窗口内不能有重复值;
- 满足前两条的子数组,再取最大和。
这三个条件不是互相替代关系,而是“与”关系。比如 k = 3,数组是:
text复制[1, 5, 4, 2, 9, 9, 9]
第一个长度为 3 的子数组 [1,5,4] 合法,和是 10;第二个 [5,4,2] 合法,和是 11;第三个 [4,2,9] 合法,和是 15。到了 [2,9,9],虽然数组仍然连续,和也是 20,但因为有重复的 9,整段直接作废。
所以“最大和”是在一堆合法候选里找最大,而不是直接用滑动窗口求所有长度为 k 的子数组最大和再回头校验。
1.2 为什么一定要坚持“连续子数组”
题目说子数组,不是子序列,也不是任意选 k 个位置。这意味着问题天然适合滑动窗口,而不是排序或者组合。如果题目改成“从数组中任意挑 k 个互不相同的数,使和最大”,那解法就变成另外一套:按数值排序、从大到小加和,顺便判断数值种类是否够 k 个。
正因为是连续子数组,位置关系被锁死,我们可以让窗口从左往右走。每一次移动只丢出一个旧元素、加入一个新元素,前后的窗口有大量重叠,这个重叠就是优化的最大突破口。这也是“定长滑窗”这类题目最关键的特征:窗口长度固定,移动成本必须做成增量式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法为什么会被卡,以及省掉的成本到底在哪
学算法题最容易犯的错,不是不知道高级解法,而是低估暴力解法的开销。LeetCode 2461 的数组长度能到 10^5,如果每次枚举一个起点,再重新检查窗口内的重复情况,复杂度很容易变成 O(nk)。
2.1 最直白的写法:嵌套循环加集合
很多初学者第一次会这样写:
- 枚举左端点
l,从0到n-k; - 枚举
l到l+k-1,累加和; - 用一层循环或者一个集合判断有没有重复;
- 如果没重复,和最大值比较。
核心代码类似这样:
python复制def maximum_subarray_sum_brute(nums, k):
n = len(nums)
ans = 0
for l in range(n - k + 1):
window = nums[l:l + k]
if len(set(window)) == k:
ans = max(ans, sum(window))
return ans
这段代码非常直观,但它做了三件重复的事:
- 每个窗口都重新累加一遍和,两个相邻窗口之间有
k-1个重叠元素被重复加; - 每个窗口都重新建集合,重复判断又消耗一轮;
set(window)的开销还和k有关,窗口越长越慢。
2.2 相邻窗口之间能复用什么
我们来看最核心的观察:
text复制[1, 5, 4]
[5, 4, 2]
这两个子数组只差一个元素:左边去掉 1,右边加入 2。那么:
- 和可以从
1+5+4改成1+5+4 - 1 + 2; - 重复情况也不用从零开始判断,只需要知道上一轮窗口里每个数字出现几次,再根据“删除一个元素”和“增加一个元素”来更新。
这就是定长滑动窗口的核心思路:把“判断长度为 k”的完整扫描,改成“每次只处理窗口边界的一个变化”。时间复杂度从 O(nk) 降到 O(n)。
2.3 哈希表在这里的真正作用
哈希表不负责求最大和,也不是为了炫技。它在这里的作用是:维护窗口内每个数字当前出现的次数。
你可能会说:“判断重复不是用一个 set 就行了吗?”如果是静态数组,确实可以。但在窗口移动的过程中,数字可能被加入窗口,也可能被移除窗口。set 只能告诉你“在不在”,不能告诉你“如果移除一个元素后,它还在不在”。
举个例子,窗口里有两个 9,如果把其中一个 9 从窗口前段移除,另一个 9 还在窗口里,那么窗口依然是“有重复”的状态。如果你只用一个 set,你根本不知道 9 出现了两次。这就是为什么需要 哈希表 来记录频次,而不只是记录存在性。
3. 维护“重复状态”最容易翻车的地方
很多人写滑动窗口时会维护一个 sum,这部分通常不会错。真正容易错的是:怎么判断窗口内“全都是不同数字”。
3.1 一个布尔值根本不够用
如果你维护 has_duplicate,那每次窗口移动后,你很难知道这个布尔值是否该从 True 恢复成 False。比如窗口一开始是 [4, 2, 9],没有重复;下一轮窗口变成 [2, 9, 9],此时有重复;再下一轮变成 [9, 9, 9],依然有重复。假如在第一轮进入重复时把布尔值改成 True,你必须在删除元素时判断“重复是否已经解除了”。可是如果只记 True/False,系统不会告诉你当前到底有几个“重复组”。
更好的做法是维护一个变量 dup,含义是:当前窗口里有多少个“出现次数不少于 2 的数值”。
dup = 0:窗口内所有值只出现了一次;dup > 0:窗口内至少存在一组重复。
这样,增加元素和删除元素的时候,就能精确判断“重复组是否新增/是否消失”。
3.2 四个关键更新动作
假设当前频次表叫 freq,变量 dup 表示窗口内有几组重复值。窗口向右移动时,要处理两条边:
第一条边:加入新元素 x。
如果加入前 freq[x] == 1,说明 x 本来只出现一次,加入后变成两次,它会新增一组重复,所以 dup += 1。之后 freq[x] += 1。
如果加入前 freq[x] == 0,说明是新数字,不产生重复;如果加入前 freq[x] >= 2,说明它早就是重复组里的成员,加入一个副本不会新增重复组。
第二条边:移除旧元素 y。
先执行 freq[y] -= 1。如果 freq[y] 变成 1,说明 y 从“重复组”变成了只出现一次,dup -= 1。
如果 freq[y] 从 2 变成 1,说明这一组重复被彻底解除;如果从 3 变成 2,那 y 仍然是重复状态,dup 不应改变。
这里有个非常值得注意的细节:计数从 2 变到 1 才代表重复消失,不是从 3 变到 2 就消失。
3.3 用一个实际例子推演状态
还是用 nums = [1, 5, 4, 2, 9, 9, 9],k = 3。
| 操作 | 当前窗口 | 窗口和 | dup | 是否更新答案 |
|---|---|---|---|---|
| 加入1,窗口未满 | [1] | 1 | 0 | 否 |
| 加入5,窗口未满 | [1,5] | 6 | 0 | 否 |
| 加入4,窗口满 | [1,5,4] | 10 | 0 | 是,ans=10 |
| 加入2,移除1 | [5,4,2] | 11 | 0 | 是,ans=11 |
| 加入9,移除5 | [4,2,9] | 15 | 0 | 是,ans=15 |
| 加入9,移除4 | [2,9,9] | 20 | 1 | 否 |
| 加入9,移除2 | [9,9,9] | 27 | 1 | 否 |
最终答案是 15。注意最后两个窗口,虽然和越来越大,但因为 dup > 0,都不能参与答案比较。这就是固定长度窗口的纪律性:先满足合法条件,再谈最大和。
4. 可直接抄作业的标准实现
4.1 Python 实现
python复制from typing import List
class Solution:
def maximumSubarraySum(self, nums: List[int], k: int) -> int:
n = len(nums)
freq = {}
dup = 0
total = 0
ans = 0
for r in range(n):
x = nums[r]
# 加入新元素
if freq.get(x, 0) == 1:
dup += 1
freq[x] = freq.get(x, 0) + 1
total += x
# 窗口超过 k 个时,移除最左侧元素
if r >= k:
y = nums[r - k]
total -= y
freq[y] -= 1
if freq[y] == 1:
dup -= 1
# 窗口长度达到 k 后,开始判断
if r >= k - 1 and dup == 0:
ans = max(ans, total)
return ans
这段代码的主干是:
text复制for r in range(n):
加入 nums[r]
如果窗口超长,移除 nums[r-k]
如果窗口满且无重复,更新答案
不需要额外的 while,因为窗口长度是固定的,左指针永远可以通过 r-k 算出来。
4.2 C++ 实现
cpp复制class Solution {
public:
long long maximumSubarraySum(vector<int>& nums, int k) {
int n = nums.size();
unordered_map<int, int> freq;
long long total = 0, ans = 0;
int dup = 0;
for (int r = 0; r < n; ++r) {
int x = nums[r];
if (++freq[x] == 2) {
++dup;
}
total += x;
if (r >= k) {
int y = nums[r - k];
total -= y;
if (--freq[y] == 1) {
--dup;
}
}
if (r >= k - 1 && dup == 0) {
ans = max(ans, total);
}
}
return ans;
}
};
这里的 long long 也很关键。虽然题目的单个数字最大到 10^5,但 k 最大能到 10^5,求和之后会超过 int 的范围。如果你用 int 存和,虽然 LeetCode 官方用例可能没卡你,但这是个隐患。
4.3 另一种写法:删除频次为 0 的键,再用 len(freq) 判断
如果你不想维护 dup,也可以用“频次表里有多少个键”来判断窗口是否合法。因为长度为 k 的窗口如果没有任何重复,那么窗口中恰好有 k 个不同的数,也就是 len(freq) == k。
这种写法要注意:删除元素后,如果 freq[y] 变成 0,必须把 y 这个键从哈希表里删掉,否则 len(freq) 会偏大,判断会失效。
python复制from collections import defaultdict
class Solution:
def maximumSubarraySum(self, nums: List[int], k: int) -> int:
freq = defaultdict(int)
total = 0
ans = 0
for r, x in enumerate(nums):
total += x
freq[x] += 1
if r >= k:
y = nums[r - k]
total -= y
freq[y] -= 1
if freq[y] == 0:
del freq[y]
if r >= k - 1 and len(freq) == k:
ans = max(ans, total)
return ans
两种写法复杂度一样,都是 O(n)。第一种多维护一个 dup,有点绕但不用删键;第二种更好理解,但依赖“及时删除零频次键”这个操作。在 Python 里删除 dict 的键是常数时间操作,所以性能没问题。我个人更推荐第二种给新手看,因为 len(freq) == k 非常直观:没有重复 == 不同数的个数等于窗口长度。
5. 题外容易被忽略的边界情况和实操细节
5.1 k=1 时会发生什么
当 k = 1 时,长度为 1 的子数组天然没有重复。只要数组中有一个元素,它就一定合法,答案就是所有元素里的最大值。
你别笑,这个场景真的能暴露初始化问题。假设你用 ans = 0 作为默认值,而题目改成了允许负数,那么当数组中所有元素都是负数时,正确答案应该是最大的那个负数,而不是 0。LC 2461 的原题范围保证了 nums[i] 是正数,所以返回 0 没有歧义,但如果你在做扩展练习,或者把函数拿去做别的事,最好把默认值改成负无穷,最后再判断是否存在过合法窗口。
5.2 如果整个数组都没有合法窗口
比如:
text复制nums = [1, 1, 1, 1], k = 3
所有长度为 3 的窗口都包含多个 1,dup 永远不为 0,ans 一直不会被更新。按照题面要求返回 0 是没问题的。
这里还有一个容易写错的习惯:有人会在循环内因为某个窗口不合法就 continue,结果漏掉了窗口滑动时对左边界元素的移除。实际上,无论窗口是否合法,滑动过程都必须继续执行。判断合法性只是影响“要不要更新答案”,不能影响“窗口本身的增删”。
5.3 更新 ans 的时机必须放在“满窗口”之后
如果不小心把更新 ans 的代码放在 if r >= k 之前,那么窗口还没满的时候也会去比较,结果依然是错的。正确顺序归纳成口诀:
text复制加入右边界 -> 移除超出窗口的左边界 -> 判断窗口是否满 -> 判断窗口是否合法 -> 更新答案
这个顺序不要改。尤其是“移除左边界”和“判断窗口是否满”,两个条件看起来相似,但含义不同:r >= k 说明当前加入后,窗口里已经有 k+1 个元素,必须移除最左边那个;r >= k-1 说明当前窗口长度已经达到 k,可以开始产出候选答案。
5.4 和“可变滑动窗口”的区别
有些同学刷过“无重复字符的最长子串”或“最多 K 个不同字符的最长子串”,遇到这种“不同元素”的题容易条件反射用 while 缩短左边界。但 LeetCode 2461 是定长窗口,左指针不是靠 while 一点一点缩出来的,而是每次必须右移一位。
两套模板的差异是:
| 题目类型 | 左指针移动方式 | 典型判断条件 |
|---|---|---|
| 最长无重复子串 | 有重复就持续移动 left,直到无重复 | 窗口长度不固定 |
| 长度恰好为 K 的子数组 | 每轮固定移动一步 left = r - k + 1 | 窗口长度严格等于 K |
如果你总是套用可变窗口模板,代码里会出现无用的 while,反而更难分析。
5.5 频次表用哈希表还是普通数组
LC 2461 的输入值范围比较友好,如果你看原题约束,nums[i] 本身限制在正整数范围且上限固定,那么直接用数组作为频次表也可以。比如在 C++ 里开一个长度足够的 vector<int>,用“值作为下标”的频次记录,理论上常数更小。
但哈希表的好处是通用性强。如果你把题目条件改一下,允许值域非常大或包含负数,数组下标方案就要做偏移和额外判断,哈希表却不需要改逻辑。在面试中,优先写出“窗口 + 频次表 + 重复计数器”的结构,比纠结常数优化更能体现思路稳定。
6. 这题背后值得沉淀的思考方式
LeetCode 2461 的难度并不高,但它是“定长滑窗”和“哈希表状态维护”结合的经典代表。做完这道题,你应该顺手记下几个可复用的结论。
第一,固定长度滑动窗口的总复杂度通常是 O(n)。当一个数组的连续子数组问题是“固定长度”,你几乎不需要考虑二分答案或前缀和,先把滑动窗口模板写出来。
第二,当窗口需要判断内容是否重复,哈希表记录的是“次数”,不是“是否存在”。因为滑动会导致旧元素离开窗口,你必须知道离开后它是否还存在,set 做不到这一点,Counter 或 dict 作为频次表才能胜任。
第三,重复状态的更新依赖“临界次数变化”。新增元素时,次数从 1 变成 2 才新增一组重复;删除元素时,次数从 2 变成 1 才消除一组重复。把这些临界条件写清楚,代码就不会在这种题上反复出 bug。
我在实际刷题过程中,习惯每道“窗口题”都画一遍状态表:窗口由哪些元素构成、总和是多少、频次表长什么样、重复组数量是多少。画完这个表之后再去写代码,你会发现判断条件根本不需要硬背。定长滑窗的边界条件就那么多,真正值钱的不是背下某道题的答案,而是能一眼看出“这个题能不能用窗口规律把计算从重复扫描变成增量更新”。LeetCode 2461 就是一个很标准的训练样本:窗口负责和的滚动,哈希表负责合法性的滚动,两者拼在一起,整个问题就从“枚举所有子数组并检查”变成了线性时间。
