區間修改&區間查詢問題
【引言】信息學奧賽中常見有區間操作問題,這種類型的題目一般數據規模極大,無法用簡單的模擬通過,因此本篇論文將討論關於可以實現區間修改和區間查詢的一部分算法的優越與否。
【關鍵詞】區間修改、區間查詢、線段樹、樹狀數組、分塊
【例題】
題目描述:
如題,已知一個數列,你需要進行下面兩種操作:
1.將某區間每一個數加上x
2.求出某區間每一個數的和
輸入格式:
第一行包含兩個整數N、M,分別表示該數列數字的個數和操作的總個數。
第二行包含N個用空格分隔的整數,其中第i個數字表示數列第i項的初始值。
接下來M行每行包含3或4個整數,表示一個操作,具體如下:
操作1: 格式:1 x y k 含義:將區間[x,y]內每個數加上k
操作2: 格式:2 x y 含義:輸出區間[x,y]內每個數的和
輸出格式:
輸出包含若干行整數,即為所有操作2的結果。
輸入樣例:
5 5
1 5 4 2 3
2 2 4
1 2 3 2
2 3 4
1 1 5 1
2 1 4
輸出樣例:
11
8
20
說明
時空限制:1000ms,128M
數據規模:
對於30%的數據:N<=8,M<=10
對於70%的數據:N<=1000,M<=10000
對於100%的數據:N<=100000,M<=100000
(保證數據在int64/long long數據范圍內)
@線段樹
【分析】本題是典型的高性能題目,根據題中的數據規模,我們可以得出普通的模擬顯然是不行的(如果出題人願意,最大的數據可以使模擬程序的時間復雜度為O(nm)),因此需要一種更加高效的算法,我們最先不想想到的是線段樹。
線段樹的定義:
線段樹是一種二叉搜索樹,與區間樹相似,它將一個區間划分成一些單元區間,每個單元區間都對應了線段樹中的一個葉結點。
對於線段樹中的每一個非葉子節點[a,b],它的左兒子表示的區間為[a,(a+b)/2],右兒子表示的區間為[(a+b)/2+1,b]。因此線段樹是平衡二叉樹,最后的子節點數目為N,即整個線段區間的長度。
使用線段樹可以快速的查找某一個節點在若干條線段中出現的次數,時間復雜度為O(logN)。而未優化的空間復雜度為2N,因此有時需要離散化讓空間壓縮。
因此線段樹是一種特別不高效的算法,但是需要的空間大小更多,能承受的數據量就相對於其他(高效的)算法要少。在線段樹中,我們把當前節點所包含的區間分成兩半,分別給左右子節點,一直到只包含一個元素為底部。
【程序】
#include<cstdio>
#include<cstring>
#include<algorithm>
#define line_feed putchar(10)
#define llt unsigned long long int
#define maxn1 100005
#define chil1(x) (x<<1)
#define chil2(x) (x<<1|1)
using namespace std;
llt edge[maxn1*4];
llt l[maxn1*4],r[maxn1*4];
llt n,m;
llt x,y,t;
inline void read(llt &x){//快讀
char temp;
while(temp=getchar()){
if(temp>='0'&&temp<='9'){
x=temp-'0';
break;
}
}
while(temp=getchar()){
if(temp<'0'||temp>'9'){
break;
}
x=x*10+temp-'0';
}
return ;
}
void init(llt now,llt x,llt y){//初始化節點所管的范圍的下標
llt mid=(x+y)>>1;
l[now]=x;
r[now]=y;
if(x==y){
return ;
}
init(chil1(now),x,mid);
init(chil2(now),mid+1,y);//遍歷左右子節點
return ;
}
void build(llt now,llt v){
if(r[now]<v||l[now]>v){
return ;
}
edge[now]+=t;
if(l[now]==r[now]&&l[now]==v){
return ;
}
build(chil1(now),v);
build(chil2(now),v);
return ;
}
void change(llt now){
if(r[now]<x||l[now]>y){
return ;
}
if(l[now]==r[now]){
edge[now]+=t;
return ;
}
change(chil1(now));
change(chil2(now));//遍歷左右子節點
edge[now]=edge[chil1(now)]+edge[chil2(now)];//更新當前節點
return ;
}
llt get(llt now){
if(r[now]<x||l[now]>y){
return 0;//如果完全不包含則返回零
}
if(l[now]>=x&&r[now]<=y){
return edge[now];//如果完全包含則返回節點值
}
return get(chil1(now))+get(chil2(now));//有交集則繼續遍歷左右子節點
}
int main(){
llt i;
memset(edge,0,sizeof(edge));
read(n);
read(m);
init(1,1,n);
for(i=1;i<=n;i++){
read(t);
build(1,i);
}
for(i=1;i<=m;i++){
read(t);
read(x);
read(y);
if(t==1){
read(t);
change(1);
}
else{
printf("%lld",get(1));
line_feed;//高科技快速換行,目測要快一些(前面有定義putchar()換行)
}
}
return 0;
}
【分析】
但是這樣的線段樹任然無法在規定時間內完成數據為m=100000 n=100000的數據,因為區間修改和區間詢問在單純的線段樹中無法高效解決問題,如果在遞歸時訪問了所有被更改的節點,那么最壞情況下(依照書上說的)時間復雜度為O(mnlogn)qwq。於是我們想出了一種高科技算法——延遲標記(懶標記)。延遲標記即當整個區間都被操作時,就直接記錄在公共祖先節點上;只修改了一部分,那么就記錄在這部分的公共祖先上;如果四環以內只修改了自己的話,那就只改變自己。我們就需要在每次區間的查詢修改時pushdown一次,以免重復或者沖突或者爆炸。pushdown其實就是純粹的pushup的逆向思維。因為pushup是向上傳導信息,所以開始回溯時執行pushup;但我們如果要讓它向下更新,就要調整順序,在向下遞歸的時候執行pushdown。其中延遲標記有兩種算法——標記下傳、標記永久化。
以下是第一種標記下傳的代碼。
【程序】
#include<cstdio>
#include<cstring>
#include<algorithm>
#define line_feed putchar(10)
#define llt unsigned long long int
#define maxn1 100005
#define chil1(x) (x<<1)
#define chil2(x) (x<<1|1)
using namespace std;
llt sum[maxn1*4],edge[maxn1*4];
llt l[maxn1*4],r[maxn1*4];
llt n,m;
llt x,y,t;
inline void read(llt &x){
char temp;
while(temp=getchar()){
if(temp>='0'&&temp<='9'){
x=temp-'0';
break;
}
}
while(temp=getchar()){
if(temp<'0'||temp>'9'){
break;
}
x=x*10+temp-'0';
}
return ;
}
void init(llt k,llt x,llt y){//初始化左右范圍下標
llt mid=(x+y)>>1;
l[k]=x;
r[k]=y;
if(x==y){
return ;
}
init(chil1(k),x,mid);
init(chil2(k),mid+1,y);
return ;
}
void add(llt k,llt v){
sum[k]+=v;
edge[k]+=(r[k]-l[k]+1)*v;
return ;
}
void pushdown(llt k){//標記下傳
if(!sum[k]){//如果沒有標記就不用考慮
return ;
}
add(chil1(k),sum[k]);
add(chil2(k),sum[k]);//遍歷左右子節點
sum[k]=0;//清零標記
return ;
}
void change(llt k){//區間修改
if(l[k]>=x&&r[k]<=y){
add(k,t);//如果完全包含維護區間和
return ;
}
llt mid=(l[k]+r[k])>>1;
pushdown(k);//下傳標記
if(x<=mid){
change(chil1(k));
}
if(mid<y){
change(chil2(k));
}
edge[k]=edge[chil1(k)]+edge[chil2(k)];
return ;
}
llt get(llt k){//區間查詢
if(l[k]>=x&&r[k]<=y){
return edge[k];
}
pushdown(k);//下傳標記
llt mid=(l[k]+r[k])>>1,reply=0;
if(x<=mid){
reply+=get(chil1(k));
}
if(mid<y){
reply+=get(chil2(k));
}
return reply;
}
int main(){
llt i;
memset(edge,0,sizeof(edge));
memset(sum,0,sizeof(sum));
read(n);
read(m);
init(1,1,n);
for(i=1;i<=n;i++){
read(t);
x=i;
y=i;
change(1);
}
for(i=1;i<=m;i++){
read(t);
read(x);
read(y);
if(t==1){
read(t);
change(1);
}
else{
printf("%lld",get(1));
line_feed;//高科技快速換行(前面有定義putchar()換行)
}
}
return 0;
}
【分析】
還有一種方案不需要下傳延遲標記,即標記永久化。這種算法在詢問操作中計算每遇到的節點對當前詢問的影響。這種算法實際上是我自己想到的,但無奈自己的程序怎么都過不了,只好參考書上的程序。
【程序】
#include<cstdio>
#include<cstring>
#include<algorithm>
#define line_feed putchar(10)
#define llt unsigned long long int
#define maxn1 100005
#define chil1(x) (x<<1)
#define chil2(x) (x<<1|1)
using namespace std;
llt edge[maxn1*4],sum[maxn1*4];
llt l[maxn1*4],r[maxn1*4];
llt n,m;
llt x,y,t;
inline llt maxx(llt x,llt y){
return x>y?x:y;
}
inline llt minx(llt x,llt y){
return x<y?x:y;
}
inline void read(llt &x){
char temp;
while(temp=getchar()){
if(temp>='0'&&temp<='9'){
x=temp-'0';
break;
}
}
while(temp=getchar()){
if(temp<'0'||temp>'9'){
break;
}
x=x*10+temp-'0';
}
return ;
}
void init(llt k,llt x,llt y){
llt mid=(x+y)>>1;
l[k]=x;
r[k]=y;
if(x==y){
return ;
}
init(chil1(k),x,mid);
init(chil2(k),mid+1,y);
return ;
}
void change(llt k){
if(l[k]>=x&&r[k]<=y){
sum[k]+=t;//如果完全包含就直接加到延遲標記中並結束
return ;
}
edge[k]+=(minx(r[k],y)-maxx(l[k],x)+1)*t;//如果有交集則按線段樹標准操作加上
/*
這個地方實際上我也想到了,並集的數量乘以區間操作加上的值便是該節點所增加的值
*/
llt mid=(l[k]+r[k])>>1;
if(x<=mid){
change(chil1(k));
}
if(mid<y){
change(chil2(k));
}
return ;
}
llt get(llt k){
if(l[k]>=x&&r[k]<=y){
return edge[k]+(r[k]-l[k]+1)*sum[k];
}//如果完全包含,直接輸出該節點的包含區域的數據的和加上懶標記的值
llt mid=(l[k]+r[k])>>1;
llt reply=(minx(r[k],y)-maxx(l[k],x)+1)*sum[k];
if(x<=mid){
reply+=get(chil1(k));
}
if(mid<y){
reply+=get(chil2(k));//遍歷左右子節點所包含的區間的和
}
return reply;
}
int main(){
llt i;
memset(edge,0,sizeof(edge));
memset(sum,0,sizeof(sum));
read(n);
read(m);
init(1,1,n);
for(i=1;i<=n;i++){
read(t);
x=i;
y=i;
change(1);
}
for(i=1;i<=m;i++){
read(t);
read(x);
read(y);
if(t==1){
read(t);
change(1);
}
else{
printf("%lld",get(1));
line_feed;//高科技快速換行(前面有定義putchar()換行)
}
}
return 0;
}
【分析】
以上便是線段樹所有的操作及優化。
@樹狀數組
【分析】
現在我們來將線段樹與一種特別高效的算法進行比較,那就是傳說中的——樹狀數組。
樹狀數組的定義:
樹狀數組(Binary Indexed Tree(B.I.T), Fenwick Tree)是一個查詢和修改復雜度都為log(n)的數據結構。主要用於查詢任意兩位之間的所有元素之和,但是每次只能修改一個元素的值;經過簡單修改可以在log(n)的復雜度下進行范圍修改,但是這時只能查詢其中一個元素的值(如果加入多個輔助數組則可以實現區間修改與區間查詢)。
注意:樹狀數組能處理的下標為1~n的數組,但不能處理下標為零的情況。因為lowbit(0)==0,這樣就會陷入死循環(此句源自一本通)。各位喜歡開n-1的大佬勿入。
線段樹所開的數組較大,數據承受的能力較小,一般線段樹的數據承受能力大約是四位數,加上優化后十萬級已經是極限,而樹狀數組可承受的數據規模較大,承受的數據范圍約是百萬級(整整十倍),並且樹狀數組編程與線段樹相比之下較容易,同樣可以輕松地擴展到多維。但是樹狀數組無法實現線段樹的延遲標記優化,使用范圍也比較小,求區間最值沒有較好的方法。因此在某種程度上線段樹更加優秀。
以下是樹狀數組的實現。
【程序】
#include<cstdio>
#include<cstring>
#include<algorithm>
#define maxn1 100005
#define lowbit(x) (x&(-x))
#define line_feed putchar(10)
#define llt unsigned long long int
using namespace std;
llt n,m;
llt edge1[maxn1],edge2[maxn1];//edge2[i]==(i-1)*edge1[i]
inline void read(llt &x){
char temp;
while(temp=getchar()){
if(temp>='0'&&temp<='9'){
x=temp-'0';
break;
}
}
while(temp=getchar()){
if(temp<'0'||temp>'9'){
break;
}
x=x*10+temp-'0';
}
return ;
}inline void add(llt*temp,llt x,llt y){//加法操作單點增加
while(x<=n){
temp[x]+=y;
x+=lowbit(x);
}
}
void update(llt x,llt v){
add(edge1,x,v);
add(edge2,x,v*(x-1));
return ;
}
inline llt get(llt*temp,llt x){//查詢前綴和
llt sum=0;
while(x>0){
sum+=temp[x];
x-=x&(-x);
}
return sum;
}
llt answer(llt x,llt y){
return (y*get(edge1,y)-(x-1)*get(edge1,x-1))-(get(edge2,y)-get(edge2,x-1));
}//第x個數到第y個數的和即前y個數的前綴和減去前(x-1)個數的前綴和
int main(){
llt i;
llt t,x=0,y;
read(n);
read(m);
for(i=1;i<=n;i++){
y=x;
read(x);
y=x-y;
update(i,y);
}
for(i=1;i<=m;i++){
read(t);
read(x);
read(y);
if(t==1){
read(t);
update(x,t);
update(y+1,-t);
}
else{
printf("%lld",answer(x,y));
line_feed;
}
}
return 0;
}
@分塊查找
【分析】
分塊查找是折半查找和順序查找的一種改進方法,分塊查找由於只要求索引表是有序的,對塊內節點沒有排序要求,因此特別適合於節點動態變化的情況。當節點變化很頻繁時,可能會導致塊與塊之間的節點數相差很大,沒寫快具有很多節點,而另一些塊則可能只有很少節點,這將會導致查找效率的下降。
操作步驟
step1 先選取各塊中的最大關鍵字構成一個索引表;
step2 查找分兩個部分:先對索引表進行二分查找或順序查找,以確定待查記錄在哪一塊中;
然后,在已確定的塊中用順序法進行查找。

