差分 --演算法競賽專題解析(32)
技術標籤:基本演算法
本系列文章將於2021年整理出版。前驅教材:《演算法競賽入門到進階》 清華大學出版社
網購:京東 噹噹 作者簽名書:點我
有建議請加QQ 群:567554289
文章目錄
差分是一種處理資料的巧妙而簡單的方法,它應用於區間的修改和詢問問題。把給定的資料元素集A分成很多區間,對這些區間做很多次操作,每次操作是對某個區間內的所有元素做相同的加減操作,若一個個地修改這個區間內的每個元素,非常耗時。引入“差分陣列”D,當修改某個區間時,只需要修改這個區間的“端點”,就能記錄整個區間的修改,而對端點的修改非常容易,是
O
(
1
)
O(1)
資料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[]
差分陣列D[]的定義是
D
[
k
]
=
a
[
k
]
−
a
[
k
−
1
]
D[k] = a[k] - a[k-1]
D[k]=a[k]−a[k−1],即原陣列
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[]都是長度為正的線段,只是為了方便圖示。
如何用差分陣列記錄區間修改?為什麼利用差分陣列能提升修改的效率呢?
把區間
[
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
1≤x<L,字首和
a
[
x
]
a[x]
a[x]不變;
(2)
L
≤
x
≤
R
L ≤ x ≤ R
L≤x≤R,字首和
a
[
x
]
a[x]
a[x]增加了
d
d
d;
(3)
R
<
x
≤
N
R < x ≤ N
R<x≤N,字首和
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[i−1]+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[i−1]=D[i],它是差分陣列的定義。
注意
a
[
]
a[]
a[]的計算方法。
a
[
i
]
=
a
[
i
−
1
]
+
D
[
i
]
a[i] = a[i-1] + D[i]
a[i]=a[i−1]+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
i、k。
//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[i−1]+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)
(i−1,j)、(i,j)、(i−1,j−1)、(i,j−1)。座標點
(
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)所圍成的所有格子的總面積。圖中故意把小格子畫得長寬不同,是為了體現它們的面積不同。
注意在一些題目中,
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[i−1]。在二維情況下,差分變成了相鄰的
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[i−1][j]–a[i][j−1]+a[i−1][j−1]。這個公式可以通過上面的圖來觀察。陰影方格表示
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[i−1][j]、a[i][j−1],由於兩個小面積的公共面積
a
[
i
−
1
]
[
j
−
1
]
a[i-1][j-1]
a[i−1][j−1]被減了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]+d,D[x2+1][y1]−d,兩個 d d d抵消, a [ x 2 + 1 ] [ y 2 ] a[x2+1][y2] a[x2+1][y2]保持不變。
下面給出洛谷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[i−1][j]+a[i][j−1]−a[i−1][j−1];
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[][]。
以陰影處的
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]的值是圖中小立方體的體積。
(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
x、y、z三個方向累加小立方體的體積,計算出所有的字首和。
#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省賽“三體攻擊”