第二課——線段樹

zhywyt發表於2024-03-23

上一節課講了樹狀陣列,也介紹了樹狀陣列的優點與不足,這裡簡單回顧一下。
優點:樹狀陣列的程式碼非常簡短,易於實現,被劉老師親切的稱為IO選手的"HelloWorld!",就是因為程式碼短。
缺點:樹狀陣列的缺點也非常的明顯,只能處理單點修改區間查詢或者區間修改單點查詢的問題(以較高的效率)。而區間修改區間查詢的問題沒有辦法很優雅的解決,於是引出了線段樹。

線段樹

先來看一個問題:


7-1 張煊的金箍棒(2)
張煊的金箍棒升級了!

升級後的金箍棒是由幾根相同長度的金屬棒連線而成(最開始都是銅棒,從1到N編號);

張煊作為金箍棒的主人,可以對金箍棒施以任意的變換,每次變換操作就是將一段連續的金屬棒(從X到Y編號)改為銅棒,銀棒或金棒。

金箍棒的總價值計算為N個金屬棒的價值總和。其中,每個銅棒價值為1;每個銀棒價值為2;每個金棒價值為3。

現在,張煊想知道多次執行操作後的金箍棒總價值。
輸入格式:

輸入的第一行是測試資料的組數(不超過10個)。

對於每組測試資料,第一行包含一個整數N(1 <= N <= 100000),表示金箍棒有N節金屬組成,第二行包含一個整數Q(0 <= Q <= 100,000),表示執行變換的操作次數。

接下來的Q行,每行包含三個整數X,Y,Z(1 <= X <= Y <= N,1 <= Z <= 3),它定義了一個操作:將從X到Y編號的金屬棒變換為金屬種類Z,其中Z = 1代表銅棒,Z = 2代表銀棒,Z = 3代表金棒。
輸出格式:

對於每組測試資料,請輸出一個數字,表示操作後金箍棒的總價值。

每組資料輸出一行。
輸入樣例:

1
10
2
1 5 2
5 9 3

輸出樣例:

24

可以看到題目中非常明顯的區間修改+區間查詢的意圖,這也是線段樹的一道入門題目。接下來我來介紹這個神奇的資料結構。

構成

線段樹由一個四倍原陣列長的陣列組成,對於陣列中的元素也有著特殊的含義,但是比起樹狀陣列來說要好理解多了。
首先我們不從陣列層面來看這個資料結構,而是從一個二叉樹,一棵完全二叉樹。假設我們的原陣列有8個數。那麼線段數和原陣列的關係就像這樣:
image
線上段樹上的每一個節點表示對應區間的某個屬性值,只要這個屬性值滿足區間的加法即可。
舉個例子,這個屬性值可以是區間和,可以是區間最值等等,這些具體的屬性由題目來決定了,由於屬性值的自由度極高,導致線段樹在非常多的場合可以用於加速。

見過了線段樹的二叉樹形狀,接下里給線段樹一個陣列的表示方式。這個也非常的簡單:
image
和《資料結構》中一致,從根節點開始為 1 ,寬度優先搜尋的順序升序標號。有了標號,我們就能用陣列來儲存這棵二叉樹了。可是為什麼我們需要四倍的原陣列空間呢

這裡我們從長度為5的陣列開始,來探討一下這個問題。
image
原陣列長度為5,那麼理論上黑色的節點已經夠用了,但是我們使用的靜態陣列,一般會選擇直接把完全二叉樹所需的空間開出來,所以會用到最多四倍的空間。

左右子結點的訪問

學過《資料結構》的讀者可以跳過這塊內容。
這部分比較簡單,假設根的標誌是1,那麼左右子結點分別可以用以下兩個函式訪問:

int left(int d){
	return data[d<<1];
}
int right(int d){
	return data[d<<1|1];	//等價於 d * 2 + 1
}

稍微解釋一下訪問右節點的操作,一個二進位制數在左移動後最低位一定是0,那麼這時候可以用1與該數位或,就能得到乘2加一的效果。

樹的初始化

首先定義一下線段樹的結構(程式碼層面)

