差分 --算法竞赛专题解析(32)

本系列文章将于2021年整理出版。前驱教材:《算法竞赛入门到进阶》 清华大学出版社
网购:京东 当当   作者签名书:点我
有建议请加QQ 群:567554289

   差分是一种处理数据的巧妙而简单的方法,它应用于区间的修改和询问问题。把给定的数据元素集A分成很多区间,对这些区间做很多次操作,每次操作是对某个区间内的所有元素做相同的加减操作,若一个个地修改这个区间内的每个元素,非常耗时。引入“差分数组”D,当修改某个区间时,只需要修改这个区间的“端点”,就能记录整个区间的修改,而对端点的修改非常容易,是 O ( 1 ) O(1) O(1)复杂度的。当所有的修改操作结束后,再利用差分数组,计算出新的A。
  数据A可以是一维的线性数组 a [ ] a[] a[]、二维矩阵 a [ ] [ ] a[][] a[][]、三维立体 a [ ] [ ] [ ] a[][][] a[][][]。相应地,定义差分数组 D [ ] 、 D [ ] [ ] 、 D [ ] [ ] [ ] D[]、D[][]、D[][][] D[]D[][]D[][][]。一维差分很容易理解,二维和三维需要一点想象力。

1. 一维差分

1.1 一维差分的概念

   讨论这样一个场景:
   (1)给定一个长度为n的一维数组 a [ ] a[] a[],数组内每个元素有初始值。
   (2)修改操作:做m次区间修改,每次修改对区间内所有元素做相同的加减操作。例如第 i i i次修改,把区间 [ L i , R i ] [Li, Ri] [Li,Ri]内所有元素加上 d i di di
   (3)询问操作:询问一个元素的新值是多少。
   如果简单地用暴力法编码,那么每次修改的复杂度是 O ( n ) O(n) O(n)的,m次修改共 O ( m n ) O(mn) O(mn),总复杂度 O ( m n ) O(mn) O(mn),效率很差。利用差分法,可以把复杂度减少到 O ( m + n ) O(m+n) O(m+n)
   在差分法中,用到了两个数组:原数组 a [ ] a[] a[]、差分数组 D [ ] D[] D[]
   差分数组D[]的定义是 D [ k ] = a [ k ] − a [ k − 1 ] D[k] = a[k] - a[k-1] D[k]=a[k]a[k1],即原数组 a [ ] a[] a[]的相邻元素的差。从定义可以推出 a [ k ] = D [ 1 ] + D [ 2 ] + . . . + D [ k ] a[k] = D[1] + D[2] + ... + D[k] a[k]=D[1]+D[2]+...+D[k] ,也就是说, a [ ] a[] a[] D [ ] D[] D[]的前缀和。这个公式揭示了 a [ ] a[] a[] D [ ] D[] D[]的关系,“差分是前缀和的逆运算”,它把求 a [ k ] a[k] a[k]转化为求D的前缀和。为加深对前缀和的理解,可以把每个 D [ ] D[] D[]看成一条直线上的小线段,它的两端是相邻的 a [ ] a[] a[];这些小线段相加,就得到了从起点开始的长线段 a [ ] a[] a[]
   注意, a [ ] a[] a[] D [ ] D[] D[]的值都可能为负,下面图中所有的 D [ ] D[] D[]都是长度为正的线段,只是为了方便图示。

图1 把每个D[]看成小线段,把每个a[]看成从a[1]开始的小线段的和

  
  如何用差分数组记录区间修改?为什么利用差分数组能提升修改的效率呢?
  把区间 [ L , R ] [L, R] [L,R]内每个元素加上 d d d,对应的 D [ ] D[] D[]做以下操作:
  (1)把 D [ L ] D[L] D[L]加上 d d d

     D[L] += d

  (2)把 D [ R + 1 ] D[R+1] D[R+1]减去 d d d

     D[R+1] -= d

  每次操作只需要修改区间 [ L , R ] [L, R] [L,R]的两个端点的 D [ ] D[] D[]值,复杂度是 O ( 1 ) O(1) O(1)的。经过这种操作后,原来直接在 a [ ] a[] a[]上做的复杂度为 O ( n ) O(n) O(n)的区间修改操作,就变成了在 D [ ] D[] D[]上做的复杂度为 O ( 1 ) O(1) O(1)的端点操作。
  利用 D [ ] D[] D[],能精确地实现只修改区间内元素的目的,而不会修改区间外的 a [ ] a[] a[]值。因为前缀和 a [ x ] = D [ 1 ] + D [ 2 ] + . . . + D [ x ] a[x] = D[1] + D[2] + ... + D[x] a[x]=D[1]+D[2]+...+D[x],有:
  (1) 1 ≤ x < L 1 ≤ x < L 1x<L,前缀和 a [ x ] a[x] a[x]不变;
  (2) L ≤ x ≤ R L ≤ x ≤ R LxR,前缀和 a [ x ] a[x] a[x]增加了 d d d
  (3) R < x ≤ N R < x ≤ N R<xN,前缀和 a [ x ] a[x] a[x]不变,因为被 D [ R + 1 ] D[R+1] D[R+1]中减去的 d d d抵消了。
  完成区间修改并得到 D [ ] D[] D[]后,最后用 D [ ] D[] D[]计算 a [ ] a[] a[],复杂度是 O ( n ) O(n) O(n)的。m次区间修改和1次查询,总复杂度为 O ( m + n ) O(m + n) O(m+n),比暴力法的 O ( m n ) O(mn) O(mn)好多了。
  下面给出一个例题。


