对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。------ 算法:资深前端开发者的进阶引擎
LeetCode 560. 和为 K 的子数组:前缀和的精妙应用
1. 题目描述
给你一个整数数组 nums 和一个整数 k,请你统计并返回该数组中和为 k 的连续子数组的个数。
示例 1:
输入:nums = [1,1,1], k = 2
输出:2
解释:[1,1] 与 [1,1] 为两种不同的情况(注意:虽然值相同,但索引位置不同)
示例 2:
输入:nums = [1,2,3], k = 3
输出:2
解释:[1,2] 和 [3]
约束条件:
- 数组长度范围:1 <= nums.length <= 2 * 10⁴
- 数组元素范围:-1000 <= nums[i] <= 1000
- k 的范围:-10⁷ <= k <= 10⁷
2. 问题分析
2.1 问题核心
这个问题要求统计数组中连续子数组 的和等于给定值 k 的个数。这是一个典型的子数组求和问题,需要特别注意:
- 子数组必须是连续的
- 数组元素可以是负数,这增加了问题的复杂性
- 需要考虑空数组吗?题目没有明确说明,但根据示例,至少需要包含一个元素
2.2 前端视角的类比
在前端开发中,类似的问题场景包括:
- 统计页面中连续点击次数达到特定阈值的情况
- 计算用户行为序列中特定模式的出现次数
- 分析性能监控数据中连续时间段内指标达标的情况
3. 解题思路
3.1 思路演进
3.1.1 暴力枚举法
最直观的想法是枚举所有可能的子数组,计算它们的和,统计等于 k 的数量。
3.1.2 前缀和优化
暴力法的时间复杂度为 O(n²),对于 2×10⁴ 的数据量会超时。我们需要更高效的方法。
前缀和(Prefix Sum)概念 :
前缀和是一种预处理技术,通过预先计算并存储数组的前缀和,可以在 O(1) 时间内计算任意子数组的和。
定义前缀和数组 preSum,其中 preSum[i] 表示 nums[0] + nums[1] + ... + nums[i-1] 的和。
那么子数组 nums[i..j] 的和可以表示为:
sum(nums[i..j]) = preSum[j+1] - preSum[i]
3.1.3 哈希表优化
对于每个 j,我们需要找到有多少个 i 满足 preSum[i] = preSum[j+1] - k。使用哈希表可以在 O(1) 时间内完成查找。
核心公式:
preSum[j+1] - preSum[i] = k
=> preSum[i] = preSum[j+1] - k
3.2 复杂度分析
| 方法 | 时间复杂度 | 空间复杂度 | 是否推荐 |
|---|---|---|---|
| 暴力枚举 | O(n²) | O(1) | 不推荐,会超时 |
| 前缀和+哈希表 | O(n) | O(n) | 推荐,最优解 |
4. 各思路代码实现
4.1 暴力枚举法(不推荐,仅用于理解)
javascript
/**
* 暴力枚举法
* @param {number[]} nums
* @param {number} k
* @return {number}
*/
var subarraySumBruteForce = function(nums, k) {
let count = 0;
const n = nums.length;
for (let i = 0; i < n; i++) {
let sum = 0;
for (let j = i; j < n; j++) {
sum += nums[j];
if (sum === k) {
count++;
}
}
}
return count;
};
4.2 前缀和+哈希表法(最优解)
javascript
/**
* 前缀和+哈希表法(最优解)
* @param {number[]} nums
* @param {number} k
* @return {number}
*/
var subarraySum = function(nums, k) {
// 哈希表:键为前缀和,值为该前缀和出现的次数
const map = new Map();
// 初始化:前缀和为0出现了1次(空数组的情况)
map.set(0, 1);
let count = 0;
let prefixSum = 0;
for (let i = 0; i < nums.length; i++) {
// 计算当前前缀和
prefixSum += nums[i];
// 如果存在某个前缀和等于 currentPrefixSum - k
// 说明从那个位置到当前位置的子数组和为 k
if (map.has(prefixSum - k)) {
count += map.get(prefixSum - k);
}
// 更新当前前缀和出现的次数
map.set(prefixSum, (map.get(prefixSum) || 0) + 1);
}
return count;
};
4.3 带详细注释的版本(便于理解)
javascript
/**
* 前缀和+哈希表法(详细注释版)
* @param {number[]} nums
* @param {number} k
* @return {number}
*/
var subarraySumWithComments = function(nums, k) {
// 哈希表:存储前缀和及其出现次数
// 为什么需要这个哈希表?
// 我们要找的是:prefixSum[j] - prefixSum[i] = k
// 即:prefixSum[i] = prefixSum[j] - k
// 所以对于每个j,我们需要知道之前有多少个i满足这个条件
const prefixSumCount = new Map();
// 为什么要初始化前缀和为0出现了1次?
// 考虑整个数组从开头到某个位置的子数组和为k的情况
// 即:prefixSum[j] - 0 = k,这时候prefixSum[i]为0(i为-1,空数组)
prefixSumCount.set(0, 1);
let currentSum = 0; // 当前前缀和
let result = 0; // 结果计数
for (let i = 0; i < nums.length; i++) {
// 计算到当前位置的前缀和
currentSum += nums[i];
// 核心逻辑:如果存在一个前缀和等于 currentSum - k
// 那么从那个位置到当前位置的子数组和就是k
// 例如:nums = [1, 2, 3], k = 3
// 当i=2时,currentSum = 6
// currentSum - k = 3,如果之前出现过前缀和3,那么从那个位置到当前位置的和就是3
const target = currentSum - k;
if (prefixSumCount.has(target)) {
// 可能有多个位置的前缀和都等于target,所以都要加上
result += prefixSumCount.get(target);
}
// 更新当前前缀和的出现次数
// 这里使用 || 0 来处理undefined的情况(如果该前缀和之前没出现过)
prefixSumCount.set(
currentSum,
(prefixSumCount.get(currentSum) || 0) + 1
);
}
return result;
};
5. 各实现思路的复杂度、优缺点对比
5.1 对比表格
| 实现方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| 暴力枚举法 | O(n²) | O(1) | 1. 思路直观简单 2. 不需要额外空间 | 1. 效率低下,n=20000时会超时 2. 不适合大数据量 | 小规模数据(n<1000)或教学演示 |
| 前缀和+哈希表 | O(n) | O(n) | 1. 时间复杂度最优 2. 能处理包含负数的情况 3. 适合大规模数据 | 1. 需要额外O(n)空间 2. 逻辑相对复杂 | 大规模数据处理、生产环境推荐使用 |
5.2 详细分析
5.2.1 暴力枚举法
-
时间复杂度分析:
- 外层循环:n次
- 内层循环:平均n/2次
- 总复杂度:O(n²)
- 当n=20000时,操作次数约2亿次,明显超时
-
空间复杂度:O(1),只使用了常数级别的额外空间
5.2.2 前缀和+哈希表法
-
时间复杂度分析:
- 单次遍历数组:O(n)
- 每次操作哈希表:平均O(1)
- 总复杂度:O(n)
- 当n=20000时,操作次数约2万次,效率极高
-
空间复杂度:
- 最坏情况下,每个前缀和都不同,需要存储n个键值对
- 空间复杂度:O(n)
6. 总结与前端应用场景
6.1 核心要点总结
- 前缀和思想:将子数组求和问题转化为前缀和之差的问题
- 哈希表优化:通过哈希表记录前缀和出现次数,实现O(1)时间查找
- 边界处理:注意初始化前缀和为0的情况(对应空子数组)
- 负数处理:由于数组元素可能为负数,不能使用双指针滑动窗口法
6.2 实际应用场景(前端视角)
6.2.1 性能监控与分析
javascript
// 监控连续时间段内API错误率达到阈值的情况
const errorRates = [0.1, 0.2, 0.05, 0.3, 0.15, 0.25];
const threshold = 0.5;
// 统计连续时间段内错误率总和超过阈值的时间段数量
function countErrorSpikes(errorRates, threshold) {
const map = new Map();
map.set(0, 1);
let count = 0;
let prefixSum = 0;
for (let rate of errorRates) {
prefixSum += rate;
if (map.has(prefixSum - threshold)) {
count += map.get(prefixSum - threshold);
}
map.set(prefixSum, (map.get(prefixSum) || 0) + 1);
}
return count;
}
6.2.2 用户行为分析
javascript
// 分析用户连续操作序列
// 例如:统计用户连续点击次数达到特定模式的情况
const userActions = ['click', 'scroll', 'click', 'hover', 'click'];
const targetPattern = 2; // 连续click的次数
function countActionPatterns(actions, targetAction, targetCount) {
const map = new Map();
map.set(0, 1);
let count = 0;
let currentStreak = 0;
for (let action of actions) {
// 如果是目标行为,streak加1,否则重置为-1(或其他负值)
currentStreak += (action === targetAction ? 1 : -1);
// 查找是否有位置使得连续目标行为次数等于targetCount
if (map.has(currentStreak - targetCount)) {
count += map.get(currentStreak - targetCount);
}
map.set(currentStreak, (map.get(currentStreak) || 0) + 1);
}
return count;
}
6.2.3 数据处理与可视化
javascript
// 在数据可视化中,统计连续时间段内数据超过阈值的情况
class DataAnalyzer {
constructor() {
this.prefixSumMap = new Map();
}
// 实时数据流处理
processDataStream(dataStream, threshold) {
const result = [];
let prefixSum = 0;
let map = new Map([[0, [-1]]]); // 存储前缀和及其出现的位置
for (let i = 0; i < dataStream.length; i++) {
prefixSum += dataStream[i];
// 查找满足条件的位置
const target = prefixSum - threshold;
if (map.has(target)) {
const positions = map.get(target);
for (let pos of positions) {
result.push([pos + 1, i]); // 子数组的起始和结束索引
}
}
// 更新哈希表
if (!map.has(prefixSum)) {
map.set(prefixSum, []);
}
map.get(prefixSum).push(i);
}
return result;
}
}