1. 程式人生 > >線段樹和樹狀陣列學習筆記

線段樹和樹狀陣列學習筆記

    學習了一週的線段樹和樹狀陣列,深深地體會到了這每種操作幾乎都是 $O(logN)$ 級別的資料結構的美,但是做起題來還是相當痛苦的(特別是一開始只會模板的時候,很難靈活運用線段樹的性質)。還好有雨巨大神帶入門,視訊講解十分直觀(b站上也有很多介紹線段樹的視訊),不用像以前一樣看各種部落格題解入門。但是我現在就是在寫部落格了,希望能儘可能將我目前理解的知識整理出來,畢竟能讓別人看懂(網上已經這麼多關於線段樹和樹狀陣列的文章你還能找到我,相信我,你沒選錯),才說明自己也是真的懂了(~~雖然我還有好多不懂~~ $QAQ$)。全文篇幅較長,細心理解一定會有收穫的♪(\^∇\^\*)。 ##線段樹 ###一些概念     線段樹是一種二叉搜尋樹,**每一個結點都是一個區間(也可以叫作線段,可以有單點的葉子結點)**,有一張比較形象的圖如下(侵刪): ![img](https://images.cnblogs.com/cnblogs_com/ailanxier/1819371/o_200802082407%E5%85%AC%E5%BC%8F.jpg)     可以看出,線段樹除根結點外的其他節點,都由其父節點二分長度得到,這種優秀的性質使得我們可以把它近似看成是一棵完全二叉樹。而完全二叉樹可以用一個數組表示:設根節點下標為 $now$ (在程式碼中我習慣用 $now$ 表示當前節點, $ls(now)$ 表示左孩子結點, $rs(now)$ 表示右孩子結點),則: $$ls(now) = now*2,rs(now) = now*2+1$$     這樣就可以快速得到孩子的下標,根節點的下標為1,從上到下,從左往右編號既可將一顆線段樹存入小巧的數組裡了,不用煩人的指標。一般我會把左孩子和右孩子寫到巨集定義去,讓程式碼更簡潔,並且使用位運算,即: $$ls(now) = now<<1,rs(now) = now<<1|1$$    這是等效的寫法,同時我們要獲得中間值,來二分 $now$ 結點,同樣我用了巨集定義: $$mid = (l+r)/2$$     $l$ 和 $r$ 是 $now$ 的左右邊界,即它所能管理(覆蓋)的範圍,明確這一點非常重要。線段樹有很多種寫法,我看的很多程式碼都是把 $l$ 和 $r$ 寫線上段樹節點的結構體裡面,我習慣是用傳引數的方法(因為帶我入門的雨巨就是這樣寫的。其實兩種寫法都是可以的,看個人習慣,最後熟練一種即可,但是另一種也要看得懂)。**左孩子的左邊界還是** $l$ , **右邊界變成了** $mid$ ;**右孩子的左邊界變成** $mid+1$ ,**右邊界是** $r$ 。這樣就可以遞迴建樹了。    這個線段樹陣列的大小也是要注意(經常 $RE$ 的地方),要**開4倍的陣列**,就是說一個長度為10的區間,得開到40的陣列。一般題目資料的範圍都是在 $10^5$ 的量級。有時資料範圍過大,還可以用離散化解決它,這在後面的部落格中會講到。 ###能解決什麼問題    線段樹能解決超多有關區間的問題,還有一些不這麼明顯的區間問題(廢話)。像什麼單點修改,單點查詢,區間修改,區間查詢都不在話下,應用範圍比樹狀陣列廣,變通性極強(**樹狀陣列能解決的問題線段樹都能解決,但是後者能解決的一些問題樹狀陣列還是搞不了的,但是樹狀陣列時空常數小,程式碼量少,還不容易寫錯**)。線段樹可以區間維護區間和、區間乘,區間根號,區間最大公因數,連續的串長度等、區間最值操作等。這裡我給出一個洛谷題單和一個牛客題單,我也是剛剛刷完了這些題,質量都很高。 * 題單一:[洛谷【資料結構2-2】線段樹與樹狀陣列](https://www.luogu.com.cn/training/206#problems) * 題單二:[牛客演算法競賽入門課第九節(線段樹)習題](https://ac.nowcoder.com/acm/problem/collection/621)    我是先做牛客的再去做洛谷的,順序沒什麼。牛客題解比較少,需要自行百度理解(摘自牛客的一些比賽的比較多),洛谷質量就很高,題解豐富,做法也很多。可以先去做掉洛谷的四道模板題(線段樹兩道,樹狀陣列兩道,你還會發現線段樹其實有三道模板題,模板三理解起來比較困難,建議先刷完前面的題再去攻克,不然很可能會自閉(~~我坦白了,我已經自閉了~~))。寫完這篇總結後我會挑一些比較好的題再寫一篇部落格,算是題解總結吧。 ###建樹、修改和查詢    題目一:[P3372 【模板】線段樹 1](https://www.luogu.com.cn/problem/P3373)    我們從模板題入手,題目中的兩個操作正是線段樹很經典的操作:區間修改和區間查詢。我們思考以下幾種做法:    ① 如果讓我們暴力地修改區間每一個值,再暴力查詢區間的每一個值,修改和暴力都是 $O(n)$ ,加上 $m$ 次操作,總的時間複雜度就是 $O(nm)$ 的,必定 $TLE$ 。    ② 預處理字首和,進行離線操作。每次修改為 $O(n)$ ,查詢為 $O(1)$ ,時間複雜度依舊是 $O(nm)$ ,還是會 $TLE$ 。    ③ 以上操作的瓶頸都每次操作時間複雜度都是 $O(n)$ ,這時我們想起了每次操作都是 $O(logn)$ 線段樹, 總的時間複雜度就是 $O(mlogn)$ , $AC$ 了。    開始介紹之前,先交代一下線段樹結構體: ```cpp const int maxn = 1e5+5; struct node{ ll sum,lazy; //sum為區間和,lazy為懶標記 }t[maxn<<2]; //開四倍空間 ```    區間和就不用說了,重點講一下線段樹 $O(logn)$ 操作的關鍵:**懶標記** $lazy$ 。懶標記就是懶,將對區間操作的命令不立刻執行,而是到萬不得已的時候才執行下去,否則就繼續“偷懶”,將命令繼續壓在自己手上,不往自己孩子傳。什麼時候可以不往孩子節點傳下去(即偷懶)呢?**如果此時要修改的區間範圍已經囊括了當前節點能管理的範圍,那我就把這個命令直接在當前節點消化掉,不必再通知孩子也要執行這個命令了**。    舉個栗子,比如我命令 $[1,10]$ 區間全部給我加 $10$ ,到 $[1,10]$ 這個節點的時候,$[1,10]$ 正好包含住我管理的區間,那我直接給它的懶標記加上 $10$ ,給區間和加上 $100$ ,美滋滋地結束了任務,孩子們甚至不用知道這件事。下次如果要查詢 $[1,10]$ 的區間和的話,我直接在 $[1,10]$ 這個節點返回就好了,因為它已經修改正確了。但是很多時候命令的區間是多個節點的並集區間。如果接下來我要 $[4,7]$ 這個區間加 $5$ ,這個區間是 $[4,5]$ 和 $[6,7]$ 這兩個節點的並,你可能會說這不就是在這兩個節點打上標記就完事了嗎?可事實上你之前在 $[1,10]$ 打上了懶標記,這個會影響 $[4,5]$ 和 $[6,7]$ 的區間和,而它的影響因為上次偷懶還沒傳下去呢,實際上 $[4,5]$ 和 $[6,7]$ 的懶標記應該打上 $15$ 才對。那我們怎麼亡羊補牢呢?誒,這需要 $pushdown$ 函式幫忙啦,先賣個關子,先來講講怎麼建樹和修改。 #####建樹build    先給出程式碼,四行建樹。 ```cpp void build(int now,int l,int r){ if(l == r) { cin>> t[now].sum ; return;} build(ls,l,mid); build(rs,mid+1,r); t[now].sum = t[ls].sum + t[rs].sum; } ```     $now$是當前節點的陣列下標,$l$ 和 $r$ 是它所管轄的範圍,如果 $l$ 和 $r$ 相等,說明到了葉子節點,也就是單點的情況,這時就直接讀入資料好了,只有一個元素,區間和肯定就是它本身了,**注意之後要返回,因為它不可再細分了**;否則,將管轄範圍一刀兩半,利用類似完全二叉樹的性質,遞迴建立左子樹 $ls$ 和右子樹 $rs$,這是我的巨集定義(你可以修改各個變數名,很多人習慣用 $p$ 代表當前節點,我比較直接就用 $now$ 了): ```cpp #define ls now<<1 #define rs now<<1|1 #define mid (l+r)/2 ```    建樹程式碼最後一行是關鍵,也是線段樹建樹的精髓,我們一般將這句話寫在一個函式 $pushup$ 裡面,與 $pushdown$ 正好對應。在這裡,它在遞迴返回時,左右子樹已經建好了,**要將它們的區間和資訊整合到根節點**,這裡直接累加即可,不必成單獨一個函式。但是當要維護的資訊量很多時,因為這個 $pushup$ 後面還會呼叫,我們會將它單獨寫成一個函式以減少程式碼量,降低維護成本。 這裡的 $pushup$ 程式碼可以寫成: ```cpp void pushup(int now){ t[now].sum = t[ls].sum + t[rs].sum; } ```    建樹完畢,總結一下:    1.先寫遞迴返回條件 $l == r$ 。    2.遞迴左右子樹 。    3.合併兩子樹 $pushup$ 。 #####修改update    同樣先給出程式碼: ```cpp void update(int now, int l, int r, int x, int y, int value){ if(x <= l && r <= y) { t[now].lazy += value; t[now].sum += (r - l + 1) * value; return; } if(t[now].lazy) pushdown(now,r-l+1); //懶標記下傳,與本次修改的資訊無關,只是清算之前修改積壓的懶標記 if(mid >