Color the ball hdu 1556 http://acm.hdu.edu.cn/showproblem.php?pid=1556
问题描述:N个气球排成一排,从左到右依次编号为1, 2, 3 … N。每次给定2个整数L, R(L<= R),lele从气球L开始到气球R依次给每个气球涂一次颜色。但是N次以后lele已经忘记了第I个气球已经涂过几次颜色了,你能帮他算出每个气球被涂过几次颜色吗?
输入:每个测试实例第一行为一个整数N,(N <= 100000)。接下来的N行,每行包括2个整数L, R(1 <= L<= R<= N)。当N = 0,输入结束。
输出:每个测试实例输出一行,包括N个整数,第I个数代表第I个气球总共被涂色的次数。


  这个例题是简单差分法的直接应用,下面给出代码。代码第13、14行是区间修改,第17行的 a [ i ] = a [ i − 1 ] + D [ i ] a[i] = a[i-1] + D[i] a[i]=a[i1]+D[i],即利用 D [ ] D[] D[]求得了最后的 a [ ] a[] a[]。这个式子就是 a [ i ] − a [ i − 1 ] = D [ i ] a[i] - a[i-1] = D[i] a[i]a[i1]=D[i],它是差分数组的定义。
  注意 a [ ] a[] a[]的计算方法。 a [ i ] = a [ i − 1 ] + D [ i ] a[i] = a[i-1] + D[i] a[i]=a[i1]+D[i]是一个递推公式,通过它能在一个 i i i循环中求得所有的 a [ ] a[] a[]。如果不用递推,而是直接用前缀和 a [ k ] = D [ 1 ] + D [ 2 ] + . . . + D [ k ] a[k]=D[1] + D[2] + ... + D[k] a[k]=D[1]+D[2]+...+D[k] 来求所有的 a [ ] a[] a[],就需要用两个循环 i 、 k i、k ik

//hdu 1556用差分数组求解
#include<bits/stdc++.h>
using namespace std;
const int Maxn = 100010;
int a[Maxn],D[Maxn];               //a是气球,D是差分数组

int main(){
    int n;
    while(~scanf("%d",&n)) { 
        memset(a,0,sizeof(a)); memset(D,0,sizeof(D));
        for(int i=1;i<=n;i++){
            int L,R; scanf("%d%d",&L,&R);
            D[L]++;                 //区间修改,这里d=1
            D[R+1]--;
        }
//小技巧:17行到20行,把a[]改成D[]也行
        for(int i=1;i<=n;i++){              //求原数组
            a[i] = a[i-1] + D[i];           //差分。求前缀和a[],a[i]就是气球i的值
            if(i!=n)  printf("%d ", a[i]);  //逐个打印结果
            else      printf("%d\n",a[i]);
        }        
    }
    return 0;
}

  上面的代码用了一个小技巧,可以省掉 a [ ] a[] a[],从而节省空间。在17行后求原数组 a [ ] a[] a[]的时候,在推导式子 a [ i ] = a [ i − 1 ] + D [ i ] a[i] = a[i-1] + D[i] a[i]=a[i1]+D[i]时,把已经使用过的较小的 D [ ] D[] D[]直接当成 a [ ] a [] a[]即可。把第17~20行的 a [ ] 改 为 D [ ] a[]改为D[] a[]D[],也能通过。这个技巧在后面的二维差分、三维差分中也能用,节省一倍的空间。

1.2 差分的局限性

  读者已经注意到,利用差分数组 D [ ] D[] D[]可以把 O ( n ) O(n) O(n)的区间修改,变成 O ( 1 ) O(1) O(1)的端点修改,从而提高了修改操作的效率。
  但是,一次查询操作,即查询某个 a [ i ] a[i] a[i],需要用 D [ ] D[] D[]计算整个原数组 a [ ] a[] a[],计算量是 O ( n ) O(n) O(n)的,即一次查询的复杂度是 O ( n ) O(n) O(n)的。在上面的例题中,如果查询不是发生了一次,而是这样:有m次修改,有k次查询,且修改和查询的顺序是随机的。此时总复杂度是:m次修改复杂度 O ( m ) O(m) O(m),k次查询复杂度 O ( k n ) O(kn) O(kn),总复杂度 O ( m + k n ) O(m + kn) O(m+kn)。还不如直接用暴力法,总复杂度 O ( m n + k ) O(mn + k) O(mn+k)
  这种题型是“区间修改+单点查询”,用差分数组往往不够用。因为差分数组对“区间修改”很高效,但是对“单点查询”并不高效。此时需要用树状数组和线段树来求解,详情见第4章的树状数组、线段树专题。在树状数组专题中,重新讲解了hdu 1556这道例题。
  树状数组常常结合差分数组来解决更复杂的问题,见本博客的树状数组专题。差分数组也常用于“树上差分”,见本博客LCA专题的“树上差分”。

2. 二维差分

  从一维差分容易扩展到二维差分。一维是线性数组,一个区间 [ L , R ] [L, R] [L,R]有两个端点;二维是矩阵,一个区间由四个端点围成。
  下面给出一个模板题。


