【算法】【学习笔记】前缀和

前缀和

前缀和是一种极其经典且高效的预处理算法思想。

他的核心逻辑是:通过一次简单的预遍历,将原本复杂的“区间查询”或“状态匹配”开销,从 \(O(N)\) 降到 \(O(1)\)

在处理处理数列问题时,数列往往是存储在数组里,可以先思考到前缀和数组。

⚙️定义

对于一个给定的数组 \(nums\),其前缀和数组 \(P\) 即为:从数组开头累加到当前位置的元素之和。

在实际编程中,每次计算和时,要把上一个数字加上去,那么在下标为 0 时,就要进行判断P是否已经有上一个数才可以做加法。这样子太麻烦了,所以为了优雅地处理边界条件(避免判断 L == 0),我们通常使用哨兵补零范式:

定义长度为 \(n + 1\) 的前缀和数组 \(P\),其中 \(P[0] = 0\)

\[P[i] = \sum_{k=0}^{i-1} nums[k] \]

区间和公式:对于任意区间 \(nums[L \dots R]\)(包含 \(L\)\(R\)),其元素之和为:

\[\text{Sum}(L, R) = P[R + 1] - P[L] \]

💡 为什么需要 \(P[0] = 0\) 呢?

  1. 在最初初始化前缀和时,第一个下标 \(0\),按照公式\(P[1] = P[0] + nums[0]\),不给\(P[0]\)赋值会无法计算。
  2. 当我们需要求从开头开始的区间 \(nums[0 \dots R]\) 之和时,公式写作 \(P[R+1] - P[0]\),因为 \(P[0] = 0\),可以直接得到正确结果,不再需要索引越界或多余的分支判断。

🔎特性

  1. 可减性(差分逆运算)

    前缀和的核心基础是减法。
    可以通过两个前缀状态相减得到局部状态,即

    \[\text{Sum}(L, R) = P[R + 1] - P[L] \]

    • 适合:求和、计数、奇偶性等具备可减性的操作。
    • 不适合:最大值、最小值、最大公约数等不具备可减性的操作。
  2. 空间与时间的权衡

    • 标准前缀和:预处理 \(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 整除

【点击跳转】使数组和能被 P 整除——力扣

解题思路

假设原数组 nums 的总和为 \(S\)
我们需要找到一个最短的子数组,将其切掉后,剩下的部分和能被 \(p\) 整除。

计算出整个数组的总和余数 \(k\)

\[k = S \pmod p \tag{1} \]

求总和余数 \(k\) 时,在编程可以一边相加一边求余,这样子可以避免数值过大。

则有:

  • 如果 \(k == 0\),说明原本的和就已经能被 \(p\) 整除,直接返回 0。
  • 如果 \(k > 0\),意味着整个数组多出了 \(k\) 这个余数。

\(k > 0\)的情况下,必须把这多余的 \(k\) 带走,那么我们要切掉的那个子数组,它的元素和取模 \(p\) 也必须刚好等于 \(k\),即

\[\text{切掉的子数组和} \pmod p = k \tag{2} \]

那么问题此时转移为:

找最短的子数组,使其数组和与 \(p\) 的余数为 \(k\)

为了需要快速寻找到该子数组,使用前缀和+哈希表组合实现,在单次循环中一边求前缀和一边维护哈希表。

假设前缀和取模数组为 \(P\),其中 \(P[j]\) 表示前 \(j\) 个元素的和取模 \(p\) 的结果。设有一 \(x\) ,满足子数组 \(nums[x \dots j]\) 的和取模 \(p\)\(k\),即

\[(P[j] - P[x-1]) \pmod p = k \tag{3} \]

则此时子数组 \(nums[x \dots j]\) 的长度,即为当前令数组可被p整除的子数组长度,目标——寻找该 \(x\) 是否已经存在。

我们把这个式子稍微移项变形:

\[P[x-1] \equiv (P[j] - k) \pmod p \tag{4} \]

\(\equiv\) 为同余符号,表示两个数在某个基数(模数)下,除出来的余数相同。

为了防止 \(P[j] - k\) 变成负数,在代码里加上 \(p\) 即可:

\[\text{target} = P[x-1] = (P[j] - k + p) \pmod p \tag{5} \]

给任意一个数加上(或减去)\(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\) 处时,

\[\text{偶数时} \begin{cases} curOddSum = (evenSum - leftEvenSum) + leftOddSum - nums[i] \\ curEvenSum = (oddSum - leftOddSum) + leftEvenSum \end{cases} \tag{6} \]

\[\text{奇数时} \begin{cases} curOddSum = (evenSum - leftEvenSum) + leftOddSum \\ curEvenSum = (oddSum - leftOddSum) + leftEvenSum - nums[i] \end{cases} \tag{7} \]

💡 一句话口诀:删掉 \(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 辅助整理。

posted @ 2026-07-24 22:21  SEHOD  阅读(26)  评论(0)    收藏  举报