線段樹筆記(Segment Tree)
假如我們給定一個數組 arr
,該數組可能非常大。在程序運行過程中,你可能要做好幾次query
和update
操作:
query(arr, L, R) 表示計算數組arr中,從下標L到下標R之間的所有數字的和。
update(arr, i, val) 表示要把arr[i]中的數字改成val。
怎樣儘可能快地完成一系列query和update的操作?
線段樹這個神奇的數據結構,可以在花費一些額外空間的情況下,把這兩個操作的時間複雜度都控制在
線段樹是啥
先明確線段樹是啥。
每棵線段樹都表示一個長度爲 的區間。一棵線段樹的根節點 表示一個 的區間,它的左兒子表示一個 的區間,而右兒子就是表示 的區間。
通過上面這段話,顯而易見地,如果一個節點表示的是 ,那麼,該節點的左兒子表示的就是 ,右兒子就應該是 。需要注意的是,以上操作用到了 ,它的值是 ,這也間接地暗示了線段樹的本質實際上就是由二分實現的。
對於每一個節點,我們可以維護一個值,比如區間和,區間最大值,區間最小值等等符合區間可加性的東東。
區間可加性:舉個栗子,我們已經知道,左子樹表示的區間 中所有元素的最大值 ,又知道右子樹表示的區間 中所有元素的最大值 ,那麼我們是否能夠很方便地通過 和 來求得根節點的區間最大值?很顯然,取 即可。這樣我們就稱其“符合區間可加性”。
同理,區間和、區間最小值也符合區間可加性。
動手構建一棵線段樹(build)
這樣可能有點難懂,以區間和爲例,我們已經知道了一個數組 ,可以構建出下面的這棵樹:
而代碼的話,我先放上來,比較好理解:
#include<bits/stdc++.h>
using namespace std;
int tree[10010] = {0};
void build(int arr[], int tree[], int node, int left, int right) {
if(left == right) { //看,標準的二分,很好理解吧,這是遞歸出口
tree[node] = arr[left]; //left或者right都行
}
else {
int mid = (left + right) / 2; //標準二分
int left_child = 2 * node;
int right_child = 2 * node + 1; //用的是二倍父子標記法
//接下來向左右子樹遞歸併計算區間和
build(arr, tree, left_child, left, mid);
build(arr, tree, right_child, mid + 1, right);
tree[node] = tree[left_child] + tree[right_child]; //計算區間和
}
}
int main() {
int arr[] = {1, 3, 5, 7, 9, 11};
int len = 6;
build(arr, tree, 1, 0, len - 1); //從1號根節點開始
for(int i = 1; i <= 15; ++ i)
printf("tree[%d] = %d\n", i, tree[i]);
return 0;
}
運行結果如下:
tree[1] = 36
tree[2] = 9
tree[3] = 27
tree[4] = 4
tree[5] = 5
tree[6] = 16
tree[7] = 11
tree[8] = 1
tree[9] = 3
tree[10] = 0
tree[11] = 0
tree[12] = 7
tree[13] = 9
tree[14] = 0
tree[15] = 0
這其實就是上面的那棵樹。
該樹中,每個函數所存儲的值都是區間 的區間和,左兒子就代表 的區間和,右兒子同理,代表 的區間和。可以看到,遞歸函數 build()
有三個參數,一個是 node
,代表當前節點,需要注意的是,node
是一個編號;其餘的兩個參數是代表區間之用的 left
和 right
,是基於arr[]
數組的指針。
學會構建了線段樹,那麼,線段樹可以做些什麼呢?
區間修改,單點修改,區間查詢什麼的……
線段樹的單點修改(update)
關於線段樹的單點修改,具體要求是這樣的:給定一個下標 idx
,將 arr[idx]
修改成 val
。
需要注意的是,線段樹的單點修改“牽一髮而動全身”,還是以區間和爲例,修改了一個葉子節點(也就是該節點代表的區間長度爲1
)的區間和,它的父親節點就一定會變動,然後祖父節點也會變動,曾祖父節點也會變動……一直修改到根節點,這也就意味着,我們要在每一次遞歸修改子節點之後,回頭再算一遍當前節點的區間和,僞代碼是 ,這也就是傳說中的“維護”操作。
還是用上面的那棵樹,我們要將4號節點(idx=4
,值爲9)的值修改爲6,以下是代碼:
#include<bits/stdc++.h>
using namespace std;
int tree[10010] = {0};
void build(int arr[], int tree[], int node, int left, int right) {
if(left == right) { //看,標準的二分,很好理解吧,這是遞歸出口
tree[node] = arr[left]; //left或者right都行
}
else {
int mid = (left + right) / 2; //標準二分
int left_child = 2 * node;
int right_child = 2 * node + 1; //用的是二倍父子標記法
//接下來向左右子樹遞歸併計算區間和
build(arr, tree, left_child, left, mid);
build(arr, tree, right_child, mid + 1, right);
tree[node] = tree[left_child] + tree[right_child]; //計算區間和
}
}
void update(int arr[], int tree[], int node, int left, int right, int idx, int val) {
if(left == right) { //遞歸出口
arr[idx] = val; //修改節點
tree[node] = val; //因爲是葉子節點,所以代表的區間只有一個節點,區間和就是該節點的值
}
else {
int mid = (left + right) / 2;
int left_child = 2 * node;
int right_child = 2 * node + 1;
if(idx >= left && idx <= mid)
update(arr, tree, left_child, left, mid, idx, val);
else
update(arr, tree, right_child, mid + 1, right, idx, val);
tree[node] = tree[left_child] + tree[right_child]; //還要再算一遍(維護操作)
}
}
int main() {
int arr[] = {1, 3, 5, 7, 9, 11};
int len = 6;
build(arr, tree, 1, 0, len - 1); //從1號根節點開始建樹
for(int i = 1; i <= 15; ++ i)
printf("tree[%d] = %d\n", i, tree[i]);
update(arr, tree, 1, 0, len - 1, 4, 6); //把4號節點修改爲6
cout << endl;
for(int i = 1; i <= 15; ++ i)
printf("tree[%d] = %d\n", i, tree[i]);
return 0;
}
運行結果如下:
tree(修改前) | tree(修改後) |
---|---|
36 | 33 |
9 | 9 |
27 | 24 |
4 | 4 |
5 | 5 |
16 | 13 |
11 | 11 |
1 | 1 |
3 | 3 |
0(NULL) | 0(NULL) |
0(NULL) | 0(NULL) |
7 | 7 |
9 | 6 |
0(NULL) | 0(NULL) |
0(NULL) | 0(NULL) |
這是修改後的樹:(綠色代表修改後,紅色是序號,黑色方框代表區間)
線段樹的區間查詢(query)
區間查詢的具體要求是這樣的:(我們還是以區間和爲例)給出區間 ,詢問該區間的數值總和。
不用說,肯定還是遞歸對吧?這次的遞歸函數應該也有四個參數:
- 當前遍歷到的區間,當然,你也可以理解爲當前節點 代表的區間
- 查詢區間的左右邊界,其實這兩參數在遞歸時是沒有變化的,你完全可以將其寫成全局變量
讓我們回想一下,平時你看到一道題,叫你求區間和,你會咋辦?前綴和?暴力枚?NO NO NO,用上線段樹,你完全可以把 的時間複雜度降到 (當然,建樹的時間不算)
遞歸的時候,大致分爲以下幾種情況:
- 要查詢的區間與目前遞歸到的區間沒有交集,那還遞歸個啥,返回
0
就行了 - 目前遞歸到的區間是查詢區間的子集,不用遞歸了,時間就是在這裏省下來的,直接返回該節點所代表的區間和(建樹的時候不是都算好了嗎……)
- 葉子節點,直接返回該節點的值(這時候的區間自然地就是 了)
- 有交集,但不存在包含關係:繼續二分遞歸下去,以 爲斷點向下查找
以下是代碼,我們以求區間 爲例:
#include<bits/stdc++.h>
using namespace std;
int tree[10010] = {0};
void build(int arr[], int tree[], int node, int left, int right) {
if(left == right) { //看,標準的二分,很好理解吧,這是遞歸出口
tree[node] = arr[left]; //left或者right都行
}
else {
int mid = (left + right) / 2; //標準二分
int left_child = 2 * node;
int right_child = 2 * node + 1; //用的是二倍父子標記法
//接下來向左右子樹遞歸併計算區間和
build(arr, tree, left_child, left, mid);
build(arr, tree, right_child, mid + 1, right);
tree[node] = tree[left_child] + tree[right_child]; //計算區間和
}
}
void update(int arr[], int tree[], int node, int left, int right, int idx, int val) {
if(left == right) { //遞歸出口
arr[idx] = val; //修改節點
tree[node] = val; //因爲是葉子節點,所以代表的區間只有一個節點,區間和就是該節點的值
}
else {
int mid = (left + right) / 2;
int left_child = 2 * node;
int right_child = 2 * node + 1;
if(idx >= left && idx <= mid)
update(arr, tree, left_child, left, mid, idx, val);
else
update(arr, tree, right_child, mid + 1, right, idx, val);
tree[node] = tree[left_child] + tree[right_child]; //還要再算一遍(維護操作)
}
}
int query(int arr[], int tree[], int node, int left, int right, int start, int end) {
if(start > right || left < end) //要查詢的區間與目前遞歸到的區間沒有交集
return 0;
else if(start <= left && right <= end) //目前遞歸到的區間是查詢區間的子集
return tree[node];
else if(left == right) //葉子節點
return tree[node];
else {
int mid = (left + right) / 2; //計算mid
int left_child = node * 2;
int right_child = node * 2 + 1;
int sum_left = query(arr, tree, left_child, left, mid, start, end);
int sum_right = query(arr, tree, right_child, mid+1, right, start, end);
return sum_left + sum_right;
}
}
int main() {
int arr[] = {1, 3, 5, 7, 9, 11};
int len = 6;
build(arr, tree, 1, 0, len - 1); //從1號根節點開始建樹
printf("Tree after build:\n");
for(int i = 1; i <= 15; ++ i)
printf("tree[%d] = %d\n", i, tree[i]);
update(arr, tree, 1, 0, len - 1, 4, 6); //把4號節點修改爲6
printf("Tree after update:\n");
for(int i = 1; i <= 15; ++ i)
printf("tree[%d] = %d\n", i, tree[i]);
printf("input left & right: left = 2, right = 5\n");
cout << query(arr, tree, 1, 0, len - 1, 2, 5) << endl; //查詢 (2, 5) 區間
return 0;
}
運行結果:
Tree after build:
tree[1] = 36
tree[2] = 9
tree[3] = 27
tree[4] = 4
tree[5] = 5
tree[6] = 16
tree[7] = 11
tree[8] = 1
tree[9] = 3
tree[10] = 0
tree[11] = 0
tree[12] = 7
tree[13] = 9
tree[14] = 0
tree[15] = 0
Tree after update:
tree[1] = 33
tree[2] = 9
tree[3] = 24
tree[4] = 4
tree[5] = 5
tree[6] = 13
tree[7] = 11
tree[8] = 1
tree[9] = 3
tree[10] = 0
tree[11] = 0
tree[12] = 7
tree[13] = 6
tree[14] = 0
tree[15] = 0
input left & right: left = 2, right = 5
29
修改後的 數組:,可以看到,區間 之和恰好爲29。
這就結束了對線段樹的解釋
都看到這了,不點贊還想走?