序列与区间处理 (Sequence & Range Processing)
这种方法用于将一个“朴素”的 O(n2) 解法优化到 O(n)。
在我们的算法题中, 可能会遇到两类问题: - 区间查询 (Range Query):频繁地询问“数组 i 到 j 之间的和/积/异或值是多少?” - 区间更新 (Range Update):频繁地操作“把数组 i 到 j 之间的所有数都加上 k。”
如果你对每一次“查询”或“更新”都老老实实地跑一个 for 循环,那么每次操作都是 O(n), q 次操作就是 O(nq),这通常会超时。
下面是两种常见且互逆的优化技巧:
前缀和
前缀和 (Prefix Sum)一般用于快速区间查询。 - 原理:花费 O(n) 时间预处理一个prefixSum数组。 - 效果:之后每一次区间查询都降为 O(1) 时间。
例如, 给定一个数组 nums,假如题目要求你频繁地计算子数组 nums[i…j](闭区间)的和或者相关信息, 此时不急着直接入手, 我们可以构建一个前缀和数组 prefixSum 来预处理,其中 prefixSum[i] 存储 nums[0…i-1](从第0个到第 i − 1 个元素)的总和。
那么,nums[i…j](闭区间)的和 sum(i, j) 是多少? - sum(i, j) = (前 j 个元素的和) − (前 i − 1 个元素的和) - sum(i, j) = prefixSum[j + 1] − prefixSum[i]
这样,我们用 O(n) 预处理,换来了 O(1) 的查询。
再比如, 问题变为“和为 K 的子数组有多少个?” 这个问题等价于:找到多少对 (i, j) 使得 sum(i, j) = K。
用前缀和公式代入:prefixSum[j + 1] − prefixSum[i] = K。 变换一下:prefixSum[i] = prefixSum[j + 1] − K。
这成为了一个“两数之和”问题!这就是“枚举右,维护左”的完美应用: - 枚举右 (j):我们遍历数组,计算出当前的 prefixSum[j+1](记为 current_sum)。 - 提问:我们需要在 j 的左边找到一个 i,使得 prefixSum[i] = current_sum - K。 - 维护左:我们使用一个哈希表 memo 来存储所有历史上的 prefixSum 值及其出现的次数。
例题: 给定一个整数数组 nums 和一个整数 K,请你统计并返回该数组中和为 K 的子数组的个数。(LC 560 和为 K 的子数组个数)
1 | int subarraySum(std::vector<int>& nums, int k) { |
如果更一般的, 我们会两次遍历 (Two-pass),提前预处理 - 第一次遍历
(预处理):创建一个 std::vectorstd::unordered_map<int, int> memo。 - 遍历这个
prefixSum 数组(从 prefixSum[0] 到 prefixSum[n])。 - 在 memo 中查找
prefixSum[j] - k,累加答案。 - 将 prefixSum[j] 存入 memo。 例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29int subarraySum(std::vector<int>& nums, int k) {
int n = nums.size();
// 步骤 1: 预处理前缀和数组
std::vector<int> prefixSum(n + 1, 0);
for (int i = 1; i <= n; ++i) {
prefixSum[i] = prefixSum[i - 1] + nums[i - 1];
}
// 步骤 2: 初始化哈希表
std::unordered_map<int, int> memo;
memo[0] = 1; // 处理从索引 0 开始的子数组
int total_count = 0;
// 步骤 3: 遍历前缀和数组
for (int j = 1; j <= n; ++j) {
int needed = prefixSum[j] - k;
// 查找左侧存在吗?
if (memo.count(needed)) {
total_count += memo[needed];
}
// 维护哈希表
memo[prefixSum[j]]++;
}
return total_count;
}
常见题型包括: - 基础(一维)与二维前缀和 - 前缀和 + 哈希表:解决特定和(如和为 k)的子数组问题的经典组合。 - 距离和(绝对值问题) - 状态压缩前缀和(处理位运算或小状态集)
树上的前缀和
在树结构中,前缀和的概念可以通过路径和来实现。路径和是指从树的根节点到当前节点的所有节点值的累加和。例如LC 437. 路径总和 III.
题目: 给定一个二叉树的根节点 root 和一个整数目标和 targetSum ,求该树中 路径和等于目标和 的路径数量。路径 不需要从根节点开始,也不需要在叶子节点结束,但路径方向必须是向下的(只能从父节点到子节点)。
1 | // 1. "前缀和账本" (哈希表) |
前缀积
前缀积 (Prefix Product) 与前缀和类似,但用于计算数组元素的乘积。它在处理区间乘积查询时非常有用。 例如LC 238. 除自身以外数组的乘积: 给你一个整数数组 nums,返回一个数组 answer,其中 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积。要求你在 O(n) 时间复杂度内完成此操作,且不使用除法。
1 | std::vector<int> productExceptSelf(std::vector<int>& nums) { |
这道题利用了前缀积和后缀积的思想, 通过两次遍历数组, 分别计算出每个位置左边和右边的乘积保存下来, 从而实现了在一次遍历的过程中还可以O(1)时间复杂度查询内得到每个位置的结果.
在遍历的过程中实现O(1)时间复杂度查询是个很常见的技巧, 关键在于预先计算并存储所需的信息(如前缀积和后缀积, 以及哈希表等),使得每次查询时不需要重复计算。
差分数组
差分数组 (Difference Array) 一般用于高效区间更新。 - 原理:通过维护一个差分数组 diff,使得每次区间更新操作都降为 O(1) 时间。 - 效果:之后可以通过一次前缀和计算,得到最终的数组状态。
给你一个数组 nums,我们定义它的差分数组 diff 如下: - diff[0] =
nums[0] - diff[i] = nums[i] - nums[i-1] (对于 i > 0) - 惊人的特性:从 diff
数组还原 nums 数组,只需要对 diff 求前缀和即可。 -
关键操作:如果我们要对 nums 的区间 [i, j](闭区间)上的所有元素都加上 val: - nums[i] 增加了 val,而 nums[i-1] 不变,所以 diff[i](即 nums[i] - nums[i-1])增加了 val。 - nums[i+1] 到 nums[j] 这一段,由于 nums[k] 和 nums[k-1] 都增加了 val,它们的差 diff[k] 不变 - nums[j] 增加了 val,而 nums[j+1] 不变,所以 diff[j+1](即 nums[j+1] - nums[j])减少了 val。
结论:一次 O(n)
的区间更新 [i, j] 被转化为了两次 O(1) 的单点更新! -
diff[i] += val; if (j + 1 < n) { diff[j + 1] -= val; }
例题: LC 1109. 航班预订统计. 有 n 个航班,编号从 1 到 n。有一个预订表 bookings,其中 bookings[i] = [first, last, seats] 表示从 first 到 last 的航班都预订了 seats 个座位。请返回一个长度为 n 的数组 answer,其中 answer[i] 是第 i 个航班的总预订座位数。
1 | std::vector<int> corpFlightBookings(std::vector<std::vector<int>>& bookings, int n) { |
常见题型包括: - 一维差分:用于高效处理区间更新、单点查询的问题,常与扫描线思想结合。 - 二维差分:用于处理子矩阵的批量更新。