前缀和
此文包含了LeetCode出现的大部分可以用前缀和求解的题目。
1、原理
- 构建前缀和数组,保存原数组前 i 位的和。参考
2、算法核心
// 原数组
int[] nums = {2, 4, 5, -1, -2, 3};
// 构造前缀和数组
int[] pre = new int[nums.length + 1];
pre[0] = 0;
// 前缀和初始化,遍历原数组
for (int i = 0; i < nums.length; i++) {
// 算法核心
pre[i + 1] = pre[i] + nums[i];
}
说明:
1、前缀和数组的元素 pre[i] 表示原数组 nums[i] 左边的所有元素的和(不包含nums[i])。
2、前缀和数组的首元素 pre[0] 初始化为 0,表示原数组 nums[0] 左边的所有元素的和(不包含nums[0])。
3、前缀和数组的长度为原数组长度+1(多了pre[0])。
4、注意前缀和数组下标和原数组对应关系: pre[i] 表示区间 [0, i -1] 的和(不包含 nums[i])。
5、求解 nums 在 [j, i] 区间的子数组的和转化为:pre[i + 1] - pre[j];
3、应用场景
- 求数组中所有满足条件的子数组(数组中元素连续的一段称为子数组)。
- 通过两层 for 循环来遍历所有子数组往往会超时,通过前缀和可以快速遍历&搜索目标子数组。
用法:
1、通过前缀和数组求解子数组的和
2、通过问题转化,将原问题转化为寻找满足条件的子数组的和
// 前缀和元素表示范围
pre[0] = 0;
pre[1] = nums[0];
pre[i] = nums[0] + nums[1] + ... + nums[i - 1];
// 前缀和元素间关系
pre[i + 1] = pre[i] + nums[i];
pre[j] = nums[0,j) 左闭右开
pre[i + 1] = nums[0, i] 左闭右闭
// 区间为[j, i]的子数组的和
int sum = pre[i + 1] - pre[j];
// 子数组长度
int len = i + 1 -j;
pre[0] = 0 可以保证在遍历子数组时能取到[0, i]间的和(pre[j] = 0)
4、前缀和+哈希表
前缀和算法常和哈希表搭配使用,哈希表常作为频次表。
4.1 标准模板
标准的应用模板 以题目 560. 和为 K 的子数组 为例:寻找和为 k 的连续子数组的个数 。
题目可以转化为:
1、[j, i] 区间的子数组和 k = pre[i + 1] - pre[j];
2、寻找满足pre[j] = pre[i + 1] - k; 的pre[j],在[0, i] 区间内可能有多个满足的pre[j];
3、可以用map哈希表保存pre[j]及其出现次数。
public int subarraySum(int[] nums, int k) {
// 记录个数
int count = 0;
// 构造前缀和函数
int[] pre = new int[nums.length + 1];
pre[0] = 0;
// 哈希表用于保存遍历过的前缀和值及其个数
Map<Integer, Integer> map = new HashMap<>();
map.put(0, 1);
// 遍历原数组
for (int i = 0; i < nums.length; i++) {
// 前缀和初始化
pre[i + 1] = pre[i] + nums[i];
// 判断满足条件的pre[j]是否存在
if (map.containsKey(pre[i + 1] - k)) {
// 满足pre[j] = pre[i+1]-k的pre[j]的个数
count += map.get(pre[i + 1] - k);
}
// 保存当前前缀和
map.put(pre[i + 1], map.getOrDefault(pre[i + 1], 0) + 1);
}
return count;
}
1、注意前缀和数组长度比原数组多一位pre[0] = 0,这样可以取到pre[j] = 0; 以保证遍历子数组时,能取到[0, i]区间的子数组和。
2、注意哈希表初始化时,要提前放入pre[0]及其下标,map.put(pre[0], 0);
3、注意是通过遍历原数组来初始化前缀和数组 i < nums.length
4、注意记录个数count时,要+之前所有满足条件的pre[j]个数
5、注意更新map时,要判断map中是否已存在,再+1。
// 代码简化
public int subarraySum(int[] nums, int k) {
int count = 0;
int pre = 0;
Map<Integer, Integer> map = new HashMap<>();
map.put(0, 1);
for (int i = 0; i < nums.length; i++) {
pre = pre + nums[i];
count += map.getOrDefault(pre - k, 0);
map.put(pre, map.getOrDefault(pre, 0) + 1);
}
return count;
}
可套用此模板的题目:
930. 和相同的二元子数组
完全一样
1248. 统计优美子数组
前缀和数组保存奇数出现的个数,转为pre[i+1] - k
扩展题目:
974. 和可被 K 整除的子数组
题目转化为寻找满足 pre[i + i] % k = pre[j] % k 的 pre[j]
由于被除数存在负数情况,求模时会出现负数模值,需要转化为正数后才可与正数模值匹配:(pre % k + k) % k
如:nums = [-7,10], k = 5; pre = [0,-7,3];
若负模值不转换: -7%5 = -2; 而3%5=3; -2!=3,此时子数组为[10]。
将负模值转换后: (-7 % 5 + 5) % 5 = 3; 可得正解
public int subarraysDivByK(int[] nums, int k) {
int count = 0;
int[] pre = new int[nums.length + 1];
pre[0] = 0;
// 保存目标出现频次
Map<Integer, Integer> map = new HashMap<>();
// 即 (0 % k + k) % k
map.put(0, 1);
for (int i = 0; i < nums.length; i++) {
pre[i + 1] = pre[i] + nums[i];
if (map.containsKey((pre[i + 1] % k + k) % k)) {
count += map.get((pre[i + 1] % k + k) % k);
}
map.put((pre[i + 1] % k + k) % k, map.getOrDefault((pre[i + 1] % k + k ) % k, 0) + 1);
}
return count;
}
523. 连续的子数组和
题目转化为:寻找满足 pre[i + i] % k = pre[j] % k 的 pre[j]
哈希表保存 pre[i + i] % k 的模值,由于不求解子数组个数,而要求子数组 长度>=2,哈希表的value值应是nums的下标,且应保存该模值 第一次出现的下标,以保证子数组长度最长。
1、注意 map 保存的是nums的下标,put(0, -1)
2、注意求解子数组长度时,是 i - map.get(),因为map保存的是 i,不是 i+1
3、注意map已经包含当前模值时,保持最小下标不变,不更改value值
4、注意更新map时,记录的是nums的下标 i,不是 i+1
public boolean checkSubarraySum(int[] nums, int k) {
int[] pre = new int[nums.length + 1];
pre[0] = 0;
Map<Integer, Integer> map = new HashMap<>();
// 保存<前缀和%k的模值,nums最小的下标>
map.put(0, -1);
for (int i = 0; i < nums.length; i++) {
pre[i + 1] = pre[i] + nums[i];
if (map.containsKey(pre[i + 1] % k)) {
// 注意是i-map(),不是i+1。Map保存的是nums的下标(已经是j-1)
if (i - map.get(pre[i + 1] % k) >= 2){
return true;
}
// 已包含时,保持最小下标不变
} else {
// 不包含时,记录当前nums的下标
map.put(pre[i + 1] % k, i);
}
}
return false;
}
4.2 变形题目
前缀和数组除了保存元素之和外,还可变形用于多种 寻找所有满足条件的子数组 的场景。 套路不变,转换到子数组 pre[i + 1] - pre[j] 模板是关键。
525. 连续数组
和523类似,最长子数组,哈希表value保存第一次出现的下标。
相同数量 的0和1的子数组的处理,可以将 0改为-1 保存到前缀和数组,个数相同时 pre[i + 1] = pre[j];
public int findMaxLength(int[] nums) {
// 保存结果
int ret = 0;
// 前缀和+哈希表
int[] pre = new int[nums.length + 1];
pre[0] = 0;
// 为求最长子数组,记录第一次出现的前缀和的nums下标
Map<Integer, Integer> map = new HashMap<>();
map.put(0, -1);
// 将0/1数组转化为-1/1数组
for (int i = 0; i < nums.length; i++) {
// 将问题转化为寻找pre[i + 1] = pre[j]的前缀和
pre[i + 1] = pre[i] + ((nums[i] == 0) ? -1 : 1);
if (map.containsKey(pre[i + 1])) {
// 如果已有满足条件的前缀和,求当前子数组长度
ret = Math.max(i - map.get(pre[i + 1]), ret);
continue;
}
// 如果没有,则加入
map.put(pre[i + 1], i);
}
return ret;
}
1124. 表现良好的最长时间段
根据题目条件,可以将nums元素转为二元数值:将劳累的记为1,不劳累记为-1。
劳累数 > 不劳累数 的问题转化为寻找满足 pre[j] < pre[i + 1] 的前缀和
当子数组和 > 0 时:表示此区间满足条件,直接更新结果取max
当子数组和 < 0 时:表示此区间不满足条件,在此区间寻找满足 pre[j] < pre[i + 1] 的前缀和。
要满足 pre[j] < pre[i+1],只要找 pre[i+1]-1是否存在即可:
不存在pre[i+1]-1则必不存在 pre[i+1]-2、pre[i+1]-3 …。
有pre[i+1]-2必有pre[i+1]-1, 且 -1的数组必比-2的数组长。
1、注意map只保存 pre[i+1] < 0 的前缀和及其下标
2、注意map只保存前缀和第一次出现的下标,以保证子数组最长
3、注意 pre[i+1] > 0 的场景直接刷新结果,与ret取Math.max();
4、注意搜索目标 pre[j] 是 pre[i + 1] - 1
5、注意子数组长度是 i - map.get() ,与ret取Math.max();
6、注意map保存的是前缀和 pre[i + 1] ,保存时要判断当前前缀和是否已存在,不存在时才刷新,保证记录的是第一次出现的下标。
7、特例:[9,6,9] [6,9,9] [9,9,9]
public int longestWPI(int[] hours) {
int ret = 0;
int[] pre = new int[hours.length + 1];
pre[0] = 0;
// 哈希表记录不满足条件的前缀和第一次出现的下标,即pre[i+1]-pre[j]<=0的场景
Map<Integer, Integer> map = new HashMap<>();
map.put(0, -1);
for (int i = 0; i < hours.length; i++) {
pre[i + 1] = pre[i] + ((hours[i] > 8) ? 1 : -1);
// 注意此时分两种情况
if (pre[i + 1] > 0) {
// pre[i + 1]>0时,说明[0,i]即当前满足要求的最长子数组
ret = i + 1 - 0;
continue;
}
// 当pre[i+1]<=0时,在当前区间[0,i]找满足条件的子数组,即找pre[j]满足pre[i+1]>pre[j]
if (map.containsKey(pre[i + 1] - 1)) {
// 只要找pre[i+1]-1是否存在即可:有pre[i+1]-2必有pre[i+1]-1, -1的数组必比-2的数组长
// 不存在pre[i+1]-1则必不存在pre[i+1]-2
// 注意ret取最大值
ret = Math.max(i - map.get(pre[i + 1] - 1), ret);
// 注意map保存的是hours下标i,所以取出后求长度时是i-map.get(),不是i+1-map.get()
}
// 注意目标值是pre[j] = pre[i+1]-1,map保存的是pre[i+1]<=0时的前缀和及其下标
if (!map.containsKey(pre[i + 1])) {
// 注意判断当前前缀和是否已存在,不存在时才刷新,保证记录的是第一次出现的下标
// 以保证后续取出计算时是最长子数组
map.put(pre[i + 1], i);
}
}
return ret;
}