樹狀數組 線段樹
樹狀數組
樹狀數組的基本用途是維護序列的前綴和,相比前綴和數組,樹狀數組優勢在於高效率的單點修改,單點增加(前綴和數組單點修改效率比較低)
因為樹狀數組的思想,原理還是很好理解的,就直接講基本算法;
1 lowbit函數
關於lowbit這個函數,可能會有點難以理解,但其實你不理解也沒關系,把模板背下來就好
根據任意正整數關於2的不重復次冪的唯一分解性質,例如十進制21用二進制表示為10101,其中等於1的位是第0,2,4(最右端是第0位)位,即21被二進制分解成\(2^4+2^2+2^0\);
進一步地,整個區間[1,21]可以分成如下3個小區間:
長度為\(2^4\)的小區間[1,\(2^4\)];
長度為\(2^2\)的小區間[\(2^4+1\),\(2^4+2^2\)];
長度為\(2^0\)的小區間[\(2^4+2^2+1\),\(2^4+2^2+2^0\)];
對於給定的初始序列A,我們可以建立一個數組c,c[x]表示序列A的區間[x-lowbit(x)+1,x)]中所有數的和;
int lowbit(int x){return x&-x;}
2 單點增加操作
void update(int x,int y){
for(;x<=n;x+=lowbit(x))
c[x]+=y;
}
3 查詢前綴和
int sum(int x){ int ans=0; for(;x;x-=lowbit(x)) ans+=c[x]; return ans; }
4 擴展
上述查詢前綴和是統計[1,x]的前綴和,若要統計區間[x,y]的和,則調用sum函數即可:sum(y)-sum(x-1);
多維樹狀數組:
(擴充為m維)將原來的修改和查詢函數中的一個循環,改成m個循環m維數組c中的操作;
以\(n*m\)的二維數組為例:
將(x,y)的值加上z,不是把區間[x,y]中的每個值加z int update(int x,int y,int z){ int i=x; while(i<=n){ int j=y; while(j<=m){ c[i][j]+=z; j+=lowbit(j); } i+=lowbit(i); } } int sum(int x,int y){ int res=0,i=x; while(i>0){ int j=y; while(j>0){ res+=c[i][j]; j-=lowbit(j); } i-=lowbit(i); } return res; }
註意樹狀數組的下標絕對不能為0,因為lowbit(0)=0,這樣會陷入死循環
兩道模板題,多打打模板~~
https://www.luogu.org/problemnew/show/P3374
https://www.luogu.org/problemnew/show/P3368
線段樹
線段樹是一種基於分治思想的二叉樹結構,比樹狀數組更加通用,下文總結會比較這兩種數據結構;
線段樹的基本用途是對序列進行維護,支持查詢與修改指令;
線段樹的每個節點都代表一個區間;
線段樹具有唯一的根節點,代表的是整個區間,即[1,n];
線段樹的每個葉節點都代表一個長度為1的區間,即[x,x];
對於每個內部節點[l,r],它的左子節點是[l,mid],右子節點是[mid+1,r],其中mid=(l+r)/2(向下取整);(子節點與父節點的性質近似於二叉堆的結構);
**保存的數組長度要不小於4*n;**
1 建樹:線段樹的二叉樹結構可以很方便地從下往上傳遞信息;
下面以區間最大值為例:
struct TREE{
int l,r;
int dat;
}t[n*4];
//一般會用結構體存儲線段樹
void build(int p,int l,int r){
t[p].l=l;t[p].r=r;//節點p代表區間[l,r]
if(l==r){t[p].dat=a[l];return;}//葉節點
int mid=(l+r)/2;
build(p*2,l,mid);//左子節點
build(p*2+1,mid+1,r);//右子節點
t[p].dat=max(t[p*2].dat,t[p*2+1].dat);
//從下往上傳遞信息
}
build(1,1,n);//調用入口
2 單點修改:線段樹中,根節點(編號為1的節點)是執行各種指令的入口;上述建樹過程中的調用入口的第一個1就是這個道理;
我們還是以區間最大值問題為例
void change(int p,int x,int v){
if(t[p].l==t[p].r){t[p].dat=v;return;}
//找到葉節點
int mid=(t[p].l+t[p].r)/2;
if(x<=mid) change(p*2,x,v);
else change(p*2+1,x,v);
//判斷x屬於哪邊區間
t[p].dat=max(t[p*2].dat,t[p*2+1].dat);
}
change(1,x,v);
3 區間查詢
以查詢區間最大值為例:
若[l,r]完全覆蓋了當前節點代表的區間,則立即回溯,並且該節點的dat值為候選答案
若左子節點與[l,r]有重疊部分,則遞歸訪問左子節點;
若右子節點與[l,r]有重疊部分,則遞歸訪問右子節點;
(這裏自己稍微理解一下就懂了,或者自己畫個圖)
假設我們查詢區間[2,7]的最大值,現在有一個節點的代表區間為[3,5],那麽屬於完全覆蓋的情況,return,並且該節點的dat值為候選答案;
現在還有[2,3]和[5,7]這兩個區間沒有處理,我們繼續尋找節點,如果有一個節點與該區間有交集,即有重疊的區間,我們就繼續向下訪問該節點,直至找到一個節點完全被[2,3]或[5,7]覆蓋,那麽跟上面一樣處理即可;(記住我們是從上往下訪問線段樹,所以區間範圍從上往下是越來越小的)
int ask(int p,int l,int r){
if(l<=t[p].l&&r>=t[p].r)return t[p].dat;
int mid=(t[p].l+t[p].r)/2;
int val=-(1<<30);
if(l<=mid) val=max(val,ask(p*2,l,r));
if(r>mid) val=max(val,ask(p*2+1,l,r));
return val;
}
ask(1,l,r);
延遲標記:標識該節點曾經被修改,但其子節點尚未被修改(即一個節點被打上延遲標記的同時,它自身保存的信息應該已經被修改完畢)
通俗地講,我們在進行區間增加操作時,如果去更改區間中的每個數(即遍歷到每個葉節點),時間復雜度會增加到O(N);
試想一下,有可能我們修改了該區間,但所有的查詢中與該區間沒有關系(即該區間沒有對我們的答案產生貢獻),相當於我們做了一次無用的操作;
於是就可以用到延遲標記,我們在執行修改指令時,給節點p一個標記,標識該節點曾經被修改,但其子節點尚未被修改,如果在後續的查詢指令中,我們需要知道p節點的子節點的信息,如果p節點有標記,那麽更新p的兩個子節點,同時給p的兩個子節點打上延遲標記,然後清除p的標記;
以區間增加問題為例:
void spread(int p){
if(t[p].add){//如果節點P有標記
t[p*2].sum+=t[p].add*(t[p*2].r-t[p*2].l+1);
//更新左子節點信息
t[p*2+1].sum+=t[p].add*(t[p*2+1].r-t[p*2+1].l+1);
t[p*2].add+=t[p].add;//給左子節點打延遲標記
t[p*2+1].add+=t[p].add;
t[p].add=0;//清除p的標記
}
}
void change(int p,int x,int y,int v){
if(x<=t[p].l&&y>=t[p].r){//完全覆蓋
t[p].sum+=v*(t[p].r-t[p].l+1);
//更新節點信息
t[p].add+=v;//給節點打上延遲標記
return;
}
spread(p); //下傳延遲標記
int mid=(t[p].l+t[p].r)/2;
if(x<=mid) change(p*2,x,y,v);
if(y>mid) change(p*2+1,x,y,v);
t[p].sum=t[p*2].sum+t[p*2+1].sum;
}
long long ask(int p,int l,int r){
if(l<=t[p].l&&r>=t[p].r)
return t[p].sum;
spread(p);//下傳延遲標記
int mid=(t[p].l+t[p].r)/2;
long long ans=0;
if(l<=mid) ans+=ask(p*2,l,r);
if(r>mid) ans+=ask(p*2+1,l,r);
return ans;
}
最後比較一下樹狀數組和線段樹:
樹狀數組可以實現單點修改和區間求和(如果將序列進行差分,那麽還可以實現區間價值和單點詢問);
線段樹能實現樹狀數組的所有操作,除此之外,還可以通過標記下傳或標記永久化進行區間修改和區間詢問;
但樹狀數組的常數比線段樹小,實現也較為簡單(線段樹真的是隨隨便便100行,看來我還是太蒻了)
總之,能用樹狀數組就用樹狀數組吧,畢竟一般都只能用線段樹;
樹狀數組 線段樹