線段樹的復雜度為O(logn),而分塊的時間復雜度為O(sqrt(n)),咋一看好像線段樹的復雜度要低得多,但線段樹(無優化的)如果要可持續化和樹套樹,占用空間非常大。分塊的拓展性較強,可滿足多種變形題型的解決。
由於我並未深入學習分塊,以下程序借鑒作者ZJL_OIJR。
【程序】
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
#define maxn1 100005
#define maxn2 1005
#define llt long long unsigned int
llt n,m;
llt l,r;
llt t,length,tot;
llt ans;
llt a[maxn1],sum[maxn2],inc[maxn2];
llt b[maxn1],left[maxn2],right[maxn2];
inline int minx(llt a,llt b){
return a<b?a:b;
}
inline int maxx(llt a,llt b) {
return a>b?a:b;
}
inline void read(llt &x){
char temp;
while(temp=getchar()){
if(temp>='0'&&temp<='9'){
x=temp-'0';
break;
}
}
while(temp=getchar()){
if(temp<'0'||temp>'9'){
break;
}
x=x*10+temp-'0';
}
return ;
}
int main(){
llt i;
memset(a,0,sizeof(a));
memset(sum,0,sizeof(sum));
memset(inc,0,sizeof(inc));
memset(b,0,sizeof(b));
memset(left,0,sizeof(left));
memset(right,0,sizeof(right));
read(n);
read(m);
length=sqrt(n);//得到每一塊的長度
tot=n/length;//求出塊的個數
if(n%length){ //不能正好分割
tot++;//多一個不完整的塊
}
for(i=1;i<=n;i++){
read(*(a+i));
*(b+i)=(i-1)/length+1;
sum[b[i]]+=a[i];//b[i]表示i所在的塊
}
for(i=1;i<=tot;i++){
left[i]=(i-1)*length+1,right[i]=i*length;//塊的左右邊界
}
for(;m;m--){
read(t);
read(l);
read(r);
if(t==1){
read(t);
for(i=l;i<=minx(r,right[b[l]]);i++){
a[i]+=t;
sum[b[i]]+=t;//左邊多出來的部分加上
}
for(i=r;i>=maxx(l,left[b[r]]);i--){
a[i]+=t;
sum[b[i]]+=t;//右邊多出來的部分加上
}
for(i=b[l]+1;i<=b[r]-1;i++){
inc[i]+=t;//中間的塊inc加上t
}
}
else{
ans=0;
for(i=l;i<=minx(r,right[b[l]]);i++){
ans+=a[i]+inc[b[i]];//左邊的計入答案
}
for(i=r;i>=maxx(l,left[b[r]]);i--){
ans+=a[i]+inc[b[i]];//右邊的計入答案
}
for(i=b[l]+1;i<=b[r]-1;i++){
ans+=sum[i]+inc[i]*(right[i]-left[i]+1);//將中間完整的塊計入答案,注意inc要乘以區間長度
}
if(b[l]==b[r]){
ans-=a[l]+inc[b[l]]+inc[b[r]]+a[r];//如果l,r在同一塊就會重復,減去重復的兩端
}
printf("%lld\n",ans);
}
}
return 0;
}//此程序借鑒作者ZJL_OIJR
【總結】
區間修改與區間查詢的問題有四種算法可以實現(平衡數Treap沒有在文中提到),但如果求區間最值樹狀數組就無法使用,但如果單純地求區間和,樹狀數組是最優解,而分塊查詢的思想變通性較強,拓展性較強,使用題型較為廣泛。線段樹代碼量較大,但是優化后的速度也較快,因此不同的算法適用於不同的題目。
以下給出本文提到的算法通過例題的總時間:
線段樹(未優化): 不通過
線段樹(標記下傳): 用時:462ms 空間:11.89MB 代碼量:1.69KB
線段樹(標記永久化):用時:418ms 空間:11.77MB 代碼量:1.72KB
樹狀數組: 用時:147ms 空間:03.28MB 代碼量:1.18KB
分塊查詢: 用時:700ms 空間:02.27MB 代碼量:1.68KB
BTW:
以上所有時間復雜度均源自一本通
【參考文獻】:百度百科、一本通、博客園
