字首和 線段樹 樹狀陣列講解(超詳細入門)
部落格目錄
Part one、字首和
引入問題:現輸入長度為n的數列co,再輸入q個詢問,每個詢問都給出兩個整數l,r。對於每個詢問都要求給出對於數列co在區間[l,r]上的和(假設下標從0開始)。
1. 最直觀的方法,就是直接暴力求解,每給出一對l和r,遍歷陣列co從l到r上所有值並求和。這是初學者最容易想到的方法,但這不是演算法愛好者採用的方法,因為它的時間複雜度高達O(n*q),對於n,q<=10 0000來說需要1e10的計算量,時間成本是不可接受的。
2.字首和,是懂演算法的人比較容易想到的,也是最優的方法。引入一個輔助陣列b,大小為n,其中b[i]=co[0]+co[1]+...+co[i],也就是數列co的前i個元素的和。但是陣列b一般不這麼算,因為這麼算有大量重複計算,複雜度高達O(n*n)。而是採用遞推式:
b[i]=b[i-1]+co[i];
公式也不難理解,因為b[i-1]已經是前i-1的和了,再加上co[i]就是所求bi。回到原題,b陣列只要一遍初始化,之後對於每個詢問L,r,都可以用字首和來求解:ans=b[r]-b[L-1],(自己畫畫圖就明白了,離散數學中也有類似的應用),整個題目的時間複雜度降低為O(n+q)。
擴充套件:
字首和效率極高,程式碼簡單,但是限制條件比較多。
只要滿足 1.數列不變 2.運算結果可以由逆運算一步步還原 3.區間運算求值 一般可以用字首和求解。
舉個例子:
與非運算結果可以由與非的逆運算(與非的逆運算還是與非)還原得到,即與非的與非還是本身。求區間與非運算值(就是簡單地將加法換成與非運算)可以由字首和計算。
好了,字首和並不是我們的重點,此處也不另找例子和程式碼說明了,重點是引入一下兩個資料結構:
Part two、線段樹
線段樹習題和程式碼: 傳送門 <<<<<<<<本篇文章不過多貼上程式碼,詳情程式碼請參考此處。
引入習題:
在上面的題目的基礎上加一處修改:如果詢問的過程中還可以對陣列進行修改怎麼辦?
現輸入長度為n的數列co,再輸入q個詢問,每個詢問給出一個字母x,字母x要麼是‘a’,要麼是'q',
如果是‘a’,則後面會跟三個整數L,r,k,表示給陣列co的[L,r]區間上所有元素增加k
如果是‘q’,則後面會跟兩個整數L,r,要求計算陣列co區間[L,r]上所有元素和。
約定陣列大小n和詢問/修改個數q<=1e5,即不能暴力模擬。
資料結構簡述:
線段樹是一個完全二叉樹,如下圖所示:對於陣列2 9 5 8 7 10 4,陣列元素作為葉子節點,從左往右每兩個葉子求和,作為父親節點的值,如此遞迴下去生成根節點的值,即為陣列所有元素之和。
一般來說將二叉樹放置在sum數組裡,空置sum的0號位置不用,root節點放在1位置將二叉樹從上到下從左到右依次排列線上性陣列中。設父親節點下標為F,其左孩子下標為L,右孩子下標為R,則有以下關係式:
F=L/2
F=R/2
L=F*2
R=F*2+1
注意:除法向下取整。
(二叉樹的的存放和性質,資料結構有講)
所以,如果可以很容易地找到一個節點x的右孩子的下標:x*2+1;也可以很容易找到一個節點的父親下標:x/2,根節點沒有父親除外。這樣我們就將二叉樹存放在了數組裡而不破壞二叉樹的邏輯形狀。
建立操作(build)由遞迴實現,(請參照:傳送門,下同)
基本思想為:想要建立root節點,必須要先建立他的兩個孩子節點,要建立孩子節點,必須要先建立孩子的孩子節點,遞迴直到葉子節點。遞迴返回的過程,是由葉子節點向上更新所有的祖先的過程,即給所有的祖先加上葉子的值(因為祖先表示了他掛載的所有葉子之和)。
#define lson l , m , rt << 1
#define rson m + 1 , r , rt << 1 | 1
const int maxn = 55555;
int sum[maxn<<2];
void PushUP(int rt){
sum[rt] = sum[rt<<1] + sum[rt<<1|1];
}
void build(int l,int r,int rt){
if(l == r){
scanf("%d",&sum[rt]);
return;
}
int m =(l + r)>>1;
build(lson);
build(rson);
PushUP(rt);
}
查詢區間和:對於區間[L,R],將區間L,R遞迴分解為若干個節點之和。設當前節點值value表示從A到B上之和,求和方法為sum(L,R,root),由於[L,R]不超過[1,n],從根節點出發,一定可以有[A,B]包含[L,R],則:
節點左右孩子表示的區間的分界線為:左<=m,右>m,其中m=(A+B)/2
左孩子表示的區間範圍為:[A,m],右孩子表示的範圍是:[m+1,B],由父親節點就能算出當前節點表示範圍[A,B]
所以,虛擬碼:
int sum(L,R,節點){ //節點引數中包含A和B,即當前節點應當表示的區間範圍
令,ret=0,m=(A+B)/2
如果L<=A 且R>=B 則表示達到了遞迴終止條件,所以return value;因為遞迴過程中引數L,R不變,而節點表示的區間範圍A和B一直在縮小,直到A和B剛好被L,R包含。
如果L<m 表示L,R區間有在左孩子上的部分,則ret=ret+sum(L,R,左孩子)//左孩子和右孩子引數包含孩子節點表示的區間範圍。
如果R>=m 表示L,R區間有在右孩子上的部分,則ret=ret+sum(L,R,右孩子)
return ret;
}
int query(int L,int R,int l,int r,int rt){
if (L <= l && r <= R){
return sum[rt];
}
int m = (l+r)>>1;
int ret = 0;
if(L <= m) ret += query(L , R , lson);
if(R > m) ret += query(L , R , rson);
return ret;
}
修改操作:首先從root遞迴找到葉子,然後返回的過程將葉子所有的祖先更新(跟build類似)
void update(int p,int add,int l,int r,int rt){
if(l == r){
sum[rt] += add;
return;
}
int m = (l + r) >> 1;
if(p <= m) update(p , add , lson);
else update(p , add , rson);
PushUP(rt);
}
空間複雜度:
為了保證二叉樹能夠存下,所以sum陣列至少開4*n,
時間複雜度:
樹的建立,由於每個葉子都要向上訪問修改所有的祖先(有log(n)個祖先),有n個葉子,所以為O( nlog(n) )
查詢區間和:跟遞迴深度有關,二叉樹的深度最大為log(n),所以為O(logn)
修改值:與查詢類似,要找到葉子並返回跟遞迴深度有關,所以O(logn)
所以使用線段樹的總體時間複雜度為O(nlogn+qlogn),計算量在可接受範圍內。
成段更新:
(通常這對初學者來說是一道坎),需要用到延遲標記(或者說懶惰標記),簡單來說就是每次更新的時候不要更新到底,用延遲標記使得更新延遲到下次需要查詢到的時候。
擴充套件:
各種運算的區間求和,跟線性基結合,求區間最值,求逆序數(逆序數還可以用歸併排序求),etc.
凡是字首和能解決的問題,線段樹都可以解決,線段樹比字首和靈活(主要體現在可修改上),但是程式碼複雜且需要遞迴(雖然所有遞迴都可以手動迴圈模擬,但是遞迴是線段樹的精髓,不能用遞迴還是換別的方法吧)
注意:線段樹不需要逆運算,而字首和需要逆運算。所以求區間最值類似的無法逆向的運算不能用字首和。
而線段樹某些功能可以用下面要講的樹狀陣列更輕量級地實現。
Part Tree、樹狀陣列(Binary Indexed Tree)
樹狀陣列是線段樹的輕量版。
一下是一顆二叉樹(線段樹)的一部分:其中 1~8可看做一顆完整的線段樹。橫座標表示數列序號,每個節點(矩形)覆蓋的橫座標表示區間範圍。用紅色標記的矩形均為右孩子。
由字首和的思想,已知父親節點表示的區間和F範圍為L到R,左孩子表示的區間和C範圍為L到M,右孩子表示的區間和為D範圍為M到R。我們發現有重複資料:
根據字首和,由父親的資料和左孩子的資料,即可算出右孩子的資料:
D=F-C,即右孩子表示的區間和=父親-左孩子。
所以,我們可以將線段樹上所有的右孩子刪掉,即將所有紅色節點刪掉。
這樣,第零層葉子節點會被刪掉2/n個,第一層雙親節點會被刪掉n/4個,第二層雙親節點會被刪掉n/8個......
所以只需要n的空間就可以儲存剩餘的節點。節點和存放位置對應關係如下圖 ( 連線表示求和關係):
大家關注一下不同層次上的節點的序號。
lowbit
設F以二進位制表示中最低位的1表示的位權為 lowbit(F) 。注意表示的是位權而不是位置,比如二進位制100100中lowbit=4。
第0層(也就是葉子)序號都是奇數(最低位是1)。也就是二進位制中位權最低的1在第0位 lowbit=2^0
第1層序號除以2^1(2的一次方)之後是奇數 。 也就是二進位制中位權最低的1在第1位 lowbit=2^1
第2層序號除以2^2之後是奇數。 也就是二進位制中位權最低的1在第2位 lowbit=2^2
....... ......
具體為什麼跟二進位制有關
其中lowbit函式網上有詳細的解釋,計算方法和原理如下:
lowbit(x)=x&-x,其中&是按位與。
我們知道計算機記憶體中負數存放的是補碼,由原始碼到補碼的轉換為取反加一(符號位不變),也等價為保持最低位的1和1以後(低位的方向)的0不變,將高位所有位取反(符號位不變)。比如11010100變成補碼的過程為:保持符號位不變,保持最低位的1和以後的0不變(也就是第三位100不變,最高位1不變),將高位1010取反:0101,最終結果為10101100。負數的補碼跟正數有相同的低位(也就是最低位的1和1以後的0),我們所求的lowbit正是相同的低位表示的值。所以只要把相同保留,不同的置0即可。所以這裡可以用與運算。當然用與或也可以就是麻煩一點,lowbit(x)=((x^-x)+1)>>1,非主流做法大家一試便知,不做過多解釋。
求父親節點的位置:
設父節點序號為F,它的左孩子節點為L
有結論F=L+lowbit(F) , 相當於加了一個最小的1,即跳轉到同層本來應該存在的(但是被刪掉的)下一個位置,(同層的加法就是加最低位1的位權,參考上面層數和最低位的1的位置關係)。請大家體會一下。
建立(build):
同樣,儲存樹的陣列(假設為a[])下標也要從1開始。
需要將陣列初始化為0。
與線段樹類似的是,讀入一個元素之後要更新它所有的祖先,不同的是不需要遞迴尋找這個元素位置了,因為奇數個元素序號直接就是下標,而偶數個元素直接被刪掉了,替換成了節點的元素和之中(不管是第幾層的元素和),所以偶數葉子的操作應該是加法。由於陣列初始化為0了,所以奇偶個元素的新增都可以統一成加法。新增元素後,需要將改動向上傳遞,直到傳遞到root為止。
單點更新
void update(int i, int x){ //i點增量為x
while(i <= n){
c[i] += x;
i += Lowbit(i);
}
}
建立:
for(i = 1; i <= n; i++){ //i須從1開始
scanf("%d",&a[i]);
pushup(i,a[i]);
}
查詢區間和:
利用了字首和的思想求和。
int sum(int x){//區間求和 [1,x]
int sum=0;
while(x>0){
sum+=c[x];//從後面的節點往前加
x-=Lowbit(x);//同層中向前移動一格,如果遇到L=1的節點會減成0
}
return sum;
}
int Getsum(int x1,int x2){ //求任意區間和
return sum(x2) - sum(x1-1);
}
樹狀陣列的區間修改
小結
字首和是非常基礎的一種方法,效率極高,有很多意想不到的用處,但只能完成非常有限的功能,且需要逆運算。
線段樹不需要逆運算。
樹狀陣列是線段樹的簡化版,能用樹狀陣列實現的一定能用線段樹實現,且線段樹更加靈活,雖然時間複雜度都一樣,但樹狀陣列更加簡潔,時間常數更小,空間複雜度常數更小。
但是樹狀陣列對於沒有逆運算的運算還是有點不方便。(不是說不能,而是不會,對於我這樣的新人來說還是得用線段樹)