【算法】前缀和

前缀和

此文包含了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;
}

5、前缀和 + 二分法(待整理)

待整理
1712. 将数组分成三个子数组的方案数
528. 按权重随机选择
497. 非重叠矩形中的随机点

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值