數據結構-線段樹

數據結構-線段樹

參考資料

暫無

線段樹是所有 RMQ 中最常用的數據結構。

功能:區間修改區間查詢。不止最值、求和。只要可遞推的值都可以構造線段樹。

如果區間大小為 (n),線段樹有 (cnt) 個節點,那麼 (2n-1le cnt<4n)

節點

對於每個節點 (x),和堆類似,父親節點為 (x>>1)(即 (x/2) 下取整的位運算方法,位運算方便而且快),左兒子為 (x<<1)(即 (2x)),右兒子為 (x<<1|1)(即 (2x+1))。

同時每個節點對應一段區間,所以叫線段樹。節點 (1) 對應的區間為 (1sim n)。設一個節點對應的區間為 (lsim r),那麼它的左兒子對應的區間就是 (lsim mid),其中 (mid=(l+r)>>1),右兒子區間為 (mid+1sim r)。如果一個節點對應單點區間,就沒有兒子。

同時每個節點對應一個值,即該區間的 RMQ 值。如果是求最值問題,就表示該區間最大值;如果是求和問題,就表示該區間的和。

操作(單點修改區間查詢)

一個線段樹是求和還是求最值或者求別的東西,取決於 (pushup(k)) 函數,其中 (k) 為節點編號,時間複雜度 (O(1))

void pushup(int k){v[k]=max(v[k<<1],v[k<<1|1]);}//求最大值

根據原序列構造初始的線段樹用 (build()) 函數,單點節點上的值就為單點的值,遞歸從下到上構造,時間複雜度 (O(nlog n))

