# 線段樹 > [color=blue]於競賽而言強大的工具。 ## 用法 & 概念 線段樹除了可以用來快速解區間和問題,還可以用來執行許多與區間有關的操作。大致上用以下方式建構區間 ```graphviz digraph SEG{ nodesep=0.7 // increases the separation between nodes node [color=Red,fontname=Courier,shape=box] //All nodes will this shape and colour edge [color=Blue, style=dashed] //All the lines look like this "1~8"->{"1~4" "5~8"} "1~4"->{"1~2" "3~4"} "1~2"->{"1" "2"} "3~4"->{"3" "4"} "5~8"->{"5~6" "7~8"} "5~6"->{"5" "6"} "7~8"->{"7" "8"} } ``` 每個區間視情況放不同的數值,例如:最大/小,或是區間總和等。 接著,每個區間就都可以分為$O(log(n))$個區間,例如`2~7`可以分為`2, 3~4, 5~6, 7` ```graphviz digraph SEG{ nodesep=0.7 // increases the separation between nodes node [color=Red,fontname=Courier,shape=box] //All nodes will this shape and colour edge [color=Blue, style=dashed] //All the lines look like this "1~8"->{"1~4" "5~8"} "1~4"->{"1~2" "3~4"[color=DarkGreen]} "1~2"->{"1" "2"[color=DarkGreen]} "3~4"->{"3" "4"} "5~8"->{"5~6"[color=DarkGreen] "7~8"} "5~6"->{"5" "6"} "7~8"->{"7"[color=DarkGreen] "8"} } ``` 查詢時皆以最大區間為出發點,例如上圖就會是從`1~8`這個區間開始,如果要查詢的區間是`2~7`。因為`1~8`這個區間並沒有完全包含`2~7`,因此需要往下遞迴,分成`1~4`和`5~8`再次查詢。 接著,因為`1~4`和`5~8`仍然沒有完全被`2~7`包含,因此要再次遞迴,這次是分解成`1~2`,`3~4`,`5~6`以及`7~8`。 這次`3~4`和`5~6`都有被完全包含,因此可以直接回傳這個區間的值。而`1~2`和`7~8`還是沒有。所以這兩個區間還要再次向下查詢。 查詢時最重要的是,若區間完全被包含就直接回傳,若完全沒被包含就不往那邊搜尋,否則再將區間分成兩塊向下遞迴。 ## 實作 可以發現他是一顆二元樹,於是我們有兩種做法:指標型與陣列型。以下實作以區間總和為範例。 ### 指標型 請詳讀程式碼,我盡可能詳細註解 ```cpp= #include<bits/stdc++.h> using namespace std; int n; struct node{ //value int val; //左右子樹的指標 node *rch,*lch; //建構式 node(){ val=0; rch=lch=nullptr; } //帶有初始值的建構式 node(int v){ val=v; rch=lch=nullptr; } //當左右子樹改變時,應重新計算父節點的值 void pull(){ //歸零 val=0; //必須先有左右子樹才能拜訪 //if(l) 相當於 if(l!=nullptr) if(lch) val+=lch->val; if(rch) val+=rch->val; } //單點修改 void modify(int p,int v,int lb=1,int rb=n){ //終止條件:區間長度為1 if(lb==rb){ val=v; return; } //若左右子樹沒有結點則開一個新的 if(!lch) lch=new node(); if(!rch) rch=new node(); //設mid為區間中點,均分為左右區間 //>>1相當於/2,但快很多 int mid=lb+rb>>1; //左邊走左,右邊走右 if(p<=mid) lch->modify(p,v, lb ,mid); if(mid<p) rch->modify(p,v,mid+1,rb); //還記得子樹修改完要做什麼? pull(); } int query(int l,int r,int lb=1,int rb=n){ //終止條件:所在區間位於欲查詢區間之中 if(l<=lb && rb<=r){ return val; } //同modify int mid=lb+rb>>1; //左右遞迴求解 int ret=0; //若左右區間不在要查詢的區間或是沒有左右子樹則不遞迴 if(lch && l<=mid) ret+=lch->query(l,r, lb ,mid); if(rch && mid<r) ret+=rch->query(l,r,mid+1,rb); //為求保險,以及供以後懶人標記使用 pull(); return ret; } }; node *rt=new node();//root ``` ### 陣列型 > [color=blue] **設根節點idx為1,在完滿二元樹中,左子樹就會是$idx \times 2$,右子樹就是$idx \times 2+1$。** ```cpp= #include<bits/stdc++.h> using namespace std; const int N=100010; int a[N]; //陣列seg的大小建議是N*4,否則你要先知道大於N的最小2^k值 // 131072 -> 262144 所以其實開 262200 就可以了 int seg[N*4]; int n,MXN=1; //不同於指標型的是:陣列行要預建構 void build(int lb=1,int rb=MXN,int idx=1){ //終止條件:區間長度為1 if(lb==rb) return; //設mid為區間中點,均分為左右區間 //>>1相當於/2,但稍快 int mid=lb+rb>>1; //idx*2 為左子樹 //idx*2+1 為右子樹 build(lb,mid,idx*2); build(mid+1,rb,idx*2+1); seg[idx]=seg[idx*2]+seg[idx*2+1]; } void init(){ //為了讓他長度為2^k while(MXN<n) MXN<<=1; //歸零 for(int i=MXN+1;i<MXN*2;i++) seg[i]=0; //以陣列的內容對其初始化 for(int i=1;i<=n;++i) seg[MXN+i-1]=a[i]; //將所有節點都建構好 build(); } void modify(int x,int k){ //此部分也與指標型不同,陣列可以直接依據座標修改 x=x+MXN-1; seg[x]=k; //向上更新節點 while(x>1){ x>>=1; seg[x]=seg[x*2]+seg[x*2+1]; } } int query(int l,int r,int lb=1,int rb=MXN,int idx=1){ //終止條件:所在區間位於欲查詢區間之中 if(l<=lb && rb<=r) return seg[idx]; //設mid為區間中點,均分為左右區間 //>>1相當於/2,但稍快 int mid=lb+rb>>1; //左右遞迴求解 int ret=0; //若左右區間不在要查詢的區間或是沒有左右子樹則不遞迴 if(l<=mid) ret+=query(l,r, lb ,mid,idx*2); if(r>=mid+1) ret+=query(l,r,mid+1,rb,idx*2+1); return ret; } int main(){ ios::sync_with_stdio(0);cin.tie(0); cin>>n; for(int i=1;i<=n;++i) cin>>a[i]; //一定要記得init() init(); } ``` ## 懶人標記 以上的實作方法都只能用於單點修改,而若需要區間加值的話則會很費時,因此有人想到一個方法:若要修改的區間剛好完全包含線段樹上的某個區間的話,就可以先在上面打一個標記。等到需要動到他下面的子區間再向下放。這樣的話區間加值也可以從 $O(nlog(n))$ 進步到 $O(log(n))$ 。 ### 實作 #### 指標型 請詳讀程式碼,我盡可能詳細註解 ```cpp= #include<bits/stdc++.h> using namespace std; int n; struct node{ //value tag int val,tag; //左右子樹的指標 node *rch,*lch; //建構式 node(){ val=tag=0; rch=lch=nullptr; } //帶有初始值的建構式 node(int v){ val=v, tag=0; rch=lch=nullptr; } //多了一個push(),將標記下放 void push(int l,int r){ //>>運算會在+-之後 int len=r-l+1>>1; if(lch){ lch->val+=tag*len; lch->tag+=tag; } if(rch){ rch->val+=tag*len; rch->tag+=tag; } tag=0; } //當左右子樹改變時,應重新計算父節點的值 void pull(){ //歸零 val=0; //必須先有左右子樹才能拜訪 //if(l) 相當於 if(l!=nullptr) if(lch) val+=lch->val; if(rch) val+=rch->val; } //單點修改 void modify(int p,int v,int lb=1,int rb=n){ //終止條件:區間長度為1 if(lb==rb){ val=v; return; } //若左右子樹沒有結點則開一個新的 if(!lch) lch=new node(); if(!rch) rch=new node(); //在遞迴之前先下放標記 push(lb,rb); //設mid為區間中點,均分為左右區間 //>>1相當於/2,但快很多 int mid=lb+rb>>1; //左邊走左,右邊走右 if(p<=mid) lch->modify(p,v, lb ,mid); if(mid<p) rch->modify(p,v,mid+1,rb); //還記得子樹修改完要做什麼? pull(); } int query(int l,int r,int lb=1,int rb=n){ //終止條件:所在區間位於欲查詢區間之中 if(l<=lb && rb<=r){ return val; } //在遞迴之前先下放標記 push(lb,rb); //同modify int mid=lb+rb>>1; //左右遞迴求解 int ret=0; //若左右區間不在要查詢的區間或是沒有左右子樹則不遞迴 if(lch && l<=mid) ret+=lch->query(l,r, lb ,mid); if(rch && mid<r) ret+=rch->query(l,r,mid+1,rb); pull(); return ret; } //多了一個函式,區間加值 void add(int l,int r,int v,int lb=1,int rb=n){ //終止條件:所在區間位於欲查詢區間之中 if(l<=lb && rb<=r){ //由於區間為閉區間[lb,rb]因此要加1 val+=v*(rb-lb+1); tag+=v; return; } //在遞迴之前先下放標記 push(lb,rb); //同modify int mid=lb+rb>>1; //左右遞迴求解 int ret=0; //若左右區間不在要查詢的區間或是沒有左右子樹則不遞迴 if(lch && l<=mid) lch->add(l,r,v, lb ,mid); if(rch && mid<r) rch->add(l,r,v,mid+1,rb); pull(); } }; node rt;//root ``` ## 習題 ### [Segment Tree 練習(ZJe409)](https://zerojudge.tw/ShowProblem?problemid=e409) #### 題目概述 : 將 $A[x]$的值更新為 $y$ 要查詢 $A[X]$~$A[Y]$ 之中最大值$maxA$及最小值$minA$的差 #### 輸入說明 : 第1列有兩個正整數 $k$ $m$ ,第2列有 $k$ 個正整數, 請讀入放至 $A[1..k]$ 接著讀入第3列的 $N$ $Q$ 兩個正整數,呼叫所附的產生測資程式 `gen_dat()` 以上 $5<k \leq n, 5<m<1000$ 然後就是解題,依$C,X,Y$陣列的值 執行更新或查詢 #### 輸出說明 : 若為調查,則輸出區域中的元素總和,若為修改,則不必輸出 #### 範例輸入 1 : ``` 10 541 12 34 56 78 91 23 45 67 89 111 25 15 ``` #### 範例輸出 1 : ``` 328 68 406 0 79 251 327 489 0 ``` --- ### [戰術資料庫(110宜中資訊社校內賽pF)](https://zerojudge.tw/ShowProblem?problemid=g625) #### 題目敘述 : 要求能在一個陣列中做以下操作 1. 搜尋區間和 $find sum$ $(l)$ $(r)$ 2. 搜尋區間最大和最小值 $find$ $max/min (l)$ $(r)$ 3. 單點加減值 $plus/minus$ $(position)$ $(k)$ #### 輸入說明 : 輸入第一行有一個數字 n, q ,下一行有 n 個數字,緊接著有 q 筆操作,可能為以下五種 1. 搜尋區間和 2. 搜尋區間最大和最小值 3. 單點加減值 相鄰數字間以空白隔開。 $n, q \leq 100000 , a[i] \leq 100000$ #### 輸出說明 : 對於每個搜尋指令做出回答 #### 範例輸入 1 : ``` 7 10 1 2 3 4 5 6 7 find max 2 5 find min 1 4 minus 3 1 plus 2 4 find sum 1 7 plus 7 -3 find sum 1 3 minus 6 0 plus 1 1 find max 1 7 ``` #### 範例輸出 1 : ``` 5 1 31 9 6 ``` > **2022/1/10** > [name=mtmatt] [time=Mon, Jan 10, 2022 21:10 ] [color=#00eeff]