= x) update(ls, l, mid, x, y, value); if(mid < y) update(rs, mid + 1, r, x, y, value); pushup(now); } ```    前三個引數是固定引數,只與線段樹本身有關,可以無腦打上,後三個引數在本題的意義為將 $x$ 到 $y$ 區間上每個值加上 $value$ (可正可負)。    首先我們還是得先寫遞迴返回條件:如果要修改的區間滿足 $[l,r]\in[x,y]$ ,也就是說已經涵蓋了本區間了,那我就沒有必要再將修改資訊往下傳了,在我這裡修改就可以了嘛。所以我們偷個懶,打上標記,給區間和加上增量,搞定,返回。但是如果要修改的區間是 $[l,r]$ 的一部分,就要像之前我們說的,要執行神祕的 $pushdown$ 操作了,即將懶標記下傳。這裡特別要注意的是,**本次下傳懶標記和這次修改沒有任何關係,從傳遞的引數也可以看出來與後三個引數無關,它的作用就是清算之前偷懶造成的影響**。來看看這個重要的 $pushdown$ 程式碼 ```cpp void pushdown(int now,int tot){ t[ls].lazy += t[now].lazy; //懶標記給左孩子 t[rs].lazy += t[now].lazy; //懶標記給右孩子 t[ls].sum += (tot - tot/2) * t[now].lazy; //區間和加上懶標記的影響,注意範圍 t[rs].sum += (tot/2) * t[now].lazy; t[now].lazy = 0; //記得懶標記下傳後清0 } ```   **新引數 $tot$ 表示當前節點管轄區間的範圍大小,注意左孩子管轄範圍為** $tot-tot/2$ ,**右孩子是** $tot/2$ ,在加區間和的時候要小心。把之前偷懶的部分傳給孩子後,偷懶記錄就**清零**啦(摸魚成功)。這時不要不放心繼續 $pushdown$ 左孩子和右孩子,這是沒有必要的,因為之後我們還會繼續 $update$ 左子樹和右子樹(看上上個程式碼),如果有需要就會進行 $pushdown$,沒有需要就繼續偷懶。這樣就能保證完整的 $update$ 操作是 $O(logn)$ 的。注意,**遞迴左右子樹之前先判斷需不需要遞迴**。分兩種情況,**如果你要修改的部分完全在左子樹,就沒有必要修改右子樹**;同理亦是如此。這樣可以防止無限遞迴了(如果在測試樣例的時候發現不出結果,大概率就是這裡沒寫對)。    修改完畢,總結一下:    1.先寫遞迴返回條件 $x <= l ~\&\&~ r <= y$ ,執行偷懶操作。    2.**如果當前節點有懶標記積壓**,執行 $pushdown$ 操作,先清算之前的賬。    3.根據條件判斷遞迴哪棵子樹(可能兩棵都會修改)進行修改。    4.合併兩子樹 $pushup$ 。 #####查詢query   最後一部分就是查詢了,與修改操作其實比較類似: ```cpp ll query(int now, int l, int r, int x, int y){ if(x <= l && r <= y) return t[now].sum; if(t[now].lazy) pushdown(now,r-l+1); ll ans = 0; if(mid >= x) ans += query(ls, l, mid, x, y); if(mid < y) ans += query(rs, mid + 1, r, x, y); return ans; } ```   你可能也發現查詢操作比較好理解,最不容易寫錯,也是寫的比較開心的一部分了。要注意的還是記得 $pushdown$ ,因為要查詢的區間可能是當前節點的某個孩子,如果不把之前的懶標記下傳,查詢會出錯。遞迴返回區間和就行,並不用 $pushup$ (查詢不會修改當前節點的值)。    查詢完畢,總結一下:    1.先寫遞迴返回條件 $x <= l ~\&\&~ r <= y$ ,返回區間和資訊即可。    2.如果當前節點有懶標記積壓,執行 $pushdown$ 操作,先清算之前的賬。    3.根據條件判斷遞迴子樹進行查詢。    4.合併兩子樹查詢結果並返回。 ###完整程式碼: ```cpp #include using namespace std; #define For(i,sta,en) for(int i = sta;i <= en;i++) #define speedUp_cin_cout ios::sync_with_stdio(false);cin.tie(0); cout.tie(0); #define ls now<<1 #define rs now<<1|1 #define mid (l+r)/2 typedef long long ll; const int maxn = 1e5+5; int n,m; struct node{ ll sum,lazy; //sum為區間和,lazy為懶標記 }t[maxn<<2]; void pushup(int now){ t[now].sum = t[ls].sum + t[rs].sum; } void build(int now,int l,int r){ if(l == r) { cin>> t[now].sum ; return;} build(ls,l,mid); build(rs,mid+1,r); pushup(now); } void pushdown(int now,int tot){ t[ls].lazy += t[now].lazy; //懶標記給左孩子 t[rs].lazy += t[now].lazy; //懶標記給右孩子 t[ls].sum += (tot - tot/2) * t[now].lazy; //區間和加上懶標記的影響,注意範圍 t[rs].sum += (tot/2) * t[now].lazy; t[now].lazy = 0; //記得懶標記下傳後清0 } void update(int now, int l, int r, int x, int y, int value){ if(x <= l && r <= y) {t[now].lazy += value; t[now].sum += (r - l + 1) * value;return;} if(t[now].lazy) pushdown(now,r-l+1); //懶標記下傳,與本次修改的資訊無關,只是清算之前修改積壓的懶標記 if(mid >
= x) update(ls, l, mid, x, y, value); if(mid < y) update(rs, mid + 1, r, x, y, value); pushup(now); } ll query(int now, int l, int r, int x, int y){ if(x <= l && r <= y) return t[now].sum; if(t[now].lazy) pushdown(now,r-l+1); ll ans = 0; if(mid >= x) ans += query(ls, l, mid, x, y); if(mid < y) ans += query(rs, mid + 1, r, x, y); return ans; } int main(){ speedUp_cin_cout//加速讀寫 cin>>n>>m; build(1,1,n); //建樹順便讀入,省一個數組 int op,l,r,d; For(i,1,m){ cin>>op; if(op == 1) { // l 到 r 加上 d cin>>l>>r>>d; update(1, 1, n, l, r, d); }else { cin>
>l>>r; //查詢 l 到 r 的值 cout << query(1, 1, n, l, r) << endl; } } return 0; } ```   幹掉這題就可以去做[P3373 【模板】線段樹 2](https://www.luogu.com.cn/problem/P3373)了,這題會提升你對懶標記的理解,$pushdown$ 的懶標記下傳處理變得有些複雜。因為它要同時維護區間加標記和區間乘標記,**區間乘標記會同時影響區間加標記和區間乘標記**,想要 $AC$ 還是得細心理解乘和加的關係。 ##樹狀陣列 ###一些概念   樹狀陣列,顧名思義,~~就是像一棵樹的陣列~~。其實它和樹關係不太大,實際操作時有類似在樹上節點的跳躍的過程,但是在寫程式碼的時候它也不過是個一維陣列而已,和線段樹還是有一點點像的,不過是將線段樹的一些精華部分充分壓縮了。來,讓我們看看傳說中的樹狀陣列長啥樣(純手工,不要在意細節): ![img](https://images.cnblogs.com/cnblogs_com/ailanxier/1819371/o_200802082415%E5%8E%9F%E6%A0%91%E7%8A%B6%E6%95%B0%E7%BB%84.jpg)   綠油油的就是樹狀陣列啦,**它頭上紅色的是這個下標對應的二進位制**,最底下的就是原陣列了。直覺告訴你,他們之間隱約存在某些關係。你可能會覺得這裡一共有兩個陣列,還有一堆連線,要維護起來是不是很麻煩啊。其實他們可以只用一個一維陣列儲存所有資訊,而其中關鍵的紐帶就是**二進位制**。   我們設原陣列為 $A$ ,樹狀陣列為 $T$ ,定義連線的意義就是一個節點能管轄的範圍。例如 $T_1$ 能直接管轄管轄到 $A_1$ , $T_2$ 能管轄到 $T_1$ (間接管轄到 $A_1$)和 $A_2$, $T_4$ 能管轄到 $T_2$ 、$T_3$ 和 $A_4$ ,亦即 $T_4$ 能管轄到原陣列 $A_1$ 、$A_2$ 、$A_3$和 $A_4$ ...以此類推,$T_8$ 能管轄到原陣列所有值。所以,我們只要儲存 $T_1$ , $T_2$ 的值,原陣列中 $A_2$ 可以由這兩者相減得到;同理, $A_5$ 、$A_6$ 、$A_7$和 $A_8$ 的總和可以由 $T_8$ 減去 $T_4$ 得到。所以,**我們只要保留樹狀陣列,原陣列的資訊完全可以由樹狀陣列維護出來,並且輕鬆知道任意一個區間的資訊和**。   那麼新的問題出現了,我們如何知道誰管轄誰,他們之間有什麼聯絡嗎?這時,奇妙的二進位制出現了。觀察樹狀陣列頭上的二進位制,看出被管轄者與管轄著之間在二進位制上的聯絡了嗎?揭曉答案,**被管轄者加上 $2^k$,$k$ 為被管轄者二進位制末尾零的個數,即可得到管轄著的二進位制**!舉個栗子,$T_2$ 的二進位制為 $0010$ ,加上 $2^1(0010)$ ,得到 $0100$ ,即 $T_4$ 。我們一般將 $2^k$ 寫成一個函式叫 $lowbit$ ,樹狀陣列下標 $x$ 與它的 $lowbit$ 如下關係: $$lowbit = x\&(-x)$$   證明其實沒必要,會用就行,這涉及到負數在計算機中儲存的形式,可以自己證一下。 ###修改update   [P3374 【模板】樹狀陣列 1](https://www.luogu.com.cn/problem/P3374)   樹狀陣列完全不用像線段樹一樣需要一個函式來建樹,聲明瞭一個一維陣列(**陣列大小等於資料量即可,不用開多幾倍**)直接就可以進行修改查詢等操作了。它的修改函式程式碼非常短,而且形式幾乎不變。 ```cpp void update(int now,int value){ while(now <= n){ t[now] += value; now += lowbit(now); } } ```   三行迴圈就結束了,線段樹自愧不如。這個函式的意義是在原陣列的 $now$ 的下標位置加上 $value$ ,迴圈的終點是大於了**樹狀陣列的下標範圍 $n$** 。它是怎麼通過加上 $lowbit$ 實現的呢?來看下面這張圖: ![img](https://images.cnblogs.com/cnblogs_com/ailanxier/1819371/o_200802082411%E4%BF%AE%E6%94%B9.jpg)   假如我們要修改原陣列 $5$ 這個位置的值,能管轄到它的只有 $T_6$ 和 $T_8$ 。因為我們要求區間和,所以 $T_6$ 和 $T_8$ 要都加上 $T_5$ 修改後的值才行。**這時我們用一個 $lowbit$ 在迴圈中從 $T_5$ 跳到 $T_6$,再跳到 $T_8$** ,一氣呵成。這樣,單點修改操作就完成啦。 ###查詢query   查詢操作永遠是和修改操作配套的,一切修改的目的都是為了查詢的方便。既然修改程式碼這麼短小精悍,那麼查詢程式碼就更加小巧了,請看: ```cpp ll query(int now){ ll ans = 0; //long long 型別的答案 while(now){ ans += t[now]; now -= lowbit(now); }return ans; } ```   程式碼的意義是查詢原陣列從 $1$ 到 $now$ 的字首和,即從 $A_1$ 到 $A_{now}$ 的和。**注意這時我們的 $lowbit$ 操作變成了減,而之前修改操作是加**。原理也可以看圖說明: ![查詢](https://images.cnblogs.com/cnblogs_com/ailanxier/1819371/o_200802082401%E6%9F%A5%E8%AF%A2.jpg)   圖中我們查詢的是 $1$ ~ $7$ 的字首和,我們先加上 $T_7$ 的答案,再減去它的 $lowbit$ 跳到 $T_6$ ,最後跳到 $T_4$ ,因為 $T_6$ 和 $T_4$ 在前面的修改操作中已經維護出了自己管轄區域的區間和,都加上就是 $1$ ~ $7$ 的字首和了。   知道了字首和,區間和其實就很容易了,假如我們要求 $[x,y]$ 的區間和,其實就是 $query(y)-query(x-1)$ ,**注意是 $x-1$ ,要自己想一想,這個地方總是容易被忽略**。 ###完整程式碼 ```cpp #include using namespace std; #define For(i,sta,en) for(int i = sta;i <= en;i++) #define speedUp_cin_cout ios::sync_with_stdio(false);cin.tie(0); cout.tie(0); #define lowbit(x) x&(-x) typedef long long ll; const int maxn = 5e5+5; int t[maxn],n,m,num; void update(int now,int value){ while(now<=n){ t[now]+=value; now += lowbit(now); } } ll query(int now){ ll ans = 0; //long long 型別的答案 while(now){ ans += t[now]; now -= lowbit(now); }return ans; } int main(){ speedUp_cin_cout cin>>n>>m; For(i,1,n) { cin>>num; update(i,num); }int op,x,y; For(i,1,m){ cin>>op>>x>>y; if(op == 1) update(x,y); else cout<