線段樹(1)建樹、單點修改、單點查詢、區間查詢和例題

吴一鸣發表於2024-07-29

閒了好久的wym復活拉~更個辣雞的線段樹

如果你不知道什麼是線段樹這個就不用看

由於我們平時可能會遇到一些噁心的題叫做給你 \(10^5\) 個數的陣列,然後 \(10^5\) 次修改或查詢,這樣顯然暴力是可以做的而且ST表我們無視修改。這個時候可以用線段樹、樹狀陣列或者其他大佬們的神仙演算法,由於我不知道何謂lowbit所以用的線段樹。

線段樹的空間是樹狀陣列的4倍,或者某些例外,8倍,即 \(4N\)\(8N\)

線段樹的好處在於它的功能比樹狀陣列多,最重要的在於,樹狀陣列維護的是字首和,所以不能維護最大最小值。而線段樹維護的是實實在在的區間和,所以樹狀陣列能做到的,線段樹都能做到。但是線段樹的時空複雜度都比樹狀陣列高,而且程式碼更復雜。有些 \(N=5 \times 10^6\) 的題就不能用線段樹做了。

好了瞎扯完了,現在來看線段樹的思想是什麼。

看圖(圖中線段樹維護的是區間和):

線段樹(1)建樹、單點修改、單點查詢、區間查詢和例題

由圖可以得出,首先,線段樹是一個二叉堆,或者叫作完全二叉樹,而且樹中每個點對應陣列中的一個區間。這樣方便的地方在於,我查詢一個區間的時候,如果恰好遇到一個查詢區間包含的區間時,就能直接取值了。

題目給出一個陣列,首先我們要建出這個線段樹。

const int N=1e5+10;
int n,a[N],t[N*4]; //a陣列是輸入的,t陣列用來儲存線段樹,根為1,一個節點 i 的左子節點為 2i,右子節點為 2i+1
void build(int now,int tl,int tr){ //now表示線段樹的節點,tl和tr表示原陣列的區間
	if(tl==tr){ //遇到一個原陣列的數(區間長度為1),即線段樹中的葉子節點了
		t[now]=a[tl]; //或a[tr]
		return ;
	}
	int mid=(tl+tr)/2; //區分左子樹和右子樹
	build(now*2,tl,mid); //遞迴建左子樹
	build(now*2+1,mid+1,tr); //遞迴建右子樹
	t[now]=t[now*2]+t[now*2+1]; //區間和,或者可以定義其他操作,注意這裡一定不要忘寫
} 

可以輸出測試一下,以上圖為例:

#include<bits/stdc++.h>
using namespace std;
const int N=1e5+10;
int n,a[N],t[N*4];
int flag[N];
void build(int now,int tl,int tr){
	if(tl==tr){
		flag[now]=1;
		t[now]=a[tl];
		return ;
	}
	int mid=(tl+tr)/2;
	build(now*2,tl,mid);
	build(now*2+1,mid+1,tr);
	t[now]=t[now*2]+t[now*2+1];
	flag[now]=1;
}
int main(){
	n=5;
	a[1]=1,a[2]=2,a[3]=3,a[4]=4,a[5]=5;
	build(1,1,n);
	for(int i=1;i<=n*4;i++){
		if(flag[i]){
			cout<<t[i]<<" ";
		}
	}
	return 0;
}

輸出結果:

15 6 9 3 3 4 5 1 2 

相當於原圖片中樹的層次遍歷。你也可以寫一個dfs求前序/中序/後序遍歷。或者也可以用dfs/bfs求樹中每個點代表的區間或數。甚至,你可以用bfs寫build。

很明顯,build函式時間複雜度 \(O(n)\)

現在樹建好了,我們先講查詢,再講修改。

單點查詢:跟build類似,但略有不同。自己思考一下為什麼tl==tr不需要再判斷tl==postr==pos

int query(int now,int tl,int tr,int pos){ //pos代表查詢的陣列下標
	if(tl>pos||tr<pos){ //不在範圍內
		return 0;
	}
	if(tl==tr){ //找到了
		return t[now];
	}
	int mid=(tl+tr)/2;
	return query(now*2,tl,mid,pos)+query(now*2+1,mid+1,tr,pos);
}

區間查詢:這個時候,t陣列中非葉子的節點,即代表原陣列中區間的部分就派上用場了。查詢時,如果發現了一個查詢範圍包含的區間,就可以直接取走了。否則,把目前的區間分成兩半,然後遞迴去找。

線段樹(1)建樹、單點修改、單點查詢、區間查詢和例題

比如我們要查詢區間 \([2,5]\) 的和。

\([1,5]\) 不完全屬於 \([2,5]\),分成 \([1,3]\)\([4,5]\)

