1. 程式人生 > 其它 >差分 --演算法競賽專題解析(32)

差分 --演算法競賽專題解析(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 1 8 10^18 1018。經過壓維,用一維陣列 D [ ] D[] D[],總長度仍然是 1 0 6 10^6 106的。這個技巧很有用。
  (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省賽“三體攻擊”