地毯 洛谷P3397 https://www.luogu.com.cn/problem/P3397
问题描述:在 n×n 的格子上有m个地毯。给出这些地毯的信息,问每个点被多少个地毯覆盖。
输入: 第一行是两个正整数n,m。接下来m行,每行2个坐标(x1, y1)和(x2, y2),代表一块地毯,左上角是(x1, y1),右下角是(x2, y2)。
输出: 输出n行,每行n个正整数。第i行第j列的正整数表示(i, j)这个格式被多少地毯覆盖。


  这一题是hdu 1556的二维扩展,其修改操作和查询操作完全一样。
  存储矩阵需要很大的空间。如果题目有空间限制,例如100M,那么二维差分能处理多大的n?定义两个二维矩阵 a [ ] [ ] 和 D [ ] [ ] a[][]和D[][] a[][]D[][],设矩阵的每个元素是2字节的 i n t int int型,可以计算出最大的n = 5000。不过,也可以不定义 a [ ] [ ] a[][] a[][],而是像一维情况下一样,直接用 D [ ] [ ] 来 表 示 a [ ] [ ] D[][]来表示a[][] D[][]a[][],这样能剩下一半的空间。
  在用差分之前,先考虑能不能用暴力法。每次修改复杂度是 O ( n 2 ) O(n^2) O(n2),共m次,总复杂度 O ( m × n 2 ) O(m×n^2) O(m×n2),超时。
  二维差分的复杂度是多少?一维差分的一次修改是 O ( 1 ) O(1) O(1)的,二维差分的修改估计也是 O ( 1 ) O(1) O(1)的;一维差分的一次查询是 O ( n ) O(n) O(n)的,二维差分是 O ( n 2 ) O(n^2) O(n2)的,所以二维差分的总复杂度是 O ( m + n 2 ) O(m + n^2) O(m+n2)。由于计算一次二维矩阵的值需要 O ( n 2 ) O(n^2) O(n2)次计算,所以二维差分已经达到了最好的复杂度。
  下面从一维差分推广到二维差分。
  (1)前缀和。
  在一维差分中,原数组 a [ ] a[] a[]是从第1个 D [ 1 ] D[1] D[1]开始的差分数组 D [ ] D[] D[]的前缀和: a [ k ] = D [ 1 ] + D [ 2 ] + . . . + D [ k ] a[k] = D[1] + D[2] + ... + D[k] a[k]=D[1]+D[2]+...+D[k]
  在二维差分中, a [ ] [ ] a[][] a[][]是差分数组 D [ ] [ ] D[][] D[][]的前缀和,即由原点坐标 ( 1 , 1 ) (1, 1) (1,1)和坐标 ( i , j ) (i, j) (i,j)围成的矩阵中,所有的 D [ ] [ ] D[][] D[][]相加等于 a [ i ] [ j ] a[i][j] a[i][j]。为加深对前缀和的理解,可以把每个 D [ ] [ ] D[][] D[][]看成一个小格;在坐标 ( 1 , 1 ) 和 ( i , j ) (1, 1)和(i, j) (1,1)(i,j)所围成的范围内,所有小格子加起来的总面积,等于 a [ i ] [ j ] a[i][j] a[i][j]。下面的图中,每个格子的面积是一个 D [ ] [ ] D[][] D[][],例如阴影格子是 D [ i ] [ j ] D[i][j] D[i][j],它由4个坐标点定义: ( i − 1 , j ) 、 ( i , j ) 、 ( i − 1 , j − 1 ) 、 ( i , j − 1 ) (i-1, j)、(i, j)、(i-1, j-1)、(i, j-1) (i1,j)(i,j)(i1,j1)(i,j1)。坐标点 ( i , j ) (i, j) (i,j)的值是 a [ i ] [ j ] a[i][j] a[i][j],它等于坐标 ( 1 , 1 ) 和 ( i , j ) (1, 1)和(i, j) (1,1)(i,j)所围成的所有格子的总面积。图中故意把小格子画得长宽不同,是为了体现它们的面积不同。

图2 把每个a[][]看成总面积,把每个D[][]看成小格子的面积

  
  注意在一些题目中, D [ ] [ ] D[][] D[][]可以为负。图中把 D [ ] [ ] D[][] D[][]用“面积”来演示,而面积都是正的,这个图示只是为了加深对前缀和的理解。
  (2)差分的定义。在一维情况下, D [ i ] = a [ i ] − a [ i − 1 ] D[i] = a[i] - a[i-1] D[i]=a[i]a[i1]。在二维情况下,差分变成了相邻的 a [ ] [ ] a[][] a[][]的“面积差”,计算公式是: D [ i ] [ j ] = a [ i ] [ j ] – a [ i − 1 ] [ j ] – a [ i ] [ j − 1 ] + a [ i − 1 ] [ j − 1 ] D[i][j] = a[i][j] – a[i-1][j] – a[i][j-1] + a[i-1][j-1] D[i][j]=a[i][j]a[i1][j]a[i][j1]+a[i1][j1]。这个公式可以通过上面的图来观察。阴影方格表示 D [ i ] [ j ] D[i][j] D[i][j]的值,它的面积这样求:大面积 a [ i ] [ j ] a[i][j] a[i][j]减去两个小面积 a [ i − 1 ] [ j ] 、 a [ i ] [ j − 1 ] a[i-1][j]、a[i][j-1] a[i1][j]a[i][j1],由于两个小面积的公共面积 a [ i − 1 ] [ j − 1 ] a[i-1][j-1] a[i1][j1]被减了2次,所以需要加回来1次。
  (3)区间修改。在一维情况下,做区间修改只需要修改区间的两个端点的 D [ ] D[] D[]值。在二维情况下,一个区间是一个小矩阵,有4个端点,只需要修改这4个端点的 D [ ] [ ] D[][] D[][]值。例如坐标点 ( x 1 , y 1 ) (x1, y1) (x1,y1) ~ ( x 2 , y 2 ) (x2, y2) (x2,y2)定义的区间,对应4个端点的 D [ ] [ ] D[][] D[][]

