```mermaid graph LR A[Mermaid Keeper] --> B[保持激活] ```

前言

想象你是一个老师,手里有 1000 个学生的成绩。校长问你:“第 100 名到第 200 名的总分是多少?“你扫了一遍,算出来了。然后校长又问:“第 50 名到第 300 名呢?“你又扫了一遍。然后又问”第 1 名到第 500 名呢”……

你烦了:“能不能一次算好,以后直接查?”

这就是前缀和的起源。它的想法朴素而强大:先把所有”从开头到第 ii 个”的和算好存在一个数组里,之后任何区间查询只需要查两个数、做一次减法。花 O(n)O(n) 预处理,换来每次查询 O(1)O(1)

差分是前缀和的”逆操作”。前缀和是”从元素数组算出累加和”,差分就是”从累加和还原出元素数组”。这个逆关系让”区间修改”(给一段区间的每个数都加 xx)变得极快——两次单点操作就搞定了。

问题的本质

前缀和:用减法代替累加

假如有一列数:a=[3,1,4,1,5,9,2,6]a = [3, 1, 4, 1, 5, 9, 2, 6]。定义前缀和 Si=a1+a2++aiS_i = a_1 + a_2 + \cdots + a_i

ii012345678
aia_i31415926
SiS_i0348914232531

问”第 3 到第 6 个数的和”——也就是 a3+a4+a5+a6=4+1+5+9=19a_3 + a_4 + a_5 + a_6 = 4 + 1 + 5 + 9 = 19

用前缀和:S6S2=234=19S_6 - S_2 = 23 - 4 = 19。一次减法搞定。

为什么是对的?S6S_6a1+a2+a3+a4+a5+a6=23a_1 + a_2 + a_3 + a_4 + a_5 + a_6 = 23S2S_2a1+a2=4a_1 + a_2 = 4。一减就把 a1,a2a_1, a_2 消掉了,只剩下 a3a_3a6a_6

差分:用加法代替区间修改

反过来想:如果我想把 a3a_3a6a_6 全部加 10,朴素做法是循环 4 次给 a3,a4,a5,a6a_3, a_4, a_5, a_6 各加 10。但如果用差分数组 dd

d[3] += 10     ← 从位置 3 开始"加 10"生效
d[7] -= 10     ← 从位置 7 开始"加 10"取消

然后对 dd 做一次前缀和,就能还原出修改后的 aa。不管区间多长,修改操作永远只需两步。

理论 + 代码

一维前缀和

long long a[100010], S[100010];

// 预处理
S[0] = 0;                              // ① 边界:S[0] = 0
for (int i = 1; i <= N; i++)
    S[i] = S[i - 1] + a[i];            // ② 递推

// 查询区间 [l, r] 的和
long long sum = S[r] - S[l - 1];       // ③ 一次减法
  • ① 为什么需要 S[0]=0S[0] = 0?因为查询 [1,r][1, r] 时要用到 S[l1]=S[0]S[l-1] = S[0]。如果不设 S[0]=0S[0] = 0S[1]S[0]S[1] - S[0] 里的 S[0]S[0] 就没有定义。
  • ② 每步只做一次加法,O(N)O(N)
  • ③ 无论区间多长,查询都是一次减法——O(1)O(1)

差分数组

long long d[100010] = {};   // 全部初始化为 0

// 对区间 [l, r] 的所有元素加 x
d[l] += x;                  // ① 从 l 开始,值增加了 x
d[r + 1] -= x;              // ② 从 r+1 开始,增加的效果取消

// 还原
for (int i = 1; i <= N; i++)
    d[i] += d[i - 1];       // ③ 对差分数组做前缀和 = 修改后的原数组

走一遍具体过程:原数组 a=[0,0,0,0,0,0]a = [0, 0, 0, 0, 0, 0](6 个 0),对 [2,4][2, 4] 加 10。

步骤 1:d[2] += 10, d[5] -= 10d=[0,0,10,0,0,10,0]d = [0, 0, 10, 0, 0, -10, 0]

