【解雨臣的算法课】第三课:前缀和与差分——用空间换时间,提前算好别现算

【解雨臣的算法课】第三课:前缀和与差分——用空间换时间,提前算好别现算

“在墓里,最怕的就是重复走回头路。每进一个墓室都要重新探路?那得浪费多少时间。”
——解雨臣


一、上节课回顾

上一课我们讲了二分答案——猜答案,再验证。

黑瞎子用这招猜机关高度,半小时就找到了机关,而胖子还在摸砖。

胖子:“你们怎么找到的?”
黑瞎子:“二分。”
胖子:“二什么分?”
黑瞎子:“就是……你先猜一个高度,然后验证……”
胖子:“那猜错了呢?”
黑瞎子:“猜错了就再猜。”
胖子:“这不就是瞎猜吗?我也会!”
我:“……”

今天我们讲一个预处理的利器——前缀和与差分


二、故事引入:解雨臣算机关次数

有一次下斗,我遇到一个机关。

这个机关有 n 个格子排成一排,每个格子有一个数字。我每次操作可以选一段连续的格子,把它们全部 +1

胖子说:“这还不简单?每次找最左边还没到目标值的格子,然后一直往后加,直到遇到已经到目标值的格子为止。模拟一遍就知道了!”

我说:“模拟一次要 O(n),如果目标值很大,那不得 O(n \times \text{目标值})?”

胖子:“那就慢慢算呗。”

我:“你算吧。n = 10^5,目标值 = 10^9,你算到明年。”

关键问题:我需要一种方法,能快速知道一个区间内所有数加了多少次

这就是差分的用武之地。


三、什么是前缀和?

3.1 暴力 vs 前缀和

问题:给定数组 a_1, a_2, ..., a_n,有 q 次询问,每次问 [l, r] 的和。

暴力:每次循环累加,O(n) 一次,总 O(nq)

前缀和:提前算好前缀和数组 s,其中:
s_i = a_1 + a_2 + ... + a_i

那么:
\text{sum}(l, r) = s_r - s_{l-1}

每次询问 O(1),总 O(n + q)

3.2 前缀和的构建

int a[N];   // 原数组
int s[N];   // 前缀和数组

for (int i = 1; i <= n; i++) {
    s[i] = s[i-1] + a[i];
}

int sum = s[r] - s[l-1];

注意:通常我们让数组下标从 1 开始,这样 s_0 = 0,避免边界判断。


四、二维前缀和——墓室地图

题目背景

墓室是个 n \times m 的网格,每个格子有宝藏价值。我要多次查询某个矩形区域的总价值。

二维前缀和公式

s_{i,j} 表示从 (1,1)(i,j) 的矩形和。

递推公式
s_{i,j} = s_{i-1,j} + s_{i,j-1} - s_{i-1,j-1} + a_{i,j}

查询矩形 (x_1, y_1)(x_2, y_2)
\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}

代码实现

int n, m, q;
int a[N][N];
int s[N][N];

int main() {
    cin >> n >> m >> q;
    
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            cin >> a[i][j];
            s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j];
        }
    }
    
    while (q--) {
        int x1, y1, x2, y2;
        cin >> x1 >> y1 >> x2 >> y2;
        int ans = s[x2][y2] - s[x1-1][y2] - s[x2][y1-1] + s[x1-1][y1-1];
        cout << ans << endl;
    }
    
    return 0;
}

五、什么是差分?

5.1 从问题引入

问题:我有 n 个格子,初始都是 0。要进行 m 次操作,每次把 [l, r] 内的所有格子 +1。最后问每个格子被加了多少次?

暴力:每次操作循环 [l, r]1O(nm)

差分:用 O(1) 记录操作,最后 O(n) 还原。

5.2 差分的定义

差分数组 d 满足:
d_i = a_i - a_{i-1}

其中 a_0 = 0

性质

  • 对原数组 a[l, r] 区间 +k,等价于:
    d_l += k,\quad d_{r+1} -= k
  • 还原原数组:a_i = a_{i-1} + d_i,即对 d 求前缀和

5.3 差分模板

int a[N];   // 原数组
int d[N];   // 差分数组

