刷题顺序参考于 《2023华为机考刷题指南:八周机考速通车》
前言
前缀和是指某序列的前n项和,可以把它理解为数学上的数列的前n项和,而差分可以看成前缀和的逆运算。合理的使用前缀和与差分,可以将某些复杂的问题简单化。
关于各类「区间和」问题如何选择解决方案,(加粗字体为最佳方案):
- 数组不变,求区间和:「前缀和」、「树状数组」、「线段树」
- 数组单点修改(多次修改某个数),求区间和:「树状数组」、「线段树」
- 数组区间修改,单点查询(输出最终结果):「差分」、「线段树」
- 数组区间修改,区间查询(求区间和):「线段树」
……
Note:上述总结是对于一般性而言的(能直接解决的),对标的是模板问题。但存在经过一些经过“额外”操作,对问题进行转化,从而使用别的解决方案求解的情况。例如某些问题,我们可以先对原数组进行差分,然后使用树状数组,也能解决区间修改问题。或者使用多个树状数组来维护多个指标,从而实现类似线段树的持久化标记操作。
1. 前缀和
前缀和是指某序列的前n项和,可以把它理解为数学上的数列的前n项和。
前缀和算法的作用: 在给定数组不变的情况下,求取区间和,通过前缀和的方法,我们可以将原来暴力解法的
O(n * m)
时间复杂度,降低至O(n+m)
。
1.1 一维前缀和
下面我们可以来看一下前缀和最基础的模板题,来帮助理解:DP34【模板】前缀和
……
Question:输入一个长度为 n 的整数序列。接下来再输入 q 个询问,每个询问输入一对 l, r。对于每个询问,输出原序列中从第 l 个数到第 r 个数的和。
……
解题思路:根据题意,我们很容易就能够想出,可以通过暴力求解,每次询问的时候,从 l 遍历到 r 加和求解。但正是因为每次都需要从 l 遍历到 r ,这就使得程序要重复 q 次这样的动作,时间复杂度为O(n * q)
。这种情况下,一旦 n 和 q 的数据量稍微大一点就有可能引发超时,所以有没有什么办法能够将这种需要多次循环遍历的解法简化为一次遍历呢?这就用到了我们开头提到的前缀和。
……
前缀和算法主要分为两步操作:
- 预处理操作,具体做法就是:先定义一个前缀和数组
sum[]
,sum[i]
代表 a 数组中前 i 个数的和;然后通过一次对给定 a[] 数组的遍历,即可完成对前缀和数组的初始化;
- 查询操作,对于每次查询,只需执行
sum[r]-sum[l-1]
,时间复杂度为O(1)
……
原理图解:
……
完整代码则如下所示:
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int n = in.nextInt(); // 获取整数个数
int q = in.nextInt(); // 获取查询次数
int[] arr = new int[n+1];
for(int i = 1; i <= n; i++) { // 将输入的整数存入数组
arr[i] = in.nextInt();
}
// 构建前缀和数组,int[]会溢出,改用long[]
long[] sum = new long[n+1];
for (int i = 1; i <= n; i++) {
sum[i] = sum[i-1] + arr[i];
}
for (int j = 0; j < q; j++) { // 计算区间和
int l = in.nextInt();
int r = in.nextInt();
System.out.println(sum[r]-sum[l-1]);
}
}
}
1.2 二维前缀和
题目练习:【模板】二维前缀和
……
Question:输入一个 n 行 m 列的整数矩阵,再输入 q 个询问,每个询问包含四个整数x1
,y1,
x2
,y2
,表示一个子矩阵的左上角坐标和右下角坐标。对于每个询问输出子矩阵中所有数的和。
……
解题思路:同一维前缀和一样,二维前缀和的实现也是两步操作。
- 预处理操作,我们先来定义一个二维数组
s[][]
,s[i][j]
表示二维数组中,左上角(1, 1)到右下角(i, j)所包围的矩阵元素的和,之后我们根据推导出来的预处理公式来构建前缀和数组:s[i][j] = s[i - 1][j] + s[i][j - 1 ] - s[i - 1][j - 1] + a[i] [j]
;
……
推导过程,如下图:
从图中我们可以看出,整个外围蓝色矩形面积s[i][j]
= 绿色面积s[i - 1][j]
+ 紫色面积s[i][j - 1]
- 重复加的红色的面积s[i - 1][j - 1]
+ 小方块的面积a[i][j]
;
……- 查询操作,
res = s[x2, y2] - s[x1 - 1, y2] - s[x2, y1 - 1] + s[x1 - 1, y1 - 1]
……
推导过程,如下图:
从图中我们可以看出,绿色矩形的面积 = 整个外围面积s[x2, y2]
- 黄色面积s[x2, y1 - 1]
- 紫色面积s[x1 - 1, y2]
+ 重复减去的红色面积s[x1 - 1, y1 - 1]
……
2. 差分
类似于数学中的求导和积分,差分 可以看成 前缀和的逆运算。
差分数组可以用
diff[]
命名,与前缀和类似,差分数组也是一个常用的辅助数组,它的定义是原数组相邻两元素之差:
diff[0] = nums[0]
diff[1] = nums[1] - nums[0]
diff[2] = nums[2] - nums[1]
……
diff[i] = nums[i] - nums[i - 1]
……
diff[n-1] = nums[n-1] - nums[n-2]
由于差分数组是前缀和的逆运算,所以求差分数组
diff[]
的前缀和,刚好就能得到原始数组:
nums[0] = diff[0]
nums[1] = nums[0] + nums[1] - nums[0] = diff[0] + diff[1]
nums[2] = nums[0] + nums[1] - nums[0] + nums[2] - nums[1] = diff[0] + diff[1] + diff[2]
……
nums[n-1] = nums[0] + nums[1] - nums[0] + ... nums[n-1] - nums[n-2] = diff[0] + ... diff[n-1]
差分数组的作用:差分数组能快速的对区间更新。区间更新是指对于数组 nums,长度为 n,想要对区间 [l, r] 做更新,比如都加上一个数 x,或者都减去一个数 y。常规的实现肯定遍历 [l, r] 然后对每个元素做更新,这是线性时间 O(n) 的,而用差分数组可以在常数时间完成区间更新。
……
Note:只对差分数组的区间两端做加减法就可以实现原数组区间增加,即在区间的左边界处加 x,在区间的右边界后一个数处减 x。但如果你想得出原数组的真实修改后的结果,仍需要对差分数组做前缀和才可以。所以,差分数组是一个辅助数组,它的作用不像前缀和那样明显,它只能配合使用,无法单独使用。
2.1 一维差分
题目练习:DP37 【模板】差分
差分数组对应的概念是前缀和数组,对于数组 [1,2,2,4],其差分数组为 [1,1,0,2],差分数组的第 i 个数即为原数组的第 i−1 个元素和第 i 个元素的差值。
……
思路不难,可以直接记公式:b[l] + = c
,b[r+1] - = c
……
推导过程,如下图:
b[l] + c
,效果使得 a 数组中a[l]
及以后的数都加上了c
(红色部分),但我们只要求l
到r
区间加上c
, 因此还需要执行b[r + 1] - c
,让a数组中a[r + 1]
及往后的区间再减去c
(绿色部分),这样对于a[r]
以后区间的数相当于没有发生改变。
……
反求前缀和:b[i] += b[i - 1]
;
2.2 二维差分
题目练习:DP38 【模板】二维差分
……
二维差分直接构造公式:b[i][j] = a[i][j] − a[i − 1][j] − a[i][j − 1] + a[i −1 ][j − 1]
……
推导过程,如下图:
b[x1][y1] += c
; 对应图1,让整个a数组中蓝色矩形面积的元素都加上了c;b[x1,][y2 + 1] -= c
; 对应图2 ,让整个a数组中绿色矩形面积的元素再减去c,使其内元素不发生改变;b[x2 + 1][y1] -= c
; 对应图3 ,让整个a数组中紫色矩形面积的元素再减去c,使其内元素不发生改变;b[x2 + 1][y2 + 1] += c
; 对应图4,让整个a数组中红色矩形面积的元素再加上c,红色内的相当于被减了两次,再加上一次c,才能使其恢复。……
构造过程的模板,可写成如下形式:
// 对b数组执行插入操作,等价于对a数组中的(x1,y1)到(x2,y2)之间的元素都加上了c
void insert(int x1,int y1,int x2,int y2,int c)
{
b[x1][y1] += c;
b[x2 + 1][y1] -= c;
b[x1][y2 + 1] -= c;
b[x2 + 1][y2 + 1] += c;
}
for(int i = 1;i <= n;i++)
{
for(int j = 1;j <= m;j++)
{
insert(i, j, i, j, a[i][j]); //构建差分数组
}
}
反求前缀和:
b[i][j] += b[i - 1][j] + b[i][j - 1] - b[i - 1][j - 1]
;
一、1109. 航班预订统计
1. 题目描述
这里有 n 个航班,它们分别从 1 到 n 进行编号。
有一份航班预订表 bookings ,表中第 i 条预订记录
bookings[i] = [firsti, lasti, seatsi]
意味着在从 firsti 到 lasti (包含 firsti 和 lasti )的 每个航班 上预订了 seatsi 个座位。
请你返回一个长度为 n 的数组
answer
,里面的元素是每个航班预定的座位总数。
2. 测试用例
3. 题解
3.1 差分数组 – O(n+m)(⭐)
差分数组 diff[i] = num[i] - num[i-1]
,差分数组的作用就是忽略中间的变化,同增同减就可以认为没有变化。
- 这道题里原数组一开始为 [0, 0, 0, 0],那么差分数组就也为 [0, 0, 0, 0];
- 当对0到2的航班订2个位置,原数组变化为 [2, 2, 2, 0],而差分数组只需要变成[2, 0, 0, -2];
- 最后,我们通过对差分数组求前缀和,又可以推导出原数组 [2, 2+0, 2+0+0, 2+0+0+(-2)] = [2, 2, 2, 0]。
class Solution {
public int[] corpFlightBookings(int[][] bookings, int n) {
int[] nums = new int[n]; // 定义前缀和数组
// 构造差分数组 b[i] = a[i] - a[i-1]
for (int[] booking : bookings) { // 遍历二维数组,每次取出当前行的一维数组
nums[booking[0] - 1] += booking[2]; // b[l] += c
if (booking[1] < n) {
nums[booking[1]] -= booking[2]; // b[r+1] -= c
}
}
for (int i = 1; i < n; i++) { // 求前缀和运算
nums[i] += nums[i - 1]; // a[i] = b[i] + a[i-1]
}
return nums;
}
}
3.2 暴力求解 – O(n*m)
从头开始遍历所有的预订记录,一个一个的加。
class Solution {
public int[] corpFlightBookings(int[][] bookings, int n) {
int[] res = new int[n];
for (int i = 0; i < bookings.length; i++) { // 读取数组
int[] order = bookings[i];
for (int j = order[0]; j <= order[1]; j++) { // 从first一直遍历到last
res[j-1] += order[2];
}
}
return res;
}
}
二、小结
前缀和 与 差分 数组一般作为辅助数组使用,理解了它们的原理对于理解更复杂的数组结构如
树状数组
非常有帮助。
三、参考资料
[1] 前缀和与差分 图文并茂 超详细整理 – 林小鹿@ (原理图来源)
[2] 前缀和与差分数组简介 – 稀有猿诉 (差分举例来源)
以上两篇文章写的都很好,介绍的也非常透彻,推荐反复多次阅读,常温常新。