淺談 線段樹
其實真不太想寫的。
前置芝士:可以先看看其他的 RMQ 演算法,如樹狀陣列,ST之類的。
畢竟線段樹這個東西很重要,而看懂了其他的會方便理解一些,早學不一定精。
當然,也歡迎翻翻我之前寫的。給個鏈。
0x01 基本概念
線段樹(Segment Tree)是一個基於分治の資料結構。
通常處理區間,序列中的查詢,更改問題。但因為其可維護變數的多樣性,所以常在各類題目中遇到。準確說,是各類優化中遇到。
線段樹是個有根二叉樹,我們記為 \(t\),其每個節點 \(t[p]\) 均儲存著一組關鍵資訊:\(l\),\(r\)。我通常將其稱為鑰匙資訊。(意會即可)
它們合在一起表示當前這個節點儲存的是哪一個區間的資訊。
比如,如果你的線段樹維護的有區間最值,那麼對於一個結點 \(t[p]\) 滿足 \(t[p].l = L, t[p].r = R\),則其維護的值就是 \(max\{a[L], a[L + 1]...a[R - 1], a[R]\}\),其中 \(a[i]\) 為給定區間的第 \(i\) 號元素。
而對於每個節點 \(t[p]\),我們定義 \(mid = \frac {(t[p].l + t[p].r)} {2}\) ,並定義,對於其左兒子節點 \(t[p \times 2]\),有 \(t[p \times 2].l = t[p].l\),\(t[p \times 2].r = mid\)
同理對於其右兒子 \(t[p \times 2 + 1]\),有 \(t[p \times 2 + 1].l = mid + 1\),\(t[p \times 2 + 1].r = t[p].r\)。
這樣,在我們知道所有的葉子節點後就可以往上更新至全部的區間資訊,這就是線段樹的大體思想。
不過我們還需要完善最後一個定義。對於葉子節點 \(t[q]\),顯然擁有一個特性:沒有兒子。那也就是說它們無法找到合理的 \(mid\) 將其表示的區間再次分化,那就是 \(t[q].l = t[q].r\) 唄。我們在序列裡稱這樣的區間為元區間,它也是我們的邊界,與我們的答案來源。畢竟這是一開始題目就會給你的東西。
我不想畫圖。。。
但為了方便讀者理解,我還是扒一個下來吧。
上面這張圖中,線段樹起到了儲存區間最小值的作用。
其原序列 \(a\) 為:
1 3 5 7 9 10 2 4 8 6
節點上標註的 \([x, y]\) 表示 \(b_i(i \in [x, y], b_i = a_i)\) 這個序列,而 \(minv[p]\) 即 \(Min Value\),表示節點 \(t[p]\) 儲存的 \(b\) 序列的最小值。
很容易理解了吧,接下來我們來看看實現。
0x02 程式碼實現
沒點基礎碼力還真不敢碰這玩意。
注:這裡的實現均以區間和為例。
step0.前言:關於空間
線段樹如果不看所有的葉子節點,它一定是顆滿二叉樹,這個毋庸置疑。
那麼我們設除去最下面一層以外的深度最深的一層有 \(x\) 個節點。
則上一層定有 \(\frac x 2\) 個節點,畢竟兩個兒子對應一個父親嘛。
如果我們設最下面一層的節點數為 \(y\),那麼整棵線段樹的節點總和為:\(y + x + \frac x 2 + \frac x 4 +...+ 1\)。等比數列求和,答案為 \(2 \times n - 1 + y\)。又因為最下層的節點均為元區間,且原序列中元區間個數為 \(n\) 個,那麼所以 \(y < n\)。所以總節點數一定小於 \(3 \times n\)。
但是因為我們的存圖方式是有空點存在的,詳見上圖。且最後一行最多容納 \(2 \times n\) 個點,那麼我們線段樹的陣列就需要開足四倍空間。
這也是線段樹的一個小缺陷所在。
step1.建樹
首先,我們已經瞭解了線段樹的構造,那麼這一步就相當於是去模擬了。
再理一下。
- 顯然,\(t[p].Sum = t[p \times 2].Sum + t[p \times 2 + 1].Sum\)
- 當 \(l = r\) 時,屬於元區間,來到葉子節點,直接更新,並返回。
- 每次記得儲存每個節點的鑰匙資訊,即 \(l\) 和 \(r\) 的值,並從 \(mid\) 開始繼續往下劃分。
void Make_Tree(int p, int l, int r) {
t[p].l = l;
t[p].r = r;
// 記錄節點の鑰匙資訊。
if(l == r) {
t[p].Sum = a[l];
return ;
}
// 葉子節點(元區間)
int mid = (l + r) >> 1;
Make_Tree(p << 1, l, mid);
Make_Tree(p << 1 | 1, mid + 1, r);
t[p].Sum = t[p << 1].Sum + t[p << 1 | 1].Sum;
// 遞迴建樹並維護區間和
}
step2.更新
這裡的例子中我們完成操作:單點修改。
即一次修改一個原序列中一個元素的值。記我們要改的元素為 \(a_index\),表示它在原序列 \(a\) 中的第 \(index\) 位。
那麼線上段樹中改一個點,其實就是找到其對應的元區間,更改元區間後再更新它的所有父親嘛。
首先明確,對於節點 \(t[p]\),如果 \(index <= mid\),則我們需要往左兒子找,因為根據定義,此時的 \(index\) 一定屬於區間 \([t[p \times 2].l, t[p \times 2].r]\) 中。那麼反之,如果 \(index > mid\),則需要往右兒子找。
始終記住,在大多情況下,元區間都是我們的邊界條件。
不就結了嗎。
void Update(int p, int index, int x) {
if(t[p].l == t[p].r) {
t[p].Sum += x;
return ;
}
// 找到元區間,直接修改
int mid = (t[p].l + t[p].r) >> 1;
if(index <= mid)
Update(p << 1, index, x);
else
Update(p << 1 | 1, index, x);
t[p].Sum = t[p << 1].Sum + t[p << 1 | 1].Sum;
// 左右兒子依次訪問,並再次更新
}
step3.查詢
單點修改,單點查詢顯然沒必要對吧。那個不是有手就行嗎。
於是我們考慮單點修改,區間查詢。
首先對於我們想要的區間 \([L, R]\),如果 \([L, R]\) 完全覆蓋一個區間 \([t[p].l, t[p].r]\),則 \(t[p].Sum\) 一定會對我們想要的答案產生價值。
那麼這次我們就不需要再查詢 \(t[p]\) 的兒子節點了,因為它的兒子節點可以帶來的價值一定全部包含在 \(t[p].Sum\) 中。
但是同樣,對於 \(t[p]\),也完全有可能不完全覆蓋。 那麼我們就暫時不能累加價值,因為這時候 \(t[p]\) 的有部分價值可能是我們不想要的。
那這個時候,我們就應該去訪問 \(t[p]\) 的兒子節點了,必定在整棵線段樹中一定能找到完全覆蓋的情況。
真就往下搜唄。
不過不一定是兩個兒子都需要訪問。大多時候我們都是需要訪問才訪問。需要訪問也就是說要查的區間與某個兒子表示的區間擁有交集。那就直接比較 \(L\),\(R\),\(mid\) 的大小即可。如果 \(L <= mid\),即我們要查的區間有一丟丟在左兒子裡,那麼就去拜訪它。同理,如果 \(R > mid\),則還需去看看右兒子。當然也可能會出現兩邊都要訪問的情況,也就是 \(mid\) 剛好把要查區間從中截斷(兩邊各有一丟丟嘛)。
LL Query(int p, int l, int r) {
if(l <= t[p].l && t[p].r <= r)
return t[p].Sum;
// 完全覆蓋就直接返回
int mid = (t[p].l + t[p].r) >> 1;
LL val = 0;
if(l <= mid)
val += Query(p << 1, l, r);
if(r > mid)
val += Query(p << 1 | 1, l, r);
// 看情況訪問左右兒子
return val;
}
step4.完整程式碼
還是放一個吧。線段樹一定要慢慢調哦。
可以交交這道題。「線段樹」模板題1
#include <cstdio>
const int MAXN = 1e6 + 5;
const int MAXT = 1e6 * 4 + 5;
typedef long long LL;
struct Segment_Tree {
int l, r;
LL Sum;
Segment_Tree() {}
Segment_Tree(int L, int R, LL S) {
l = L;
r = R;
Sum = S;
}
} t[MAXT];
int a[MAXN];
void Make_Tree(int p, int l, int r) { // 建樹
t[p].l = l;
t[p].r = r;
if(l == r) {
t[p].Sum = a[l];
return ;
}
int mid = (l + r) >> 1;
Make_Tree(p << 1, l, mid);
Make_Tree(p << 1 | 1, mid + 1, r);
t[p].Sum = t[p << 1].Sum + t[p << 1 | 1].Sum;
}
void Update(int p, int index, int x) { // 更新
if(t[p].l == t[p].r) {
t[p].Sum += x;
return ;
}
int mid = (t[p].l + t[p].r) >> 1;
if(index <= mid)
Update(p << 1, index, x);
else
Update(p << 1 | 1, index, x);
t[p].Sum = t[p << 1].Sum + t[p << 1 | 1].Sum;
}
LL Query(int p, int l, int r) { // 詢問
if(l <= t[p].l && t[p].r <= r)
return t[p].Sum;
int mid = (t[p].l + t[p].r) >> 1;
LL val = 0;
if(l <= mid)
val += Query(p << 1, l, r);
if(r > mid)
val += Query(p << 1 | 1, l, r);
return val;
}
int main() {
int n, q;
scanf ("%d %d", &n, &q);
for(int i = 1; i <= n; i++)
scanf ("%d", &a[i]);
Make_Tree(1, 1, n);
for(int i = 1; i <= q; i++) {
int flag;
scanf ("%d", &flag);
if(flag == 1) {
int v, x;
scanf ("%d %d", &v, &x);
Update(1, v, x);
}
else {
int l, r;
scanf ("%d %d", &l, &r);
printf("%lld\n", Query(1, l, r));
}
}
return 0;
}
其實話說,整體把線段樹的程式碼拉出來還挺好看的。
上一個有這種感覺的是笛卡爾曲線方程:\(r = a(1 - \sin \Theta)\)。抱歉扯遠了。
0x03 推廣
我們在上一個版塊中只講到了單點修改。
那如果想要區間修改呢?之前知道的只有樹狀陣列有個非常麻煩的實現方法(霧。
於是引入:懶惰標記 (Lazy Tag),又叫延遲標記 (Daley Tag)。
首先,關於區間修改,我們可以按照剛剛的思路將這個區間修改改為很多個小的單點修改,但這顯然會超時。那麼考慮優化。
你會發現如果我們每次都跑到元區間其實是很不划算的,因為我們在查詢的時候並不是每次都查到了元區間。也就是說我們只需要將我們需要的點,即會對答案產生價值的點進行精確更改即可。
這個很顯然吧,一個小貪心。
那麼我們可以將每個節點儲存的資訊多加一個:\(add\)。這個 \(add\) 表示,之前區間修改時沒累加在當前節點但其實需要去累加的價值。
也就是說我們需要將所有之前的操作更改的價值累加起來,在我們需要查詢 \(t[p \times 2]\),我們再由標記在 \(t[p]\) 上的 \(add\) 去更新 \(t[p \times 2]\),求得實際的值。\(t[p \times 2 + 1]\) 同理。
好像很抽象?我還是扒個圖吧。
這裡面的綠點表示修改區間 \([3, 9]\) 時本來會改變的線段樹上的節點。
而我們每次 lazy 只標記黃點。
在我們下次要去查詢某個綠點時,我們再由黃點的 lazy tag 去更新綠點的資訊。
也就是說真實的綠點 \(t[q]\) 滿足:\(t[q].Sum = t[q].Sum + t[p].add * (t[q].r - t[q].l + 1)\)。其中 \(t[p]\) 為 \(t[q]\) 的父親節點。而 \(t[p].add\) 需要乘上它表示的節點個數,因為我們存的是單個節點的 lazy tag。
啊,沒有智商了,那就結合程式碼再分析吧。
#include <cstdio>
typedef long long LL;
const int MAXN = 1e6 + 5;
const int MAXT = 1e6 * 4 + 5;
struct Segment_Tree {
int l, r, len;
LL Sum, add; // lazy tag
Segment_Tree() {}
Segment_Tree(int L, int R, LL S, LL A, int Len) {
l = L;
r = R;
Sum = S;
add = A;
len = Len;
}
} t[MAXT];
int a[MAXN];
void Spread(int p) {
// 從父親往兒子更新標記
if(t[p].add) {
t[p << 1].Sum += t[p].add * t[p << 1].len;
t[p << 1 | 1].Sum += t[p].add * t[p << 1 | 1].len;
t[p << 1].add += t[p].add;
t[p << 1 | 1].add += t[p].add;
t[p].add = 0;
}
}
void Make_Tree(int p, int l, int r) {
t[p].l = l;
t[p].r = r;
t[p].len = r - l + 1;
if(l == r) {
t[p].Sum = a[l];
return ;
}
int mid = (l + r) >> 1;
Make_Tree(p << 1, l, mid);
Make_Tree(p << 1 | 1, mid + 1, r);
t[p].Sum = t[p << 1].Sum + t[p << 1 | 1].Sum;
}
void Update(int p, int l, int r, int x) {
if(l <= t[p].l && t[p].r <= r) {
t[p].Sum += (LL)x * t[p].len;
t[p].add += x;
return ;
}
Spread(p);
// 更新標記。
int mid = (t[p].l + t[p].r) >> 1;
if(l <= mid)
Update(p << 1, l, r, x);
if(r > mid)
Update(p << 1 | 1, l, r, x);
t[p].Sum = t[p << 1].Sum + t[p << 1 | 1].Sum;
}
LL Query(int p, int l, int r) {
if(l <= t[p].l && t[p].r <= r)
return t[p].Sum;
Spread(p);
// 更新標記。
int mid = (t[p].l + t[p].r) >> 1;
LL val = 0;
if(l <= mid)
val += Query(p << 1, l, r);
if(r > mid)
val += Query(p << 1 | 1, l, r);
return val;
}
int main() {
int n, q;
scanf ("%d %d", &n, &q);
for(int i = 1; i <= n; i++)
scanf ("%d", &a[i]);
Make_Tree(1, 1, n);
for(int i = 1; i <= q; i++) {
int flag;
scanf ("%d", &flag);
if(flag == 1) {
int l, r, x;
scanf ("%d %d %d", &l, &r, &x);
Update(1, l, r, x);
}
else {
int l, r;
scanf ("%d %d", &l, &r);
printf("%lld\n", Query(1, l, r));
}
}
return 0;
}