刷到 LeetCode 2841 这道题时,第一反应是:又是定长滑窗。不过细看之后,发现它把“定长窗口”和“哈希表计数”结合得恰到好处,是一道非常适合作为滑窗专题分水岭的题目。题目要求在一个整数数组中找出所有长度为 k 的子数组,在这些子数组中,如果不同元素的个数不少于给定的 m,就称这个子数组是“几乎唯一”的,最后返回这些子数组的最大和。很多人第一次看到“几乎唯一”会被绕一下,实际上它要处理的无非两件事:窗口的和,以及窗口内到底有多少种不同的数字。
这道题没有复杂的数学推导,也不涉及高级数据结构,核心就是用哈希表维护每个数字在窗口中出现的次数,再用一个变量实时记录当前窗口内有多少种数字,同时维护窗口的和。适合正在刷滑动窗口、但还没完全掌握“窗口内状态维护”的朋友。如果你已经会做“无重复字符的最长子串”“长度最小的子数组”,再来看这道题,会发现定长滑窗的模板作用非常明显。下面我把整道题的拆解、代码和踩坑经历都写出来,当作一次完整的题解记录。
1. 题目理解与核心思路拆解
1.1 题意还原与“几乎唯一”的正确定义
先别急着写代码,把题意拆清楚。给定一个整数数组 nums,长度 n,以及两个整数 k 和 m。你要枚举的是所有长度为 k 的连续子数组,比如 k=3 时,窗口依次是 [0,1,2]、[1,2,3]、[2,3,4]……。对每个窗口,除了要计算窗口内数字之和,还要统计窗口内“不同数值的个数”。如果这个个数不小于 m,就称这个窗口是“几乎唯一”的,用它的窗口和去更新答案。如果没有一个窗口满足条件,答案就是 0。
这里有个容易被忽略的点:m 是“至少”而不是“恰好”。哪怕窗口里出现了 100 种不同数字,只要 m 是 3,它也完全符合要求。所以判断条件永远是kinds >= m,不是kinds == m。有些人在样例上跑对了,一到大数据就出错,往往就是条件写成了等于。
另外,子数组是连续的。这意味着我们不能随便挑 k 个元素出来组合,必须是数组中连续的一段。这一点和“子序列”有本质区别。题目只要看到“连续子数组”加“长度为 k”,基本上就应该锁定定长滑动窗口。
1.2 为什么必须用定长滑动窗口
先从最朴素的暴力做法说起。枚举所有子数组的起点,然后向后数 k 个元素,统计窗口内的不同元素数量并求和。统计不同元素时,如果用哈希表先清空再逐个插入,每个窗口需要 O(k) 时间,总复杂度是 O(n*k)。当 n 和 k 都到 10^5 级别时,不可能通过。
于是想到滑窗:相邻两个长度为 k 的子数组,其实只差一个元素——右边多进来一个,左边少出去一个。只要我们能高效维护“每种数字出现次数”和“不同数字种类数”,就能在窗口移动时用 O(1) 的时间更新状态。这正是定长滑窗的优势:不需要像变长滑窗那样频繁调整左右边界,左右指针始终同步移动,窗口长度恒定。
可能有同学会问:为什么不能用前缀和这种办法?前缀和可以快速得到任意区间之和,但它无法快速告诉你“区间内有多少种不同数字”。想要知道种类数,必须维护一个窗口内的计数结构。所以滑窗 + 哈希表是这个问题的自然解法。
1.3 定长滑窗与变长滑窗的写法差异
很多人在学滑窗时,脑子里只有一个模板:右指针往右走,不满足条件时移动左指针。这个模板解决的是“满足某个条件的最短/最长连续子数组”问题,比如“无重复字符的最长子串”“长度最小的子数组”。但本题窗口长度被固定死,左指针不是“按需移动”,而是每一轮都必须前进一步。
因此代码结构会变成三明治式:
- 初始化:先让右指针走到 k-1 的位置,也就是先加入前 k-1 个元素;
- 主循环:每轮加入一个新的右边界元素,此时窗口正好完整,计算答案,再移除当前窗口最左边的元素。
这个写法比 while 式变长滑窗更简单,也更不容易出错。如果尝试用 while 去收缩左边界,反而会破坏定长性质。所以看到“长度为 k 的子数组”这一类描述,第一反应就应该是固定窗口,而不是双指针左右调整。总结成一句话:变长滑窗看“条件何时失效”,定长滑窗看“窗口何时完整”。
2. 哈希表维护窗口内元素频率
2.1 哈希表与数组计数的选型
窗口移动时,我们需要知道两件事:当前窗口的和,以及当前窗口不同元素的个数。和的维护很简单,加右端点、减左端点。麻烦的是“不同元素个数”。最直接的办法是给每个元素一个计数器,记录它在窗口内的出现次数。
用什么存计数器?如果数组元素值域有限且非负,比如范围只有 0~10^5,用数组计数非常合适,速度也快。但很多题目的值域很大,或者包含负数,数组下标根本没法处理。更通用的做法是哈希表:键是元素值,值是它在当前窗口中的出现次数。C++ 用unordered_map,Python 用dict。
这里不建议使用需要排序的数据结构,比如map/TreeMap。因为每次窗口移动都要更新计数,排序结构会引入 O(log k) 的复杂度,虽然也能过,但没必要,还会让代码更繁琐。哈希表的 O(1) 均摊更新才是正道。
2.2 用一个变量跟踪“不同元素个数”
在哈希表之外,我建议单独维护一个整型变量,记作kinds或distinct。为什么需要单独变量?因为每次想知道窗口里有多少种不同的数,如果现场遍历哈希表,代价是 O(k),这又回到了暴力。但如果一直维护kinds,就能在 O(1) 时间拿到答案。
维护规则非常简单,但需要理解得很透:
- 加入一个元素 x:
cnt[x]加 1;如果cnt[x]恰好从 0 变成 1,说明 x 第一次在窗口中出现,kinds加 1。 - 移除一个元素 x:
cnt[x]减 1;如果cnt[x]恰好变成 0,说明 x 已经彻底离开窗口,kinds减 1。
这里最关键的是要理解“恰好”两个字。加入时,如果这个元素原来已经出现过两次,现在变成三次,不同元素个数并不会增加;移除时,如果这个元素原来出现三次,现在变成两次,它依然还在窗口里,不同元素个数也不会减少。很多 bug 就出在每次加减时都把kinds跟着变,导致计数失真。
2.3 窗口滑动时的状态转移示例
写代码之前,建议先手动模拟一遍。假设nums = [1,2,1,3,2],k = 3,m = 2。初始时先加入前两个元素nums[0]=1、nums[1]=2,此时cnt = {1:1, 2:1},kinds = 2,sum = 3。注意现在窗口还不完整,不能算答案。
接着主循环从 i=2 开始:
- 加入
nums[2]=1后,cnt[1]变成 2,kinds仍然是 2,因为 1 之前已经出现过;sum变成 4。窗口[1,2,1]完整,不同元素 2 个,满足 m=2,更新ans=4。 - 移除左端点
nums[0]=1,cnt[1]从 2 变成 1,kinds不变;sum变成 3。此时窗口内部相当于[2,1],但还没有完整,等下一轮补右端点。 - 下一轮 i=3,加入
nums[3]=3,cnt[3]=1,kinds变成 3,sum变成 6。窗口[2,1,3]完整,不同元素 3 个,满足条件,ans=6。 - 移除左端点
nums[1]=2,cnt[2]从 2 变成 0,kinds变成 2,sum变成 4。 - 下一轮 i=4,加入
nums[4]=2,kinds变成 3,sum变成 6。窗口[1,3,2]完整,不同元素 3 个,满足条件,ans保持 6。
整个过程,kinds的变化是 2 -> 2 -> 3 -> 2 -> 3,每次需要时 O(1) 可得。手动模拟一遍后,再去写代码,基本上不会把顺序写错。
3. 完整实现与代码解析
3.1 C++ 代码:初始化与主循环的“三明治”结构
先给一段可以直接运行的 C++ 实现。这里有一个很多题解都不会特意强调的细节:ans的初始值应该使用LLONG_MIN加一个“是否找到过合法窗口”的判断,而不能直接初始化为 0。因为如果存在满足条件的子数组,但它的和是负数,直接初始化为 0 会把正确答案无视掉。
class Solution { public: long long maxSum(vector<int>& nums, int m, int k) { unordered_map<int, int> cnt; long long sum = 0; long long ans = LLONG_MIN; int kinds = 0; // 先放进前 k-1 个元素,让主循环每次正好凑成一个完整窗口 for (int i = 0; i < k - 1; ++i) { int x = nums[i]; if (cnt[x] == 0) kinds++; cnt[x]++; sum += x; } for (int i = k - 1; i < (int)nums.size(); ++i) { int x = nums[i]; if (cnt[x] == 0) kinds++; cnt[x]++; sum += x; if (kinds >= m) { if (ans == LLONG_MIN) ans = sum; else ans = max(ans, sum); } int left = nums[i - k + 1]; cnt[left]--; if (cnt[left] == 0) kinds--; sum -= left; } return ans == LLONG_MIN ? 0 : ans; } };代码逻辑分三段:
- 初始化阶段:加入
nums[0]到nums[k-2],共 k-1 个元素。此时窗口尚未完整,不能更新答案。 - 主循环阶段:从
i = k-1开始,每次先加入nums[i],窗口[i-k+1, i]长度正好是 k。此时可以判断条件并更新答案。更新完答案后再移除窗口左端点,为下一轮做准备。 - 收尾阶段:循环结束后,如果
ans还是LLONG_MIN,说明没有任何满足条件的窗口,返回 0;否则返回 ans。
这个“先加右、再算答案、再删左”的顺序非常重要。如果调换顺序,比如先删左再加右,窗口里的元素虽然还是 k 个,但更新答案的时机和左端点对应关系容易出岔子。尤其是当 k 等于 1 时,先删再加会让窗口为空,逻辑直接崩掉。
3.2 Python 实现及与 Counter 的取舍
Python 版本同样要注意答案初始值。这里用None表示还没找到合法窗口,而不是用 0。
class Solution: def maxSum(self, nums: List[int], m: int, k: int) -> int: cnt = {} cur_sum = 0 kinds = 0 ans = None # 先加入前 k-1 个元素 for i in range(k - 1): x = nums[i] cnt[x] = cnt.get(x, 0) + 1 if cnt[x] == 1: kinds += 1 cur_sum += x for i in range(k - 1, len(nums)): x = nums[i] cnt[x] = cnt.get(x, 0) + 1 if cnt[x] == 1: kinds += 1 cur_sum += x if kinds >= m: if ans is None: ans = cur_sum else: ans = max(ans, cur_sum) left = nums[i - k + 1] cnt[left] -= 1 if cnt[left] == 0: kinds -= 1 cur_sum -= left return 0 if ans is None else ans为什么不用Counter?Counter本身也是哈希表,用起来很方便,但它的底层实现包含额外的计数逻辑,在频繁增减的场景下,手写dict的get加赋值反而更可控。另外Counter移除元素时需要判断为 0 后del,否则会残留大量计数为 0 的键,影响后续判断。手写dict同样可以选择删键或保留零值键,但逻辑更透明。
3.3 复杂度分析与空间优化细节
时间复杂度是 O(n)。每个元素进入窗口一次、离开窗口一次,哈希表的插入、删除、修改均为均摊 O(1)。空间复杂度需要稍微注意:如果计数减到 0 时不删除键,哈希表会保留所有曾进入窗口的元素,键的数量可能从 O(k) 退化到 O(n)。虽然大多在线判题能过,但为了严谨,我个人建议在计数减到 0 时删除键。
C++ 中对应代码是:
if (--cnt[left] == 0) { kinds--; cnt.erase(left); }Python 中则是:
cnt[left] -= 1 if cnt[left] == 0: kinds -= 1 del cnt[left]删键之后,哈希表大小严格控制在窗口内不同元素的个数以内,也就是 O(k)。同时减少哈希表体积,也能降低碰撞概率,实测在大数据量下有一定性能提升。不过要注意,删除键之后,后续再加入同一个元素时,cnt[x]从无到有,kinds 才会加 1,逻辑仍然是正确的。
4. 常见坑点与排查技巧实录
4.1 “至少 m 个”和“恰好 m 个”的区别
前面提到过,判断条件必须写成kinds >= m。如果你写成了kinds == m,那么当窗口内不同元素个数超过 m 时,你会漏掉答案。题目里的“几乎唯一”并没有限制上限,理解成“至少有 m 种不同元素”就对了。
有些变体题确实要求“恰好 m 个不同元素”,那种写法要复杂得多,通常需要同时维护两个滑窗来相减。但本题不是那种情况,所以不要过度设计。
4.2 更新答案的时机错了,窗口长度对不上
我见过很多次这样的错误:先初始化窗口的前 k 个元素,然后进入循环,在加入新元素之前就更新答案。这时候窗口里其实有 k+1 个元素(旧窗口的 k 个加新加入的一个),如果满足条件,拿到的和是非法窗口的。比如 k=3,旧窗口是 [1,2,3],加上新元素 4 后窗口变成了 [1,2,3,4],长度为 4,用它去更新答案就是错的。
标准顺序应该是:
- 加入右端点元素,此时窗口恰好完整。
- 更新答案。
- 移除左端点元素,准备下一轮。
把这个顺序背下来,可以避免大量边界问题。
4.3 哈希表里残留的“幽灵键”
如果你不删除值为 0 的键,在kinds正确的情况下,一般不影响答案。但残留键会带来两个隐患。第一,内存不必要地增长;第二,如果你后续在同一个函数里还需要判断“窗口中是否存在某个元素”,你会看到一个值为 0 的键,误以为它存在。所以在写完这道题后,最好养成一个习惯:计数器减到 0 就删键,或者约定“判断存在性只通过kinds和计数是否大于 0”,不要只看键是否存在。
4.4 最大值初始值:一个很多人都会踩的负数陷阱
这道题最值得单独拿出来说的一点,就是ans的初始值。题目说的“没有满足条件的子数组返回 0”,不代表可以把 0 当作初始答案。来看一个反例:nums = [-1, -2, -3],k = 2,m = 2。唯一的长度为 2 的子数组是[-1,-2],不同元素个数是 2,满足 m,窗口和是 -3。正确返回值应该是 -3。
但如果把ans初始化为 0,更新时写成ans = max(ans, sum),那么由于max(0, -3) = 0,最终会错误地返回 0。这个坑在元素和可能为负的题目里非常普遍。正确做法是初始化为LLONG_MIN或None,并额外标记“是否找到了合法窗口”。我前面给出的代码就是这个思路。
4.5 求和溢出与整数范围
题目给定nums[i]可能是 int 范围内的数字,假设每个数是 2e9,k 是 1e5,窗口和就是 2e14,远超 32 位 int。所以 C++ 中sum和ans必须用long long。Java 中也要用long。Rust 里用i64。Python 大整数天然无溢出,但接受输入为List[int]时没有限制,其实不必担心。
很多人只把ans声明成long long,却忘了sum也要long long。在每一步累加时,sum早就溢出了,最后赋给ans的也是一个错误值。这个细节比较隐蔽,建议从一开始就把两个变量都设为 64 位。
4.6 常见错误速查表
| 现象 | 可能原因 | 修复方式 |
|---|---|---|
| 答案偏大 | 更新答案时窗口长度是 k+1 | 统一“先加右、再算、再删左” |
| 答案偏小 | 判断条件写成了kinds == m | 改成kinds >= m |
| 负数用例返回 0 | ans初始化为 0 | 初始化为LLONG_MIN或用 None 标记 |
kinds一直不变 | 增减kinds时没有判断 0->1 或 1->0 | 严格按照“恰好第一次出现/最后一次离开”处理 |
| 内存超限或键越来越多 | 计数器归零后未删除键 | 归零时erase/del |
| 全负数用例反而跑对 | 可能没有合法窗口 | 注意区分“无解”和“有解但和为负” |
5. 变体与延伸思考
5.1 不限制窗口长度的版本
如果把题目改成长度不固定,要求找“不同元素个数至少为 m 的所有子数组中,和最大的那个”,解法就会变成变长滑窗。思路是右指针不断扩展窗口,当窗口内不同元素个数满足条件时,尝试移动左指针缩小窗口,并在这个过程中更新答案。虽然哈希表的维护逻辑和本题完全一致,但窗口何时收缩、收缩到什么程度,需要根据“和最大”这一目标来设计。这也说明定长滑窗和变长滑窗虽然共用同一套计数框架,但控制流程差异明显。
5.2 要求返回子数组本身
如果题目不只要最大和,还要返回对应的子数组,那就需要在更新答案时记录窗口的左右端点。比如在 C++ 中额外维护两个变量bestL和bestR,每次ans被更新时,同时记录bestL = i - k + 1、bestR = i。最后返回[bestL, bestR]或者切片。这里唯一需要注意的是,如果存在多个合法窗口和相同,题目要求返回任意一个,那就随便覆盖;如果要求返回最早或最晚出现的,更新条件需要额外加边界判断。
5.3 统计“恰好 m 个不同元素”的经典做法
如果真的遇到要求恰好 m 个不同元素的子数组计数问题,一种常见套路是用两个滑窗分别维护“不同元素个数 <= m”和“不同元素个数 <= m-1”的情况,然后两者相减。原因是“恰好等于 m”很难直接维护,但“小于等于某个阈值”可以随右指针移动、左指针收缩来维护。本题用不到这么复杂,但面试时如果被问到,能顺势说出这层思路会加分。
5.4 数组值域有限时的常数优化
如果题目保证nums[i]在 0 到 10000 之间,可以用一个普通的 int 数组代替哈希表,每次窗口移动仍然是 O(1),但常数更小,缓存也更友好。甚至可以配合vector<int>预留足够空间,避免哈希表的扩张开销。这种优化只适合值域明确且较小的场景,通用性不如哈希表。刷题时除非内存要求非常严格,否则我一般优先写哈希表,保证逻辑稳定。
6. 刷题时的自查清单与个人习惯
最后分享一点我刷这道题时的个人体会。第一次写这种“定长滑窗 + 哈希表 + 种类计数”的题,最容易翻车的地方不是滑窗本身,而是状态维护的细节。
我后来给自己定了一个默写模板:
- 先问自己:窗口长度固定吗?固定,则右端点从 k-1 开始,左端点同步走。
- 再问自己:窗口内需要维护哪些信息?本题是“和”和“不同元素数”。
- 最后问自己:满足条件时如何更新答案?注意 ans 不能用 0 做初始值。
按这个顺序想,基本不会漏。写完之后,不要急着提交,先在脑子里跑一遍k=1、m=1、数组全负数的用例,确认答案不是 0。然后再跑一个窗口内元素重复多次的用例,确认kinds没有错乱。这两步都过了,提交基本就稳了。
这道题的代码量不大,但背后代表了一个很实用的能力:如何在窗口滑动时同步维护一个非线性的统计信息。这个能力在后续刷很多题时都会用到,比如统计窗口内奇偶数的数量、出现次数为某值的元素个数、甚至是按某些条件筛选后的“合法元素数量”。把 2841 彻底吃透,比盲目刷十道简单滑窗题更有价值。