Files
cpp-courseware/2.Course/2.深入浅出程序设计竞赛(提高篇)/3.前缀和与差分/C++ 算法讲义:前缀和.md
T

129 lines
5.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# C++ 算法讲义:前缀和
### 第一部分:问题引
**1. 场景模拟:高频区间查询**
假设这样一个数据处理场景:班级里有 100,000 名学生站成一排,已知每个人的考试分数。
此时,班主任频繁提出查询需求:“第 3 名到第 25 名学生的总分是多少?”
最直观的解法是编写一个 `for` 循环,将区间内的分数逐一累加。
但这仅仅是单次查询。如果系统在极短时间内(例如 1 秒内)发起了 10 万次不同区间的查询请求(如“第 10 到 80 名”、“第 2 到 99,999 名”),会发生什么?
**2. 暴力解法的缺陷分析**
如果每次询问都通过 `for` 循环遍历相加,单次查询的时间复杂度为 $O(N)$。如果有 $M$ 次查询,总时间复杂度将高达 $O(N \cdot M)$。
在 $N$ 和 $M$ 均为 100,000 的量级下,程序需要执行约 100 亿次运算,这在任何算法竞赛都会导致超时(TLE)。
除时间复杂度高之外,暴力解法的核心缺陷在于**存在大量的重复计算**。
例如,程序刚刚计算完“第 2 名到第 6 名”的总和,紧接着又要计算“第 2 名到第 5 名”的总和。在暴力解法中,程序会无视之前的计算成果,将第 2 到第 5 名的数据重新遍历累加一遍。
**思考:**
是否存在一种算法结构,能够将计算过的中间结果缓存下来,消除重复遍历?无论查询的区间跨度有多大,都能在常数时间 **$O(1)$** 内直接返回答案?
---
### 第二部分:类比思考
想象你正驾驶汽车行驶在一条笔直的高速公路上。这条公路被划分为了多个**路段**。
1. **真实路段(原数组 $a$)**:每一段路都有其独立的长度。例如第 1 段路长 10 公里,第 2 段路长 20 公里。
2. **累计里程碑(前缀和数组 $S$)**:在每一段路结束的终点处,都设立了一块里程碑,上面记录着“从公路起点行驶至此的总里程”。
- 驶出第 1 段路时,里程碑标记为 10。
- 驶出第 2 段路时,里程碑标记为 30(即 10 + 20)。
**第 $i$ 个里程碑的位置,对应第 $i$ 段路的终点。**
---
### 第三部分:推导公式
* 现在,我们需要计算**“第 3 段路 + 第 4 段路 + 第 5 段路”**的总长度(即求原数组区间 `[3, 5]` 的和)。
依据直觉,许多初学者会尝试直接使用两端的里程碑相减:**用第 5 个碑的读数减去第 3 个碑的读数。**
**直觉陷阱剖析:**
如果你以“第 3 个碑”作为起点进行计算,请注意你当前所处的位置:你在**第 3 段路的终点**!
如果你从这里开始计算距离,你所包含的第一段路将是**第 4 段路**。这样一来,第 3 段路的长度被完全遗漏了。
为了确保计算结果**完整包含**第 3 段路,我们的计算基准点必须设置在第 3 段路的**起点**。
而在累计里程碑系统中,第 3 段路的起点,在物理位置上等同于**第 2 段路的终点**,即**第 2 个里程碑**所在的位置。
因此,计算第 3 到第 5 段路总距离的正确等式为:
**第 5 个里程碑的读数 $-$ 第 2 个里程碑的读数。**
**抽象为通用算法公式:**
若需求取原数组从第 $L$ 个元素到第 $R$ 个元素的总和,设前缀和数组为 $S$:
- 区间的终点标记为:$S[R]$。
- 区间的起点标记(必须是第 $L$ 段开始前的位置)为:$S[L-1]$。
- 区间查询公式:
$$Sum(L, R) = S[R] - S[L-1]$$
---
### 第四部分:前缀和数组 与 如何高效求取?
而我们刚才反复提到的那个用来记录里程碑的数组 𝑆[],其实就叫做:
**—— 前缀和数组 (Prefix Sum Array)**
简单的来说,前缀和数组就是一个用来存储累加和的辅助数组。
那么如何在程序中高效地生成这个 $S$ 数组(即建立所有的里程碑)呢?
在构建第 $i$ 个里程碑时,我们无需从第 1 段路重新累加。只需查阅紧邻的前一个里程碑的数值,再加上当前这一段路的长度即可。
**公式:**
当前前缀和 $=$ 前一个前缀和 $+$ 当前元素的值
$$S[i] = S[i-1] + a[i]$$
---
### 第五部分:C++ 代码
```cpp
long long a[100005]; // 原数组
long long S[100005]; // 前缀和数组(里程碑)
// 第一步:处理前缀和数组
for (int i = 1; i <= n; i++) {
cin >> a[i];
// 递推:当前总和 = 前一个总和 + 当前数字
S[i] = S[i-1] + a[i];
}
// 第二步:查询第 L 个元素到第 R 个元素的区间和
for (int i = 0; i < q; i++) {
int L, R;
cin >> L >> R;
// 终点 - 起点的前一个
cout << S[R] - S[L-1] << endl;
}
```
1. **数组下标从 1 开始!**
* 如果要求区间 `[1, 5]` 的和,套公式是 $S[5] - S[0]$。我们刚好就默认 $S[0] = 0$,非常完美。
* 如果你非要从下标 0 开始存数据(求 `[0, 4]`),公式就会变成 $S[4] - S[-1]$。数组下标变成负数,程序直接越界崩溃!**所以,写前缀和,数组从 1 开始!否则你就要特判下标为 -1 的情况了。**
2. **前缀和数组务必开 `long long`!**
* 原数组 $a$ 的数字可能不大,但十万个数加在一起很有可能超过 `int` 的上限(约 21 亿)。为了不爆数据,前缀和数组请开 `long long`。