D[x1][y1]     += d;     //二维区间的起点
D[x1][y2+1]   -= d;     //把x看成常数,y从y1到y2+1
D[x2+1][y1]   -= d;     //把y看成常数,x从x1到x2+1
D[x2+1][y2+1] += d;     //由于前两式把d减了2次,多减了1次,这里加1次回来

  下图是区间修改的图示。2个黑色点围成的矩形是题目给出的区间修改范围。只需要改变4个 D [ ] [ ] D[][] D[][]值,即改变图中的4个阴影块的面积。读者可以用这个图,观察每个坐标点的 a [ ] [ ] a[][] a[][]值的变化情况。例如符号“∆”标记的坐标 ( x 2 + 1 , y 2 ) (x2+1, y2) (x2+1,y2),它在修改的区间之外; a [ x 2 + 1 ] [ y 2 ] a[x2+1][y2] a[x2+1][y2]的值是从 ( 1 , 1 ) 到 ( x 2 + 1 , y 2 ) (1,1)到(x2+1, y2) (1,1)(x2+1,y2)的总面积,在这个范围内, D [ x 1 ] [ y 1 ] + d , D [ x 2 + 1 ] [ y 1 ] − d D[x1][y1]+d,D[x2+1][y1]-d D[x1][y1]+dD[x2+1][y1]d,两个 d d d抵消, a [ x 2 + 1 ] [ y 2 ] a[x2+1][y2] a[x2+1][y2]保持不变。

图3 二维差分的区间修改

  下面给出洛谷P3397的两种实现。

2.1 用差分数组的递推公式求前缀和

  前缀和 a [ ] [ ] a[][] a[][]的计算用到了递推公式:
     a [ i ] [ j ] = D [ i ] [ j ] + a [ i − 1 ] [ j ] + a [ i ] [ j − 1 ] − a [ i − 1 ] [ j − 1 ] ; a[i][j] = D[i][j] + a[i-1][j] + a[i][j-1] - a[i-1][j-1]; a[i][j]=D[i][j]+a[i1][j]+a[i][j1]a[i1][j1];
  16行到23行用 D [ ] [ ] D[][] D[][]推出 a [ ] [ ] a[][] a[][]并打印出来。
  为了节约空间,可以不定义 a [ ] [ ] a[][] a[][],而是把用过的 D [ ] [ ] D[][] D[][]看成 a [ ] [ ] a[][] a[][]。这个小技巧在一维差分中介绍过。

#include<bits/stdc++.h>
using namespace std;
int D[5000][5000];     //差分数组
//int a[5000][5000];   //原数组,不定义也行
int main(){
    int n,m;
    scanf("%d%d",&n,&m);
    while(m--){
        int x1,y1,x2,y2;
        scanf("%d%d%d%d",&x1,&y1,&x2,&y2);
        D[x1][y1]     += 1;        //计算差分数组
        D[x2+1][y1]   -= 1;
        D[x1][y2+1]   -= 1;
        D[x2+1][y2+1] += 1;
    }
    for(int i=1;i<=n;++i){   //根据差分数组计算原矩阵的值(想象成求小格子的面积和)
        for(int j=1;j<=n;++j){      //把用过的D[][]看成a[][],就不用再定义a[][]了
            //a[i][j] = D[i][j] + a[i-1][j] + a[i][j-1] - a[i-1][j-1];
            //printf("%d ",a[i][j]);  //这两行和下面两行的效果一样
            D[i][j] += D[i-1][j]+D[i][j-1]-D[i-1][j-1];
            printf("%d ",D[i][j]);
        }
        printf("\n");//换行
    }
    return 0;
}

2.2 直接计算前缀和

  其实不用递推公式,而是直接求前缀和也行。根据图2,前缀和是总面积,分别从 x x x方向和 y y y方向,用两次循环计算,并直接用 D [ ] [ ] D[][] D[][]记录结果,最后算出的 D [ ] [ ] D[][] D[][]就是 a [ ] [ ] a[][] a[][]