步骤 2:对 dd 做前缀和:

iidid_i(差分)did_i(前缀和后)= 修改后的 aia_i
000
100
21010
3010
4010
5-100
600

还原后 a=[0,0,10,10,10,0]a = [0, 0, 10, 10, 10, 0]。位置 2、3、4 各加了 10,其他位置不变。✓

为什么管用?d[l] += x 让从 ll 开始的所有位置在做前缀和后都多了 xxd[r+1] -= x 让从 r+1r+1 开始多的 xx 被抵消。所以效果恰好是 [l,r][l, r] 加了 xx

二维前缀和

二维的情况用容斥原理(后面 02-06 容斥原理 会详细讲):

S[i][j]=S[i1][j]+S[i][j1]S[i1][j1]+a[i][j]S[i][j] = S[i-1][j] + S[i][j-1] - S[i-1][j-1] + a[i][j]

为什么有减法?因为 S[i1][j]S[i-1][j](上方矩形)和 S[i][j1]S[i][j-1](左方矩形)都包含了 S[i1][j1]S[i-1][j-1](左上角)的部分,被加了两次。减掉一次才是正确的。

查询矩形 (x1,y1)(x_1, y_1)(x2,y2)(x_2, y_2)

sum=S[x2][y2]S[x11][y2]S[x2][y11]+S[x11][y11]\text{sum} = S[x_2][y_2] - S[x_1-1][y_2] - S[x_2][y_1-1] + S[x_1-1][y_1-1]

同样的容斥:减去上面和左边的多余部分,加回被多减的左上角。

前缀思想的推广

前缀和的核心思想是”预处理递推信息,O(1)O(1) 查询”。这个思想不局限于”求和”——任何满足结合律的操作都可以用前缀预处理:

