Lesson 15: 前缀和与差分数组
目录
1. 前缀和(Prefix Sum)
定义
前缀和是一种常用的算法技巧,用来快速计算数组中任意区间的元素之和。前缀和数组的每个元素表示原数组从起点到该位置的元素和。通过使用前缀和,可以在常数时间内高效计算任意区间的和,而不需要每次都重新遍历数组。
给定一个长度为 n 的数组 A,前缀和数组 P 的定义如下:
P[i] = A[0] + A[1] + ... + A[i],对于所有i在区间[0, n-1]内。
构建前缀和数组
构建前缀和数组的时间复杂度是 O(n),因为我们只需要遍历一遍数组即可计算出所有前缀和。按照这个思路,代码如下:
思考:这段代码有没有什么问题
数组越界
对于:
| Python | |
|---|---|
解决方式:添加条件判断
思考:有没有更好的解决方式
构建前缀和数组时多开一格
| Python | |
|---|---|
总结:prefix_sum 多开一格的原因与好处
避免边界问题:
假设你不多开一格,并且前缀和数组 prefix[i] 表示 arr[0] 到 arr[i] 的和,那么对于区间 [i, j] 的和,你需要计算:
区间和 [i, j] = prefix[j] - prefix[i-1]
但此时,如果 i = 0,就会出现 prefix[-1] 访问越界的问题。为了避免每次都要额外判断 i = 0 的情况,可以通过多开一格,让 prefix[0] = 0,从而使得:
区间和 [0, j] = prefix[j + 1] - prefix[0]
这样,即使 i = 0 时,也不用特别判断,直接套用通用公式 prefix[j + 1] - prefix[i],从而避免越界问题。
代码实现
| Python | |
|---|---|
示例解释
假设我们有一个数组 arr = [1, 2, 3, 4],对应的前缀和数组 prefix 为:
prefix[1] = 1表示arr[0]的和为 1。prefix[2] = 3表示arr[0] + arr[1]的和为 3。prefix[3] = 6表示arr[0] + arr[1] + arr[2]的和为 6。prefix[4] = 10表示arr[0] + arr[1] + arr[2] + arr[3]的和为 10。
要计算区间 [i, j] 的和,比如 [1, 3],我们直接用公式:
| Text Only | |
|---|---|
这是因为 arr[1] + arr[2] + arr[3] = 2 + 3 + 4 = 9。
优点
- 高效查询:对于频繁的区间和查询,前缀和可以在
O(1)的时间内给出结果。 - 简洁:前缀和的概念简单易用,可以用于多种场景。
缺点
- 空间消耗:需要额外的数组存储前缀和,会占用额外的空间。
2. 差分数组(Difference Array)
定义
差分数组是一种快速处理数组区间更新的技巧。它能够在常数时间内对数组的区间进行加减操作,并在最终需要时构建出原始数组的结果。
给定一个数组 A,其差分数组 D 定义如下:
D[0] = A[0]D[i] = A[i] - A[i-1],对于所有i在区间[1, n-1]内。
也就是说,差分数组的每个元素表示原数组相邻元素之间的差。
构建差分数组
构建差分数组的时间复杂度是 O(n),因为我们只需要遍历一遍原数组即可计算差分数组。代码如下:
| Python | |
|---|---|
更新区间
差分数组的一个强大之处在于,可以在常数时间内对原数组的一个区间 [l, r] 进行加 x 操作。具体方法如下:
D[l] += x(表示从l开始加x)- 如果
r + 1 < n,则D[r + 1] -= x(表示从r + 1开始减去x,恢复后续不受影响)
恢复原数组
在完成所有区间操作后,可以通过前缀和的方式恢复原数组。恢复操作的时间复杂度为 O(n),代码如下:
| Python | |
|---|---|
示例
假设有一个数组 A = [2, 5, 7, 11, 15],构建它的差分数组 D:
D[0] = 2D[1] = 5 - 2 = 3D[2] = 7 - 5 = 2D[3] = 11 - 7 = 4D[4] = 15 - 11 = 4
因此差分数组为 D = [2, 3, 2, 4, 4]。
如果要对区间 [1, 3] 加 2,那么:
D[1] += 2,差分数组变为D = [2, 5, 2, 4, 4]D[4] -= 2,差分数组变为D = [2, 5, 2, 4, 2]
最终通过前缀和可以恢复出修改后的数组。
参考代码
优点
- 高效更新:可以在
O(1)的时间内对数组的区间进行加减操作,适用于需要频繁更新的场景。 - 简单恢复:通过一次遍历即可恢复原数组,时间复杂度为
O(n)。
缺点
- 只适用于加减操作:差分数组只能处理加减更新,无法用于乘法或其他复杂操作。
- 额外的空间消耗:需要存储一个与原数组大小相同的差分数组。
3. 例题讲解
3.1 前缀和
3.1.1 LC303 区域和检索 - 数组不可变
题目描述
给定一个整数数组 nums,处理以下类型的多个查询:
计算索引 left 和 right(包含 left 和 right)之间的 nums 元素的和,其中 left ≤ right
实现 NumArray 类:
NumArray(nums: List[int])使用整数数组 nums 初始化对象。def sumRange(self, left: int, right: int) -> int:返回数组nums中索引left和right之间的元素的总和,包含left和right两点(也就是nums[left] + nums[left + 1] + ... + nums[right])。
解题思路
我们可以通过 前缀和 的方式优化计算多个区间的和。通过构建一个前缀和数组,我们可以快速求出任意区间的和。
前缀和数组定义
对于给定的数组 nums,我们构建一个前缀和数组 prefixSum,其中 prefixSum[i] 表示数组 nums 从第 0 项到第 i 项的元素之和。
用公式表示为:
prefixSum[i] = nums[0] + nums[1] + ... + nums[i-1]
注意这里 prefixSum 的长度为 n + 1,其中 prefixSum[0] = 0。
如何计算区间和
要计算 nums[i] 到 nums[j] 之间的和,可以通过前缀和数组来实现:
sumRange(i, j) = prefixSum[j + 1] - prefixSum[i]
步骤:
- 构建前缀和数组
prefixSum。 - 对于每次查询,使用
prefixSum数组计算出区间和。
参考解答
示例
以数组 nums = [-2, 0, 3, -5, 2, -1] 为例,前缀和数组 prefixSum 如下:
prefixSum[0] = 0prefixSum[1] = -2prefixSum[2] = -2prefixSum[3] = 1prefixSum[4] = -4prefixSum[5] = -2prefixSum[6] = -3
查询示例:
sumRange(0, 2)返回1,即prefixSum[3] - prefixSum[0] = 1 - 0 = 1sumRange(2, 5)返回-1,即prefixSum[6] - prefixSum[2] = -3 - (-2) = -1sumRange(0, 5)返回-3,即prefixSum[6] - prefixSum[0] = -3 - 0 = -3
prefixSum 需要多开一格的原因
- 在前缀和的定义中,
prefixSum[i]表示从数组nums[0]到nums[i-1]的和。这样,prefixSum[0]就表示没有元素时的和,即0。 - 这样,在计算区间和时,可以统一使用
prefixSum[right + 1] - prefixSum[left],无需单独处理left = 0的情况。
3.2 差分数组例题讲解
3.2.1 LC2848 与车相交的点
问题描述
给定一个下标从 0 开始的二维整数列表 nums,其中 nums[i] = [start_i, end_i] 表示第 i 辆车在数轴上占据的区间 [start_i, end_i]。需要返回数轴上被至少一辆车覆盖的整数点的数目。
解题思路
我们可以使用差分数组来高效解决这个问题。差分数组是解决区间更新和查询问题的常用技巧,它可以帮助我们快速计算区间上的变化和覆盖情况。
具体步骤:
- 寻找数轴的最大端点: 找到所有区间中的最大终点
C,这样我们只需考虑1到C之间的点。 - 构建差分数组:
- 初始化一个长度为
C + 2的差分数组diff。 - 对于每个区间
[start_i, end_i],在diff[start_i]位置增加1,在diff[end_i + 1]位置减去1。
- 初始化一个长度为
- 构建前缀和:
- 遍历差分数组
diff,逐步累加,得到前缀和数组。前缀和数组中,每个位置的值表示该点被覆盖的次数。
- 遍历差分数组
- 统计被覆盖的点数:
- 遍历前缀和数组,统计所有被覆盖的点,即前缀和大于
0的点。
- 遍历前缀和数组,统计所有被覆盖的点,即前缀和大于
特别注意:为什么需要 C + 2 的长度
当处理到某个区间 [start, end] 时,我们在 end + 1 位置进行减 1 操作,这就需要确保 end + 1 在差分数组的范围内,防止越界。因此,我们在初始化差分数组时,需要为 C + 1 和 C + 2 留出空间。
案例分析
假设我们有如下区间输入:
| Text Only | |
|---|---|
-
寻找最大终点
C:- 通过遍历
nums,我们可以发现最大终点C为6。
- 通过遍历
-
构建差分数组:
- 初始化一个差分数组
diff,长度为C + 2 = 8,即diff[0]到diff[7]。
- 初始化一个差分数组
-
差分数组填充过程:
- 对于第一个区间
[1, 3]:diff[1] += 1(从1开始覆盖)diff[4] -= 1(从4位置不再覆盖)
- 对于第二个区间
[2, 5]:diff[2] += 1(从2开始覆盖)diff[6] -= 1(从6位置不再覆盖)
- 对于第三个区间
[4, 6]:diff[4] += 1(从4开始覆盖)diff[7] -= 1(从7位置不再覆盖)
- 对于第一个区间
可以发现,对于本题而言,需要 C + 2 大小的数组才不会发生越界的情况。
参考解答
解释
- 寻找最大终点
C: - 遍历输入列表
nums,找到所有区间中的最大终点。 - 构建差分数组:
- 对于每个区间
[start_i, end_i],在diff[start_i]位置加1,表示从start_i开始有车覆盖;在diff[end_i + 1]位置减1,表示从end_i + 1以后不再有覆盖。 - 计算前缀和:
- 遍历差分数组
diff,逐点累加前缀和。每个点的前缀和表示该点被覆盖的车的数量。 - 统计被覆盖的点数:
- 统计前缀和大于
0的点,即为被车覆盖的整数点。
4. 举一反三
4.1 前缀和举一反三
4.1.1 LC724 寻找数组的中心下标
题目描述
给定一个整数数组 nums,请计算并返回数组的中心下标。数组的中心下标是满足以下条件的一个下标 i:
nums[0] + nums[1] + ... + nums[i-1] = nums[i+1] + nums[i+2] + ... + nums[n-1],即i左边元素的和等于右边元素的和。- 如果中心下标位于数组的最左端,那么左侧的和视为 0,因为下标左边没有元素;同样地,如果中心下标位于数组的最右端,则右侧的和视为 0。
- 如果数组有多个中心下标,返回最靠近左边的那个。如果数组不存在中心下标,则返回
-1。
解题思路
我们可以通过前缀和的思想来解决这个问题。核心思想是,遍历数组的每个下标 i,检查它是否满足条件:左侧的前缀和等于右侧的前缀和。
假设数组的总和为 total,那么对于任意下标 i,左侧的前缀和为 leftSum,右侧的前缀和为 total - leftSum - nums[i]。如果满足 leftSum == total - leftSum - nums[i],那么 i 就是中心下标。
参考解答
| Python | |
|---|---|
代码解释
-
计算数组的总和:首先,计算所有元素的总和
total。 -
遍历数组,寻找中心下标
-
初始化左侧和
leftSum = 0。 - 遍历数组的每一个元素
nums[i],检查是否满足leftSum == total - leftSum - nums[i]。 -
如果满足,则返回下标
i。 -
返回结果:如果没有找到符合条件的下标,返回
-1。
4.1.2 LC1588 所有奇数长度子数组的和
问题描述
给定一个正整数数组 arr,要求计算数组中所有可能的奇数长度子数组的和。一个子数组是指数组中一段连续的子序列。我们需要返回所有奇数长度子数组的和。
解题思路
要高效地求解这个问题,可以使用前缀和来快速计算子数组的和。
详细思路:
- 定义前缀和数组:构建前缀和数组
prefixSum,可以快速计算任意区间[i, j]的和。 - 遍历所有子数组:枚举数组中的每一个起点
i,并从该起点开始,枚举所有可能的奇数长度的子数组。 - 累加和:对于每一个奇数长度的子数组,计算其和并累加到最终结果中。
参考解答
代码解释
- 构建前缀和数组:
prefixSum[i + 1] = prefixSum[i] + arr[i],表示前i+1个元素的累加和。- 遍历奇数长度子数组:
start表示子数组的起始位置。length表示子数组的长度,只取奇数(1, 3, 5...)。- 使用前缀和数组快速计算每个奇数长度子数组的和。
- 返回结果:
- 最后返回
totalSum,即所有奇数长度子数组的和。
4.1.3 LC1732 找到最高海拔
问题描述
有一个自行车手打算进行一场公路骑行,这条路线总共由 n + 1 个不同海拔的点组成。自行车手从海拔为 0 的点 0 开始骑行。
给你一个长度为 n 的整数数组 gain,其中 gain[i] 是点 i 和点 i + 1 的净海拔高度差(0 <= i < n)。请你返回最高点的海拔。
思路讲解
为了找到最高海拔,我们可以使用前缀和的思想:
- 初始化:从海拔
0开始骑行,设定currentAltitude为0,maxAltitude为0。 - 遍历数组:依次遍历
gain数组中的每个海拔高度差: - 更新当前海拔
currentAltitude += gain[i]。 - 更新最高海拔
maxAltitude = max(maxAltitude, currentAltitude)。 - 返回结果:遍历完成后,
maxAltitude就是最高海拔。
参考解答
| Python | |
|---|---|
4.2 差分数组举一反三
4.2.1 LC1094 拼车
题目描述
车上最初有 capacity 个空座位。车只能向一个方向行驶(也就是说,不允许掉头或改变方向)
给定整数 capacity 和一个数组 trips,trip[i] = [numPassengers_i, from_i, to_i] 表示第 i 次旅行有 numPassengers_i 乘客,接他们和放他们的位置分别是 from_i 和 to_i。这些位置是从汽车的初始位置向东的公里数。
当且仅当你可以在所有给定的行程中接送所有乘客时,返回 true,否则请返回 false。
题目解析
我们需要判断在任何时刻,车上的乘客数量是否会超过 capacity。
思路讲解
使用差分数组来解决这个问题,思路如下:
- 创建差分数组:差分数组的大小取决于最远的行程终点,表示沿途的每个位置乘客的变化情况。
- 处理每个行程:对于每个行程,在差分数组的
from位置增加乘客,在to位置减少乘客。 - 前缀和计算当前乘客数:通过计算差分数组的前缀和,得到每个位置实际的乘客数,并判断是否超出载客量。
代码实现
代码解释
- 找出最远的目的地:确定差分数组的大小。
- 构建差分数组:记录每个位置乘客数的变化。
- 前缀和计算:计算车上实际的乘客数,一旦超过
capacity,返回False。
4.2.2 LC1854 人口最多的年份
问题描述
给你一个二维整数数组 logs,其中每个 logs[i] = [birth_i, death_i] 表示第 i 个人的出生和死亡年份。
年份 x 的人口定义为这一年期间活着的人的数目。第 i 个人被计入年份 x 的人口需要满足:x 在闭区间 [birth_i, death_i - 1] 内。注意,人不应当计入他们死亡当年的人口中。
返回人口最多且最早的年份。
题目解析
我们需要找到一个年份,该年份的人口数最多,且如果有多个年份人口数相同,返回最早的那个年份。
每个人的生存年份是一个区间 [birthi, deathi - 1],即从出生年算起到死亡年份的前一年为止。我们要统计每个年份的生存人口数。
思路
使用差分数组来记录每一年的人口变化:
- 初始化数组:创建一个长度为
101的数组(年份范围为 1950 到 2050)。 - 记录人口变化:
- 出生年份人口加
1。 - 死亡年份人口减
1(因为死亡年不计入人口数)。 - 计算每年的实际人口:通过前缀和计算每年的实际人口数量,找到人口最多的年份。
代码实现
代码解释
代码解释
-
年份映射:
-
由于年份范围是从 1950 年到 2050 年,因此我们使用长度为 101 的数组
populationChange来记录每一年的人口变化。populationChange[0]对应的是 1950 年,populationChange[100]对应的是 2050 年。 -
处理每个人的出生和死亡年份:
-
对于每个人的出生年份,增加 1 表示增加了一个人口:
| Text Only | |
|---|---|
- 对于死亡年份,减少 1,表示死亡当年不计入人口:
-
前缀和计算:
-
遍历
populationChange数组,累加每年的变化值,得到该年的人口数量。 -
在遍历过程中,记录最大的人口值及对应年份:
-
| Text Only | |
|---|---|
4.2.3 LC1450 在既定时间做作业的学生人数
题目描述
给你两个整数数组 startTime(开始时间)和 endTime(结束时间),并指定一个整数 queryTime 作为查询时间。
已知,第 i 名学生在 startTime[i] 时开始写作业并于 endTime[i] 时完成作业。
请返回在查询时间 queryTime 时正在做作业的学生人数。形式上,返回能够使 queryTime 处于区间 [startTime[i], endTime[i]](含)的学生人数。
请使用差分数组的方法完成此题。
思路讲解
-
定义范围:
- 创建一个足够大的差分数组,数组的大小至少为
maxTime + 2,防止越界。 - 当我们处理某个学生的作业时间
[startTime[i], endTime[i]]时,我们在startTime[i]位置上增加 1,表示从这个时刻开始有一个学生在做作业。 - 因为题目要求包含
endTime,即endTime时学生仍在做作业,因此需要在endTime[i] + 1位置上减少 1,表示从endTime[i] + 1这个时刻开始,这个学生不再做作业。 - 如果
endTime是最大时间,比如maxTime,那么endTime[i] + 1将会是maxTime + 1。 - 为了确保我们在访问
diff数组的endTime[i] + 1位置时不会越界,因此我们需要将差分数组的长度设为maxTime + 2。
- 创建一个足够大的差分数组,数组的大小至少为
-
构建差分数组:
- 对于每个学生的作业时间
[startTime[i], endTime[i]],在差分数组的startTime[i]位置增加1,在endTime[i] + 1位置减少1。这样可以标记在作业开始和结束时的变化。
- 对于每个学生的作业时间
-
计算前缀和:
- 遍历差分数组并计算前缀和,以得到在每个时间点的正在做作业的学生人数。
-
查询结果:
- 最后,根据
queryTime的值直接返回差分数组中对应的结果。
- 最后,根据
代码实现
5. 课后练习
前缀和
| 题目编号 | 题目名称 | 简介 |
|---|---|---|
| LC238 | 除自身以外数组的乘积 | 给定一个整数数组 nums,返回一个数组,其中每个元素是 nums 中其他所有元素的乘积。 |
| LC560 | 和为 K 的子数组 | 给定一个整数数组 nums 和一个整数 k,计算 nums 中和为 k 的连续子数组的个数。 |
| LC1991 | 找到数组的中心位置 | 给定一个整数数组,找到中心位置,使得该位置左边的元素和等于右边的元素和,返回该位置的索引。 |
| LC3028 | 边界上的蚂蚁 | 给定一个数组,模拟蚂蚁在边界上移动,返回最后蚂蚁的位置。 |
| LC974 | 和可被 K 整除的子数组 | 给定一个整数数组 nums 和一个整数 k,返回 nums 中和可被 k 整除的子数组的个数。 |
差分数组
| 题目编号 | 题目名称 | 简介 |
|---|---|---|
| LC995 | K 连续位的最小翻转次数 | 给定一个二进制数组,求出将 K 个连续的位翻转为 1 所需的最小翻转次数。 |
| LC1109 | 航班预定统计 | 给定一个二维数组 bookings,每个航班的乘客预定信息,计算每个航班的最终乘客数量。 |
| LC1893 | 检查是否区域内所有整数都被覆盖 | 给定一个二维数组 ranges,检查是否在指定的区间内所有整数都被覆盖。 |
| LC2381 | 字母移位II | 给定一个字符串和一个移位值,返回移位后的新字符串。 |
| LC2251 | 花期内花的数目 | 给定一个植物的花期和一个查询,返回在查询范围内开花的植物数目。 |