class SeqTree{
public:
	//方法定義
private:
	struct Data{
		int val;
	}data[N<<2];
}seqTree;			//線段樹類
int arr[N];			//原陣列

樹的初始化是從原陣列構造我們的線段樹,以前面提到的題目為例子。節點的屬性是區間和

//arr為原陣列、l為區間左邊、r為區間右邊、rt為線段樹上的位置
void build(int*arr,int l,int r,int rt){
	if(l==r){							//到了葉子節點,直接賦值
		data[rt].val = arr[l-1];
	}
	int m = (l+r)>>1;					//尋找左右子結點的區間邊界
	build(arr,l,m,rt<<1);				//遞迴構造兩邊的線段樹
	build(arr,m+1,r,rt<<1|1);
	pushUp(rt);							//利用兩邊的子節點更新當前節點
}
inline void pushUp(int rt){
	data[rt].val = data[rt<<1].val + data[rt<<1|1].val;
}

可以發現線段樹的構造是非常容易理解的。由於二分的存在它的複雜度也只是O(NlogN)。

單點修改

線段樹的修改,相當於修改最下層的某個節點,它會影響到上層的非常多節點,依照樹的初始化的想法,我們可以很容易的寫出修改程式碼,這裡不提供。

區間查詢

首先有一個理論保障:線段樹的每次查詢不會超過O(logN)的複雜度。為什麼呢?

  • 任一連續區間至多由\(2log_2^N\)個子區間組成
    • 原因:任一區間不線上段樹同一層出現兩個子區間,並且樹高不超過\(logN\)
      • 原因的原因:因為區間連續,所以如果在同一層出現了兩個子區間,那麼這兩個子區間一定可以合成上一層的一個區間。

所以查詢的複雜度有了保障。於是我們來講查詢的思路。
對於一個區間查詢\([L,R]\),我們從根節點[0,4N]出發,進行二分查詢,並把符合要求的區間上的節點都進行修改。Idea is pool show me the code!

//區間查詢[R,L]
int query(int R,int L,int r,int l,int rt){
	if(R>l||L<r)return 0;
	if(R<=r&&L>=l)return data[rt].val;
	int m = (l+r)>>1;
	return query(R,L,r,m,rt<<1)+query(R,L,m+1,l,rt<<1|1);
}

是不是超級簡單?哈哈,劉老師說:“當年我們沒有人教,沒有題目刷的時候,學會了線段樹就開始大殺四方,當時覺得是很稀奇的東西。你們今天倒好,隨便就能學到如此有意思的演算法。”

區間修改

這個就厲害了,不僅實現了區間修改,還引入了最高效的偷懶方式——lazy
思路是這樣的:我們修改一個區間的時候,如果要把值給到每個受影響的節點,會非常的麻煩,並且涉及到多次修改時,程式的複雜度會較高。但是仔細想想,我們線段樹上的節點不是能代表屬性麼,那是不是也可以記錄修改的屬性呢?於是lazy誕生了。
修改一個區間的時候,我們不修改對應的葉子節點,而是在最上層的區間節點上記錄本次修改,並在查詢的時候應用。
我們首先修改一下資料結構體:

class SeqTree{
public:
	//方法定義
private:
	struct Data{
		int val;
		int lazy;	//lazy標誌
	}data[N<<2];
}seqTree;			//線段樹類
int arr[N];			//原陣列

然後重寫之前的各個方法:

void build(int*arr,int l,int r,int rt){
	if(l==r){
		data[rt].val = arr[l-1];
	}
	int m = (l+r)>>1;
	data[rt].lazy = 0;										//給lazy初始化值
	build(arr,l,m,rt<<1);
	build(arr,m+1,r,rt<<1|1);
	pushUp(rt);
}
//區間查詢[R,L]
int query(int R,int L,int r,int l,int rt){
	if(R>l||L<r)return 0;
	if(R<=r&&L>=l)return data[rt].val;
	int m = (l+r)>>1;
	pushDown(rt,m-r+1,l-m);									//新加了一個應用lazy的函式
	return query(R,L,r,m,rt<<1)+query(R,L,m+1,l,rt<<1|1);
}
//區間修改把[R,L]修改為C
void update(int R,int L,int C,int r,int l,int rt){
	if(R>l||L<r){
		return;
	}
	if(R<=r&&L>=l){
		data[rt].val=C*(l-r+1);								//更新節點值
		if(r<l)
			data[rt].lazy=C;								//查詢到此,繼承lazy值
		return;
	}
	int m = (l+r)>>1;
	pushDown(rt,m-r+1,l-m);									//應用lazy
	update(R,L,C,r,m,rt<<1);
	update(R,L,C,m+1,l,rt<<1|1);
	pushUp(rt);												//這裡有一個細節,應用lazy要在向上計算value之前
}

