火龙信奥
  • 分享
  • 课程
  • 题库
  • 打卡
    • 代码对战
    • 快速对战
  • 题单
  • 团队
  • 荣誉墙
  • 商城
  • 登录 / 注册

前缀和与差分

作者: 作者的头像   huolong , 时间:2026-08-21 11:48:03 , 所有人可见, 阅读  45

前缀和与差分

在算法竞赛或日常工程中,前缀和与差分是极具美感的互逆思想。它们的核心在于视角转换:将高复杂度的“范围/区间处理”巧妙地转化为常数级别的“端点/单点操作”,从而将很多原本需要 $O(N)$ 甚至 $O(N^2)$ 的区间查询或修改任务,直接降至 $O(1)$ 或 $O(\log N)$ 级别。


0. 前缀和思想:从生活实例说起

0.1 物理直觉:断尺子问题

假设你手里有一把尺子,但不巧 0 到 3 厘米的地方断裂不见了,现在的刻度是从 3 厘米开始的。 如果要测量一支铅笔的长度,你可以把铅笔一端对准 3 厘米,另一端正好对准 7 厘米。你会脱口而出:铅笔长度是 $7 - 3 = 4$ 厘米。

在这个简单的减法里,就已经包含了“前缀和思想”的缩影: * 3 厘米:尺子起点的“初始偏移量(基准值)”。 * 7 厘米:从起点一直到铅笔末端的“累计总刻度”(在算法里,这叫前缀和,即 Prefix Sum)。 * 铅笔长度:我们要测量的真实区间值。

$$\text{区间的真实长度} = \text{结束位置的前缀和} - \text{起点位置的前缀和}$$


0.2 动态需求:直播打赏统计

假设主播在直播过程中不断收到打赏: * 第 1 秒:$5$ 元 * 第 2 秒:$3$ 元 * 第 3 秒:$8$ 元 * 第 4 秒:$2$ 元 * 第 5 秒:$6$ 元

如果弹幕里有观众频繁提问: * “第 2 秒到第 5 秒,一共打赏了多少钱?” * “第 4 秒到第 5 秒,一共打赏了多少钱?”

若采用暴力统计,每次询问都要把对应区间内的数加一遍。当提问次数很多,或者查询的区间跨度极长(如第 1 秒到第 10000 秒)时,计算会非常缓慢。

若采用前缀和法,我们提前计算好每个时刻的“累计打赏额”: * 第 1 秒结束,累计:$5$ * 第 2 秒结束,累计:$5 + 3 = 8$ * 第 3 秒结束,累计:$8 + 8 = 16$ * 第 4 秒结束,累计:$16 + 2 = 18$ * 第 5 秒结束,累计:$18 + 6 = 24$

现在,面对任何区间的提问,只需要一次减法即可给出答案: * 查询 2 到 5 秒:用第 5 秒累计值减去第 1 秒累计值 $\to 24 - 5 = 19$ 元。 * 查询 4 到 5 秒:用第 5 秒累计值减去第 3 秒累计值 $\to 24 - 16 = 8$ 元。

无论区间多长,只要有前缀和数组,每次查询都是 $O(1)$,效率得到了极大的飞跃。


1. 一维前缀和

1.1 定义与核心思想

对于给定的数组 $a$(采用 1-based 索引),定义其前缀和数组 $s$ 如下: $s[i]$ 表示原数组 $a$ 中从第 1 个元素到第 $i$ 个元素的总和。 $$s[i] = \sum_{j=1}^{i} a[j]$$

为了消除边界讨论,我们约定 $s[0] = 0$。前缀和数组的递推构造非常简单: $$s[i] = s[i-1] + a[i]$$

💡 空间换时间:我们通过 $O(N)$ 的时间进行预处理,以此换取后续任意区间求和查询的 $O(1)$ 响应。


1.2 核心操作:区间查询

当我们需要查询原数组 $a$ 在闭区间 $[l, r]$ 内所有元素的和时: 因为: $$s[r] = a[1] + a[2] + \dots + a[l-1] + a[l] + \dots + a[r]$$ $$s[l-1] = a[1] + a[2] + \dots + a[l-1]$$

两式相减可得核心查询公式: $$\sum_{j=l}^{r} a[j] = s[r] - s[l-1]$$