\([1,3]\) 不完全屬於 \([2,5]\)。分成 \([1,2]\)\([3,3]\)

\([1,2]\) 不完全屬於 \([2,5]\)。分成 \([1,1]\)\([2,2]\)

\([1,1]\) 不屬於 \([2,5]\)。返回 \(0\)

\([2,2]\) 屬於 \([2,5]\)。返回 \(t\) 陣列中 \([2,2]\) 對應的 \(2\)

\([1,2]\) 收到返回值 \(0\)\(2\)。相加得到 \(2\)\([1,2]\) 返回 \(2\)

\([3,3]\) 屬於 \([2,5]\)。返回 \(t\) 陣列中 \([3,3]\) 對應的 \(3\)

\([1,3]\) 收到返回值 \(2\)\(3\)。相加得到 \(5\)\([1,3]\) 返回 \(5\)

\([4,5]\) 屬於 \([4,5]\)。返回 \(t\) 陣列中 \([4,5]\) 對應的 \(9\)

\([1,5]\) 收到返回值 \(5\)\(9\)。相加得到 \(14\)。查詢函式結束,函式返回值 \(14\)

看似複雜,只要畫個圖,就明白了。自己嘗試一下吧。可以結合程式碼。

int query(int now,int tl,int tr,int l,int r){ //l和r表示查詢的區間
	if(tl>=l&&tr<=r){ //[tl,tr]完全屬於[l,r]
		return t[now];
	}
	if(tl>r||tr<l){ //[tl,tr]不再[l,r]範圍內
		return 0;
	}
	int mid=(tl+tr)/2; //不完全屬於
	return query(now*2,tl,mid,l,r)+query(now*2+1,mid+1,tr,l,r);
}

自己執行試一下,也可以自己修改程式碼:

#include<bits/stdc++.h>
using namespace std;
const int N=1e5+10;
int n,a[N],t[N*4];
void build(int now,int tl,int tr){
	if(tl==tr){
		t[now]=a[tl];
		return ;
	}
	int mid=(tl+tr)/2;
	build(now*2,tl,mid);
	build(now*2+1,mid+1,tr);
	t[now]=t[now*2]+t[now*2+1];
}
int query(int now,int tl,int tr,int l,int r){
	if(tl>=l&&tr<=r){
		return t[now];
	}
	if(tl>r||tr<l){
		return 0;
	}
	int mid=(tl+tr)/2;
	return query(now*2,tl,mid,l,r)+query(now*2+1,mid+1,tr,l,r);
}
int main(){
	n=5;
	a[1]=1,a[2]=2,a[3]=3,a[4]=4,a[5]=5;
	build(1,1,n);
	cout<<query(1,1,n,2,5);
	return 0;
}

單點修改:其實和build還是區別不大。你可以嘗試自己理解。通常情況下會是區間增加。

void modify(int now,int tl,int tr,int pos,int x){ //修改陣列中下標為pos的數為x
	if(tl>pos||tr<pos){ //不在範圍內
		return ;
	}
	if(tl==tr){ //這個點就是要修改的點
		t[now]=x;
		return ;
	}
	int mid=(tl+tr)/2;
	modify(now*2,tl,mid,pos,x);
	modify(now*2+1,mid+1,tr,pos,x);
	t[now]=t[now*2]+t[now*2+1]; //這個地方不能忘,修改之後要更新所有祖先的值
}

結合區間查詢的程式碼(可以自行修改):

#include<bits/stdc++.h>
using namespace std;
const int N=1e5+10;
int n,a[N],t[N*4];
void build(int now,int tl,int tr){
	if(tl==tr){
		t[now]=a[tl];
		return ;
	}
	int mid=(tl+tr)/2;
	build(now*2,tl,mid);
	build(now*2+1,mid+1,tr);
	t[now]=t[now*2]+t[now*2+1];
}
int query(int now,int tl,int tr,int l,int r){
	if(tl>=l&&tr<=r){
		return t[now];
	}
	if(tl>r||tr<l){
		return 0;
	}
	int mid=(tl+tr)/2;
	return query(now*2,tl,mid,l,r)+query(now*2+1,mid+1,tr,l,r);
}
void modify(int now,int tl,int tr,int pos,int x){
	if(tl>pos||tr<pos){
		return ;
	}
	if(tl==tr){
		t[now]=x;
		return ;
	}
	int mid=(tl+tr)/2;
	modify(now*2,tl,mid,pos,x);
	modify(now*2+1,mid+1,tr,pos,x);
	t[now]=t[now*2]+t[now*2+1];
}
int main(){
	n=5;
	a[1]=1,a[2]=2,a[3]=3,a[4]=4,a[5]=5;
	build(1,1,n);
	modify(1,1,n,4,5);
	cout<<query(1,1,n,2,5);
	return 0;
}

