【數據結構1】該死的線段樹,毀我青春……

線段樹筆記(Segment Tree)

假如我們給定一個數組 arr,該數組可能非常大。在程序運行過程中,你可能要做好幾次queryupdate操作:

query(arr, L, R) 表示計算數組arr中,從下標L到下標R之間的所有數字的和。

update(arr, i, val) 表示要把arr[i]中的數字改成val。

怎樣儘可能快地完成一系列query和update的操作?

線段樹這個神奇的數據結構,可以在花費一些額外空間的情況下,把這兩個操作的時間複雜度都控制在 O(log(n))O(log(n))

線段樹是啥

先明確線段樹是啥。

每棵線段樹都表示一個長度爲 NN 的區間。一棵線段樹的根節點 rootroot 表示一個 (1n)(1,n) 的區間,它的左兒子表示一個 (1,1+n2)(1,\frac{1+n}{2}) 的區間,而右兒子就是表示 (1+n2+1,n)(\frac{1+n}{2}+1, n) 的區間。

通過上面這段話,顯而易見地,如果一個節點表示的是 (l,r)(l, r) ,那麼,該節點的左兒子表示的就是 (l,mid)(l, mid),右兒子就應該是 (mid+1,r)(mid+1, r)。需要注意的是,以上操作用到了 midmid,它的值是 l+r2\frac{l+r}{2},這也間接地暗示了線段樹的本質實際上就是由二分實現的。

對於每一個節點,我們可以維護一個值,比如區間和區間最大值區間最小值等等符合區間可加性的東東。

區間可加性:舉個栗子,我們已經知道,左子樹表示的區間 (l,mid)(l, mid) 中所有元素的最大值 uu,又知道右子樹表示的區間 (mid+1,r)(mid+1, r) 中所有元素的最大值 vv,那麼我們是否能夠很方便地通過 uuvv 來求得根節點的區間最大值?很顯然,取 max(u,v)max(u, v) 即可。這樣我們就稱其“符合區間可加性”。

同理,區間和、區間最小值也符合區間可加性

動手構建一棵線段樹(build)

這樣可能有點難懂,以區間和爲例,我們已經知道了一個數組 arrarr,可以構建出下面的這棵樹:

用數組arr構建出的區間和線段樹

而代碼的話,我先放上來,比較好理解:

#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

這其實就是上面的那棵樹。

該樹中,每個函數所存儲的值都是區間 (l,r)(l, r) 的區間和,左兒子就代表 (l,l+r2)(l, \frac{l+r}{2}) 的區間和,右兒子同理,代表 (l+r2+1,r)(\frac{l+r}2 + 1, r) 的區間和。可以看到,遞歸函數 build() 有三個參數,一個是 node,代表當前節點,需要注意的是,node是一個編號;其餘的兩個參數是代表區間之用的 leftright,是基於arr[]數組的指針

學會構建了線段樹,那麼,線段樹可以做些什麼呢?

區間修改,單點修改,區間查詢什麼的……

線段樹的單點修改(update)

關於線段樹的單點修改,具體要求是這樣的:給定一個下標 idx ,將 arr[idx]修改成 val

需要注意的是,線段樹的單點修改“牽一髮而動全身”,還是以區間和爲例,修改了一個葉子節點(也就是該節點代表的區間長度爲1)的區間和,它的父親節點就一定會變動,然後祖父節點也會變動,曾祖父節點也會變動……一直修改到根節點,這也就意味着,我們要在每一次遞歸修改子節點之後,回頭再算一遍當前節點的區間和,僞代碼是 tree[node]=tree[left_child]+tree[right_child]tree[node] = tree[left\_child] + tree[right\_child] ,這也就是傳說中的“維護”操作。

還是用上面的那棵樹,我們要將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)

這是修改後的樹:(綠色代表修改後,紅色是序號,黑色方框代表區間)

update後的線段樹

線段樹的區間查詢(query)

區間查詢的具體要求是這樣的:(我們還是以區間和爲例)給出區間 (l,r)(l, r),詢問該區間的數值總和。

不用說,肯定還是遞歸對吧?這次的遞歸函數應該也有四個參數:

  • right,leftright, left 當前遍歷到的區間,當然,你也可以理解爲當前節點 nodenode 代表的區間
  • start,endstart, end 查詢區間的左右邊界,其實這兩參數在遞歸時是沒有變化的,你完全可以將其寫成全局變量

讓我們回想一下,平時你看到一道題,叫你求區間和,你會咋辦?前綴和?暴力枚?NO NO NO,用上線段樹,你完全可以把 O(N)O(N) 的時間複雜度降到 O(logN)O(log N) (當然,建樹的時間不算)

遞歸的時候,大致分爲以下幾種情況:

  • 要查詢的區間與目前遞歸到的區間沒有交集,那還遞歸個啥,返回 0 就行了
  • 目前遞歸到的區間是查詢區間的子集,不用遞歸了,時間就是在這裏省下來的,直接返回該節點所代表的區間和(建樹的時候不是都算好了嗎……)
  • 葉子節點,直接返回該節點的值(這時候的區間自然地就是 (x,x)(x, x) 了)
  • 有交集,但不存在包含關係:繼續二分遞歸下去,以 midmid 爲斷點向下查找

以下是代碼,我們以求區間 (2,5)(2, 5) 爲例:

#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

修改後的 arrarr 數組:{1,3,5,7,6,11}\{1, 3, 5, 7, 6, 11\},可以看到,區間 (2,5)(2, 5) 之和恰好爲29。

這就結束了對線段樹的解釋

都看到這了,不點贊還想走?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章