为什么推荐使用 1-based 索引? 如果采用 0-based 索引,当查询区间包含第 0 个元素时(即 $l=0$),公式中的 $s[l-1]$ 会导致下标越界($s[-1]$),需要专门写 if 语句分情况讨论。使用 1-based 索引时,当 $l=1$,$s[l-1]$ 自然指向已经初始化为 0 的 $s[0]$,公式对所有区间都可以完美统一。


1.3 C++ 模板实现

#include <iostream>
#include <vector>
using namespace std;

const int N = 100005;
int n, m;
int a[N];
long long s[N]; // 前缀和容易超出 int,推荐用 long long

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);

    if (!(cin >> n >> m)) return 0;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
    }

    // 预处理前缀和数组,s[0] 默认初始化为 0
    for (int i = 1; i <= n; ++i) {
        s[i] = s[i-1] + a[i];
    }

    while (m--) {
        int l, r;
        cin >> l >> r;
        // O(1) 区间求和
        cout << s[r] - s[l-1] << "\n";
    }

    return 0;
}
  • 时间复杂度:预处理 $O(N)$,查询单次 $O(1)$,总复杂度 $O(N + M)$。
  • 空间复杂度:$O(N)$。

2. 一维差分

如果说前缀和是为“查询”而生,那么差分就是为“修改(尤其是区间批量修改)”而生的技术。

2.1 定义与核心思想

对于给定的数组 $a$,其差分数组 $d$ 定义如下(1-based 索引): $$d[1] = a[1]$$ $$d[i] = a[i] - a[i-1] \quad (\text{for } i \ge 2)$$

也就是说,差分数组记录的是原数组中相邻元素的差值。 由此易得一个最核心的性质:差分数组的前缀和就是原数组本身。 $$a[i] = \sum_{j=1}^{i} d[j]$$

推导如下: $$\sum_{j=1}^{i} d[j] = d[1] + d[2] + \dots + d[i] = a[1] + (a[2]-a[1]) + \dots + (a[i]-a[i-1]) = a[i]$$


2.2 核心操作:区间修改

假设需要对原数组 $a$ 的闭区间 $[l, r]$ 内的所有元素都加上一个值 $c$。 如果进行暴力修改,单次操作的时间复杂度为 $O(r - l + 1)$。

观察此时差分数组 $d$ 的变化: 1. 对于 $a[l]$,其值增加了 $c$。由于 $d[l] = a[l] - a[l-1]$,$a[l-1]$ 未受影响,因此新差分值 $d'[l]$ 变为: $$d'[l] = (a[l] + c) - a[l-1] = d[l] + c \quad (\text{即 } d[l] \text{ 增加了 } c)$$ 2. 对于区间 $(l, r]$ 内的元素 $a[i]$($l < i \le r$),其自身与前驱元素都增加了 $c$,新差分值为: $$d'[i] = (a[i] + c) - (a[i-1] + c) = a[i] - a[i-1] = d[i] \quad (\text{保持不变})$$ 3. 对于 $a[r+1]$,其值没有变,但前驱元素 $a[r]$ 增加了 $c$,新差分值为: $$d'[r+1] = a[r+1] - (a[r] + c) = d[r+1] - c \quad (\text{即 } d[r+1] \text{ 减少了 } c)$$ 4. 其他位置的元素与其前驱均无变化,差分值不变。

结论:对原数组 $a$ 的区间 $[l, r]$ 进行加 $c$ 运算,在物理层面上等价于对差分数组 $d$ 执行两次单点操作: $$\begin{cases} d[l] \leftarrow d[l] + c \ d[r+1] \leftarrow d[r+1] - c \end{cases}$$

区间修改复杂度瞬间从 $O(N)$ 降至 $O(1)$。


2.3 从差分还原回原数组

当经历多次区间修改之后,若我们想恢复并查询最终的原数组,只需对差分数组 $d$ 求一遍前缀和即可: 1. 初始构建差分数组 $d[i] = a[i] - a[i-1]$(若初始数组全为 0,则差分数组也默认为全 0)。 2. 每次区间修改 $[l, r]$ 加 $c$:在 $d[l]$ 上加 $c$,在 $d[r+1]$ 上减 $c$。 3. 全部修改结束后,递推还原:$a[i] = a[i-1] + d[i]$。


2.4 C++ 模板实现

#include <iostream>
#include <vector>
using namespace std;