下面是應用lazy的函式的實現:

inline void pushDown(int rt,int rn,int ln){
	if(data[rt].lazy){
		data[rt<<1].val=data[rt].lazy*rn;
		data[rt<<1].lazy=data[rt].lazy;
		data[rt<<1|1].val=data[rt].lazy*ln;
		data[rt<<1|1].lazy=data[rt].lazy;
		data[rt].lazy=0;
	}
}

到這裡就講完了,線段樹我似乎沒有進行多少理論的分析,大部分都是show you the code.但是線段樹是一個抽象的,強大的最佳化工具,而不是一個演算法。想要理解線段樹,還需要自己去編碼實現。這裡提供完整的程式程式碼供你參考。

點選檢視程式碼
#include <iostream>
#include <algorithm>
#include <vector>
#include <string>
#include <string.h>
using namespace std;

#define N 100000

class SeqTree{
public:
	inline void clear(int size){
		memset(data,0,sizeof(Data)*(size<<2));
		for(int i=1;i<=(size<<2);i++){
			data[i].val = 1;
		}
	}
	inline void pushUp(int rt){
		data[rt].val = data[rt<<1].val + data[rt<<1|1].val;
	}
	inline void pushDown(int rt,int rn,int ln){
		if(data[rt].lazy){
			data[rt<<1].val=data[rt].lazy*rn;
			data[rt<<1].lazy=data[rt].lazy;
			data[rt<<1|1].val=data[rt].lazy*ln;
			data[rt<<1|1].lazy=data[rt].lazy;
			data[rt].lazy=0;
		}
	}
	void build(int*arr,int l,int r,int rt){
		if(l==r){
			data[rt].val = arr[l-1];
		}
		int m = (l+r)>>1;
		data[rt].lazy = 0;
		build(arr,l,m,rt<<1);
		build(arr,m+1,r,rt<<1|1);
		pushUp(rt);
	}
	//區間查詢[R,L]
	int query(int R,int L,int r,int l,int rt){
		if(R>l||L<r)return 0;
		if(R<=r&&L>=l)return data[rt].val;
		int m = (l+r)>>1;
		pushDown(rt,m-r+1,l-m);
		return query(R,L,r,m,rt<<1)+query(R,L,m+1,l,rt<<1|1);
	}
	void update(int R,int L,int C,int r,int l,int rt){
		if(R>l||L<r){
			return;
		}
		if(R<=r&&L>=l){
			data[rt].val=C*(l-r+1);
			if(r<l)
				data[rt].lazy=C;
			return;
		}
		int m = (l+r)>>1;
		pushDown(rt,m-r+1,l-m);
		update(R,L,C,r,m,rt<<1);
		update(R,L,C,m+1,l,rt<<1|1);
		pushUp(rt);
	}
	void debug(int size){
		cout<<"############## debug ##############\n";
		for(int i=1;i<=(size<<2);i++){
			cout<<data[i].val<<" "<<data[i].lazy<<"\n";
		}
		cout<<"############## debug ##############\n";

	}
private:
	struct Data{
		int val;
		int lazy;
	}data[N<<2];
}seqTree;

int main(){
	int b,n,q;
	cin>>b;
	while(b--){
		cin>>n>>q;
		seqTree.clear(n);
		int x,y,z;
		for(int i=0;i<q;i++){
			cin>>x>>y>>z;
			seqTree.update(x,y,z,1,n,1);
		}
		cout<<seqTree.query(1,n,1,n,1)<<"\n";
		// seqTree.debug(n);
	}
}

好好領悟線段樹的節點屬性吧。