前缀和
导言
- 前缀和 是一个很简单,而且很实用的算法技巧,一定要学会它!
- 接下来我会引入一个问题,并逐步解决它,之后引出我们的主角 — 前缀和。
- 看到最后,小伙伴就知道标题的含义了。
问题
现在有一个数组A,求该数组在区间
[
l
e
f
t
,
r
i
g
h
t
]
[left, right]
[left,right] 的总和。其中数组A长度在
[
1
,
1
0
6
]
[1, 10^6]
[1,106] 范围内,left
、right
都是合法的,且区间总和不会溢出。
例子: A = [1 2 3 4 5],left = 0,right = 3,则区间 [0, 3] 总和 sum = 1 + 2 + 3 + 4 = 10。
可能有的小伙伴觉得这题太简单了!直接使用一个 for
循环不就可以了吗?代码如下:
// 计算数组A,在区间[left, right]的和, 并返回。
int calculate(int* A,int left,int right){
int sum = 0;
for (int i=left;i<=right;i++){
sum += A[i];
}
return sum;
}
的确,对于上面的问题,我们可以只用一个 for
循环解决。那我把问题升个级。
现在有一个数组 A
,再给出一个查询次数 times
,每一次查询都给出一个区间
[
l
e
f
t
,
r
i
g
h
t
]
[left, right]
[left,right],求数组 A
在该区间的总和。其中数组 A
长度在
[
1
,
1
0
6
]
[1, 10^6]
[1,106] 范围内,times
在
[
1
,
1
0
6
]
[1, 10^6]
[1,106] 范围内,left
、right
都是合法的,且区间总和不会溢出。
思考 1 秒后 …
可能小伙伴会说,此时也可以使用 for
循环解决阿,代码如下。
// A是一个数组,times是需要查询的次数
int calculate(int* A, int times){
int left, right;
while(times--){
scanf("%d%d",&left,&right); // 输入区间
// 计算总和
int sum = 0;
for (int i=left;i<=right;i++){
sum += A[i];
}
printf("%d\n",sum); // 输出结果
}
}
我想说可以,但很不幸的是,依照普通计算机的计算能力 (每秒执行 1 0 7 10^7 107 条指令),如果此时 t i m e s = 1 0 6 times=10^6 times=106,每次查询的区间都是 [ 0 , 1 0 6 − 1 ] [0, 10^6-1] [0,106−1],这意味着计算机大约要执行 1 0 12 10^{12} 1012 条指令,执行耗时约为 1 0 5 10^5 105 秒,相当于 27.7 小时。显然,时间是宝贵的,不能这样浪费。
那我们还有什么办法来解决这个问题呢?
答案就是 前缀和。采用 前缀和,我们可以在 0.1 秒解决这个问题。
进入正题
前缀和的思想就是: 定义一个满足以下规则数组 prefixSum
。
p r e f i x S u m ( i ) = { 0 , i = 0 A [ 0 ] + A [ 1 ] + . . . . + A [ i − 1 ] , i > 0 prefixSum(i) = \begin{cases} 0, & \text{$i$ = 0} \\ A[0] + A[1] + .... + A[i - 1], & \text{$i$ > 0} \end{cases} prefixSum(i)={0,A[0]+A[1]+....+A[i−1],i = 0i > 0
于是:
A
在
[
l
e
f
t
,
r
i
g
h
t
]
区
间
的
总
和
=
p
r
e
f
i
x
S
u
m
[
r
i
g
h
t
+
1
]
−
p
r
e
f
i
x
S
u
m
[
l
e
f
t
]
A 在 [left, right] 区间的总和 = prefixSum[right + 1] - prefixSum[left]
A在[left,right]区间的总和=prefixSum[right+1]−prefixSum[left]
下面是推导过程。(不复杂,不要跳过哈)
① 由前缀和定义,我们可以得出:
p r e f i x S u m [ r i g h t + 1 ] = A [ 0 ] + A [ 1 ] + . . . + A [ l e f t − 1 ] + A [ l e f t ] + . . . + A [ r i g h t ] p r e f i x S u m [ l e f t ] = A [ 0 ] + A [ 1 ] + . . . + A [ l e f t − 1 ] \begin{aligned} prefixSum[right + 1] &= A[0] + A[1] + ... + A[left - 1] + A[left] + ... + A[right]\\ \\ prefixSum[left] &= A[0] + A[1] + ... + A[left - 1]\\ \end{aligned} prefixSum[right+1]prefixSum[left]=A[0]+A[1]+...+A[left−1]+A[left]+...+A[right]=A[0]+A[1]+...+A[left−1]
② 二者相减,可得:
p r e f i x S u m [ r i g h t + 1 ] − p r e f i x S u m [ l e f t ] = A [ l e f t ] + . . . . + A [ r i g h t ] prefixSum[right + 1] - prefixSum[left] = A[left] + .... + A[right] prefixSum[right+1]−prefixSum[left]=A[left]+....+A[right]
明白了上面的推导后。接下来我们进入下一个问题:如何获取 prefixSum
数组呢?
其实也不难,先来看推导。(不复杂,不要跳过哈)
① 由前缀和定义,我们可以得出:
p r e f i x S u m [ i − 1 ] = A [ 0 ] + A [ 1 ] + . . . + A [ i − 2 ] p r e f i x S u m [ i ] = A [ 0 ] + A [ 1 ] + . . . + A [ i − 2 ] + A [ i − 1 ] \begin{aligned} prefixSum[i - 1] &= A[0] + A[1] + ... + A[i - 2]\\ \\ prefixSum[i] &= A[0] + A[1] + ... + A[i - 2] + A[i - 1] \\ \end{aligned} prefixSum[i−1]prefixSum[i]=A[0]+A[1]+...+A[i−2]=A[0]+A[1]+...+A[i−2]+A[i−1]
② 于是,我们可以得出递推式:
p r e f i x S u m [ i ] = p r e f i x S u m [ i − 1 ] + A [ i − 1 ] , i > 0 \begin{aligned} prefixSum[i] &= prefixSum[i - 1] + A[i - 1], & \text{$i$ > 0} \end{aligned} prefixSum[i]=prefixSum[i−1]+A[i−1],i > 0
于是,求前缀和的代码出来了。
// A: 源数组
// length: A的长度
int* getPrefixSum(int* A,int length){
int* prefixSum = new int[length+1]; // 注意要 +1,因为prefixSum数组比A数组多一个元素。
prefixSum[0] = 0; // 依照定义,prefixSum[0] = 0。
for (int i=1;i<=length;i++){
prefixSum[i] = prefixSum[i-1] + A[i-1];
}
return prefixSum;
}
接下来,我们把代码拼接一下,得到求取区间总和的最终代码。
// A: 源数组
// length: 数组A的长度
// times: 需要查询的次数
// 这个函数用于求取区间总和
int calculate(int* A,int length,int times){
int left, right;
int* prefixSum = getPrefixSum(A, length); // 获取前缀和数组
while(times--){
scanf("%d%d",&left,&right); // 输入区间
// 计算总和 (与上面相比,求取总和的代码变了)
int sum = prefixSum[right+1] - prefixSum[left];
printf("%d\n",sum); // 输出结果
}
}
// A: 源数组
// length: A的长度
// 这个函数用于求取前缀和
int* getPrefixSum(int* A,int length){
int* prefixSum = new int[length+1]; // 注意要 +1,因为prefixSum数组比A数组多一个元素。
prefixSum[0] = 0;
for (int i=1;i<=length;i++){
prefixSum[i] = prefixSum[i-1] + A[i-1];
}
return prefixSum;
}
此时我们只用 1 条指令就能求取区间
[
l
e
f
t
,
r
i
g
h
t
]
[left,right]
[left,right] 的总和,所以即使
n
=
1
0
6
n = 10^6
n=106,每次查询的区间都是
[
0
,
1
0
6
−
1
]
[0, 10^6-1]
[0,106−1],为完成这个任务,计算机只需执行
1
0
6
10^6
106 条指令,总耗时才 0.1 秒!
拓展
-
前缀和 采用了 空间换时间 的思想。所谓 空间换时间 ,就是通过开辟一些新的存储空间,用以存储计算的结果,使得再次遇到相同的问题时,计算机只需要到对应的存储空间拿取结果,而不需要再次计算,大大节省了时间。
例子: 这就好像你背了
99
乘法表后,因为你已经记住了 9 × 9 9×9 9×9 的结果,所以就不需要再到草稿纸上计算了。 -
空间换时间 的思想很重要,很多算法都用到了这个思想,比如 散列、动态规划…
最后
以上如果有任何错误,欢迎大家指出哈。