const int N = 100005;
int n, m;
long long a[N], d[N];

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

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);

    if (!(cin >> n >> m)) return 0;
    // 若初始数组不全为 0:
    // for(int i = 1; i <= n; ++i) cin >> a[i];
    // for(int i = 1; i <= n; ++i) d[i] = a[i] - a[i-1];

    while (m--) {
        int l, r, c;
        cin >> l >> r >> c;
        add(l, r, c); // O(1) 区间修改
    }

    // 求前缀和还原原数组
    for (int i = 1; i <= n; ++i) {
        a[i] = a[i-1] + d[i];
    }

    for (int i = 1; i <= n; ++i) {
        cout << a[i] << " ";
    }
    cout << "\n";

    return 0;
}
  • 时间复杂度:单次修改 $O(1)$,最终还原 $O(N)$,总复杂度 $O(N + M)$。
  • 空间复杂度:$O(N)$。

3. 二维前缀和与二维差分

引入容斥原理(Inclusion-Exclusion Principle),我们将前缀和与差分延伸到二维矩阵空间。

3.1 二维前缀和

3.1.1 定义与递推公式

二维前缀和 $s[i][j]$ 定义为:从左上角 $(1, 1)$ 到右下角 $(i, j)$ 所包围的矩形区域内所有元素的和。 $$s[i][j] = \sum_{x=1}^{i} \sum_{y=1}^{j} a[x][y]$$

根据几何拼凑与容斥原理,大矩形 $s[i][j]$ 可以由其左边、上方的矩形累加,并扣除重叠部分得到: $$s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j]$$

+--------------+----+
|              |    |
|  s[i-1][j-1] |    |  <-- 重叠小矩形(减一次)
|              |    |
+--------------+----+
|                   |  <-- s[i][j-1] (左边大矩形)
+-------------------+

3.1.2 子矩阵查询

如果需要查询以 $(x_1, y_1)$ 为左上角、$(x_2, y_2)$ 为右下角的子矩形内所有元素的和: $$\text{Sum}(x_1, y_1, x_2, y_2) = 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)$。


3.1.3 C++ 模板实现

#include <iostream>
#include <vector>
using namespace std;

const int N = 1005;
int n, m, k;
int a[N][N];
long long s[N][N];

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);

    if (!(cin >> n >> m >> k)) return 0;
    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= m; ++j) {
            cin >> a[i][j];
        }
    }

    // 预处理二维前缀和
    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= m; ++j) {
            s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j];
        }
    }

    while (k--) {
        int x1, y1, x2, y2;
        cin >> x1 >> y1 >> x2 >> y2;
        long long ans = s[x2][y2] - s[x1-1][y2] - s[x2][y1-1] + s[x1-1][y1-1];
        cout << ans << "\n";
    }

    return 0;
}

3.2 二维差分

3.2.1 定义与核心公式

我们构造一个二维差分矩阵 $d$,使得原矩阵 $a$ 是 $d$ 的二维前缀和: $$a[i][j] = \sum_{x=1}^{i} \sum_{y=1}^{j} d[x][y]$$

差分矩阵 $d$ 的具体数学递推式为(即二维前缀和公式的逆运算): $$d[i][j] = a[i][j] - a[i-1][j] - a[i][j-1] + a[i-1][j-1]$$


3.2.2 核心操作:子矩阵修改

如果我们需要对以 $(x_1, y_1)$ 为左上角、$(x_2, y_2)$ 为右下角的子矩阵内的所有元素都加 $c$: 为了精确地限制增量范围,我们在差分矩阵 $d$ 的四个角点(端点边界)执行如下四项单点修改: $$\begin{cases} d[x_1][y_1] \leftarrow d[x_1][y_1] + c \ d[x_2+1][y_1] \leftarrow d[x_2+1][y_1] - c \ d[x_1][y_2+1] \leftarrow d[x_1][y_2+1] - c \ d[x_2+1][y_2+1] \leftarrow d[x_2+1][y_2+1] + c \end{cases}$$

(x1, y1)  [+c] ----------------- (x1, y2+1)  [-c]
   |                                 |
   |                                 |
(x2+1, y1)[-c] ----------------- (x2+1, y2+1)[+c]

这四处修改会在还原前缀和时,完美拼凑并对齐原矩阵中的目标子矩形,且不会影响其他任何区域。


3.2.3 C++ 模板实现

#include <iostream>
#include <vector>
using namespace std;