图4 在D[][]上计算前缀和

  以阴影处的 D [ 2 ] [ 2 ] D[2][2] D[2][2]为例,它最后的值代表 a [ 2 ] [ 2 ] a[2][2] a[2][2],是4个小格子的总面积:
     D [ 1 ] [ 1 ] + D [ 1 ] [ 2 ] + D [ 2 ] [ 1 ] + D [ 2 ] [ 2 ] D[1][1] + D[1][2] + D[2][1] + D[2][2] D[1][1]+D[1][2]+D[2][1]+D[2][2]
  计算过程是:
  (1)先累加计算 y y y方向,得:
     D [ 1 ] [ 2 ] = D [ 1 ] [ 1 ] + D [ 1 ] [ 2 ] 、 D [ 2 ] [ 2 ] = D [ 2 ] [ 1 ] + D [ 2 ] [ 2 ] D[1][2] = D[1][1]+ D[1][2]、D[2][2] = D[2][1]+ D[2][2] D[1][2]=D[1][1]+D[1][2]D[2][2]=D[2][1]+D[2][2]
  (2)再累加计算 x x x方向,得:
     D [ 2 ] [ 1 ] = D [ 1 ] [ 1 ] + D [ 2 ] [ 1 ] 、 D [ 2 ] [ 2 ] = D [ 1 ] [ 2 ] + D [ 2 ] [ 2 ] = D [ 1 ] [ 1 ] + D [ 1 ] [ 2 ] + D [ 2 ] [ 1 ] + D [ 2 ] [ 2 ] D[2][1]=D[1][1]+D[2][1]、D[2][2]=D[1][2]+D[2][2]= D[1][1]+D[1][2]+ D[2][1]+ D[2][2] D[2][1]=D[1][1]+D[2][1]D[2][2]=D[1][2]+D[2][2]=D[1][1]+D[1][2]+D[2][1]+D[2][2]
  实际上,在这个计算过程中, D [ 1 ] [ 1 ] 、 D [ 1 ] [ 2 ] 、 D [ 2 ] [ 1 ] 、 D [ 2 ] [ 2 ] D[1][1]、D[1][2]、D[2][1]、D[2][2] D[1][1]D[1][2]D[2][1]D[2][2]都更新了,计算结果代表了 a [ 1 ] [ 1 ] 、 a [ 1 ] [ 2 ] 、 a [ 2 ] [ 1 ] 、 a [ 2 ] [ 2 ] a[1][1]、a[1][2]、a[2][1]、a[2][2] a[1][1]a[1][2]a[2][1]a[2][2]
  把方法1代码的16-24行替换为下面的代码,最后得到的 D [ ] [ ] D[][] D[][]就是所有的前缀和,即最新的 a [ ] [ ] a[][] a[][]。请对照图2理解代码。

    for(int i=1; i<=n; ++i)           
        for(int j=1; j<n; ++j)        //注意这里是j<n
            D[i][j+1] += D[i][j];     //把i看成定值,先累加计算j方向
    for(int j=1; j<=n; ++j)
        for(int i=1; i<n; ++i)        //注意这里是i<n
            D[i+1][j] += D[i][j];     //把j看成定值,再累加计算i方向
    for(int i=1; i<=n; ++i) {         //打印
        for(int j=1; j<=n; ++j)
             printf("%d ",D[i][j]);
        printf("\n");                 //换行
    }

  对比这两种代码:
  (1)这两种代码的复杂度是一样的。从计算量上看,没有优劣之分。
  (2)代码2不如代码1清晰简洁,所以代码2这种写法一般也用不着。
  (3)代码2也有优点,它不需要用到递推公式,而是直接求前缀和。
  这里给出代码2这种方法,是为了在下一小节的三维差分中使用它。由于在三维情况下,差分数组的 D [ ] [ ] [ ] D[][][] D[][][]和原数组 a [ ] [ ] [ ] a[][][] a[][][]的递推公式很难写出来,所以用代码2这种方法更容易编码。

3. 三维差分

  三维差分的模板代码比较少见。
  三维差分比较复杂,请结合本节中的几何图进行理解。
  与一维差分、二维差分的思路类似,下面给出三维差分的有关特性。
  (1)元素的值用三维数组 a [ ] [ ] [ ] a[][][] a[][][]来定义,差分数组 D [ ] [ ] [ ] D[][][] D[][][]也是三维的。把三维差分想象成在立体空间上的操作。一维的区间是一个线段,二维是矩形,那么三维就是立体块。一个小立体块有8个顶点,所以三维的区间修改,需要修改8个 D [ ] [ ] [ ] D[][][] D[][][]值。
  (2)前缀和。
  在二维差分中, a [ ] [ ] a[][] a[][]是差分数组 D [ ] [ ] D[][] D[][]的前缀和,即由原点坐标 ( 1 , 1 ) (1, 1) (1,1)和坐标 ( i , j ) (i, j) (i,j)围成的矩阵中,所有的 D [ ] [ ] D[][] D[][](看成小格子)相加等于 a [ i ] [ j ] a[i][j] a[i][j](看成总面积)。
  在三维差分中, a [ ] [ ] [ ] a[][][] a[][][]是差分数组 D [ ] [ ] [ ] D[][][] D[][][]的前缀和。即由原点坐标 ( 1 , 1 , 1 ) (1, 1, 1) (1,1,1)和坐标 ( i , j , k ) (i, j, k) (i,j,k)所标记的范围中,所有的 D [ ] [ ] [ ] D[][][] D[][][]相加等于 a [ i ] [ j ] [ k ] a[i][j][k] a[i][j][k]。把每个 D [ ] [ ] [ ] D[][][] D[][][]看成一个小立方体;在坐标 ( 1 , 1 , 1 ) (1, 1, 1) (1,1,1) ( i , j , k ) (i, j, k) (i,j,k)所围成的空间中,所有小立体块加起来的总体积,等于 a [ i ] [ j ] [ k ] a[i][j][k] a[i][j][k]。每个小立方体由8个坐标点定义,见下面图中的坐标点。坐标点 ( i , j , k ) (i, j, k) (i,j,k)的值是 a [ i ] [ j ] [ k ] a[i][j][k] a[i][j][k] D [ i ] [ j ] [ k ] D[i][j][k] D[i][j][k]的值是图中小立方体的体积。

