LC 560 和为 K 的子数组:前缀和配哈希表,这对组合我是真的服了

91 阅读4分钟

说实话,这题我第一次看到的时候,心里想的是:不就是找子数组和等于 k 吗?两层 for 循环不就完事了?

然后我写了,提交了,超时了。

改了又改,还是超时。最后才发现,这题的精髓根本不是暴力枚举,而是一个叫 "前缀和" 的东西配上哈希表 —— 速度直接从 O (n²) 干到 O (n)。

今天就聊聊我是怎么从超时一步步爬到 beat 90% 的。

题目说的啥

给你一个整数数组 nums 和一个整数 k,让你找出数组中和为 k 的连续子数组的个数。

就这么简单。比如 nums = [1,1,1],k = 2,答案是 2,因为 [1,1] 出现了两次。

我一开始的憨憨写法

拿到题第一反应,穷举呗。

固定左端点,右端点往右滑,累加求和,等于 k 就计数加一。

var subarraySum = function(nums, k) {
    let count = 0;
    for (let i = 0; i < nums.length; i++) {
        let sum = 0;
        for (let j = i; j < nums.length; j++) {
            sum += nums[j];
            if (sum === k) count++;
        }
    }
    return count;
};

逻辑没问题,但数组一大就凉。O (n²) 的时间复杂度,LeetCode 直接给我甩个 Time Limit Exceeded。

我心想:行吧,得想办法优化。

前缀和是个啥

前缀和这个概念,说白了就是:用一个数组 pre,pre [i] 表示 nums 前 i 个元素的和。

比如 nums = [1, 2, 3],那 pre = [0, 1, 3, 6]。pre [0] = 0 是个哨兵,方便计算。

有了前缀和,任意子数组 nums [j...i-1] 的和就等于 pre [i] - pre [j]。

我们要找的就是 pre [i] - pre [j] = k 的情况,也就是 pre [j] = pre [i] - k。

换句话说,遍历到 pre [i] 的时候,我只需要知道:之前出现过多少次 pre [i] - k?

这个 "之前出现过多少次",用哈希表存就完事了。

结合代码走一遍

拿 nums = [1, 1, 1],k = 2 来举例。

前缀和数组 pre = [0, 1, 2, 3]。

  • 遍历 pre [0] = 0:查 map 里有没有 0 - 2 = -2,没有,ans 不变。把 0 存进 map,map = {0: 1}
  • 遍历 pre [1] = 1:查有没有 1 - 2 = -1,没有。把 1 存进去,map = {0:1, 1:1}
  • 遍历 pre [2] = 2:查有没有 2 - 2 = 0,有!出现了 1 次,ans += 1。把 2 存进去
  • 遍历 pre [3] = 3:查有没有 3 - 2 = 1,有!出现了 1 次,ans += 1。最终 ans = 2

完美,和预期一致。

这里有个细节要注意:先查 map 再存当前值。不然如果 k = 0 的话,会把自己也算进去,就错了。这个坑我踩过,调试了半天才发现。

最终代码

var subarraySum = function(nums, k) {
    let count = 0;
    const n = nums.length;
    
    // prefixSum[i] 表示 nums 前 i 个元素的和,prefixSum[0] = 0 是哨兵
    const prefixSum = new Array(n + 1).fill(0);
    for (let i = 1; i <= n; i++) {
        prefixSum[i] = prefixSum[i - 1] + nums[i - 1];
    }
    
    // 哈希表:key 是前缀和的值,value 是该值出现的次数
    const sumCountMap = new Map();
    
    for (const currentSum of prefixSum) {
        // 我们需要找的是:之前有多少个前缀和等于 currentSum - k
        const target = currentSum - k;
        count += sumCountMap.get(target) ?? 0;
        
        // 注意:先查再存,避免 k=0 时把自己算进去
        sumCountMap.set(currentSum, (sumCountMap.get(currentSum) ?? 0) + 1);
    }
    
    return count;
};

其实还可以再精简一下,不用单独建前缀和数组,边遍历边算前缀和就行:

var subarraySum = function(nums, k) {
    let count = 0;
    let currentSum = 0;
    
    // key: 前缀和的值,value: 出现次数。初始放入 0:1 对应哨兵
    const sumCountMap = new Map();
    sumCountMap.set(0, 1);
    
    for (const num of nums) {
        currentSum += num;
        
        // 查之前出现过多少次 currentSum - k
        const target = currentSum - k;
        count += sumCountMap.get(target) ?? 0;
        
        // 先查后存,别搞反了
        sumCountMap.set(currentSum, (sumCountMap.get(currentSum) ?? 0) + 1);
    }
    
    return count;
};

这个版本空间也是 O (n),但少了一个数组,更干净。

复杂度

时间 O (n),一趟遍历搞定。空间 O (n),哈希表最多存 n 个不同的前缀和。

比暴力的 O (n²) 香多了。

写在最后

这题的核心考点就是前缀和 + 哈希表的组合拳。说实话这个套路学会了,很多子数组求和的问题都能套。

我自己踩过的坑提醒一下:

  • 哈希表初始化要放 {0: 1},不然从数组开头开始的子数组会漏掉
  • 一定是先查再存,k = 0 的时候顺序反了结果会偏大
  • 数组里有负数也没事,前缀和照样工作,别被吓到

你有没有在这道题上卡过?或者你有更骚的写法?评论区聊聊,我会回来看的。

如果觉得有用,点个赞让更多正在刷题的朋友看到吧~