const int N = 1005;
int n, m, k;
long long a[N][N], d[N][N];

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

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);

    if (!(cin >> n >> m >> k)) return 0;

    // 若初始矩阵不全为 0:
    // for(int i=1; i<=n; ++i) {
    //     for(int j=1; j<=m; ++j) {
    //         cin >> a[i][j];
    //         add(i, j, i, j, a[i][j]); // 也可以利用公式直构
    //     }
    // }

    while (k--) {
        int x1, y1, x2, y2, c;
        cin >> x1 >> y1 >> x2 >> y2 >> c;
        add(x1, y1, x2, y2, c); // O(1) 矩形增值修改
    }

    // 二维前缀和还原原矩阵
    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];
        }
    }

    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= m; ++j) {
            cout << a[i][j] << " ";
        }
        cout << "\n";
    }

    return 0;
}

4. 与高级数据结构的联系:从静态走向动态

上述讨论的差分模型属于“批量离线修改,单次最终查询”。如果面对修改与查询互相交错进行的在线操作,我们每次修改后都要花 $O(N)$ 重构前缀和,整体开销会变得无法承受。 这正是树状数组(Binary Indexed Tree / Fenwick Tree)等动态维护工具施展拳脚的舞台。

4.1 树状数组动态前缀和

树状数组能在 $O(\log N)$ 时间内支持单点修改与前缀和查询: * add(x, c):将 $a[x]$ 加上 $c$。 * query(x):查询前缀和 $\sum_{i=1}^x a[i]$。 有了前缀和查询,区间 $[l, r]$ 求和便可直接表达为:query(r) - query(l - 1),单次耗时 $O(\log N)$。


4.2 差分与树状数组:单点查询与区间修改的结合

考虑以下动态问题:支持区间修改($[l, r]$ 全体加 $c$)与单点查询(查询 $a[x]$ 的值)。 我们知道: 1. 区间修改等价于对差分数组 $d$ 进行两次单点更新:add(l, c),add(r + 1, -c)。 2. 单点查询 $a[x]$ 本质上是差分数组的前缀和:$a[x] = \sum_{i=1}^x d[i]$。

因此,我们只需要用树状数组维护差分数组 $d$,即可将操作效率全部优化至 $O(\log N)$:

#include <iostream>
#include <vector>
using namespace std;

const int N = 100005;
int n, m;
long long t[N]; // 树状数组,维护差分数组 d

void add(int x, long long c) {
    for (; x <= n; x += x & -x) t[x] += c;
}

long long sum(int x) {
    long long res = 0;
    for (; x > 0; x -= x & -x) res += t[x];
    return res;
}

// 区间修改:[l, r] 加 c
void radd(int l, int r, int c) {
    add(l, c);
    if (r + 1 <= n) add(r + 1, -c);
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);

    if (!(cin >> n >> m)) return 0;
    // 假设初始不全为0,需要将每个 a[i] 视作区间 [i, i] 的单点修改:
    // for(int i=1; i<=n; ++i) {
    //     long long val; cin >> val;
    //     radd(i, i, val);
    // }

    while (m--) {
        int op; cin >> op;
        if (op == 1) {
            int l, r, c;
            cin >> l >> r >> c;
            radd(l, r, c); // O(log N) 修改
        } else {
            int x; cin >> x;
            cout << sum(x) << "\n"; // O(log N) 查询
        }
    }
    return 0;
}

4.3 双树状数组下的区间修改与区间查询

如果问题更进一步,在线同时支持:区间修改 与 区间查询($[l, r]$ 区间求和) 呢? 区间和可以转换为两个前缀和相减。我们需要求解 $\sum_{k=1}^x a[k]$。 因为 $a[k] = \sum_{i=1}^k d[i]$,我们将式子展开并重新整理: $$\sum_{k=1}^{x} a[k] = \sum_{k=1}^{x} \sum_{i=1}^{k} d[i]$$

分析每一项 $d[i]$ 的贡献,它总共会在 $a[i], a[i+1], \dots, a[x]$ 中出现,共计出现了 $x - i + 1$ 次。因此: $$\sum_{k=1}^{x} a[k] = \sum_{i=1}^{x} (x - i + 1) \cdot d[i]$$ $$\sum_{k=1}^{x} a[k] = (x + 1) \sum_{i=1}^{x} d[i] - \sum_{i=1}^{x} (i \cdot d[i])$$