以上所有操作的時間複雜度都是 \(O(\log n)\) 的。對於單點查詢,最多會查到數中最深的點,而一棵完全二叉樹的深度大概時 \(\log n\) 左右。對於區間查詢和單點修改,同理。進行區間操作時,會及時停止遞迴(當某子樹不在查詢範圍內時),實際上遞迴的次數是低於 \(\log n\) 的,可以自己舉幾個例子試試看。

區間增加,可以把區間每個數都單獨單點修改一次,但這樣會變成 \(O(n \log n)\)\(n\) 次區間修改就是 \(O(n^2 \log n)\)。這塊內容下次再講,我們先看例題,練習一下基礎。

習題

第一題

模板題。記得開long long

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll N=1e5+10;
ll n,m,t[N*4];
ll query(ll now,ll tl,ll tr,ll l,ll r){
	if(tl>=l&&tr<=r){
		return t[now];
	}
	if(tl>r||tr<l){
		return 0;
	}
	ll mid=(tl+tr)/2;
	return query(now*2,tl,mid,l,r)+query(now*2+1,mid+1,tr,l,r);
}
void add(ll now,ll tl,ll tr,ll pos,ll x){
	if(tl>pos||tr<pos){
		return ;
	}
	if(tl==tr){
		t[now]+=x;
		return ;
	}
	ll mid=(tl+tr)/2;
	add(now*2,tl,mid,pos,x);
	add(now*2+1,mid+1,tr,pos,x);
	t[now]=t[now*2]+t[now*2+1];
}
int main(){
	//freopen("xx.in","r",stdin);
	//freopen("xx.out","w",stdout);
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0);
	cin>>n>>m;
	for(ll i=1;i<=m;i++){
		ll k,a,b;
		cin>>k>>a>>b;
		if(k){
			cout<<query(1,1,n,a,b)<<"\n";
		}else{
			add(1,1,n,a,b);
		}
	}
	return 0;
}

第二題

這道題有點挑戰。看似沒法透過時間限制,但是這道題的操作是平方根,\(10^{12}\)\(6\) 次平方根就是 \(1\) 了。所以在修改的時候,如果發現一個子樹都是 \(1\),就不用修改了,因為 \(1\) 的平方根還是 \(1\)。否則,只能一直遞迴,直到葉子節點,再把它取平方根。記得t[now]=t[now*2]+t[now*2+1]。判定子樹都為 \(1\) 的方法很多,你可以看看子樹的和是否等於子樹大大小,或者專門再寫一個線段樹維護是否都是 \(1\)。這道題思路懂了,就好寫了。有坑,注意 \(l\) 可能大於 \(r\)

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll N=1e5+10;
ll n,m,a[N],t[N*8];
void build(ll now,ll tl,ll tr){
	if(tl==tr){
		t[now]=a[tl];
		return ;
	}
	int mid=(tl+tr)/2;
	build(now*2,tl,mid);
	build(now*2+1,mid+1,tr);
	t[now]=t[now*2]+t[now*2+1];
}
ll query(ll now,ll tl,ll tr,ll l,ll r){
	if(tl>=l&&tr<=r){
		return t[now];
	}
	if(tl>r||tr<l){
		return 0;
	}
	ll mid=(tl+tr)/2;
	return query(now*2,tl,mid,l,r)+query(now*2+1,mid+1,tr,l,r);
}
void modify(ll now,ll tl,ll tr,ll l,ll r){
	if(tl>r||tr<l){
		return ;
	}
	if(tl==tr){
		t[now]=sqrt(t[now]);
		return ;
	}
	ll mid=(tl+tr)/2;
	if(t[now*2]!=mid-tl+1){
		modify(now*2,tl,mid,l,r);
	}
	if(t[now*2+1]!=tr-mid){
		modify(now*2+1,mid+1,tr,l,r);
	}
	t[now]=t[now*2]+t[now*2+1];
}
int main(){
	//freopen("xx.in","r",stdin);
	//freopen("xx.out","w",stdout);
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0);
	cin>>n;
	for(ll i=1;i<=n;i++){
		cin>>a[i];
	}
	build(1,1,n);
	cin>>m;
	for(ll i=1;i<=m;i++){
		ll k,l,r;
		cin>>k>>l>>r;
		if(l>r){
			swap(l,r);
		}
		if(k){
			cout<<query(1,1,n,l,r)<<"\n";
		}else{
			modify(1,1,n,l,r);
		}
	}
	return 0;
}

如果這道題你是不看題解程式碼AC的,證明你對線段樹的最基礎的部分已經足夠熟悉了。但是線段樹的的基本操作比這個難,加油吧~

相關文章