图5立体的坐标

  (3)差分的定义。在三维情况下,差分变成了相邻的 a [ ] [ ] [ ] a[][][] a[][][]的“体积差”。如何写出差分的递推计算公式?
  一维差分和二维差分的递推计算公式很好写。
  三维差分, D [ i ] [ j ] [ k ] D[i][j][k] D[i][j][k]的几何意义是图中小立方体的体积,它可以通过这个小立方体的8个顶点的值推出来。思路与二维情况下类似,二维的 D [ ] [ ] D[][] D[][]是通过小矩形的四个顶点的 a [ ] [ ] a[][] a[][]值来计算的。不过,三维情况下,递推计算公式很难写,8个顶点有8个 a [ ] [ ] [ ] a[][][] a[][][],把脑袋绕晕了也不容易写对。
上一小节的二维差分中,曾用过另一种方法,直接对D数组求前缀和。在三维情况下也可以用这种方法求前缀和,得到所有的 a [ ] [ ] [ ] a[][][] a[][][]的最新值。
  (4)区间修改。在三维情况下,一个区间是一个立方体,有8个顶点,只需要修改这8个顶点的 D [ ] [ ] [ ] D[][][] D[][][]值。例如坐标点 ( x 1 , y 1 , z 1 ) (x1, y1, z1) (x1,y1,z1) ~ ( x 2 , y 2 , z 2 ) (x2, y2, z2) (x2,y2,z2)定义的区间,对应8个 D [ ] [ ] [ ] D[][][] D[][][],请对照上面的图来想象它们的位置。

D[x1][y1][z1]       += d;   //前面:左下顶点,即区间的起始点
D[x2+1][y1][z1]     -= d;   //前面:右下顶点的右边一个点
D[x1][y1][z2+1]     -= d;   //前面:左上顶点的上面一个点
D[x2+1][y1][z2+1]   += d;   //前面:右上顶点的斜右上方一个点
D[x1][y2+1][z1]     -= d;   //后面:左下顶点的后面一个点
D[x2+1][y2+1][z1]   += d;   //后面:右下顶点的斜右后方一个点
D[x1][y2+1][z2+1]   += d;   //后面:左上顶点的斜后上方一个点
D[x2+1][y2+1][z2+1] -= d;   //后面:右上顶点的斜右上后方一个点,即区间终点的后一个点

下面给出一个三维差分的例题。


三体攻击 蓝桥杯2018年省赛A组
提交地址:https://www.lanqiao.cn/problems/180/learning/
问题描述:三体人将对地球发起攻击。为了抵御攻击,地球人派出了n = A × B × C 艘战舰,在太空中排成一个 A 层 B 行 C 列的立方体。其中,第 i 层第 j 行第 k 列的战舰(记为战舰 (i, j, k))的生命值为 s(i, j, k)。
三体人将会对地球发起 m 轮“立方体攻击”,每次攻击会对一个小立方体中的所有战舰都造成相同的伤害。具体地,第 t 轮攻击用 7 个参数 x1, x2, y1, y2, z1, z2, d 描述;
所有满足i∈[x1, x2], j∈[y1, y2], k∈[z1, z2] 的战舰 (i, j, k) 会受到 d 的伤害。如果一个战舰累计受到的总伤害超过其防御力,那么这个战舰会爆炸。
地球指挥官希望你能告诉他,第一艘爆炸的战舰是在哪一轮攻击后爆炸的。
输入:第一行包括 4 个正整数 A, B, C, m;
第二行包含 A × B × C 个整数,其中第 ((i − 1)×B + (j − 1)) × C + (k − 1)+1 个数为 s(i, j, k);
第 3 到第 m + 2 行中,第 (t − 2) 行包含 7 个正整数 x1, x2, y1, y2, z1, z2, d。
A × B × C ≤ 10^6, m ≤ 10^6, 0 ≤ s(i, j, k), d ≤ 10^9。
输出:输出第一个爆炸的战舰是在哪一轮攻击后爆炸的。保证一定存在这样的战舰。


  首先看数据规模,有 n = 1 0 6 n=10^6 n=106个点, m = 1 0 6 m=10^6 m=106次攻击,如果用暴力法,统计每次攻击后每个点的生命值,那么复杂度是 O ( m n ) O(mn) O(mn)的,超时。
  本题适合用三维差分,每次攻击只修改差分数组 D [ ] [ ] [ ] D[][][] D[][][],一次修改的复杂度是 O ( 1 ) O(1) O(1) m m m次修改的总复杂度只有 O ( m ) O(m) O(m)
  但是光用差分数组并不能解决问题。因为在差分数组上查询区间内的每个元素是否小于0,需要用差分数组来计算区间内每个元素的值,复杂度是 O ( n ) O(n) O(n)的。合起来的总复杂度还是O(mn)的,跟暴力法的复杂度一样。
  本题需要结合第二个算法:二分法。从第1次修改到第m次修改,肯定有一次修改是临界点。在临界点前,没有负值(战舰爆炸);在临界点后,出现了负值,且后面一直有负值。那么对m进行二分,就能在 O ( l o g m ) O(logm) O(logm)次内找到这个临界点,这就是答案。总复杂度 O ( n l o g m ) O(nlogm) O(nlogm)