这个数学变形非常美妙,它将前缀和转变成了两个经典的前缀和查询: 1. $\sum_{i=1}^x d[i]$:差分数组 $d$ 的前缀和。 2. $\sum_{i=1}^x (i \cdot d[i])$:新数组 $d'[i] = i \cdot d[i]$ 的前缀和。

我们可以使用两个树状数组分别进行维护: * $t_1$:维护 $d[i]$。 * $t_2$:维护 $i \cdot d[i]$。

C++ 代码实现

#include <iostream>
#include <vector>
using namespace std;

const int N = 100005;
int n, m;
long long t1[N], t2[N]; // t1 维护 d[i],t2 维护 i * d[i]

void add(long long t[], int x, long long c) {
    for (; x <= n; x += x & -x) t[x] += c;
}

long long sum(long long t[], int x) {
    long long res = 0;
    for (; x > 0; x -= x & -x) res += t[x];
    return res;
}

void radd(int l, int r, long long c) {
    add(t1, l, c);
    add(t1, r + 1, -c);
    add(t2, l, l * c);
    add(t2, r + 1, -(r + 1) * c);
}

// 查询 a[1...x] 的前缀和
long long psum(int x) {
    if (x == 0) return 0;
    long long s1 = sum(t1, x);
    long long s2 = sum(t2, x);
    return (x + 1) * s1 - s2;
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);

    if (!(cin >> n >> m)) return 0;
    long long prev = 0;
    for (int i = 1; i <= n; ++i) {
        long long cur; cin >> cur;
        radd(i, i, cur - prev); // 初始值导入
        prev = cur;
    }

    while (m--) {
        int op; cin >> op;
        if (op == 1) {
            int l, r; long long c;
            cin >> l >> r >> c;
            radd(l, r, c);
        } else {
            int l, r;
            cin >> l >> r;
            cout << psum(r) - psum(l - 1) << "\n";
        }
    }
    return 0;
}

5. 树上的差分艺术

差分思想并不局限于一维或二维线性结构,在树形结构中也大放异彩。主要分为子树修改与路径修改。

5.1 子树修改与查询

对树进行 DFS 时,我们可以记录下每个节点 $u$ 的进入时间戳 $tin[u]$ 与离开时间戳 $tout[u]$。 这就是树的 DFS 序(或称为 Euler Tour)。 一个至关重要的性质是:节点 $u$ 的子树中的所有节点,其时间戳都在线性闭区间 $[tin[u], tout[u]]$ 之中。

对子树 $u$ 内所有节点加 $c$,便完全等价于在线性 DFS 序数组上进行一维区间修改 $[tin[u], tout[u]]$: * 修改:$d[tin[u]] \leftarrow d[tin[u]] + c$, $d[tout[u]+1] \leftarrow d[tout[u]+1] - c$。 * 还原:修改完毕后,在线性数组上求前缀和,即可完美映射回树中各个节点的真实值。

#include <iostream>
#include <vector>
using namespace std;

const int N = 100005;
int n, m;
vector<int> g[N];
int in[N], out[N], timer;
long long d[N];   // 差分数组,基于 DFS 序
long long val[N]; // 还原后的节点值

void dfs(int u, int p) {
    in[u] = ++timer;
    for (int v : g[u]) {
        if (v == p) continue;
        dfs(v, u);
    }
    out[u] = timer;
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);

    if (!(cin >> n >> m)) return 0;
    for (int i = 0; i < n - 1; ++i) {
        int u, v; cin >> u >> v;
        g[u].push_back(v); g[v].push_back(u);
    }

    dfs(1, 0); // 预处理 DFS 序

    while (m--) {
        int u, c;
        cin >> u >> c;
        d[in[u]] += c;
        if (out[u] + 1 <= n) {
            d[out[u] + 1] -= c;
        }
    }

    // 线性求前缀和
    for (int i = 1; i <= n; ++i) {
        d[i] += d[i - 1];
    }

    // 映射回原树节点
    for (int i = 1; i <= n; ++i) {
        val[i] = d[in[i]];
        cout << val[i] << " ";
    }
    cout << "\n";

    return 0;
}

5.2 路径修改与查询

如果我们需要将树上两点 $u, v$ 之间简单唯一路径上的所有节点都加上 $c$。 为了避开高代价的路径遍历,我们将“路径修改”转换为“差分标记”。

