1. 程式人生 > >字首和 線段樹 樹狀陣列講解(超詳細入門)

字首和 線段樹 樹狀陣列講解(超詳細入門)

部落格目錄

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);
}

樹狀陣列的區間修改

小結

字首和是非常基礎的一種方法,效率極高,有很多意想不到的用處,但只能完成非常有限的功能,且需要逆運算。

線段樹不需要逆運算。

樹狀陣列是線段樹的簡化版,能用樹狀陣列實現的一定能用線段樹實現,且線段樹更加靈活,雖然時間複雜度都一樣,但樹狀陣列更加簡潔,時間常數更小,空間複雜度常數更小。

但是樹狀陣列對於沒有逆運算的運算還是有點不方便。(不是說不能,而是不會,對於我這樣的新人來說還是得用線段樹)