下面给出代码。其中check()函数包含了三维差分的全部内容。代码有几个关键点:
  (1)没有定义 a [ ] [ ] [ ] a[][][] a[][][],而是用 D [ ] [ ] [ ] D[][][] D[][][]来代替。
  (2)压维。直接定义三维差分数组 D [ ] [ ] [ ] D[][][] D[][][]不太方便。虽然坐标点总数量 n = A × B × C = 1 0 6 n = A × B × C = 10^6 n=A×B×C=106比较小,但是每一维都需要定义到 1 0 6 10^6 106,那么总空间就是 1 0 18 10^{18} 1018。为避免这一问题,可以把三维坐标压维成一维数组 D [ ] D[] D[],总长度仍然是 1 0 6 10^6 106的。这个技巧很有用。实现函数是num(),它把三维坐标 ( x , y , z ) (x, y, z) (x,y,z)变换为一维坐标 h = ( x − 1 ) ∗ B ∗ C + ( y − 1 ) ∗ C + ( z − 1 ) + 1 h = (x-1)*B*C + (y-1)*C + (z-1) + 1 h=(x1)BC+(y1)C+(z1)+1,当 x 、 y 、 z x、y、z xyz的取值范围分别是1 ~ A、1 ~ B、1 ~ C时, h h h的范围是1 ~ A × B × C。
  如果希望按C语言的习惯从0开始, x 、 y 、 z x、y、z xyz的取值范围分别是0 ~ A-1、0 ~ B-1、0 ~ C-1,h范围是0 ~ A × B × C-1,就把式子改为: h = x ∗ B ∗ C + y ∗ C + z h = x*B*C + y*C + z h=xBC+yC+z
  同理,二维坐标 ( x , y ) (x, y) (x,y)也可以压维成一维 h = ( x − 1 ) ∗ B + ( y − 1 ) + 1 h = (x-1)*B + (y-1) + 1 h=(x1)B+(y1)+1,当 x 、 y x、y xy的取值范围分别是1 ~ A、1 ~ B时, h h h的范围是1 ~ A × B。
  (3)check()中19-26行,在 D [ ] D[] D[]上记录区间修改。
  (4)check()中29-40行的3个for循环计算前缀和,原理见二维差分的代码2。它分别从 x 、 y 、 z x、y、z xyz三个方向累加小立方体的体积,计算出所有的前缀和。

#include<stdio.h>

int A,B,C,n,m;
const int Maxn = 1000005;
int s[Maxn];   //存储舰队生命值
int D[Maxn];   //三维差分数组(压维);同时也用来计算每个点的攻击值
int x2[Maxn], y2[Maxn], z2[Maxn]; //存储区间修改的范围,即攻击的范围
int x1[Maxn], y1[Maxn], z1[Maxn]; 

int d[Maxn];                    //记录伤害,就是区间修改
int num(int x,int y,int z) {  
//小技巧:压维,把三维坐标[(x,y,z)转为一维的((x-1)*B+(y-1))*C+(z-1)+1
    if (x>A || y>B || z>C) return 0;
    return ((x-1)*B+(y-1))*C+(z-1)+1;
}
bool check(int x){              //做x次区间修改。即检查经过x次攻击后是否有战舰爆炸
    for (int i=1; i<=n; i++)  D[i]=0;  //差分数组的初值,本题是0
    for (int i=1; i<=x; i++) {         //用三维差分数组记录区间修改:有8个区间端点
        D[num(x1[i],  y1[i],  z1[i])]   += d[i];
        D[num(x2[i]+1,y1[i],  z1[i])]   -= d[i];
        D[num(x1[i],  y1[i],  z2[i]+1)] -= d[i];
        D[num(x2[i]+1,y1[i],  z2[i]+1)] += d[i];
        D[num(x1[i],  y2[i]+1,z1[i])]   -= d[i];
        D[num(x2[i]+1,y2[i]+1,z1[i])]   += d[i];
        D[num(x1[i],  y2[i]+1,z2[i]+1)] += d[i];
        D[num(x2[i]+1,y2[i]+1,z2[i]+1)] -= d[i];
    }
    //下面从x、y、z三个方向计算前缀和
    for (int i=1; i<=A; i++)
        for (int j=1; j<=B; j++)
            for (int k=1; k<C; k++)        //把x、y看成定值,累加z方向
                D[num(i,j,k+1)] += D[num(i,j,k)];
    for (int i=1; i<=A; i++)
        for (int k=1; k<=C; k++)
            for (int j=1; j<B; j++)        //把x、z看成定值,累加y方向
                D[num(i,j+1,k)] += D[num(i,j,k)];
    for (int j=1; j<=B; j++)
        for (int k=1; k<=C; k++)
            for (int i=1; i<A; i++)        //把y、z看成定值,累加x方向
                D[num(i+1,j,k)] += D[num(i,j,k)];
    for (int i=1; i<=n; i++)    //最后判断是否攻击值大于生命值
        if (D[i]>s[i])
            return true;
    return false;
}
int main() {
    scanf("%d%d%d%d", &A, &B, &C, &m);
    n = A*B*C;
    for (int i=1; i<=n; i++) scanf("%d", &s[i]);  //读生命值
    for (int i=1; i<=m; i++)                      //读每次攻击的范围,用坐标表示
        scanf("%d%d%d%d%d%d%d",&x1[i],&x2[i],&y1[i],&y2[i],&z1[i],&z2[i],&d[i]);

    int L = 1,R = m;      //经典的二分写法
    while (L<R) {     //对m进行二分,找到临界值。总共只循环了log(m)次
        int mid = (L+R)>>1;
        if (check(mid)) R = mid;
        else L = mid+1;
    }
    printf("%d\n", R);  //打印临界值
    return 0;
}

