線段樹和樹狀陣列學習筆記
學習了一週的線段樹和樹狀陣列,深深地體會到了這每種操作幾乎都是 \(O(logN)\) 級別的資料結構的美,但是做起題來還是相當痛苦的(特別是一開始只會模板的時候,很難靈活運用線段樹的性質)。還好有雨巨大神帶入門,視訊講解十分直觀(b站上也有很多介紹線段樹的視訊),不用像以前一樣看各種部落格題解入門。但是我現在就是在寫部落格了,希望能儘可能將我目前理解的知識整理出來,畢竟能讓別人看懂(網上已經這麼多關於線段樹和樹狀陣列的文章你還能找到我,相信我,你沒選錯),才說明自己也是真的懂了(雖然我還有好多不懂 \(QAQ\))。全文篇幅較長,細心理解一定會有收穫的♪(^∇^*)。
線段樹
一些概念
線段樹是一種二叉搜尋樹,每一個結點都是一個區間(也可以叫作線段,可以有單點的葉子結點),有一張比較形象的圖如下(侵刪):
可以看出,線段樹除根結點外的其他節點,都由其父節點二分長度得到,這種優秀的性質使得我們可以把它近似看成是一棵完全二叉樹。而完全二叉樹可以用一個數組表示:設根節點下標為 \(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\) 寫線上段樹節點的結構體裡面,我習慣是用傳引數的方法(因為帶我入門的雨巨就是這樣寫的。其實兩種寫法都是可以的,看個人習慣,最後熟練一種即可,但是另一種也要看得懂)。左孩子的左邊界還是
這個線段樹陣列的大小也是要注意(經常 \(RE\) 的地方),要開4倍的陣列,就是說一個長度為10的區間,得開到40的陣列。一般題目資料的範圍都是在 \(10^5\) 的量級。有時資料範圍過大,還可以用離散化解決它,這在後面的部落格中會講到。
能解決什麼問題
線段樹能解決超多有關區間的問題,還有一些不這麼明顯的區間問題(廢話)。像什麼單點修改,單點查詢,區間修改,區間查詢都不在話下,應用範圍比樹狀陣列廣,變通性極強(樹狀陣列能解決的問題線段樹都能解決,但是後者能解決的一些問題樹狀陣列還是搞不了的,但是樹狀陣列時空常數小,程式碼量少,還不容易寫錯)。線段樹可以區間維護區間和、區間乘,區間根號,區間最大公因數,連續的串長度等、區間最值操作等。這裡我給出一個洛谷題單和一個牛客題單,我也是剛剛刷完了這些題,質量都很高。
我是先做牛客的再去做洛谷的,順序沒什麼。牛客題解比較少,需要自行百度理解(摘自牛客的一些比賽的比較多),洛谷質量就很高,題解豐富,做法也很多。可以先去做掉洛谷的四道模板題(線段樹兩道,樹狀陣列兩道,你還會發現線段樹其實有三道模板題,模板三理解起來比較困難,建議先刷完前面的題再去攻克,不然很可能會自閉(我坦白了,我已經自閉了))。寫完這篇總結後我會挑一些比較好的題再寫一篇部落格,算是題解總結吧。
建樹、修改和查詢
題目一:P3372 【模板】線段樹 1
我們從模板題入手,題目中的兩個操作正是線段樹很經典的操作:區間修改和區間查詢。我們思考以下幾種做法:
① 如果讓我們暴力地修改區間每一個值,再暴力查詢區間的每一個值,修改和暴力都是 \(O(n)\) ,加上 \(m\) 次操作,總的時間複雜度就是 \(O(nm)\) 的,必定 \(TLE\) 。
② 預處理字首和,進行離線操作。每次修改為 \(O(n)\) ,查詢為 \(O(1)\) ,時間複雜度依舊是 \(O(nm)\) ,還是會 \(TLE\) 。
③ 以上操作的瓶頸都每次操作時間複雜度都是 \(O(n)\) ,這時我們想起了每次操作都是 \(O(logn)\) 線段樹, 總的時間複雜度就是 \(O(mlogn)\) , \(AC\) 了。
開始介紹之前,先交代一下線段樹結構體:
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
先給出程式碼,四行建樹。
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\) 了):
#define ls now<<1
#define rs now<<1|1
#define mid (l+r)/2
建樹程式碼最後一行是關鍵,也是線段樹建樹的精髓,我們一般將這句話寫在一個函式 \(pushup\) 裡面,與 \(pushdown\) 正好對應。在這裡,它在遞迴返回時,左右子樹已經建好了,要將它們的區間和資訊整合到根節點,這裡直接累加即可,不必成單獨一個函式。但是當要維護的資訊量很多時,因為這個 \(pushup\) 後面還會呼叫,我們會將它單獨寫成一個函式以減少程式碼量,降低維護成本。
這裡的 \(pushup\) 程式碼可以寫成:
void pushup(int now){
t[now].sum = t[ls].sum + t[rs].sum;
}
建樹完畢,總結一下:
1.先寫遞迴返回條件 \(l == r\) 。
2.遞迴左右子樹 。
3.合併兩子樹 \(pushup\) 。
修改update
同樣先給出程式碼:
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\) 程式碼
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
最後一部分就是查詢了,與修改操作其實比較類似:
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.合併兩子樹查詢結果並返回。
完整程式碼:
#include<bits/stdc++.h>
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了,這題會提升你對懶標記的理解,\(pushdown\) 的懶標記下傳處理變得有些複雜。因為它要同時維護區間加標記和區間乘標記,區間乘標記會同時影響區間加標記和區間乘標記,想要 \(AC\) 還是得細心理解乘和加的關係。
樹狀陣列
一些概念
樹狀陣列,顧名思義,就是像一棵樹的陣列。其實它和樹關係不太大,實際操作時有類似在樹上節點的跳躍的過程,但是在寫程式碼的時候它也不過是個一維陣列而已,和線段樹還是有一點點像的,不過是將線段樹的一些精華部分充分壓縮了。來,讓我們看看傳說中的樹狀陣列長啥樣(純手工,不要在意細節):
綠油油的就是樹狀陣列啦,它頭上紅色的是這個下標對應的二進位制,最底下的就是原陣列了。直覺告訴你,他們之間隱約存在某些關係。你可能會覺得這裡一共有兩個陣列,還有一堆連線,要維護起來是不是很麻煩啊。其實他們可以只用一個一維陣列儲存所有資訊,而其中關鍵的紐帶就是二進位制。
我們設原陣列為 \(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
樹狀陣列完全不用像線段樹一樣需要一個函式來建樹,聲明瞭一個一維陣列(陣列大小等於資料量即可,不用開多幾倍)直接就可以進行修改查詢等操作了。它的修改函式程式碼非常短,而且形式幾乎不變。
void update(int now,int value){
while(now <= n){
t[now] += value;
now += lowbit(now);
}
}
三行迴圈就結束了,線段樹自愧不如。這個函式的意義是在原陣列的 \(now\) 的下標位置加上 \(value\) ,迴圈的終點是大於了樹狀陣列的下標範圍 \(n\) 。它是怎麼通過加上 \(lowbit\) 實現的呢?來看下面這張圖:
假如我們要修改原陣列 \(5\) 這個位置的值,能管轄到它的只有 \(T_6\) 和 \(T_8\) 。因為我們要求區間和,所以 \(T_6\) 和 \(T_8\) 要都加上 \(T_5\) 修改後的值才行。這時我們用一個 \(lowbit\) 在迴圈中從 \(T_5\) 跳到 \(T_6\),再跳到 \(T_8\) ,一氣呵成。這樣,單點修改操作就完成啦。
查詢query
查詢操作永遠是和修改操作配套的,一切修改的目的都是為了查詢的方便。既然修改程式碼這麼短小精悍,那麼查詢程式碼就更加小巧了,請看:
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\) 操作變成了減,而之前修改操作是加。原理也可以看圖說明:
圖中我們查詢的是 \(1\) ~ \(7\) 的字首和,我們先加上 \(T_7\) 的答案,再減去它的 \(lowbit\) 跳到 \(T_6\) ,最後跳到 \(T_4\) ,因為 \(T_6\) 和 \(T_4\) 在前面的修改操作中已經維護出了自己管轄區域的區間和,都加上就是 \(1\) ~ \(7\) 的字首和了。
知道了字首和,區間和其實就很容易了,假如我們要求 \([x,y]\) 的區間和,其實就是 \(query(y)-query(x-1)\) ,注意是 \(x-1\) ,要自己想一想,這個地方總是容易被忽略。
完整程式碼
#include<bits/stdc++.h>
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<<query(y)-query(x-1)<<endl;
}
return 0;
}
是不是比線段樹短多了。這是單點修改,區間查詢。洛谷還有道P3368 【模板】樹狀陣列 2,是區間修改,單點查詢。這就需要差分的思想了。所謂的差分,其實就是後一項與前一項的差,對於第一項而言,\(a[0] = 0\) 。設陣列 \(a[~]=\{1,9,3,5,2\}\) ,那麼差分陣列\(t[~]=\{1,8,-6,2,-3\}\) ,即 \(t[i]=a[i]-a[i-1]\) ,那麼 $$a[i]=t[1]+...+t[i]$$
這不就是字首和嗎?以對原陣列的區間修改,單點查詢就是在其差分陣列上單點修改,區間查詢。但是要注意的是,這裡的單點其實是要修改兩個點。例如我們如果要讓 \([2,3]\) 區間加上 \(4\) ,首先是要修改差分陣列上的 \(t[2] +4\), 然後還要修改 \(t[4]-4\) ,這也是很好理解的,畢竟 \([2,3]\) 區間比其他區間突出了一塊,整體提高了 \(4\) ,而其他的區間的差分關係並沒有被改變。這樣,我們也可以很愉快地 \(AC\) 這道題了。
還有一些話
做模板題是快樂的(除了P6242 【模板】線段樹 3),但是實際應用起來是比較頭疼的。因為線段樹和樹狀陣列靈活性很高,可以解決很多看似無法下手的問題,但是要維護的資訊多得容易摸不著頭腦(不知道為什麼這樣做就可以了),邏輯關係環環相扣,時不時就得感嘆一下“妙”。這些都得做更多的題來體會了。還有不要死記模板,要清楚知道每一步的作用,很多時候一些順序會顛倒,來解決不同的問題,這是需要警惕的。
如果覺得對你理解有幫助的希望給我點個贊哦,ο(=•ω<=)ρ⌒☆