void build(int k=1,int l=1,int r=n){//表示外部應用默認k=1,l=1,r=n      if(l==r){v[k]=a[l];return;} //單點節點      build(k<<1,l,mid),build(k<<1|1,mid+1,r); //遞歸構造      pushup(k); //遞推  }

先講單點修改(加上 (y)),只需與 (build()) 函數類似的遞歸操作即可,如果到達單點節點,就修改,不走那些跟查詢單點沒關係的區間、別忘了修改完後也要遞推,時間複雜度 (O(log n))

void fix(int x,int y,int k=1,int l=1,int r=n){      if(l==x&&r==x){v[k]+=y;return;} //單點修改      if(mid>=x) fix(x,y,k<<1,l,mid); //遞歸左兒子      else fix(x,y,k<<1|1,mid+1,r); //遞歸右兒子      pushup(k);//遞推  }

區間查詢,如果單前節點在查詢區間內,就返回值。否則,遞歸左兒子右兒子,遞推得區間查詢值。時間複雜度 (O(log n)),因為只會走相關的 (log n) 個節點。

int fmax(int x,int y,int k=1,int l=1,int r=n){      if(x<=l&&r<=y) return v[k]; //在查找區間內,返回值      int res=0;      if(mid>=x) res=max(res,fmax(x,y,k<<1,l,mid));      if(mid<y) res=max(res,fmax(x,y,k<<1|1,mid+1,r));      return res;  }

總時間複雜度 (O(nlog n)) ,全程式碼:

#include <bits/stdc++.h>  using namespace std;  const int N=1e5+10;  int n,m,a[N];  namespace Sumtree{      #define mid ((l+r)>>1)      int v[N<<2];      void pushup(int k){v[k]=max(v[k<<1],v[k<<1|1]);}      void build(int k=1,int l=1,int r=n){          if(l==r){v[k]=a[l];return;}          build(k<<1,l,mid),build(k<<1|1,mid+1,r);          pushup(k);      }      void fix(int x,int y,int k=1,int l=1,int r=n){          if(l==x&&r==x){v[k]+=y;return;}          if(mid>=x) fix(x,y,k<<1,l,mid);          else fix(x,y,k<<1|1,mid+1,r);          pushup(k);      }      int fmax(int x,int y,int k=1,int l=1,int r=n){          if(x<=l&&r<=y) return v[k];          int res=0;          if(mid>=x) res=max(res,fmax(x,y,k<<1,l,mid));          if(mid<y) res=max(res,fmax(x,y,k<<1|1,mid+1,r));          return res;      }      #undef mid  }using namespace Sumtree;  int main(){      scanf("%d%d",&n,&m);      for(int i=1;i<=n;i++)          scanf("%d",a+i);      build();      for(int i=1,x,y,z;i<=m;i++){          scanf("%d%d%d",&x,&y,&z);          if(x==1) fix(y,z);          else printf("%dn",fmax(y,z));      }      return 0;  }

線段樹如果只能單點修改區間查詢,程式碼還這麼長,就沒人用他了。所以可想而知,線段樹還可以區間修改,區間查詢。

操作(區間修改區間查詢)

先看如何區間修改,初學者會這麼寫:

void fix(int x,int y,int z,int k=1,int l=1,int r=n){      if(x<=l&&r<=y){v[k]+=z;return;}      if(mid>=x) fix(x,y,z,k<<1,l,mid);      if(mid<y) fix(x,y,z,k<<1|1,mid+1,r);      pushup(k);  }

問題是這樣的話對於每個區間屬於 ([x,y]) 的節點,它的子節點就會沒被修改。

初學者還可能這麼寫:

void fix(int x,int y,int z,int k=1,int l=1,int r=n){      if(l==r){v[k]+=z;return;}      if(mid>=x) fix(x,y,z,k<<1,l,mid);      if(mid<y) fix(x,y,z,k<<1|1,mid+1,r);      pushup(k);  }

問題在於時間複雜度為 (O(n))

那麼區間修改的主角就要出場了——懶標記((texttt{lazytag}))。對於每個節點,多加一個值,(mk[])(mk[x]) 表示 (x) 節點的標記。每次修改在前面那個初學者的程式碼上加上終止區間懶標記。

void fix(int x,int y,int z,int k=1,int l=1,int r=n){      if(x<=l&&r<=y){v[k]+=z,mk[k]+=z;return;}      pushdown(k);      if(mid>=x) fix(x,y,z,k<<1,l,mid);      if(mid<y) fix(x,y,z,k<<1|1,mid+1,r);      pushup(k);  }

這時你注意到了上方程式碼第 (3) 行有一個 (pushdown(k)),那就是一個專門用來處理懶標記的函數,負責把標記下放,時間複雜度為 (O(1))

void pushdown(int k){      if(!mk[k]) return;      v[k<<1]+=mk[k],v[k<<1|1]+=mk[k];      mk[k<<1]+=mk[k],mk[k<<1|1]+=mk[k],mk[k]=0;  }

有了它,區間修改就沒必要一直修改到單點了,可以修改到所屬區間,然後記下懶標記。下次到這個區間的時候把它 (pushdown) 下放。

然後區間修改區間查詢的程式碼就是這樣:

#include <bits/stdc++.h>  using namespace std;  const int N=1e5+10;  int n,m,a[N];  namespace Sumtree{      #define mid ((l+r)>>1)      int v[N<<2],mk[N<<2];      void pushup(int k){v[k]=max(v[k<<1],v[k<<1|1]);}      void pushdown(int k){          if(!mk[k]) return;          v[k<<1]+=mk[k],v[k<<1|1]+=mk[k];          mk[k<<1]+=mk[k],mk[k<<1|1]+=mk[k],mk[k]=0;      }      void build(int k=1,int l=1,int r=n){          mk[k]=0;          if(l==r){v[k]=a[l];return;}          build(k<<1,l,mid),build(k<<1|1,mid+1,r);          pushup(k);      }      void fix(int x,int y,int z,int k=1,int l=1,int r=n){          if(x<=l&&r<=y){v[k]+=z,mk[k]+=z;return;}          pushdown(k);          if(mid>=x) fix(x,y,z,k<<1,l,mid);          if(mid<y) fix(x,y,z,k<<1|1,mid+1,r);          pushup(k);      }      int fmax(int x,int y,int k=1,int l=1,int r=n){          if(x<=l&&r<=y) return v[k];          pushdown(k);          int res=0;          if(mid>=x) res=max(res,fmax(x,y,k<<1,l,mid));          if(mid<y) res=max(res,fmax(x,y,k<<1|1,mid+1,r));          return res;      }      #undef mid  }using namespace Sumtree;  int main(){      scanf("%d%d",&n,&m);      for(int i=1;i<=n;i++)          scanf("%d",a+i);      build();      for(int i=1,x,y,z;i<=m;i++){          scanf("%d",&x);          if(x==1) scanf("%d%d%d",&x,&y,&z),fix(x,y,z);          else scanf("%d%d",&x,&y),printf("%dn",fmax(x,y));      }      return 0;  }

時間複雜度還是 (O(nlog n)) 的。

線段樹有個經典例題,可以幫助你弄懂線段樹的其他操作。

[USACO08FEB]酒店Hotel

第一行輸入 (n)(m)(n) 代表有 (n) 個房間,編號為 (1sim n),開始都為空房。(m) 表示以下有 (m) 行操作,以下每行先輸入一個數 (i),表示一種操作:

(i) 為1,表示查詢房間。再輸入一個數 (x),表示在 (1sim n) 房間中找到長度為 (x) 的連續空房,輸出連續 (x) 個房間中左端的房間號,盡量讓這個房間號最小。若找不到長度為 (x) 的連續空房,輸出(0)。並且在這 (x) 個空房間中住上人。

(i)(2),表示退房,再輸入兩個數 (x)(y) 代表 房間號 (xsim x+y-1) 退房,即讓房間為空。

講解:

那麼這題中每個線段樹節點需要有四個值:

(texttt{lf[k]:})(k) 這個節點區間從左邊開始連續空房數。
(texttt{rt[k]:})(k) 這個節點區間從右邊開始連續空房數。
(texttt{v[k]:})(k) 這個節點區間內最長的連續空房數。
(texttt{mk[k]:})(k) 這個節點退房、住人區間修改懶標記。

所以有遞推式(其中 (?) 為三目運算符):

void pushup(int k,int l,int r){      int mid=(l+r)>>1;      lf[k]=lf[k<<1]==mid-l+1?lf[k<<1]+lf[k<<1|1]:lf[k<<1];      rt[k]=rt[k<<1|1]==r-mid?rt[k<<1|1]+rt[k<<1]:rt[k<<1|1];      v[k]=max(max(v[k<<1],v[k<<1|1]),rt[k<<1]+lf[k<<1|1]);  }

可以這麼初始化:

void build(int k=1,int l=1,int r=n){      mk[k]=-1;      if(l==r){lf[k]=rt[k]=v[k]=1;return;}      int mid=(l+r)>>1;      build(k<<1,l,mid),build(k<<1|1,mid+1,r);      pushup(k,l,r);  }

重點在於怎麼查詢。如下程式碼,(find(x,k,l,r)) 表示尋找 (k) 這個節點區間里尋找最左的連續 (x) 空房。

int find(int x,int k=1,int l=1,int r=n){      if(v[k]<x) return -1; //如果區間內最長連續空房小於x,逃      int mid=(l+r)>>1;      pushdown(k,l,r);//千萬別忘了pushdown      if(v[k<<1]>=x) return find(x,k<<1,l,mid); //如果左兒子有滿足要求的區間,返回左兒子滿足要求的區間左端點      if(rt[k<<1]+lf[k<<1|1]>=x) return mid-rt[k<<1]+1;//如果滿足區間橫跨左右兒子區間,返回橫跨區間左端點      return find(x,k<<1|1,mid+1,r);//返回右兒子滿足區間左端點  }

可以發現,這個程式碼的時間複雜度也是 (O(nlog n)) 的。

蒟蒻的 (color{#44cc00}texttt{AC}) 程式碼:

#include <bits/stdc++.h>  using namespace std;  const int N=5e4+10;  int n,m;  namespace sumtree{      int lf[N<<2],rt[N<<2],v[N<<2],mk[N<<2];      void pushup(int k,int l,int r){          int mid=(l+r)>>1;          lf[k]=lf[k<<1]==mid-l+1?lf[k<<1]+lf[k<<1|1]:lf[k<<1];          rt[k]=rt[k<<1|1]==r-mid?rt[k<<1|1]+rt[k<<1]:rt[k<<1|1];          v[k]=max(max(v[k<<1],v[k<<1|1]),rt[k<<1]+lf[k<<1|1]);      }      void pushdown(int k,int l,int r){          if(mk[k]==-1) return;          int mid=(l+r)>>1;          lf[k<<1]=rt[k<<1]=v[k<<1]=(!mk[k])*(mid-l+1);          lf[k<<1|1]=rt[k<<1|1]=v[k<<1|1]=(!mk[k])*(r-mid);          mk[k<<1]=mk[k<<1|1]=mk[k],mk[k]=-1;      }      void build(int k=1,int l=1,int r=n){          mk[k]=-1;          if(l==r){lf[k]=rt[k]=v[k]=1;return;}          int mid=(l+r)>>1;          build(k<<1,l,mid),build(k<<1|1,mid+1,r);          pushup(k,l,r);      }      int find(int x,int k=1,int l=1,int r=n){          if(v[k]<x) return -1;          if(l==r) return l;          int mid=(l+r)>>1;          pushdown(k,l,r);          if(v[k<<1]>=x) return find(x,k<<1,l,mid);          if(rt[k<<1]+lf[k<<1|1]>=x) return mid-rt[k<<1]+1;          return find(x,k<<1|1,mid+1,r);      }      void clear(int x,int y,int k=1,int l=1,int r=n){          if(x<=l&&r<=y){              lf[k]=rt[k]=v[k]=r-l+1;              mk[k]=0; return;          }          pushdown(k,l,r);          int mid=(l+r)>>1;          if(mid>=x) clear(x,y,k<<1,l,mid);          if(mid<y) clear(x,y,k<<1|1,mid+1,r);          pushup(k,l,r);      }      void full(int x,int y,int k=1,int l=1,int r=n){          if(x<=l&&r<=y){              lf[k]=rt[k]=v[k]=0;              mk[k]=1; return;          }          pushdown(k,l,r);          int mid=(l+r)>>1;          if(mid>=x) full(x,y,k<<1,l,mid);          if(mid<y) full(x,y,k<<1|1,mid+1,r);          pushup(k,l,r);      }  }using namespace sumtree;  int main(){      scanf("%d%d",&n,&m);      build();      for(int i=1;i<=m;i++){          int op,x,y;          scanf("%d",&op);          if(op==1){              scanf("%d",&y);              if((x=find(y))==-1) puts("0");              else {                  printf("%dn",x);                  full(x,x+y-1);              }          } else {              scanf("%d%d",&x,&y);              clear(x,x+y-1);          }      }      return 0;  }

關於線段樹有很多後續知識,如線段樹合併,線段樹分裂,可持久化線段樹(主席樹)等,學習千萬不能停止腳步。

同時,線段樹的題目千變萬化,建議多練練線段樹的題。

祝大家學習愉快!