蓝桥杯第十五届抱佛脚(七)前缀和与差分
前缀和
前缀和算法是一种在数组处理中非常有效的技术,特别是当需要频繁查询某个固定区间内的元素和时。这种算法可以在 O(N) 的时间内预处理数组,并且能在 O(1) 的时间内回答区间和的查询。以下是前缀和算法的详细介绍:
基本概念
前缀和是一种对数组的预处理方法。假设有一个数组 arr[0], arr[1], ..., arr[n-1]
,前缀和数组 prefixSum
定义为:
prefixSum[0] = arr[0]
prefixSum[1] = arr[0] + arr[1]
prefixSum[2] = arr[0] + arr[1] + arr[2]
- ...
prefixSum[i] = arr[0] + arr[1] + ... + arr[i]
前缀和的构建
前缀和数组可以通过下面的方式计算:
- 初始化一个新数组
prefixSum
。 - 设置
prefixSum[0] = arr[0]
。 - 对于每个
i
(从 1 到 n-1),设置prefixSum[i] = prefixSum[i-1] + arr[i]
。
查询区间和
一旦前缀和数组被计算出来,我们可以快速计算任意区间 [L, R]
(其中 0 ≤ L ≤ R < n
)内元素的和。区间和可以表示为:
- 如果
L = 0
,那么区间和就是prefixSum[R]
。 - 如果
L > 0
,那么区间和是prefixSum[R] - prefixSum[L-1]
。
前缀和算法解题的基本思路
-
识别适用性:首先确定问题是否适合使用前缀和算法解决。通常,如果问题涉及频繁查询数组的某个区间的累加和,而且数组本身在查询过程中不经常更改,那么前缀和算法就很合适。
-
构造前缀和数组:根据原数组构建一个前缀和数组,其中每个元素表示从数组起始位置到该点的累加和。
-
快速查询:利用前缀和数组,可以快速计算任何区间的和。这是通过简单的数学运算(相减)来实现的,不需要遍历区间。
-
处理边界情况:注意在查询时处理边界情况,例如当区间的起始点是数组的第一个元素时。
-
优化存储和计算:在必要时,可以对前缀和数组或查询过程进行优化,以减少存储需求或提高效率。
前缀和的一般解题过程
- 初始化前缀和数组 :
- 创建一个与原数组长度相同(或者长度+1,第一个元素为0,以简化计算)的前缀和数组。
- 遍历原数组,更新前缀和数组,使得每个位置存储到当前位置为止的元素总和。
- 处理查询请求 :
- 对于每个查询(例如求子数组
arr[L...R]
的和),计算prefixSum[R] - prefixSum[L-1]
(注意当L=0
时只需要返回prefixSum[R]
)。
- 对于每个查询(例如求子数组
- 考虑更新原数组的情况 :
- 如果原数组在查询过程中发生更改,则需要更新前缀和数组。这可以通过对更改点后的所有元素重新计算前缀和来完成。
- 优化 :
- 在某些情况下,可能需要考虑空间优化(例如使用原数组作为前缀和数组)或者时间优化(比如在构建前缀和数组时利用某些特性减少运算)。
应用场景
前缀和算法在处理以下类型的问题时非常有用:
- 频繁查询数组的某个区间的和。
- 处理多个这样的查询,而数组本身不经常更改。
通过前缀和算法,可以大幅度降低处理此类问题的时间复杂度,特别是当面对大量的区间和查询时。
前缀和例题
区域和检索-数组不可变
给定一个整数数组 nums
,处理以下类型的多个查询:
- 计算索引
left
和right
(包含left
和right
)之间的nums
元素的 和 ,其中left <= right
实现 NumArray
类:
NumArray(int[] nums)
使用数组nums
初始化对象int sumRange(int i, int j)
返回数组nums
中索引left
和right
之间的元素的 总和 ,包含left
和right
两点(也就是nums[left] + nums[left + 1] + ... + nums[right]
)
示例 1:
输入:
["NumArray", "sumRange", "sumRange", "sumRange"]
[[[-2, 0, 3, -5, 2, -1]], [0, 2], [2, 5], [0, 5]]
输出:
[null, 1, -1, -3]
解释:
NumArray numArray = new NumArray([-2, 0, 3, -5, 2, -1]);
numArray.sumRange(0, 2); // return 1 ((-2) + 0 + 3)
numArray.sumRange(2, 5); // return -1 (3 + (-5) + 2 + (-1))
numArray.sumRange(0, 5); // return -3 ((-2) + 0 + 3 + (-5) + 2 + (-1))
java
class NumArray {
// 全局变量,两个函数都能使用
int[] sum;
public NumArray(int[] nums) {
sum = new int[nums.length + 1];
for(int i = 0;i<nums.length;i++){
// 前缀和的索引从1开始,不用特别处理边界
sum[i+1] = sum[i]+nums[i];
}
}
public int sumRange(int left, int right) {
// 由于索引是从1开始,所以右边要加1,左边不用减1了
return sum[right+1] - sum[left];
}
}
构建回文串检测
给你一个字符串 s
,请你对 s
的子串进行检测。
每次检测,待检子串都可以表示为 queries[i] = [left, right, k]
。我们可以 重新排列 子串 s[left], ..., s[right]
,并从中选择 最多 k
项替换成任何小写英文字母。
如果在上述检测过程中,子串可以变成回文形式的字符串,那么检测结果为 true
,否则结果为 false
。
返回答案数组 answer[]
,其中 answer[i]
是第 i
个待检子串 queries[i]
的检测结果。
注意:在替换时,子串中的每个字母都必须作为 独立的 项进行计数,也就是说,如果 s[left..right] = "aaa"
且 k = 2
,我们只能替换其中的两个字母。(另外,任何检测都不会修改原始字符串 s
,可以认为每次检测都是独立的)
示例:
输入:
s = "abcda", queries = [[3,3,0],[1,2,0],[0,3,1],[0,3,2],[0,4,1]]
输出:
[true,false,false,true,true]
解释:
queries[0] : 子串 = "d",回文。
queries[1] : 子串 = "bc",不是回文。
queries[2] : 子串 = "abcd",只替换 1 个字符是变不成回文串的。
queries[3] : 子串 = "abcd",可以变成回文的 "abba"。 也可以变成 "baab",先重新排序变成 "bacd",然后把 "cd" 替换为 "ab"。
queries[4] : 子串 = "abcda",可以变成回文的 "abcba"。
解题思路:
- 当子串长度为奇数时,允许有一个字符出现奇数次。
- 当子串长度为偶数时,所有字符都应出现偶数次。
- 计算当前子串中出现次数为奇数的字符数量(oddCount)。
- 如果
oddCount/2
小于等于 k,则该子串可以通过替换成为回文串。
- 核心语句解释
javaprefixXor[i + 1] = prefixXor[i] ^ (1 << (s.charAt(i) - 'a'));
在这行代码中:
s.charAt(i)
:获取字符串s
中位置i
上的字符。
s.charAt(i) - 'a'
:这是一个将字符转换为一个 0 到 25 的整数的操作,用于表示字母表中的位置。例如,如果s.charAt(i)
是'a'
,那么s.charAt(i) - 'a'
的结果是 0;如果是'b'
,结果是 1,依此类推。
1 << (s.charAt(i) - 'a')
:这是一个位运算。这里<<
是左移运算符,意味着将数字 1 的二进制表示向左移动s.charAt(i) - 'a'
位。这实际上是在创建一个只在s.charAt(i) - 'a'
位置上有一个 1 的二进制数,这个位置对应于当前字符在字母表中的位置。例如,如果s.charAt(i)
是'a'
,这个表达式的结果是1
(二进制000...0001
);如果s.charAt(i)
是'b'
,结果是2
(二进制000...0010
),依此类推。
prefixXor[i] ^ ...
:这里的^
是异或运算符。异或运算有一个性质:同一个数异或两次结果不变。在这个上下文中,它用于跟踪每个字符出现次数的奇偶性。如果字符在子串s[0...i]
中出现奇数次,它对应的位就是 1;如果出现偶数次,就是 0。
prefixXor[i + 1] = ...
:将异或的结果赋值给prefixXor[i + 1]
。这样,prefixXor[i + 1]
就包含了直到字符串的第i
个字符为止,每个字符出现次数的奇偶性。
java
class Solution {
public List<Boolean> canMakePaliQueries(String s, int[][] queries) {
int n = s.length();
// 使用一个整数数组来存储每个字符的出现次数的奇偶性
int[] prefixXor = new int[n + 1];
for (int i = 0; i < n; i++) {
// 用异或操作更新字符的出现次数的奇偶性
prefixXor[i + 1] = prefixXor[i] ^ (1 << (s.charAt(i) - 'a'));
}
List<Boolean> answer = new ArrayList<>();
for (int[] query : queries) {
int left = query[0], right = query[1], k = query[2];
// 对每个查询调用 canFormPalindrome 方法
answer.add(canFormPalindrome(prefixXor, left, right, k));
}
return answer;
}
private boolean canFormPalindrome(int[] prefixXor, int left, int right, int k) {
// 使用异或运算计算出[left, right]区间内的字符出现次数的奇偶性
int xor = prefixXor[right + 1] ^ prefixXor[left];
// 计算出现奇数次的字符数量
int oddCount = Integer.bitCount(xor);
// 对奇数长度的字符串进行特殊处理
if ((right - left + 1) % 2 == 1) {
oddCount = Math.max(0, oddCount - 1);
}
// 判断是否可以通过最多 k 次替换成为回文串
return oddCount / 2 <= k;
}
}
大学里的树木要维护
题目描述:
教室外有 N 棵树(树的编号从1~N),根据不同的位置和树种,学校已经对其进行了多年的维护。因为树的排列成线性,且非常长,我们可以将它们看作一条直线给他们编号。由于已经维护了多年,每一个树都由学校的园艺人员进行了维护费用的统计。每棵树的前期维护费用各不相同,但是由于未来需要要打药,所以有些树木的维护费用太高的话,就要重新种植。由于维护费用也称区间分布,所以常常需要统一个区间里的树木的维护开销。
现给定一个长度为 N 的数组 A 以及 M 个查询,Ai 表示第 i 棵树到维护费用。对于每个查询包含一个区间,园艺人员想知道该区间内的树木维护的开销是多少。
输入描述:
每组输入的第一行有两个整数 N和 M。N 代表马路的共计多少棵树,M 代表区间的数目,N 和 M 之间用一个空格隔开。接下来的一行,包含N 个数 A1,A2...,AN,分别表示每棵树的维护费用,每个数之间用空格隔开。接下来的 M 行每行包含两个不同的整数,用一个空格隔开,表示一个区域的起始点 工 和终止点 见 的坐标。
输出描述:
输出包括 M 行,每一行只包含一个整数,表示维护的开销。
示例:
输入:
10 3
7 5 6 4 2 5 0 8 5 3
1 5
2 6
3 7
输出:
24
22
17
解题思路:
-
计算前缀和数组 :首先,我们根据给定的维护费用数组
A
计算前缀和数组prefixSum
。prefixSum[i]
表示从第 1 棵树到第 i 棵树的总维护费用。 -
处理查询 :对于每个查询,我们可以使用前缀和数组快速计算出任意区间内树木的维护费用总和。如果查询的区间是
[L, R]
(以 1 为起始索引),那么这个区间的维护费用总和可以表示为prefixSum[R] - prefixSum[L - 1]
。 -
输出结果:对每个查询,按照上述方式计算区间和,然后输出结果。
java
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
int N = scanner.nextInt(); // 树的数量
int M = scanner.nextInt(); // 查询的数量
int[] A = new int[N + 1]; // 维护费用数组(从索引 1 开始存储)
// 计算前缀和数组
for (int i = 1; i <= N; i++) {
A[i] = scanner.nextInt();
A[i] += A[i - 1];
}
// 处理每个查询
for (int i = 0; i < M; i++) {
int L = scanner.nextInt(); // 区间起点
int R = scanner.nextInt(); // 区间终点
System.out.println(A[R] - A[L - 1]); // 计算并输出区间和
}
scanner.close();
}
}
差分法
差分法是一种常用于处理数组区间更新问题的高效技术。这种方法特别适用于当我们需要对数组的某个区间内的所有元素执行相同的操作(比如增加一个固定的值)时。
差分数组的主要作用是使区间内的更新变得容易。在差分数组 diff
上对位置 i
加上一个值,将导致原数组 arr
中从位置 i
开始的所有后续元素都增加这个值。
基本概念
在差分法中,我们使用一个额外的数组(称为差分数组)来表示原数组中相邻元素之间的差值。例如,如果原数组是 arr
,差分数组 diff
定义如下:
diff[i] = arr[i] - arr[i - 1]
对于所有i > 0
。diff[0] = arr[0]
。
差分数组的构建
- 初始化一个新数组
diff
与原数组arr
大小相同。 - 设置
diff[0] = arr[0]
。 - 对于每个
i
(从 1 到 n-1),设置diff[i] = arr[i] - arr[i - 1]
。
使用差分数组进行区间更新
- 假设我们要将
arr
中从位置L
到R
的所有元素增加val
。 - 我们将
diff[L]
增加val
,并将diff[R + 1]
(如果R + 1 < arr.length
)减去val
。
从差分数组恢复原数组
- 初始化一个新数组
result
。 - 设置
result[0] = diff[0]
。 - 对于每个
i
(从 1 到 n-1),设置result[i] = result[i - 1] + diff[i]
。
差分算法使用示例
假设我们有一个数组 arr = [1, 2, 3, 4, 5]
,我们需要将索引 2 到 4(包括索引处的元素)的元素增加 3。
1.构建差分数组
首先,我们需要构建原数组 arr
的差分数组 diff
。差分数组 diff
的每个元素表示原数组中相邻元素的差值。
对于数组 arr = [1, 2, 3, 4, 5]
:
diff[0] = arr[0] = 1
(差分数组的第一个元素总是原数组的第一个元素)- 对于
i > 0
,diff[i] = arr[i] - arr[i - 1]
因此,差分数组 diff
是 [1, 1, 1, 1, 1]
。
2.进行区间更新
现在,我们要将 arr
中索引 2 到 4 的元素每个增加 3。在差分数组 diff
中,这可以通过以下步骤完成:
- 将
diff[2]
增加 3。 - 将
diff[5]
(即原数组的长度,这里是arr.length
)减去 3。由于这个索引超出了diff
的范围,我们在这个例子中忽略这一步。
更新后的差分数组 diff
是 [1, 1, 4, 1, 1]
。
3.从差分数组恢复原数组
现在,我们需要通过更新后的差分数组 diff
来恢复更新后的原数组 arr
。我们通过累加差分数组 diff
的元素来实现这一点(原数组就是差分数组的前缀和):
arr[0] = diff[0] = 1
- 对于
i > 0
,arr[i] = arr[i - 1] + diff[i]
执行这些操作后,原数组 arr
更新为 [1, 2, 7, 8, 9]
。这个结果反映了在索引 2 到 4 上每个元素增加 3 的操作。
4.对比
原始数组 arr
为 [1, 2, 3, 4, 5]
。进行操作后,数组变为 [1, 2, 7, 8, 9]
。可以看出,索引 2 到 4 的元素确实各增加了 3。
这个例子展示了差分法如何以一种高效而简洁的方式处理数组的区间更新问题。通过差分数组,我们可以轻松地进行区间加减操作,并且可以快速恢复出更新后的原数组。
代码示例
java
public class Difference {
private int[] diff; // 差分数组
// 构造函数:根据原始数组构建差分数组
public Difference(int[] nums) {
diff = new int[nums.length];
diff[0] = nums[0];
for (int i = 1; i < nums.length; i++) {
diff[i] = nums[i] - nums[i - 1];
}
}
// 对原数组的区间 [i, j] 增加 val(包括 i 和 j)
public void increment(int i, int j, int val) {
diff[i] += val;
if (j + 1 < diff.length) {
diff[j + 1] -= val;
}
}
// 从差分数组恢复原数组
public int[] getResult() {
int[] result = new int[diff.length];
result[0] = diff[0];
for (int i = 1; i < diff.length; i++) {
result[i] = result[i - 1] + diff[i];
}
return result;
}
// 示例
public static void main(String[] args) {
int[] nums = {1, 2, 3, 4, 5};
Difference df = new Difference(nums);
// 在索引 2 到 4 的元素上每个增加 3
df.increment(2, 4, 3);
// 从差分数组恢复原数组并打印结果
int[] result = df.getResult();
for (int num : result) {
System.out.print(num + " ");
}
}
}
差分算法例题
大学里的树木要打药
题目描述:
教室外有 N 棵树,根据不同的位置和树种,学校要对其上不同的药。因为树的排列成线性,且非常长,我们可以将它们看作一条直线给他们编号树的编号从0~N-1日N < 1e6。对于树的药是成区间分布,比如3-5号的树靠近下水道,所以他们要用驱蚊虫的药,20-26号的树,他们排水不好,容易涝所以要给他们用点促进根系的药。诸如此类,每种不同的药要花不同的钱。
现在已知共有 M 个这样的区间,并且给你每个区间花的钱,请问最后,这些树木花了多少药费。
输入描述:
每组输入的第一行有两个整数 N(1<= N<= 1000000)和 M(1 <= M <= 100000)N 代表马路的共计多少棵树,M代表区间的数目,N 和 M 之间用一个空格隔开。
接下来的 M 行每行包含三个不同的整数,用一个空格隔开,表示一个区域的起始点 L 和终止点 R 的坐标,以及花费。
输出描述:
输出包括一行,这一行只包含一个整数,所有的花费。
示例:
输入
500 3
150 300 4
100 200 20
470 471 19
输出
2662
解题思路:
在本题中,我们需要计算多个区间花费的总和,这正是差分数组擅长的。
-
初始化数组 :初始化一个长度为 N 的数组
cost
,用于存储每棵树的药费,初始时所有元素为 0。 -
应用差分数组 :对于每个区间操作(起始点
L
,终止点R
,花费value
),在差分数组上进行操作:cost[L] += value
cost[R + 1] -= value
(注意边界条件,如果R + 1 < N
)
-
计算总花费:通过差分数组恢复最终的药费数组,并计算总花费。
-
输出结果:将计算出的总花费输出。
java
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
int N = scanner.nextInt(); // 树的数量
int M = scanner.nextInt(); // 区间的数量
long[] cost = new long[N + 1]; // 药费数组,使用长整型以防溢出
// 应用差分数组处理每个区间
for (int i = 0; i < M; i++) {
int L = scanner.nextInt();
int R = scanner.nextInt();
int value = scanner.nextInt();
cost[L] += value;
if (R + 1 < N) {
cost[R + 1] -= value;
}
}
// 计算总花费
long totalCost = 0;
for (int i = 0; i < N; i++) {
if (i > 0) {
cost[i] += cost[i - 1];
}
totalCost += cost[i];
}
// 输出结果
System.out.println(totalCost);
scanner.close();
}
}
这段代码首先读取树的数量和区间的数量,然后根据每个区间的起始点、终止点和花费来更新差分数组 cost
。在处理完所有区间之后,它通过累加差分数组来计算每棵树的最终药费,并求和得到总花费。最后输出这个总花费。
总结
前缀和算法
前缀和算法主要用于处理区间和的查询问题。
-
构建前缀和数组 :对于给定的数组
arr
,构建一个新的数组prefixSum
,其中prefixSum[i]
存储了从arr[0]
到arr[i]
的元素之和。 -
区间和查询:如果需要频繁查询数组的任意子数组(区间)的和,前缀和算法是一个非常有效的方法。
-
适用于静态数据:当原数组在多次查询之间不改变时尤其有效。
-
快速查询:查询任意区间和的时间复杂度降低到 O(1)。
-
更新困难:如果原数组频繁变化,需要重新计算前缀和,这可能很耗时。
差分算法
差分算法主要用于处理数组的区间更新问题。
-
构建差分数组 :对于给定的数组
arr
,构建一个新的数组diff
,其中diff[i] = arr[i] - arr[i - 1]
(对于i > 0
),且diff[0] = arr[0]
。 -
区间更新:当需要对数组的一个区间内的所有元素进行同样的增减操作时,差分算法非常高效。
-
适用于动态数据:适合于数据频繁更新的场景。
-
快速更新:在差分数组上对区间的更新操作可以在 O(1) 时间内完成。
-
复原步骤:为了获取更新后的数组,需要执行一次 O(N) 的操作来从差分数组复原到原数组。
结论
- 前缀和 适用于查询操作较多,更新操作较少的场景。
- 差分算法 适用于更新操作较多,查询操作较少的场景。