【算法】【学习笔记】前缀和
前缀和
前缀和是一种极其经典且高效的预处理算法思想。
他的核心逻辑是:通过一次简单的预遍历,将原本复杂的“区间查询”或“状态匹配”开销,从 \(O(N)\) 降到 \(O(1)\)。
在处理处理数列问题时,数列往往是存储在数组里,可以先思考到前缀和数组。
⚙️定义
对于一个给定的数组 \(nums\),其前缀和数组 \(P\) 即为:从数组开头累加到当前位置的元素之和。
在实际编程中,每次计算和时,要把上一个数字加上去,那么在下标为 0 时,就要进行判断P是否已经有上一个数才可以做加法。这样子太麻烦了,所以为了优雅地处理边界条件(避免判断 L == 0),我们通常使用哨兵补零范式:
定义长度为 \(n + 1\) 的前缀和数组 \(P\),其中 \(P[0] = 0\):
区间和公式:对于任意区间 \(nums[L \dots R]\)(包含 \(L\) 和 \(R\)),其元素之和为:
💡 为什么需要 \(P[0] = 0\) 呢?
- 在最初初始化前缀和时,第一个下标 \(0\),按照公式\(P[1] = P[0] + nums[0]\),不给\(P[0]\)赋值会无法计算。
- 当我们需要求从开头开始的区间 \(nums[0 \dots R]\) 之和时,公式写作 \(P[R+1] - P[0]\),因为 \(P[0] = 0\),可以直接得到正确结果,不再需要索引越界或多余的分支判断。
🔎特性
-
可减性(差分逆运算)
前缀和的核心基础是减法。
可以通过两个前缀状态相减得到局部状态,即\[\text{Sum}(L, R) = P[R + 1] - P[L] \]- 适合:求和、计数、奇偶性等具备可减性的操作。
- 不适合:最大值、最小值、最大公约数等不具备可减性的操作。
-
空间与时间的权衡
-
标准前缀和:预处理 \(O(N)\) 空间与时间,换取后续无数次查询的 \(O(1)\) 时间。
-
滑动/滚动前缀和:如果只需要一次遍历解决问题,可以通过维护变量将空间优化至 \(O(1)\)。
-
🤓实现
// 数组版
class PrefixSum {
private P: number[];
constructor(nums: number[]) {
const n = nums.length;
this.P = new Array(n + 1).fill(0);
for (let i = 0; i < n; i++) {
this.P[i + 1] = this.P[i] + nums[i];
}
}
// 查询闭区间 [L, R] 的和
public query(L: number, R: number): number {
return this.P[R + 1] - this.P[L];
}
}
// 滑动版
function prefix(){
let leftSum = 0;
const totalSum = nums.reduce((arr,cur)=>arr+cur, 0); // 或边加边取模
for(let i = 0; i < nums.length; i++) {
let rightSum = totalSum - leftSum - nums[i]; // 当前位置右侧的和
// ... 执行业务逻辑 ...
leftSum += nums[i]; // 推进滑动窗口
}
}
🎨思维导图
❔题目
1. 找到最高海拔
解题思路
由题,默认海拔为0,那么最大海拔一定 \(\geq 0\) 。
由于数组存放的是海拔差,只要定下初始点 \(0\) 后,持续对海拔高相加,就可以获得当前海拔。
则一次遍历内,一边查询海拔,一边检查是否最高即可。
实现
function largestAltitude(gain: number[]): number {
// 由题知道,初始海拔为0,然后一直往后加就可以了
// 一边加一遍记录最大值就好
let res = 0;
let cur = 0;
for(let i of gain){
cur += i;
res = Math.max(res, cur);
}
return res;
};
public class Solution {
public int LargestAltitude(int[] gain) {
int res = 0, cur = 0;
foreach(int i in gain){
cur+= i;
res = Math.Max(res, cur);
}
return res;
}
}
class Solution {
public:
int largestAltitude(vector<int>& gain) {
int res = 0, cur = 0;
for(auto i : gain){
cur += i;
res = max(res, cur);
}
return res;
}
};
复杂度分析
- 时间复杂度: \(O(N)\)
- 空间复杂度: \(O(1)\)
2. 使数组和能被 P 整除
解题思路
假设原数组 nums 的总和为 \(S\)。
我们需要找到一个最短的子数组,将其切掉后,剩下的部分和能被 \(p\) 整除。
计算出整个数组的总和余数 \(k\) :
求总和余数 \(k\) 时,在编程可以一边相加一边求余,这样子可以避免数值过大。
则有:
- 如果 \(k == 0\),说明原本的和就已经能被 \(p\) 整除,直接返回 0。
- 如果 \(k > 0\),意味着整个数组多出了 \(k\) 这个余数。
在\(k > 0\)的情况下,必须把这多余的 \(k\) 带走,那么我们要切掉的那个子数组,它的元素和取模 \(p\) 也必须刚好等于 \(k\),即
那么问题此时转移为:
找最短的子数组,使其数组和与 \(p\) 的余数为 \(k\)。
为了需要快速寻找到该子数组,使用前缀和+哈希表组合实现,在单次循环中一边求前缀和一边维护哈希表。
假设前缀和取模数组为 \(P\),其中 \(P[j]\) 表示前 \(j\) 个元素的和取模 \(p\) 的结果。设有一 \(x\) ,满足子数组 \(nums[x \dots j]\) 的和取模 \(p\) 为 \(k\),即
则此时子数组 \(nums[x \dots j]\) 的长度,即为当前令数组可被p整除的子数组长度,目标——寻找该 \(x\) 是否已经存在。
我们把这个式子稍微移项变形:
\(\equiv\) 为同余符号,表示两个数在某个基数(模数)下,除出来的余数相同。
为了防止 \(P[j] - k\) 变成负数,在代码里加上 \(p\) 即可:
给任意一个数加上(或减去)\(p\) 的整数倍,它对 \(p\) 取模的结果(余数)不会发生改变。
👩💻 算法流程:
先从左到右遍历数组,初始化前缀和余数 \(P[j]\);
每次计算出我们需要寻找的前半段前缀余数 \(\text{target} = (P[j] - k + p) \pmod p\)。
如果哈希表中记录过 \(\text{target}\) 上一次出现的下标 \(i-1\),说明子数组 \(nums[i \dots j]\) 的和取模 \(p\) 恰好等于 \(k\)!子数组的长度就是 \(j - (i-1)\)。
为了让子数组最短,我们应该选离当前位置最近的那个下标。
用哈希表实时更新每个“前缀和余数”出现的最新下标。
实现
function minSubarray(nums: number[], p: number): number {
const n = nums.length;
const total = nums.reduce((acc, cur) => (acc + cur) % p, 0);
const k = total % p;
if(k === 0) return 0;
let prefixMod = 0;
let minLen = n;
// key: predixMod, val: index
const dict = new Map<number, number>();
dict.set(0, -1);
for(let i = 0; i < n; i++){
prefixMod = (prefixMod + nums[i]) % p;
let target = (prefixMod - k + p) % p;
if(dict.has(target)){
minLen = Math.min(minLen, i - dict.get(target));
}
dict.set(prefixMod, i);
}
return minLen === n ? -1 : minLen;
};
public class Solution {
public int MinSubarray(int[] nums, int p) {
int n = nums.Length;
int k = nums.Aggregate(0, (acc, cur)=> (acc + cur) % p);
if(k == 0) return 0;
int minLen = n, prefixMod = 0;
var dict = new Dictionary<int, int>(n+1);
dict.Add(0, -1);
for(int i = 0; i < n; i++){
prefixMod = (prefixMod + nums[i]) % p;
int target = (prefixMod - k + p) % p;
if(dict.TryGetValue(target, out int idx)){
minLen = Math.Min(minLen, i - idx);
}
dict[prefixMod] = i;
}
return minLen == n ? -1 : minLen;
}
}
class Solution {
public:
int minSubarray(vector<int>& nums, int p) {
int n = nums.size();
// 目标:移除长度范围为[0,n-1]的数组,来获得和被p整除的数组
// 此数组要找最小值,也就是最优是0,最差是n-1 。
// 用前缀和可以获得所有子数组的和,用 j-i 的方法就好
int k = 0;
for(auto i : nums){
k = (k + i) % p;
}
if(k == 0) return 0;
// 哈希表:key:前缀和余数,val:该余数的最新下标
unordered_map<int, int> lastPos;
lastPos.reserve(n+1);
// 初始状态:前缀和为0下标即-1
lastPos[0] = -1;
int curPrefixMod = 0;
int minLen = n;
for(int i = 0; i < n; i++){
curPrefixMod = (curPrefixMod + nums[i]) % p;
// 找需要的前缀和余数
int targetMod = (curPrefixMod - k + p) % p;
// 如果target有了,更新长度
if(lastPos.count(targetMod)>0){
minLen = min(minLen, i - lastPos[targetMod]);
}
// 更新余数下标位置(持续更新,才可以获得最短的数组)
lastPos[curPrefixMod] = i;
}
return minLen == n ? -1 : minLen;
}
};
复杂度分析
- 时间复杂度: \(O(N)\)
- 空间复杂度: \(O(N)\)
3. 生成平衡数组的方案数
解题思路
将题目拆解一下目标和条件
- 目标:奇数下标的和 == 偶数下标的和。
- 条件:必须选择删一个数字,然后看看是否满足目标。
设删除的目标下标为 \(i \in [0, n-1]\),那么 \(<i\) 的下标奇偶不变,\(>i\) 的下标奇偶相反。也就是只有两种可能,一个是奇偶原版的和,一个是奇偶相反的和,然后对应相加查看和是否相等就好。
说明只需要两个前缀和就好了,后面的就反过来使用就行了。
但是呢,使用两个数组的话需要 \(O(N)\) 的空间。
优化后,先分别计算好偶数和奇数总和 \(oddSum, evenSum\),使用双指针持续计算 \(i\) 之前的前缀和 \(leftOddSum, leftEvenSum\) ,则可知,在 \(i\) 处时,
💡 一句话口诀:删掉 \(i\) 后,新偶数和 = 左原偶 + 右原奇,新奇数和 = 左原奇 + 右原偶。
根据此公式,判断 \(curOddSum = curEvenSum\) 是否成立即可。
这种方法下,只需要 \(O(1)\) 的空间。
实现
// 前缀和数组
function waysToMakeFair(nums: number[]): number {
// 目标:奇数下标的和 == 偶数下标的和
// 必须选择删一个数字,然后看看是否满足目标
// 设删除的目标下标为 i, 范围为[0, n-1]
// 那么<i的下标奇偶不变,>i的下标奇偶相反
// 也就是只有两种可能,
// 一个是奇偶原版的和,一个是奇偶相反的和
// 然后对应相加查看和是否相等就好
// 两个前缀和就好了,后面的就反过来使用就行了
const n = nums.length;
if(n <= 1) return 1;
const p1 = Array<number>(n+1).fill(0);
const p2 = Array<number>(n+1).fill(0);
// 初始化前缀和
for(let i = 0; i < n; i++){
if(i % 2 == 0){
p2[i+1] = p2[i] + nums[i];
p1[i+1] = p1[i];
}else{
p1[i+1] = p1[i] + nums[i];
p2[i+1] = p2[i];
}
}
// 开始查询
let res = 0;
for(let i = 0; i < n; i++){
if((p1[i+1] + (p2[n] - p2[i])) === (p2[i+1] + (p1[n] - p1[i]))){
res++;
}
}
return res;
};
// 滑动前缀和
function waysToMakeFair(nums: number[]): number {
const n = nums.length;
if(n === 1) return 1;
let evenSum: number = 0, oddSum: number = 0;
let leftEvenSum: number = 0, leftOddSum: number = 0;
// 先计算总和
for (let i = 0; i < n; ++i) {
if (i % 2 === 0) {
evenSum += nums[i];
} else {
oddSum += nums[i];
}
}
let res = 0;
// 使用指针移动窗口
for (let i = 0; i < n; ++i) {
const cur = nums[i];
if (i % 2 === 0) {
if (evenSum - leftEvenSum + leftOddSum - cur === oddSum - leftOddSum + leftEvenSum) {
res++;
}
leftEvenSum += cur;
}else{
if (evenSum - leftEvenSum + leftOddSum === leftEvenSum + oddSum - leftOddSum - cur) {
res++;
}
leftOddSum += cur;
}
}
return res;
};
public class Solution {
public int WaysToMakeFair(int[] nums) {
int n = nums.Length;
if(n == 1) return 1;
int oddSum = 0, evenSum = 0;
for(int i = 0; i < n; i++){
if(i % 2 == 0){
evenSum += nums[i];
}else{
oddSum += nums[i];
}
}
int leftOddSum = 0, leftEvenSum = 0;
int res = 0;
for(int i = 0; i < n; i++){
int cur = nums[i];
if(i % 2 == 0){
if(leftEvenSum + oddSum - leftOddSum == leftOddSum + evenSum - leftEvenSum - cur){
res++;
}
leftEvenSum += cur;
}else{
if(leftOddSum + evenSum - leftEvenSum == leftEvenSum + oddSum - leftOddSum - cur){
res++;
}
leftOddSum += cur;
}
}
return res;
}
}
class Solution {
public:
int waysToMakeFair(vector<int>& nums) {
int n = nums.size();
if(n == 1) return 1;
int oddSum = 0, evenSum = 0;
for(int i = 0; i < n; i++){
if(i % 2 == 0){
evenSum += nums[i];
}else{
oddSum += nums[i];
}
}
int leftOddSum = 0, leftEvenSum = 0;
int res = 0;
for(int i = 0; i < n; i++){
int cur = nums[i];
if(i % 2 == 0){
if(leftOddSum + evenSum - leftEvenSum - cur == leftEvenSum + oddSum - leftOddSum){
res++;
}
leftEvenSum += cur;
}else{
if(leftEvenSum + oddSum - leftOddSum - cur == leftOddSum + evenSum - leftEvenSum){
res++;
}
leftOddSum += cur;
}
}
return res;
}
};
复杂度分析
- 时间复杂度: \(O(N)\)
- 空间复杂度:
- 双数组法:\(O(N)\)
- 双指针法:\(O(1)\)
🤯拓展
引用
[1] 王桂平,张兵,郑兰.GESP编程能力等级认证一本通 (C++ 四级) [M].微信读书版本.
注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。

浙公网安备 33010602011771号