设 $l = \text{lca}(u, v)$ 为 $u, v$ 的最近公共祖先(LCA),我们可以对差分数组 $d$ 进行如下单点标记操作: $$\begin{cases} d[u] \leftarrow d[u] + c \ d[v] \leftarrow d[v] + c \ d[l] \leftarrow d[l] - c \ d[\text{parent}(l)] \leftarrow d[\text{parent}(l)] - c \end{cases}$$

在此差分模型下,每个节点的真实值定义为其子树内所有差分值的总和: $$\text{Value}(u) = \sum_{v \in \text{subtree}(u)} d[v]$$

核心原理解析:

  1. $d[u] += c$:使从 $u$ 到根节点路径上的所有节点都累计增加 $c$。
  2. $d[v] += c$:使从 $v$ 到根节点路径上的所有节点都累计增加 $c$。
  3. $d[l] -= c$:此时从 $l$ 到根的重合祖先路径被多加了一次,在此减去一个 $c$。
  4. $d[\text{parent}(l)] -= c$:由于 $l$ 本身在路径上应该被加一次,但 $l$ 之外的祖先部分(即 $\text{parent}(l)$ 及以上)不能被波及,因此我们在其父节点处再扣除一次 $c$。

修改完成后,自底向上进行一次 DFS 子树累加即可。

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

const int N = 100005, LOGN = 18;
int n, m;
vector<int> g[N];
long long d[N];
int dep[N], fa[N][LOGN];

void dfs1(int u, int p) {
    dep[u] = dep[p] + 1;
    fa[u][0] = p;
    for (int i = 1; i < LOGN; ++i) {
        fa[u][i] = fa[fa[u][i - 1]][i - 1];
    }
    for (int v : g[u]) {
        if (v == p) continue;
        dfs1(v, u);
    }
}

int lca(int u, int v) {
    if (dep[u] < dep[v]) swap(u, v);
    for (int i = LOGN - 1; i >= 0; --i) {
        if (dep[fa[u][i]] >= dep[v]) u = fa[u][i];
    }
    if (u == v) return u;
    for (int i = LOGN - 1; i >= 0; --i) {
        if (fa[u][i] != fa[v][i]) {
            u = fa[u][i];
            v = fa[v][i];
        }
    }
    return fa[u][0];
}

// DFS 子树求和还原
void dfs2(int u, int p) {
    for (int v : g[u]) {
        if (v == p) continue;
        dfs2(v, u);
        d[u] += d[v]; // 子树差分和累加到父节点
    }
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);

    if (!(cin >> n >> m)) return 0;
    for (int i = 0; i < n - 1; ++i) {
        int u, v; cin >> u >> v;
        g[u].push_back(v); g[v].push_back(u);
    }

    dfs1(1, 0); // 预处理 LCA 倍增数组

    while (m--) {
        int u, v; long long c;
        cin >> u >> v >> c;
        int l = lca(u, v);
        d[u] += c;
        d[v] += c;
        d[l] -= c;
        if (fa[l][0] != 0) d[fa[l][0]] -= c;
    }

    dfs2(1, 0); // 差分还原

    for (int i = 1; i <= n; ++i) {
        cout << d[i] << " ";
    }
    cout << "\n";
    return 0;
}

6. 核心精髓与避坑指南

6.1 运算的对偶性:求和与求差

前缀和与差分本质是一对互逆的操作,正如连续数学中的微积分。 * 前缀和 $\sim$ 积分:将局部变动进行全局累积,获得总量。 * 差分 $\sim$ 微分:测量相邻两个状态之间的瞬时变化率。

区间修改较为繁重时,通过求微分(差分)降低运算维度;区间求和较为繁重时,通过求积分(前缀和)获取瞬时响应。


6.2 常见实践陷阱

  1. 下标处理:切记强烈建议采用 1-based 索引,并将边界 $s[0]$ 或 $s[0][0]$ 默认置为 0,这能帮你省去大量复杂的逻辑判断。
  2. 整型溢出(Overflow):千万小心,和的增长速度是爆发性的。$10^5$ 个 $10^9$ 级别的数累加,会瞬间冲爆常规 32 位整型(int)。养成使用 long long 存储前缀和及差分值的习惯。
  3. 忘记还原:使用差分法解决问题时,最常见的粗心是在写完修改后,忘记在尾部进行最终的前缀和重构计算。差分数组本身并不是最终答案,求出其前缀和才能得到原数组。
  4. 数位和越界边界:进行二维差分修改 $d[x_2+1][y_2+1]$ 时,很容易出现 $x_2+1 > n$ 的越界情况。在分配容器大小时,数组必须习惯性开大一点(通常为 N + 2 或 N + 5),以确保越界操作安全。

