學習了一週的線段樹和樹狀數組,深深地體會到了這每種操做幾乎都是 \(O(logN)\) 級別的數據結構的美,可是作起題來仍是至關痛苦的(特別是一開始只會模板的時候,很難靈活運用線段樹的性質)。還好有雨巨大神帶入門,視頻講解十分直觀(b站上也有不少介紹線段樹的視頻),不用像之前同樣看各類博客題解入門。可是我如今就是在寫博客了,但願能儘量將我目前理解的知識整理出來,畢竟能讓別人看懂(網上已經這麼多關於線段樹和樹狀數組的文章你還能找到我,相信我,你沒選錯),才說明本身也是真的懂了(雖然我還有好多不懂 \(QAQ\))。全文篇幅較長,細心理解必定會有收穫的♪(^∇^*)。node
線段樹是一種二叉搜索樹,每個結點都是一個區間(也能夠叫做線段,能夠有單點的葉子結點),有一張比較形象的圖以下(侵刪):
能夠看出,線段樹除根結點外的其餘節點,都由其父節點二分長度獲得,這種優秀的性質使得咱們能夠把它近似當作是一棵徹底二叉樹。而徹底二叉樹能夠用一個數組表示:設根節點下標爲 \(now\) (在代碼中我習慣用 \(now\) 表示當前節點, \(ls(now)\) 表示左孩子結點, \(rs(now)\) 表示右孩子結點),則:ios
這樣就能夠快速獲得孩子的下標,根節點的下標爲1,從上到下,從左往右編號既可將一顆線段樹存入小巧的數組裏了,不用煩人的指針。通常我會把左孩子和右孩子寫到宏定義去,讓代碼更簡潔,而且使用位運算,即:c++
這是等效的寫法,同時咱們要得到中間值,來二分 \(now\) 結點,一樣我用了宏定義:算法
\(l\) 和 \(r\) 是 \(now\) 的左右邊界,即它所能管理(覆蓋)的範圍,明確這一點很是重要。線段樹有不少種寫法,我看的不少代碼都是把 \(l\) 和 \(r\) 寫在線段樹節點的結構體裏面,我習慣是用傳參數的方法(由於帶我入門的雨巨就是這樣寫的。其實兩種寫法都是能夠的,看我的習慣,最後熟練一種便可,可是另外一種也要看得懂)。左孩子的左邊界仍是 \(l\) , 右邊界變成了 \(mid\) ;右孩子的左邊界變成 \(mid+1\) ,右邊界是 \(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\) 函數幫忙啦,先賣個關子,先來說講怎麼建樹和修改。測試
先給出代碼,四行建樹。ui
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\) 。
一樣先給出代碼:
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\) 。
最後一部分就是查詢了,與修改操做其實比較相似:
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\) 以下關係:
證實其實不必,會用就行,這涉及到負數在計算機中存儲的形式,能夠本身證一下。
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\) ,一鼓作氣。這樣,單點修改操做就完成啦。
查詢操做永遠是和修改操做配套的,一切修改的目的都是爲了查詢的方便。既然修改代碼這麼短小精悍,那麼查詢代碼就更加小巧了,請看:
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),可是實際應用起來是比較頭疼的。由於線段樹和樹狀數組靈活性很高,能夠解決不少看似沒法下手的問題,可是要維護的信息多得容易摸不着頭腦(不知道爲何這樣作就能夠了),邏輯關係環環相扣,時不時就得感嘆一下「妙」。這些都得作更多的題來體會了。還有不要死記模板,要清楚知道每一步的做用,不少時候一些順序會顛倒,來解決不一樣的問題,這是須要警戒的。
若是以爲對你理解有幫助的但願給我點個贊哦,ο(=•ω<=)ρ⌒☆