4. 差分习题

一维差分:poj 3263;hdu 6273,1121;洛谷P3406,P3948,P4552
二维差分:洛谷P3397,hdu 6514
三维差分:蓝桥杯A组2018省赛“三体攻击”

已标记关键词 清除标记
相关推荐
《ACM国际大学生程序设计竞赛:算法与实现》内容简介:ACM国际大学生程序设计竞赛(ACM-ICPC)是国际上公认的水平最高、规模最大、影响最深的计算机专业竞赛,目前全球参与人数达20多万。《ACM国际大学生程序设计竞赛:算法与实现》作者将16年的教练经验与积累撰写成本系列丛书,全面、深入而系统地将ACM-ICPC展现给读者。本系列丛书包括《ACM国际大学生程序设计竞赛:知识与入门》、《ACM国际大学生程序设计竞赛算法与实现》、《ACM国际大学生程序设计竞赛:题目与解读》、《ACM国际大学生程序设计竞赛:比赛与思考》等4册,其中《ACM国际大学生程序设计竞赛:知识与入门》介绍了ACM-ICPC的知识及其分类、进阶与角色、在线评测系统;《ACM国际大学生程序设计竞赛算法与实现》介绍了ACM-ICPC算法分类、实现及索引;《ACM国际大学生程序设计竞赛:题目与解读》为各类算法配备经典例题及题库,并提供解题思路;《ACM国际大学生程序设计竞赛:比赛与思考》介绍了上海交通大学ACM-ICPC的训练及比赛,包括训练札记、赛场风云、赛季纵横、冠军之路、峥嵘岁月。 本丛书适用于参加ACM国际大学生程序设计竞赛的本科生和研究生,对参加青少年信息学奥林匹克竞赛的中学生也很有指导价值。同时,作为程序设计、数据结构、算法等相关课程的拓展与提升,本丛书也是难得的教学辅助读物。 目录 第一部分 算 法 第1章 数学 3 1.1 矩阵 3 1.1.1 矩阵类 3 1.1.2 Gauss消元 4 1.1.3 矩阵的逆 6 1.1.4 常系数线性齐次递推 7 1.2 整除与剩余 9 1.2.1 欧几里得算法 9 1.2.2 扩展欧几里得 9 1.2.3 单变元模线性方程 10 1.2.4 中国剩余定理 11 1.2.5 求原根 13 1.2.6 平方剩余 14 1.2.7 离散对数 15 1.2.8 N次剩余 16 1.3 素数与函数 18 1.3.1 素数筛法 18 1.3.2 素数判定 19 1.3.3 质因数分解 20 1.3.4 欧拉函数计算 21 1.3.5 Mobius函数计算 23 1.4 数值计算 24 1.4.1 数值积分 24 1.4.2 高阶代数方程求根 26 1.5 其他 27 1.5.1 快速幂 27 1.5.2 进制转换 28 1.5.3 格雷码 29 1.5.4 高精度整数 30 1.5.5 快速傅立叶变换 35 1.5.6 分数类 37 1.5.7 全排列散列 38 第2章 图论 40 2.1 图的遍历及连通性 40 2.1.1 前向星 40 2.1.2 割点和桥 42 2.1.3 双连通分量 43 2.1.4 极大强连通分量Tarjan 算法 45 2.1.5 拓扑排序 47 2.1.6 2SAT 49 2.2 路径 51 2.2.1 Dijkstra 51 2.2.2 SPFA 53 2.2.3 Floyd-Warshall 54 2.2.4 无环图最短路 55 2.2.5 第k短路 56 2.2.6 欧拉回路 59 2.2.7 混合图欧拉回路 61 2.3 匹配 64 2.3.1 匈牙利算法 64 2.3.2 Hopcroft-Karp算法 66 2.3.3 KM算法 68 2.3.4 一般图最大匹配 71 2.4 树 74 2.4.1 LCA 74 2.4.2 最小生成树Prim算法 77 2.4.3 最小生成树Kruskal算法 78 2.4.4 单度限制最小生成树 79 2.4.5 最小树形图 83 2.4.6 最优比例生成树 85 2.4.7 树的直径 87 2.5 网络流 89 2.5.1 最大流Dinic算法 89 2.5.2 最小割 92 2.5.3 无向图最小割 93 2.5.4 有上下界的网络流 95 2.5.5 费用流 97 2.6 其他 100 2.6.1 完美消除序列 100 2.6.2 弦图判定 101 2.6.3 最大团搜索算法 103 2.6.4 极大团的计数 105 2.6.5 图的同构 107 2.6.6 树的同构 108 第3章 计算几何 112 3.1 多边形 112 3.1.1 计算几何误差修正 112 3.1.2 计算几何点类 113 3.1.3 计算几何线段类 115 3.1.4 多边形类 117 3.1.5 多边形的重心 118 3.1.6 多边形内格点数 119 3.1.7 凸多边形类 120 3.1.8 凸多边形的直径 123 3.1.9 半平面切割多边形 124 3.1.10 半平
©️2020 CSDN 皮肤主题: 技术黑板 设计师:CSDN官方博客 返回首页