7. 经典例题选讲

例题 1:P3128 [USACO15DEC] Max Flow P (树上差分)

题目描述

在 $N$($2 \le N \le 50,000$)个牛棚之间有 $N-1$ 条管道相连,管道相互连通。 有 $K$($1 \le K \le 100,000$)条牛奶泵送路径,第 $i$ 条路径起终点分别为 $s_i$ 和 $t_i$。每条路径泵送都会对经过的牛棚产生 1 单位的流量。求路径全部泵送完后,承受流量最大的单个牛棚所承载的流量。

题解思路

这是一个极为经典的树上路径批量增值、单次最后求最值问题,可以直接套用路径树上差分模型。 1. 使用倍增法预处理出树上所有节点的深度与 LCA 关系。 2. 每次修改 $s \to t$ 流量加 1: $$d[s]++, \quad d[t]++, \quad d[\text{lca}(s, t)]--, \quad d[\text{parent}(\text{lca}(s, t))]--$$ 3. 最终通过一次自底向上的回溯 DFS 累加子树差分值恢复数据。 4. 遍历所有节点的值获取全局最大值。

代码实现

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

const int N = 50005, LOGN = 18;
int n, k;
vector<int> g[N];
int dep[N], fa[N][LOGN];
int d[N]; // 差分数组

void dfs1(int u, int p) {
    dep[u] = dep[p] + 1;
    fa[u][0] = p;
    for (int i = 1; i < LOGN; ++i) {
        fa[u][i] = fa[fa[u][i - 1]][i - 1];
    }
    for (int v : g[u]) {
        if (v == p) continue;
        dfs1(v, u);
    }
}

int lca(int u, int v) {
    if (dep[u] < dep[v]) swap(u, v);
    for (int i = LOGN - 1; i >= 0; --i) {
        if (dep[fa[u][i]] >= dep[v]) u = fa[u][i];
    }
    if (u == v) return u;
    for (int i = LOGN - 1; i >= 0; --i) {
        if (fa[u][i] != fa[v][i]) {
            u = fa[u][i];
            v = fa[v][i];
        }
    }
    return fa[u][0];
}

void dfs2(int u, int p) {
    for (int v : g[u]) {
        if (v == p) continue;
        dfs2(v, u);
        d[u] += d[v]; // 回溯时子树向上合并
    }
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);

    if (!(cin >> n >> k)) return 0;
    for (int i = 0; i < n - 1; ++i) {
        int u, v; cin >> u >> v;
        g[u].push_back(v); g[v].push_back(u);
    }

    dfs1(1, 0); // 预处理 LCA

    for (int i = 0; i < k; ++i) {
        int u, v; cin >> u >> v;
        int l = lca(u, v);
        d[u]++;
        d[v]++;
        d[l]--;
        if (fa[l][0] != 0) d[fa[l][0]]--;
    }

    dfs2(1, 0); // 还原数据

    int ans = 0;
    for (int i = 1; i <= n; ++i) {
        ans = max(ans, d[i]);
    }
    cout << ans << "\n";

    return 0;
}

例题 2:P1438 无聊的数列 (线性函数变换法)

题目描述

维护一个数列 $a_i$,需要动态支持以下两类在线操作: 1. 1 l r K D:给区间 $[l, r]$ 整体增加一个等差数列。首项为 $K$,公差为 $D$。也就是说,$a[l] \leftarrow a[l] + K$,$a[l+1] \leftarrow a[l+1] + K + D$,以此类推。 2. 2 p:在线询问单点第 $p$ 个数的值 $a_p$。

题解思路

本题要解决的关键在于:给一个区间增加一个等差数列。 我们可以对区间内的任意一个坐标 $i$($l \le i \le r$),计算其增加的值: $$\text{added_value}(i) = K + (i - l) \times D$$

我们对其进行代数变形,整理为经典线性函数格式: $$\text{added_value}(i) = D \cdot i + (K - l \cdot D)$$