操作前缀递推公式查询公式
求和S[i]=S[i1]+a[i]S[i] = S[i-1] + a[i]sum(l,r)=S[r]S[l1]\text{sum}(l,r) = S[r] - S[l-1]
最大值M[i]=max(M[i1],a[i])M[i] = \max(M[i-1], a[i])max(l,r)\max(l,r) 需要其他方法(ST 表,03-07
左侧最大值L[i]=max(L[i1],a[i])L[i] = \max(L[i-1], a[i])直接查 L[r]L[r](前 rr 个的最大值)
右侧最大值R[i]=max(R[i+1],a[i])R[i] = \max(R[i+1], a[i])直接查 R[l]R[l](从 ll 到末尾的最大值)

“左侧最大值”和”右侧最大值”在很多题目中很有用。比如:给你一个数组,QQ 次查询”去掉区间 [l,r][l,r] 后,剩余部分的最大值是多少?“答案就是 max(L[l1],R[r+1])\max(L[l-1], R[r+1])——左边部分的最大值和右边部分的最大值取较大者。预处理 O(n)O(n),查询 O(1)O(1)

例题

例题 1:M&A 038 — How Many Guests?(★2)

题目:游乐园「ALGO-RESORT」举办 NN 天的活动,第 ii 天有 AiA_i 人到场。有 QQ 个询问,每个询问给 L,RL, R,求第 LL 天到第 RR 天的总到场人数。

数据范围1N,Q1051 \le N, Q \le 10^51Ai100001 \le A_i \le 10000

—— AtCoder M&A 038

思路:裸的一维前缀和。如果每次询问都循环累加,O(NQ)=1010O(NQ) = 10^{10},TLE。用前缀和预处理 O(N)O(N),每次查询 O(1)O(1)

代码

#include <cstdio>
using namespace std;

int main() {
    int N, Q;
    scanf("%d%d", &N, &Q);
    long long A[100010], S[100010];
    S[0] = 0;
    for (int i = 1; i <= N; i++) {
        scanf("%lld", &A[i]);
        S[i] = S[i - 1] + A[i];    // ① 前缀和递推
    }

    for (int q = 0; q < Q; q++) {
        int L, R;
        scanf("%d%d", &L, &R);
        printf("%lld\n", S[R] - S[L - 1]);  // ② 一次减法
    }
}

逐行解析

  • S[i] = S[i-1] + A[i]:前缀和递推。Si=A1+A2++AiS_i = A_1 + A_2 + \cdots + A_i
  • S[R] - S[L-1]:区间 [L,R][L, R] 的和。无论区间多长,查询始终 O(1)O(1)

例题 2:Tessoku Book — A08 Two Dimensional Sum

题目:给定一个 H×WH \times W 的矩阵,第 ii 行第 jj 列的值为 Xi,jX_{i,j}。有 QQ 个询问,每个询问给 (A,B,C,D)(A, B, C, D),求以 (A,B)(A, B) 为左上角、(C,D)(C, D) 为右下角的子矩阵的元素之和。

数据范围1H,W15001 \le H, W \le 15001Q1000001 \le Q \le 1000000Xi,j90 \le X_{i,j} \le 9

—— AtCoder Tessoku Book A08

思路:二维前缀和裸题。每次查询如果扫描子矩阵,O(HWQ)3×1011O(HWQ) \approx 3 \times 10^{11},绝对 TLE。用二维前缀和,预处理 O(HW)O(HW),每次查询 O(1)O(1)

代码

#include <cstdio>
using namespace std;

int X[1510][1510];
long long S[1510][1510];

int main() {
    int H, W, Q;
    scanf("%d%d%d", &H, &W, &Q);
    for (int i = 1; i <= H; i++)
        for (int j = 1; j <= W; j++)
            scanf("%d", &X[i][j]);

    // ① 二维前缀和
    for (int i = 1; i <= H; i++)
        for (int j = 1; j <= W; j++)
            S[i][j] = S[i-1][j] + S[i][j-1] - S[i-1][j-1] + X[i][j];

    for (int q = 0; q < Q; q++) {
        int A, B, C, D;
        scanf("%d%d%d%d", &A, &B, &C, &D);
        // ② 容斥查询
        printf("%lld\n", S[C][D] - S[A-1][D] - S[C][B-1] + S[A-1][B-1]);
    }
}

逐行解析

  • ① 二维前缀和递推:S[i][j]=S[i1][j]+S[i][j1]S[i1][j1]+X[i][j]S[i][j] = S[i-1][j] + S[i][j-1] - S[i-1][j-1] + X[i][j]。减去 S[i1][j1]S[i-1][j-1] 是因为被 S[i1][j]S[i-1][j]S[i][j1]S[i][j-1] 各加了一次。
  • ② 查询子矩阵 (A,B)(A,B)(C,D)(C,D)S[C][D]S[A1][D]S[C][B1]+S[A1][B1]S[C][D] - S[A-1][D] - S[C][B-1] + S[A-1][B-1]。同样容斥:减去上面和左边多出的部分,加回被多减的左上角。

例题 3:Tessoku Book — A09 Winter in ALGO Kingdom

题目:ALGO 王国是一个 H×WH \times W 的网格。初始没有积雪。接下来 NN 天,第 tt 天在以 (At,Bt)(A_t, B_t) 为左上角、(Ct,Dt)(C_t, D_t) 为右下角的矩形区域内积雪增加 1cm。输出最终每个格子的积雪量。

数据范围1H,W15001 \le H, W \le 15001N1000001 \le N \le 100000

—— AtCoder Tessoku Book A09

思路:经典的二维差分。每次对一个矩形区域加 1,朴素做法是 O(HW)O(HW) 每次操作,NN 次共 O(NHW)2.25×1011O(NHW) \approx 2.25 \times 10^{11},TLE。用二维差分,每次操作 O(1)O(1)(修改差分数组的 4 个位置),最后做一次二维前缀和还原。

二维差分的四个修改点:

d[A][B]     += 1    // 左上角开始生效
d[A][D+1]   -= 1    // 右上角外取消
d[C+1][B]   -= 1    // 左下角外取消
d[C+1][D+1] += 1    // 右下角外补偿

代码

#include <cstdio>
using namespace std;

int d[1510][1510];

int main() {
    int H, W, N;
    scanf("%d%d%d", &H, &W, &N);

    for (int t = 0; t < N; t++) {
        int A, B, C, D;
        scanf("%d%d%d%d", &A, &B, &C, &D);
        d[A][B]++;           // ① 左上 +1
        d[A][D + 1]--;       // ② 右上外 -1
        d[C + 1][B]--;       // ③ 左下外 -1
        d[C + 1][D + 1]++;   // ④ 右下外 +1(补偿多减)
    }

    // ⑤ 两次前缀和还原
    // 先按行做前缀和
    for (int i = 1; i <= H; i++)
        for (int j = 1; j <= W; j++)
            d[i][j] += d[i][j - 1];
    // 再按列做前缀和
    for (int j = 1; j <= W; j++)
        for (int i = 1; i <= H; i++)
            d[i][j] += d[i - 1][j];

    // ⑥ 输出
    for (int i = 1; i <= H; i++) {
        for (int j = 1; j <= W; j++) {
            if (j > 1) printf(" ");
            printf("%d", d[i][j]);
        }
        printf("\n");
    }
}

逐行解析

  • ①②③④ 是二维差分的标准四点修改。把”矩形区域加 1”变成 4 次单点操作。
  • ⑤ 还原时做两次一维前缀和:先按行扫,再按列扫。等价于二维前缀和。

走一遍过程H=W=5,N=2H=W=5, N=2,操作 (1,1,3,3)(1,1,3,3)(2,2,4,4)(2,2,4,4)

操作1后差分数组:

d[1][1]=1  d[1][4]=-1
d[4][1]=-1  d[4][4]=1

操作2后差分数组:

d[2][2]+=1  d[2][5]-=1
d[5][2]-=1  d[5][5]+=1

还原后(最终积雪):

1 1 1 0 0
1 2 2 1 0
1 2 2 1 0
0 1 1 1 0
0 0 0 0 0

和题目样例一致。✓


例题 4:T90 010 — Score Sum Queries(★2)

题目:ABC 大学有 NN 名一年级学生,学号 ii 的学生在 CiC_i 班(Ci{1,2}C_i \in \{1, 2\}),期末成绩 PiP_i。有 QQ 个询问,每个给 L,RL, R,求学号 LLRR 中 1 班学生的总分和 2 班学生的总分。

数据范围1N,Q1051 \le N, Q \le 10^50Pi1000 \le P_i \le 100

—— AtCoder Typical 90 010

思路:对每个班级分别维护前缀和。S1[i]S_1[i] = 前 ii 个学生中 1 班的总分,S2[i]S_2[i] = 前 ii 个学生中 2 班的总分。查询时分别做减法。

代码

#include <cstdio>
using namespace std;

int main() {
    int N;
    scanf("%d", &N);
    int C[100010], P[100010];
    long long S1[100010] = {}, S2[100010] = {};  // ① 两个前缀和数组

    for (int i = 1; i <= N; i++) {
        scanf("%d%d", &C[i], &P[i]);
        S1[i] = S1[i - 1] + (C[i] == 1 ? P[i] : 0);  // ② 只累加 1 班
        S2[i] = S2[i - 1] + (C[i] == 2 ? P[i] : 0);  // ③ 只累加 2 班
    }

    int Q;
    scanf("%d", &Q);
    while (Q--) {
        int L, R;
        scanf("%d%d", &L, &R);
        printf("%lld %lld\n",
            S1[R] - S1[L - 1],   // ④ 1 班区间和
            S2[R] - S2[L - 1]);   // ⑤ 2 班区间和
    }
}

逐行解析

  • ②③ C[i] == 1 ? P[i] : 0:三目运算符。如果学生 ii 在 1 班就加上 PiP_i,否则加 0。这样 S1S_1 只累计 1 班的分数。
  • ④⑤ 对每个前缀和数组分别做区间查询。

这道题的核心技巧是按类别分别维护前缀和。如果类别有 KK 种,就需要 KK 个前缀和数组。


例题 5:Tessoku Book — A10 Resort Hotel

题目:某度假酒店有 NN 个房间(1 到 NN 号),ii 号房是 AiA_i 人房。接下来 DD 天每天有工程,第 dd 天不能使用 LdL_dRdR_d 号房间。对每一天,求能用的房间中最大能住多少人。

数据范围3N1000003 \le N \le 1000001D1000001 \le D \le 1000001Ai1001 \le A_i \le 1002LdRdN12 \le L_d \le R_d \le N - 1

—— AtCoder Tessoku Book A10

思路:去掉区间 [L,R][L, R] 后,剩下的部分是 [1,L1][1, L-1][R+1,N][R+1, N]。要求这两段中的最大值——这正是 00-03 理论部分 讲的”前缀最大值”的应用。

预处理两个数组:

  • L[i] = max(A1,A2,,Ai)\max(A_1, A_2, \ldots, A_i)——从左到右的前缀最大值
  • R[i] = max(Ai,Ai+1,,AN)\max(A_i, A_{i+1}, \ldots, A_N)——从右到左的后缀最大值

查询”去掉 [L,R][L, R] 后的最大值”就是 max(L[L1],R[R+1])\max(L[L-1], R[R+1])

代码

#include <cstdio>
#include <algorithm>
using namespace std;

int A[100010], L[100010], R[100010];

int main() {
    int N;
    scanf("%d", &N);
    for (int i = 1; i <= N; i++)
        scanf("%d", &A[i]);

    // ① 前缀最大值
    L[0] = 0;
    for (int i = 1; i <= N; i++)
        L[i] = max(L[i - 1], A[i]);

    // ② 后缀最大值
    R[N + 1] = 0;
    for (int i = N; i >= 1; i--)
        R[i] = max(R[i + 1], A[i]);

    // ③ 回答每个查询
    int D;
    scanf("%d", &D);
    while (D--) {
        int l, r;
        scanf("%d%d", &l, &r);
        printf("%d\n", max(L[l - 1], R[r + 1]));  // ④ 左段和右段取最大
    }
}

逐行解析

  • L[i] = max(L[i-1], A[i]):前缀最大值递推。L[i]L[i] 是前 ii 个房间的最大容量。
  • R[i] = max(R[i+1], A[i]):后缀最大值。从右往左扫,R[i]R[i] 是从 iiNN 的最大容量。
  • ④ 去掉 [l,r][l, r] 后,左边最大是 L[l1]L[l-1],右边最大是 R[r+1]R[r+1],取较大者就是答案。

走一遍过程N=7N = 7A=[1,2,5,5,2,3,1]A = [1, 2, 5, 5, 2, 3, 1]

ii012345678
AiA_i1255231
L[i]L[i]01255555
R[i]R[i]55553310

查询 L=3,R=5L=3, R=5max(L[2],R[6])=max(2,3)=3\max(L[2], R[6]) = \max(2, 3) = 3。✓ 查询 L=4,R=6L=4, R=6max(L[3],R[7])=max(5,1)=5\max(L[3], R[7]) = \max(5, 1) = 5。✓

这道题完美展示了前缀思想的推广——不只是”求和”,“求最大值”也可以用同样的预处理思路。

参考文献

教材讲解 — 競技プログラミングの鉄則 第 2 章

基础练习 — アルゴリズムと数学 演習問題集

系统练习 — 競技プログラミングの鉄則

实战练习 — 競プロ典型 90 問


系列索引

第零章 基础工具

第一章 搜索技术

第二章 数学基础

第三章 数据结构

第四章 图论

第五章 动态规划

第六章 贪心

第七章 字符串

第八章 进阶