前言
這是一篇蒟蒻的博客,可能有許多錯誤或不詳細的地方,歡迎大佬們指出。
這篇文章主要參考了這篇博文:http://blog.csdn.net/zearot/article/details/48299459
什么是線段樹
線段樹,是一種二叉搜索樹。它將一段區間划分為若干單位區間,每一個節點都儲存着一個區間。它功能強大,支持區間求和,區間最大值,區間修改,單點修改等操作。
線段樹的思想和分治思想很相像。
線段樹的每一個節點都儲存着一段區間[L..R]的信息,其中葉子節點L=R。它的大致思想是:將一段大區間平均地划分成2個小區間,每一個小區間都再平均分成2個更小區間……以此類推,直到每一個區間的L等於R(這樣這個區間僅包含一個節點的信息,無法被划分)。通過對這些區間進行修改、查詢,來實現對大區間的修改、查詢。
這樣一來,每一次修改、查詢的時間復雜度都只為\(O(\log_2n)\)。
但是,可以用線段樹維護的問題必須滿足區間加法,否則是不可能將大問題划分成子問題來解決的。
什么是區間加法
一個問題滿足區間加法,僅當對於區間[L,R]的問題的答案可以由[L,M]和[M+1,R]的答案合並得到。
經典的區間加法問題有:
- 區間求和(\(\sum_{i=L}^Ra_i=\sum_{i=L}^Ma_i+\sum_{i=M+1}^Ra_i\space(L\leq M<R)\))
- 區間最大值(\(\max_{i=L}^Ra_i=\max(\max_{i=L}^Ma_i,\max_{i=M+1}^Ra_i)\space(L\leq M<R)\))
不滿足區間加法的問題有:
- 區間的眾數
- 區間的最長不下降子序列
線段樹的原理及實現
注意:如果我沒有特別申明的話,這里的詢問全部都是區間求和
線段樹主要是把一段大區間平均地划分成兩段小區間進行維護,再用小區間的值來更新大區間。這樣既能保證正確性,又能使時間保持在log級別(因為這棵線段樹是平衡的)。也就是說,一個[L..R]的區間會被划分成[L..(L+R)/2]和[(L+R)/2+1..R]這兩個小區間進行維護,直到L=R。
下圖就是一棵[1..10]的線段樹的分解過程(相同顏色的節點在同一層)
可以發現,這棵線段樹的最大深度不超過\([log_2(n-1)]+2\)(其中\([x]\)表示對x進行下取整)
由於作者太菜,不會非遞歸的線段樹,所以這里寫的都是效率較低、較為常見的遞歸線段樹。
儲存方式
通常用的都是堆式儲存法,即編號為k的節點的左兒子編號為\(k*2\),右兒子編號為\(k*2+1\),父節點編號為\(k\space div\space2\),用位運算優化一下,以上的節點編號就變成了\(k<<1,k<<1|1,k>>1\)。其它儲存方式請見指針儲存和動態開點。
通常,每一個線段樹上的節點儲存的都是這幾個變量:區間左邊界,區間右邊界,區間的答案(這里為區間元素之和)
下面是線段樹的定義:
struct node
{
int l/*區間左邊界*/,r/*區間右邊界*/,sum/*區間元素之和*/,lazy/*懶惰標記,下文會提到*/;
node(){l=r=sum=lazy=0;}//給每一個元素賦初值
}a[N];//N為總節點數
inline void update(int k)//更新節點k的sum
{
a[k].sum=a[a[k].l].sum+a[a[k].r].sum;
//很顯然,一段區間的元素和等於它的子區間的元素和
}
初始化
常見的做法是遍歷整棵線段樹,給每一個節點賦值,注意要遞歸到線段樹的葉節點才結束。
void build(int k/*當前節點的編號*/,int l/*當前區間的左邊界*/,int r/*當前區間的右邊界*/)
{
a[k].l=l,a[k].r=r;
if(l==r)//遞歸到葉節點
{
a[k].sum=number[l];//其中number數組為給定的初值
return;
}
int mid=(l+r)/2;//計算左右子節點的邊界
build(k*2,l,mid);//遞歸到左兒子
build(k*2+1,mid+1,r);//遞歸到右兒子
update(k);//記得要用左右子區間的值更新該區間的值
}
單點修改
當我們要把下標為k的數字修改(加減乘除、賦值運算等)時,可以直接在根節點往下DFS。如果當前節點的左兒子包含下標為k的數(即對於左兒子區間\([L_{lson}..R_{lson}]\),\(L_{lson}\leq k\leq R_{rson}\)),那么就走到左兒子,否則走到右兒子(右兒子一定包含下標為k的數,因為根節點一定包含這個數,而從根節點往下走,能到達的點也一定包含這個數),直到L=R。這時就走到了只包含k的那個節點,只需要把這個點修改即可(這個點就相當於線段樹中唯一只儲存着k的信息的節點)。最后記得在回溯的時候把沿途經過的所有的點的值全部修改一下。
void change(int k/*當前節點的編號*/,int x/*要修改節點的編號*/,int y/*要把編號為x的數字修改成y*/)
{
if(a[k].l==a[k].r){a[k].sum=y;return;}
//如果當前區間只包含一個元素,那么該元素一定就是我們要修改的。
//由於該區間的sum一定等於編號為x的數字,所以直接修改sum就可以了。
int mid=(a[k].l+a[k].r)/2;//計算下一層子區間的左右邊界
if(x<=mid) change(a[k].l,x,y);//遞歸到左兒子
else change(a[k].r,x,y);//遞歸到右兒子
update(k);//記得更新點k的值,感謝qq_36228735提出此錯誤
}
區間修改
其實如果會了單點修改的話,區間修改就不會太難理解了。
區間修改大體可以分為兩步:
- 找到區間中全部都是要修改的點的線段樹中的區間
- 修改這一段區間的所有點
先來解決第一步:
我們先從根節點出發(根節點一定包含所有的點,包括被修改區間),一直往下走,直到當前區間中的元素全部都是被修改元素。
當左區間包含整個被修改區間時,我們就遞歸到左區間;
當右區間包含整個被修改區間時,我們就遞歸到右區間;
否則,情況一定就如下圖所示:
怎么辦?這種情況似乎有些難了。
不過,通過思考,我們可以發現,被修改區間中的元素間,兩兩之間都不會產生影響。
所以,我們可以把被修改區間分解成兩段,使得其中的一段完全在左區間,另一端完全在右區間。
很明顯,直接在mid的位置將該區間切開是最好的。如下圖所示:
通過一系列的玄學操作,我們成功地把修改區間分解成一段一段的。但問題來了:我們怎樣修改這些區間呢?
最暴力的做法是每一次都像建樹一樣,遍歷區間內的所有節點,一一修改。但是這樣的時間復雜度顯然\(O(n^2log_2n)\),比暴力\(O(n^2)\)還多了個log,我要這線段樹有何用?
這里就要引入一樣新的神奇的東西——懶惰標記!
懶惰標記
標記的含義:本區間已經被更新過了,但是子區間卻沒有被更新過,被更新的信息是什么(區間求和只用記錄有沒有被訪問過,而區間加減乘除等多種操作的問題則要記錄進行的是哪一種操作)
這里再引入兩個很重要的東西:相對標記和絕對標記。
相對標記和絕對標記
相對標記指的是可以共存的標記,且打標記的順序與答案無關,即標記可以疊加。 比如說給一段區間中的所有數字都+a,我們就可以把標記疊加一下,比如上一次打了一個+1的標記,這一次要給這一段區間+2,那么就把+1的標記變成+3。
絕對標記是指不可以共存的標記,每一次都要先把標記下傳,再給當前節點打上新的標記。這些標記不能改變次序,否則會出錯。 比如說給一段區間的數字重新賦值,或是給一段區間進行多種操作。
有了懶惰標記這種神奇的東西,我們區間修改時就可以偷一下懶,先修改當前節點,然后直接把信息掛在節點上就可以了!
如下面這棵線段樹,當我們要修改區間[1..4],將元素賦值為1時,我們可以先找到所有的整個區間都要被修改的節點,顯然是儲存區間[1..3]和[4..4]的這兩個節點。我們就可以先把[1..3]的sum改為3(\((3-1+1)*1=3\)),把[4..4]的sum改為1(\((1-1+1)*1=1\))然后給它們打上值為1的懶惰標記,然后就可以了。
這樣一來,我們每一次修改區間時只要找到目標區間就可以了,不用再向下遞歸到葉節點。
下面是區間+x的代碼:
void changeSegment(int k,int l,int r,int x)
//當前到了編號為k的節點,要把[l..r]區間中的所有元素的值+x
{
if(a[k].l==l&&a[k].r==r)//如果找到了全部元素都要被修改的區間
{
a[k].sum+=(r-l+1)*x;
//更新該區間的sum
a[k].lazy+=x;return;
//懶惰標記疊加
}
int mid=(a[k].l+a[k].r)/2;
if(r<=mid) changeSegment(a[k].l,l,r,x);
//如果被修改區間完全在左區間
else if(l>mid) changeSegment(a[k].r,l,r,x);
//如果被修改區間完全在右區間
else changeSegment(a[k].l,l,mid,x),changeSegment(a[k].r,mid+1,r,x);
//如果都不在,就要把修改區間分解成兩塊,分別往左右區間遞歸
update(k);
//記得更新點k的值
}
請注意:某些題目的懶惰標記屬於絕對標記(如維護區間平方和),一定要先下傳標記,再向下遞歸。
下傳標記
碰到相對標記這種容易欺負的小朋友,我們只用打一下懶惰標記就可以了。
但是,遇到絕對標記,或是下文提到的區間查詢,簡單地打上懶惰標記就明顯GG了。畢竟,懶惰標記只是簡單地在節點掛上一個信息而已,遇到復雜的情況可是不行的啊!
於是,懶惰標記的下傳操作就誕生了。
顧名思義,下傳標記就是把一個節點的懶惰標記傳給它的左右兒子,再把該節點的懶惰標記刪去。
我們先來回顧一下標記的含義:
標記的含義:本區間已經被更新過了,但是子區間卻沒有被更新過,被更新的信息是什么
顯然,父區間是包含子區間的,也就是對於父區間的標記和子區間是有聯系的。在大多數情況下,父區間和子區間的標記是相同的。因此,我們可以由父區間的標記推算出子區間應當是什么標記。
注意:以下所說的問題都是指區間賦值,除非有什么特別的申明。
如果要給一個節點中的所有元素重新賦值為x,那么它的兒子也必定要被賦值成x。所以,我們直接在子節點處修改sum值,再把子節點的標記改變一下就可以了(由於區間賦值要用絕對標記,因此當子節點已經有標記時,要先下傳子節點的標記,再下穿該節點的標記。但是區間賦值會覆蓋掉子節點的值,因此在這個問題中,直接修改標記就可以了)
代碼如下:
void pushdown(int k)//將點k的懶惰標記下傳
{
if(a[k].l==a[k].r){a[k].lazy=0;return;}
//如果節點k已經是葉節點了,沒有子節點,那么標記就不用下傳,直接刪除就可以了
a[a[k].l].sum=(a[a[k].l].r-a[a[k].l].l+1)*a[k].lazy;
a[a[k].r].sum=(a[a[k].r].r-a[a[k].r].l+1)*a[k].lazy;
//給k的子節點重新賦值
a[a[k].l].lazy=a[a[k].r].lazy=a[k].lazy;
//下傳點k的標記
a[k].lazy=0;//記得清空點k的標記
}
那么區間賦值就很容易解決了。我們直接修改當前節點的sum,再打上標記就可以了。在大多數問題中,我們要先下傳當前節點的標記,再打上標記。但由於這個問題的特殊性,我們就不用先下傳標記了。
區間查詢
上面我們很輕松地解決了修改的問題,於是我們就維護了一個完整的在線線段樹了。但是光有維護是沒用的,我們還要處理詢問的問題。最常見的莫過於區間查詢了,如詢問區間[l..r]中所有數的和。
這其實和區間修改是類似的。我們也分類討論:
當查找區間在當前區間的左子區間時,遞歸到左子區間;
當查找區間在當前區間的右子區間時,遞歸到右子區間;
否則,這個區間一定是跨越兩個子區間的,我們就把它切成2塊,分在兩個子區間查詢。最后把答案合起來處理就可以了(如查詢區間和時就把兩塊區間的和加起來,查詢最大值時就返回兩塊區間的最大值)
最后強調一個細節:記得在查詢之前下傳標記!!!
下面貼上查詢區間和的代碼:
int query(int k,int l,int r)
//當前到了編號為k的節點,查詢[l..r]的和
{
if(a[k].lazy) pushdown(k);
//如果當前節點被打上了懶惰標記,那么就把這個標記下傳,這一句其實也可以放在下一語句的后面
if(a[k].l==l&&a[k].r==r) return a[k].sum;
//如果當前區間就是詢問區間,完全重合,那么顯然可以直接返回
int mid=(a[k].l+a[k].r)/2;
if(r<=mid) return query(a[k].l,l,r);
//如果詢問區間包含在左子區間中
if(l>mid) return query(a[k].r,l,r);
//如果詢問區間包含在右子區間中
return query(a[k].l,l,mid)+query(a[k].r,mid+1,r);
//如果詢問區間跨越兩個子區間
}
指針儲存和動態開點
上面我們用的都是堆式儲存法。這種方法能快速地找出當前節點的父節點、子節點,但節點數很多,而無用節點也較多時就沒有用了。我們可以用指針儲存和動態開點解決這個問題。當然,大佬們也可以用離散化解決問題。
這其實就是用指針額外記錄當前節點的子節點(有時可能還要記錄父節點),且要用到節點時才新建節點。這樣能大大地節省空間。
下面是結構體的定義:
struct node
{
int l/*區間左邊界*/,r/*區間右邊界*/,sum/*區間元素之和*/,lazy/*懶惰標記,下文會提到*/;
node *lson/*左兒子*/,*rson/*右兒子*/;
//這兩個指針初始值為NULL,當兒子指針為NULL時表明它沒有值
node(){l=r=sum=lazy=0;lson=rson=NULL;}//給每一個元素賦初值
};
node *root=new node;//根節點
inline void setroot()//根節點初始化
{
root->l=1,root->r=n;
}
inline void update(node *k)//更新節點k的sum
{
k->sum=0;
if(k->lson) k.sum+=k->lson->sum;
if(k->rson) k.sum+=k->rson->sum;
//注意要判斷左右子節點是否存在
}
單點修改:
void change(node *k/*當前節點*/,int x/*要修改節點的編號*/,int y/*要把編號為x的數字修改成y*/)
{
if(k->l==k->r){k->sum=y;return;}
//如果當前區間只包含一個元素,那么該元素一定就是我們要修改的。
//由於該區間的sum一定等於編號為x的數字,所以直接修改sum就可以了。
int mid=(k->l+k->r)/2;//計算下一層子區間的左右邊界
if(x<=mid)
{
if(!k->lson)//如果左兒子不存在,就新建一個
{
k->lson=new node;
k->lson->l=k->l;
k->lson->r=mid;
}
change(k->lson,x,y);//遞歸到左兒子
}
else
{
if(!k->rson)//如果右兒子不存在,就新建一個
{
k->rson=new node;
k->rson->l=mid+1;
k->rson->r=k->r;
}
change(k->rson,x,y);//遞歸到右兒子
}
}
其他操作相應地改一下就可以了,這里留給讀者自己思考。
P.S:
提示一下:詢問操作並不用新建節點。
其實動態開點不一定要用指針,也可以先開一個節點數組,每次新建節點時給它分配一個下標。不過個人覺得用指針方便一些。
擴展及應用
權值線段樹
權值線段樹其實就相當於一個桶,它維護了每一個數的出現次數。它可以解決許多問題(廢話)。
下面就來看一道我腦補的題目(大佬勿噴):
給你一個長度為n的數組a,以及m個操作,每一個詢問的格式為[x,l,r],x=1表示查詢數組中值在區間[l..r]中的元素的和,x=2表示將第 l 個數加r。每個數的取值范圍:\(0\leq a_i\leq 10^6,n\leq 2*10^4\)
這題顯然可以用權值線段樹做,其中線段樹中區間[l..r]維護的是數值\(l\leq a_i\leq r\)的\(a_i\)的個數。由於節點數較多,要用動態開點。然后到了修改操作的時候,我們就把第 l 個數所對應的值單點修改(在權值線段樹中將所對應的位置的值減一),再把第 r 個位置的值加一。
這其實就相當於一個桶,很好理解的。
可持久化線段樹(主席樹)
主席樹其實就是給線段樹記錄了歷史版本。
給你一個長度為n的數組a,以及m個詢問,每一個詢問的格式為[l,r,k],表示查詢區間[l..r]中第k大的數。
碰到這種情況,排序什么的就無能為力了。這就要用到主席樹了。
我們可以開n棵權值線段樹,第 i 棵表示1~i中每一個數出現的次數(先不要擔心空間的問題)。這種方法其實類似於前綴和,詢問時把第 r 棵線段樹減去第 l-1 棵線段樹(對應位置的值相減),再在得出的線段樹中查找第k大的數。
假設現在的數列是{2,4,1},離散化為{2,3,1}。
那么這幾棵線段樹就會長成這個樣子(壯觀):
觀察這個圖,可以發現有不少信息相同的子樹,我們可以把它們合並:
這就是主席樹。
思路很簡單,如果第 i 棵線段樹的某子樹和第 i-1 棵的那一個子樹相同,那么就不用新建子樹了,兩棵線段樹共用一個子樹。在不用記錄子樹的父節點的情況下,這種方法是可行的。
觀察上圖,發現每一棵線段樹都只有一條從根到底的路徑是新建的,其余全部都是由以前的線段樹得到的。因此我們可以證明這種方法能極大地優化空間。
但萬一要需要修改怎么辦?可以用類似於樹狀數組的方法,這里留給讀者思考。
下面給出gmoj.1011 zoo的標程
#include<cstdio>
#include<algorithm>
using namespace std;
#define N 100010
struct tree
{
int sum,lson,rson;
tree(){lson=rson=sum=0;}
}node[N*30];
int a[N],id[N],b[N],root[N],s;
bool cmp(int x,int y){return a[x]<a[y];}
void insert(int p,int q,int l,int r,int k)
{
node[p].sum=node[q].sum+1;
if(l<r)
{
int mid=(l+r)/2;
if(k<=mid)
{
node[p].lson=++s;
if(node[q].rson) node[p].rson=node[q].rson;
else node[q].rson=++s;
insert(node[p].lson,node[q].lson,l,mid,k);
}
else
{
if(node[q].lson) node[p].lson=node[q].lson;
else node[q].lson=++s;
node[p].rson=++s;
insert(node[p].rson,node[q].rson,mid+1,r,k);
}
}
}
int find(int p,int q,int l,int r,int k)
{
if(l==r) return l;
int mid=(l+r)/2;
if(node[node[p].lson].sum-node[node[q].lson].sum>=k)
return find(node[p].lson,node[q].lson,l,mid,k);
return find(node[p].rson,node[q].rson,mid+1,r,
k-node[node[p].lson].sum+node[node[q].lson].sum);
}
int main()
{
int n,m,i,j,k,max=1;
scanf("%d%d",&n,&m);
for(i=1;i<=n;i++) scanf("%d",&a[i]),id[i]=i,root[i]=++s;
sort(id+1,id+n+1,cmp);
for(i=2;i<=n;i++)
{
if(a[id[i]]!=a[id[i-1]]) b[++max]=a[id[i]];
a[id[i]]=max;
}
b[1]=a[id[1]],a[id[1]]=1;
root[0]=0;
for(i=1;i<=n;i++) insert(root[i],root[i-1],1,max,a[i]);
while(m--)
{
scanf("%d%d%d",&i,&j,&k);
printf("%d\n",b[find(root[j],root[i-1],1,max,k)]);
}
return 0;
}
非遞歸式線段樹
非遞歸式線段樹(ZKW線段樹,張昆瑋線段樹),是清華大學的張昆瑋在ppt《統計的力量》中提出的。
這種線段樹最大的特點就是很重口味不用遞歸實現,因此常熟小、且碼量不長,便於調試和卡常數。
它之所以與普通線段樹不同,主要是因為它是一顆自底到根的線段樹。
總的來說,它相對於線段樹而言,主要有以下優缺點:
- 常數(時間)更小
- 代碼長度更短
- 調試復雜度更低
- 空間更小
- 學習難度更低
- 解決復雜問題的難度更低(如區間修改)