这个代数变形具有决定性意义,它告诉我们:区间加一个等差数列,等价于对区间内的每一个点 $i$,加上一个斜率为 $D$、常数项为 $K - l \cdot D$ 的线性函数。 我们可以将问题完美拆分成两个独立的部分: 1. 斜率维护部分:对区间 $[l, r]$ 每个点 $i$,其对应的值增加 $D \cdot i$。 2. 常数项维护部分:对区间 $[l, r]$ 每个点 $i$,其对应的值增加常数 $C = K - l \cdot D$。

查询任意位置 $p$ 上的总累积增量时,只需要累加所有覆盖了 $p$ 点的斜率和常数即可: $$\text{delta}[p] = \sum (D_j \cdot p + C_j) = p \cdot \left(\sum D_j\right) + \left(\sum C_j\right)$$

这被巧妙地降维转换成了两个“区间修改、单点查询”的经典问题: 1. 用第一个树状数组 $f_b$(维护斜率差分)来管理覆盖点 $p$ 的总斜率 $\sum D_j$。 2. 用第二个树状数组 $f_c$(维护常数项差分)来管理覆盖点 $p$ 的总常数项 $\sum C_j$。

对于等差数列操作 1 l r K D,执行区间差分修改: * 更新常数项 $C = K - l \cdot D$:fc.add(l, C), fc.add(r + 1, -C) * 更新斜率 $D$:fb.add(l, D), fb.add(r + 1, -D)

单点 $p$ 处的最终答案: $$a_{\text{final}}[p] = a_{\text{initial}}[p] + f_c.\text{sum}(p) + p \cdot f_b.\text{sum}(p)$$

代码实现

#include <iostream>
#include <vector>
using namespace std;

// 树状数组结构,维护基础差分
struct BIT {
    vector<long long> t;
    int n;
    BIT(int _n = 0) { init(_n); }
    void init(int _n) {
        n = _n;
        t.assign(n + 1, 0);
    }
    void add(int x, long long v) {
        for (; x <= n; x += x & -x) t[x] += v;
    }
    long long sum(int x) {
        long long s = 0;
        for (; x > 0; x -= x & -x) s += t[x];
        return s;
    }
};

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);

    int n, m;
    if (!(cin >> n >> m)) return 0;
    vector<long long> a(n + 2, 0);
    for (int i = 1; i <= n; i++) cin >> a[i];

    // fc 维护常数项 C 的差分,fb 维护斜率 D 的差分
    BIT fc(n + 1), fb(n + 1);

    while (m--) {
        int op; cin >> op;
        if (op == 1) {
            int l, r; long long k, d;
            cin >> l >> r >> k >> d;
            long long c = k - l * d;

            // 对区间 [l, r] 增值常数项 c
            fc.add(l, c);
            fc.add(r + 1, -c);

            // 对区间 [l, r] 增值斜率项 d
            fb.add(l, d);
            fb.add(r + 1, -d);
        } else {
            int p; cin >> p;
            // 还原答案:a_p + sum_C + p * sum_D
            long long res = a[p] + fc.sum(p) + p * fb.sum(p);
            cout << res << "\n";
        }
    }
    return 0;
}

—— 本文来自火龙信奥(义乌睿码科技):义乌青少年信息学奥赛与编程教育平台,专注 CSP-J/S、NOIP、GESP 竞赛培训,线上线下融合教学,助力编程升学。网址:hlcoding.com

关于火龙

  • 关于我们
  • 学员获奖
  • 预约试听
  • ACM课程
  • CSP课程
  • 学习指南

帮助中心

  • 用户协议
  • 打字练习
  • 在线画图
  • DevC++下载
  • CSP报名
  • GESP官网

推荐课程

  • C++零基础入门(可试看)
  • C++进阶提升
  • GESP考级辅导
  • GESP打卡
  • CSP-J/S打卡

公众号

火龙信奥公众号二维码

地址:义乌市北门街188号新天地商厦二楼2F 邮箱:wdlok305@126.com

© 2017-2026 义乌市睿码科技有限公司版权所有 浙ICP备2021013995号

火龙信奥
请输入登录信息


请完成安全验证
验证码底图 滑块
向右拖动滑块完成验证
请输入用户名 / 绑定的手机号码



请输入注册信息(手机号验证码注册)





验证码5分钟有效,60秒内不可重复获取,每日最多3次

微信登录

微信登录二维码

正在生成二维码...

账号已过期,请续期。
去续期

绑定手机号

📱

为了更好地保护您的账号安全,享受完整的平台服务

请您尽快绑定手机号码