力扣2841:这个哈希表的坑,让我差点道心破碎!
作者:一个被哈希表折磨了一夜的普通程序员
前言
朋友们,今天给大家带来力扣第2841题的解析。这道题看似简单,实则暗藏玄机,尤其是那个关于哈希表的细节,让我昨天想了一整夜都没想明白。直到今天早上灵光一闪,才终于弄懂了其中的门道。
题目分析
先来看看题目要求:找到几乎唯一的子数组。什么是“几乎唯一”?就是说在长度为k的子数组中,至少有m个不同的元素。
看到“子数组”这三个字,我们第一反应就是滑动窗口;看到“不同元素个数”,我们自然会想到哈希表。思路很清晰对不对?我也是这么想的,但没想到还是掉坑里了。
我的第一版思路
我一开始的思路很简单:
- 用滑动窗口遍历所有长度为k的子数组
- 用哈希表统计窗口内不同元素的个数
- 如果不同元素个数≥m,就记录当前子数组的和
- 在所有符合条件的子数组中找最大值
代码大概是这样的:
let map = new Map();
let sum = 0;
let maxSum = 0;
for(let i = 0; i < nums.length; i++) {
if(i <= k - 1) {
// 填充初始窗口
map.set(nums[i]);
sum += nums[i];
} else {
// 检查当前窗口是否符合条件
if(map.size >= m) {
maxSum = Math.max(maxSum, sum);
}
// 滑动窗口:移除左边元素,添加右边元素
map.set(nums[i]);
map.delete(nums[i - k]);
sum += nums[i];
sum -= nums[i - k];
}
}
看起来逻辑挺清晰的,对不对?但就是这个看起来没问题的代码,让我栽了个大跟头。
掉坑记录
让我们用题目给的例子来测试一下:
nums = [1, 4, 1, 4]
m = 2, k = 3
按照我们的算法:
- 第一个窗口:[1, 4, 1] → 哈希表:{1, 4},size=2,符合条件
- 滑动窗口:移除最左边的1,添加右边的4
- 第二个窗口:[4, 1, 4] → 哈希表应该是什么?
问题就出在这里!当我们执行map.delete(1)时,哈希表中所有的1都被删掉了!但是我们的窗口里明明还有一个1啊!
所以实际情况是:
- 移除1后,哈希表变成:{4}
- 添加4后,哈希表还是:{4}
- 最终size=1,不符合条件,但实际上窗口[4, 1, 4]中有两个不同的元素(1和4)!
这就是问题的核心:我们误以为哈希表的每个键只出现一次,但实际上窗口内可能有多个相同的值。
挣扎与尝试
发现问题后,我第一个想法是:既然直接删除不行,那我每个窗口都重新计数总可以吧?
for(let i = 0; i < nums.length; i++) {
// ... 其他代码
if(i > k - 1) {
// 清空哈希表,重新统计当前窗口
map.clear();
for(let j = i; j > i - k; j--) {
map.set(nums[j]);
}
// ... 其他逻辑
}
}
这个方法确实能解决准确性的问题,但带来了新的问题:时间复杂度爆炸!
每个窗口都要重新遍历k个元素,总时间复杂度变成了O(n*k)。对于大数据量的测试用例,直接超时!
离成功只差一步,却因为性能问题被卡住,这种感觉谁懂啊!😭
灵光一闪:计数哈希表
在经历了无数次的尝试和失败后,我终于想明白了:我们需要的不只是记录元素是否出现,还要记录元素出现的次数!
也就是说,我们需要一个计数哈希表,而不是简单的集合。
正确的思路应该是:
- 用哈希表记录每个元素在窗口内出现的次数
- 添加元素时,计数器+1
- 移除元素时,计数器-1
- 只有当计数器减到0时,才从哈希表中删除这个键
这样就能准确反映窗口内不同元素的真实情况了!
最终的正确代码
var maxSum = function(nums, m, k) {
let cnt = new Map(); // 计数哈希表
let s = 0; // 当前窗口的和
let ans = 0;
for(let i = 0; i < nums.length; i++) {
// 添加右边元素
s += nums[i];
cnt.set(nums[i], (cnt.get(nums[i]) || 0) + 1);
let left = i - k + 1; // 窗口左边界
if(left < 0) continue; // 窗口还没填满
// 检查当前窗口是否符合条件
if(cnt.size >= m) {
ans = Math.max(ans, s);
}
// 移除左边元素(准备滑动窗口)
const out = nums[left];
s -= out;
const c = cnt.get(out);
// 核心逻辑:只有计数为1时才删除键
if(c > 1) {
cnt.set(out, c - 1);
} else {
cnt.delete(out);
}
}
return ans;
};
让我们用一个完整的例子来理解这个解法!
题目例子
假设我们有:
nums = [1, 4, 1, 4, 2]m = 2(至少需要2个不同元素)k = 3(子数组长度为3)
逐步执行过程
第1步:i=0 (nums[0]=1)
窗口: [1]
s = 1
cnt = {1: 1}
left = 0-3+1 = -2 < 0, 跳过检查
第2步:i=1 (nums[1]=4)
窗口: [1, 4]
s = 1 + 4 = 5
cnt = {1: 1, 4: 1}
left = 1-3+1 = -1 < 0, 跳过检查
第3步:i=2 (nums[2]=1)
窗口: [1, 4, 1] ✓ 窗口填满了!
s = 5 + 1 = 6
cnt = {1: 2, 4: 1}
left = 2-3+1 = 0 >= 0
检查条件:cnt.size = 2 >= m=2 ✓
ans = max(0, 6) = 6
准备移除左边元素:out = nums[0] = 1
s = 6 - 1 = 5
c = cnt.get(1) = 2
因为c=2>1,所以只减少计数:
cnt = {1: 1, 4: 1}
第4步:i=3 (nums[3]=4)
当前窗口: [4, 1, 4](即将形成)
s = 5 + 4 = 9
cnt = {1: 1, 4: 2}
left = 3-3+1 = 1
检查条件:cnt.size = 2 >= m=2 ✓
ans = max(6, 9) = 9
准备移除左边元素:out = nums[1] = 4
s = 9 - 4 = 5
c = cnt.get(4) = 2
因为c=2>1,所以只减少计数:
cnt = {1: 1, 4: 1}
第5步:i=4 (nums[4]=2)
当前窗口: [1, 4, 2](即将形成)
s = 5 + 2 = 7
cnt = {1: 1, 4: 1, 2: 1}
left = 4-3+1 = 2
检查条件:cnt.size = 3 >= m=2 ✓
ans = max(9, 7) = 9
准备移除左边元素:out = nums[2] = 1
s = 7 - 1 = 6
c = cnt.get(1) = 1
因为c=1,所以删除键:
cnt = {4: 1, 2: 1}
最终结果
ans = 9
这个结果对应的是窗口[4, 1, 4](和为9),它包含两个不同元素(1和4)。
另一个有趣的例子
让我们看看如果所有元素都相同会发生什么:
nums = [5, 5, 5, 5]
m = 2, k = 3
执行过程:
第1-3步:构建初始窗口[5, 5, 5]
s = 15
cnt = {5: 3}
cnt.size = 1 < 2,不符合条件
第4步:滑动到[5, 5, 5]
窗口内只有元素5,cnt.size始终为1
永远不会满足cnt.size >= 2的条件
所以ans保持为0
为什么计数哈希表如此重要?
让我们回到最初的问题:[1, 4, 1, 4], m=2, k=3
如果用简单的集合(delete直接删除键):
窗口[1,4,1] → 集合{1,4} ✓
移除1,添加4 → 集合{4} ✗(丢失了第二个1)
如果用计数哈希表:
窗口[1,4,1] → 哈希表{1:2, 4:1} ✓
移除一个1(计数从2减到1) → 哈希表{1:1, 4:1} ✓
添加4 → 哈希表{1:1, 4:2} ✓
size始终是2!
算法时间复杂度分析
这个算法的时间复杂度是O(n),其中n是数组长度,因为:
- 每个元素被添加一次(O(1))
- 每个元素被移除一次(O(1))
- 每次操作哈希表的get/set都是O(1)
空间复杂度是O(k),因为哈希表最多存储k个键值对。
这就是为什么这个解法既正确又高效的原因!🎉
关键点总结
- 计数哈希表 vs 普通集合:当元素可能重复出现时,必须使用计数哈希表
- 正确的删除时机:只有当元素计数减到0时,才从哈希表中删除
- 时间复杂度:O(n),每个元素只被添加和删除一次
- 空间复杂度:O(k),最多存储k个不同的元素
类似题目推荐
如果你理解了这道题,可以尝试一下力扣第2461题《长度为k子数组的最大和》,解题思路几乎一模一样,都是滑动窗口+计数哈希表的组合。
后记
这道题让我深刻理解了一个道理:在编程中,细节决定成败。一个看似简单的删除操作,背后可能隐藏着复杂逻辑。不过也正是这种挑战,让编程变得有趣,不是吗?
希望我的踩坑经历能帮助你少走弯路。如果你也有类似的经历,欢迎在评论区分享!