void add(int l, int r, int k) {
    d[l] += k;
    d[r+1] -= k;
}

void build() {
    for (int i = 1; i <= n; i++) {
        a[i] = a[i-1] + d[i];
    }
}

六、二维差分——墓室里的机关阵

代码实现

int d[N][N];

void add(int x1, int y1, int x2, int y2, int k) {
    d[x1][y1] += k;
    d[x1][y2+1] -= k;
    d[x2+1][y1] -= k;
    d[x2+1][y2+1] += k;
}

void build() {
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            a[i][j] = a[i-1][j] + a[i][j-1] - a[i-1][j-1] + d[i][j];
        }
    }
}

七、经典例题:解雨臣的教室

题目背景

解家的学堂有 n 间教室,每间教室有 r_i 个座位。

m 份订单,每份订单要用 d_i 个座位,从第 s_i 天到第 t_i 天。

每天所有订单同时生效。如果某天某个教室座位不够,这个订单就要取消。

问:第一个需要取消的订单是第几个?

代码实现

#include <bits/stdc++.h>
using namespace std;

const int N = 1e6 + 10;
int n, m;
int a[N];        // 教室座位
int d[N], l[N], r[N];  // 订单:数量,开始,结束
int c[N];        // 差分数组

bool check(int k) {
    memset(c, 0, sizeof(c));
    
    for (int i = 1; i <= k; i++) {
        c[l[i]] += d[i];
        c[r[i] + 1] -= d[i];
    }
    
    int now = 0;
    for (int i = 1; i <= n; i++) {
        now += c[i];
        if (now > a[i]) {
            return false;
        }
    }
    return true;
}

int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }
    for (int i = 1; i <= m; i++) {
        cin >> d[i] >> l[i] >> r[i];
    }
    
    int L = 1, R = m, ans = 0;
    while (L <= R) {
        int mid = (L + R) / 2;
        if (check(mid)) {
            L = mid + 1;
        } else {
            ans = mid;
            R = mid - 1;
        }
    }
    
    if (ans == 0) {
        cout << 0 << endl;
    } else {
        cout << -1 << endl << ans << endl;
    }
    
    return 0;
}

八、解雨臣踩过的坑

坑1:下标从0还是从1?

解家箴言:一律从 1 开始,s_0 = 0,省心。

坑2:二维前缀和公式记混

s_{i,j} = s_{i-1,j} + s_{i,j-1} - s_{i-1,j-1} + a_{i,j}
解家箴言:画图!画图!画图!

坑3:差分数组开多大?

区间加 [l, r] 时会修改 d_{r+1},数组要开到 n+2
解家箴言:多开 510 个,保平安。


九、课后习题(解家的考验)

基础题

  1. 子段和:给定数组 a,求有多少个子区间的和等于 k。(提示:前缀和 + 哈希表)

  2. 二维子矩阵和:给定 n \times m 的矩阵,q 次询问,每次问一个子矩阵的和。

进阶题

  1. 解雨臣的灯光:有 n 盏灯排成一排,初始全灭。有 m 次操作,每次把 [l, r] 内的灯翻转。问最后哪些灯是亮的。

  2. 解雨臣的货物:有 n 个仓库,每个仓库有 a_i 吨货物。每天可以从某个连续区间内运走 1 吨货物(每个仓库每天最多运 1 吨)。问最少需要多少天才能运完?

挑战题

  1. 解雨臣的激光阵:墓室是 n \times m 的网格,有 k 个激光发射器,每个发射器覆盖一个矩形区域。求被激光覆盖次数最多的点的次数。($n, m \le 2000$,$k \le 10^5$)

提示:二维差分标记每个矩形,最后求二维前缀和还原。


十、下集预告

“前缀和就像下斗前的准备工作——先把地图画好,把每个墓室的位置标清楚。真正下斗的时候,你就不用再画一遍了。”
——解雨臣

下一课,二分图与匈牙利算法——如何给胖子和黑瞎子分配任务,让他们都满意。

下课。


(本系列课程由解雨臣独家赞助,信友队论坛首发。转载需注明出处。)

1 个赞

花儿爷,本人新手,以后多多指教!

此话题已在最后回复的 15 天后被自动关闭。不再允许新回复。