前缀和&差分算法

Posted by Leon Ling on September 20, 2019

在做LeetCode和其他算法题的时候,遇到复杂的DP问题经常看到分享的答案中有用到被称为“前缀和”的算法,这里把搜集到的概念和相关用法整理到一起。

[toc]

1. 前缀和

前缀和是一种重要的预处理,能大大降低查询的时间复杂度。我们可以简单理解为“数列的前 n 项的和”。

例题

有 N 个的正整数放到数组 A 里,现在要求一个新的数组 B,新数组的第 i 个数 B[i]是原数组 A 第 0 到第 i 个数的和。

对于这道题,我们有两种做法:

  • 把对数组 A 的累加依次放入数组 B 中。
  • 递推: B[i] = B[i-1] + A[i] ,前提 B[0] = A[0]

参考程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

using namespace std;

int N, A[10000], B[10000];
int main() {
  cin >> N;
  for (int i = 0; i < N; i++) {
    cin >> A[i];
  }

  B[0] = A[0];

  for (int i = 1; i < N; i++) {
    B[i] = B[i - 1] + A[i];
  }

  for (int i = 0; i < N; i++) {
    cout << B[i] << " ";
  }

  return 0;
}

输入:

1
2
5
1 2 3 4 5

输出:

1
1 3 6 10 15

首先, B[0] = A[0]; ,前缀和数组的第一项和原数组的第一项是相等的。

B[i] = B[i-1] + A[i] 意思就是:前缀和数组的第 i 项 = 原数组的 0 到 i-1 项的和 + 原数组的第 i 项。

习题

2. 二维/多维前缀和

其实前缀和几乎都是基于容斥原理,所以各种拓展自己手推一下就行了。这里用二维前缀和为例讲解一下前缀和扩展到多维的方式。

比如我们有这样一个矩阵 ,可以视为二维数组:

1
2
3
1 2 4 3
5 1 2 4
6 3 5 9

我们定义一个矩阵 sum ,

\[sum_{x,y}=\sum^x_{i=1}\sum^y_{j=1}a_{i,j}\]

那么这个矩阵长这样:

1
2
3
1 3 7 10
6 9 15 22
12 18 29 45

第一个问题就是递推求 sum 的过程,sum_i,j = sum_i-1,j + sum_i,j-1-sum_i-1,j-1+a_i,j 。因为加了 sum_i-1,j 和 sum_i,j-1 重复了 sum_i-1,j-1 ,所以减去。

第二个问题就是如何应用,譬如求 (x1, y1) - (x2, y2) 子矩阵的和。那么,根据类似的思考过程,易得答案为 sum_x2,y2 - sum_x1-1,y2 - sum x2,y1-1 + sumx1-1,y1-1 。

下面给出 洛谷 P1387 最大正方形 这道题目的参考程序来帮助大家理解二维前缀和。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <algorithm>
#include <iostream>
using namespace std;
int a[103][103];
int b[103][103];  // 前缀和数组,相当于上文的 sum[]
int main() {
  int n, m;
  cin >> n >> m;

  for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= m; j++) {
      cin >> a[i][j];
      b[i][j] =
          b[i][j - 1] + b[i - 1][j] - b[i - 1][j - 1] + a[i][j];  // 求前缀和
    }
  }

  int ans = 1;

  int l = 2;
  while (l <= min(n, m)) {
    for (int i = l; i <= n; i++) {
      for (int j = l; j <= m; j++) {
        if (b[i][j] - b[i - l][j] - b[i][j - l] + b[i - l][j - l] == l * l) {
          ans = max(ans, l);
        }
      }
    }
    l++;
  }

  cout << ans << endl;
  return 0;
}

习题

基于 DP 计算高维前缀和3.

前一节方法本质上是基于容斥原理来计算高维前缀和,其优点在于形式较为简单,无需特别记忆,但当维数升高时,其复杂度较高。这里介绍一种基于 DP 计算高维前缀和的方法。该方法即通常语境中所称的 高维前缀和

设高维空间 U 共有 F 维,需要对 F(·) 求高维前缀和 sum(·) 。令 sum[i][state] 表示同 state 后 D-i 维相同的所有点对于state 点高维前缀和的贡献。由定义可知 sum[0][state]=f[false] ,以及 sum[state]=sum[D][state]。

其递推关系为 sum[i][state] = sum[i-1][state]+sum[i][state’] ,其中 state’ 为第 i 维恰好比 state 少 1 的点。该方法的复杂度为 O(D* U ) ,其中 U 为高维空间 U 的大小。

其一种实现的伪代码如下:

1
2
3
4
5
for state
    sum[state] = f[state];
for(i = 0;i <= D;i += 1)
    for 以字典序从小到大枚举 state
        sum[state] += sum[state'];

3. 树上前缀和

设 sum_i 表示结点 i 到根节点的权值总和。

然后若是点权,x,y路径上的和为 sum_x+sum_y-sum_lca-sum_f_a_lca ;

否则若是边权,x,y路径上的和为 sum_x+sum_y-2sum_lca。

至于 lca 的求法请移步 最近公共祖先

习题

4. 差分

差分,是一种和前缀和相对的策略。这种策略是,令 b_i = a_i-ai-1 ,即相邻两数的差。

易得对这个序列做一遍前缀和就得到了原来的 a 序列。

它可以维护多次对序列的一个区间加上一个数,并在最后询问某一位的数或是多次询问某一位的数。(总之修改操作一定要在查询操作之前)

具体怎么搞?譬如使 [l,r] 每个数加上一个 k ,就是 b_l ← b_l+k, b_l+1 ← b_l+1-k。

最后做一遍前缀和就好了。

习题

5. 树上差分

我以前一直以为树上差分也是树上前缀和相对的策略,但是不知道怎么搞。

后来发现还是有点区别的。

至少人家是基于子树和而非到根的和。

如果使 x,y 路径上的点权增加 k ,

b_x ← b_x+k, b_y ← b_y+k , b_lca ← b_lca-k , b_f_a_lca ← b_f_a_lca-k , 如果是边权,

b_x ← b_x+k, b_y ← b_y+k , b_lca ← b_lca-2k。

然后一遍搜索求一下子树和答案就出来了。