【知識點】淺入線段樹與區間最值問題

Macw發表於2024-05-25

前言:這又是一篇關於資料結構的文章。

今天來講一下線段樹和線段樹的基本應用。線段樹 (Segment Tree),是一種非常高效且高階的資料結構,其主要用於區間查詢和與區間更新相關的問題,例如進行多次查詢區間最大值、最小值、更新區間等操作。

區間最值問題引入

常見的線段樹題型就是 區間最值問題 (Range Maximum/Minimum Query, RMQ)。通常來說,區間最值問題會給定使用者一個長度為 \(n\) 的陣列,對這個陣列進行多次區間查詢(最值)和區間批次修改的操作。

常見的區間最值演算法(資料結構)有很多,但線段樹在某些情況下一定是最優解。以下是不同 RMQ 演算法的優勢和劣勢:

  1. 暴力列舉 Brute Force:實現難度非常簡單,適合資料量較小的情況。但查詢效率極其低下。
  2. 樹狀陣列 Binary Indexed Tree:實現相對簡單,查詢和更新效率較高。只能處理字首區間的問題,對於任意區間查詢(例如,區間最值)需要進行一些變形和額外處理,不如線段樹靈活。
  3. 稀疏表 Sparse Table:適合處理靜態資料,即資料在預處理之後不再發生改變。如果要頻繁實現線上區間修改/單點修改的操作,ST表就非常的耗時。
  4. 線段樹 Segment Tree:查詢和更新效率高,適合動態資料,支援快速更新。但缺點是實現較為複雜,程式碼量較大。

綜上所述,每種演算法(資料結構)都有自己的優勢和劣勢,我們應該根據實際情況選用最合適的方案。

線段樹的底層是基於 二叉樹 (Binary Tree) 來實現的,因此線上段樹相關的操作中,大多數操作的時間複雜度可以被最佳化到 \(O(\log_2n)\) 級別。相比 \(O(n)\) 級別的暴力演算法而言,線段樹有顯著的優勢(據我所知,線段樹唯一的劣勢就是其碼量對於初學者而言會比較多,因此在寫線段樹的過程中由於粗心導致失誤的可能性會增加許多)。

線段樹的基本結構

線段樹是一棵二叉樹,因此對於每一個節點而言至多隻有兩個子節點。與此同時,線段樹的每一個節點都儲存了一個區間的資訊,通常是這個區間的某種 統計量(如最大值、最小值、總和等)。每個節點的區間是它的兩個子節點區間的並集,根節點表示我們需要維護的整一個區間。

舉一個形象的例子。例如我們想要構造一棵線段樹來維護一個區間 \([1, 6]\) 的某些狀態,我們所構造出來的線段樹的結構會呈現下圖所示的樣子。其中根節點負責維護的 \([1, 6]\) 區間,其左兒子負責維護 \([1, 3]\) 區間,其右兒子負責維護 \([4, 6]\) 區間。可以看出,如果將一個根結點的左兒子和右兒子所維護的區間合併,那麼這個新的區間就是該根節點所維護的區間。一般來說,一個節點的兩個子節點維護的區間大小的差應該儘可能的小。以此類推,每一個節點都維護一個區間,直到這個區間不能再分為止,也就是說這棵樹所有的葉子結點的區間長度都應該為 \(1\)

image

知道了線段樹的基本結構,那麼維護每一個節點所記錄的狀態也會變得特別簡單。以維護區間最大值為例子,如果區間 \([1, 3]\) 所記錄的最大值是 \(7\),區間 \([4, 6]\) 所記錄的最大值是 \(2\),那麼我們就可以很容易的推匯出區間 \([1, 6]\) 的最大值應該是 \(\max(7, 2) = 7\)

因此,線段樹的一個侷限性就是維護的資料必須具有可傳遞性,說白了,就是必須可以透過兩個小區間所記錄的值來推匯出某一個大區間所記錄的值。

以下程式碼將以維護區間的總和為例子來展開:

線段樹的儲存

arr 陣列是我們需要維護區間每個位置的原始數值。

我們透過 tree 陣列來儲存整一棵線段樹,由於線段樹屬於一種平衡二叉樹,在最壞的情況下,線段樹的大小將會是 \(n\) 的四倍,因此陣列至少需要開 \(4n\) 的大小。對於這個陣列,我們規定對於任何一個索引為 \(i\) 的節點,其左子節點的索引為 \(i \times 2\),右子節點的索引為 \(i \times 2 + 1\)

為了加速計算,我們可以使用位運算的方式來實現(本文將不詳細闡述位運算的過程,有需要的人可以自行上網查閱):

  1. 將一個數 x 乘上 \(2^n\),可以寫為 x << n。因此 \(n \times 4\) 可以寫成 n << 2
  2. 將一個數偶數加上 \(1\),可以透過 x | 1 或運算來實現。
struct node{
   int sum;
} tree[(n << 2) + 5];
int arr[n + 5]

線段樹的構建

線段樹的構建過程跟普通的二叉樹構建過程類似,都是透過遞迴的方式來實現的。如果我們要構建一個長度為 \(n\) 的區間,在每一層遞迴的時候我們將區間對半分成兩個部分,並分別構建其左子樹和右子樹。直到區間長度為 \(1\) 時停止。當一個節點的左子樹和右子樹都被初始化完成後,我們應該透過合併其子節點所維護的值來更新當前節點所記錄的值,這個操作也被稱之為 \(\mathtt{Push \space up}\)

\(\mathtt{Push \space up}\) 的程式碼也非常簡單,就是單純合併兩個子節點的資訊到它們的父節點當中,這裡就是把父節點所維護區間的總和賦值為其兩個子節點所維護區間總和的和:

void push_up(int root){
    tree[root].sum = tree[root << 1].sum + tree[root << 1|1].sum;
    return ;
}

線段樹的初始化(構建)程式碼如下。其中 root 變數表示當前節點在 tree 陣列中的索引。變數 lr 分別表示所維護區間的左邊界和右邊界。對於每一層遞迴來說,我們要維護一個長度為 \(r - l + 1\) 的閉區間 \([l, r]\)。當 l == r 時,則證明區間的長度正好為 \(1\),因此終止遞迴,將該葉子結點初始化為陣列中對應的值:

void build_tree(int l, int r, int root){
    if (l == r){
        tree[root] = (node){arr[l], 0};
        return ;
    }
    int mid = (l + r) >> 1;
    build_tree(l, mid, root << 1);
    build_tree(mid+1, r, root << 1|1);
    push_up(root); 
    return ;
}

透過程式碼可以看出,初始化一棵線段樹的時間複雜度為 \(\Theta(n \log_2 n)\)

線段樹的區間查詢

與線段樹的構建相同,查詢線段樹也是透過 遞迴+二分 的方式來實現的。給定一個查詢的區間 \(L, R\)。我們從根節點開始,如果當前節點表示的區間與查詢區間完全匹配,則直接返回當前節點所儲存的資訊。否則,將查詢區間分成左右兩部分,遞迴查詢左右子樹,並將結果合併。相較於初始化操作,查詢某一個區間的時間複雜度約為 \(O(\log_2 n)\)

例如,如果我們要查詢區間 \([3, 6]\) 所維護的資料,遞迴到根節點的時候,根節點的左兒子的區間為 \([1, 3]\),右兒子的區間為 \([4, 6]\),我們發現我們所要查詢的區間同時在左兒子和右兒子中,因此我們同時遞迴兩個子區間,在 \([1, 3]\) 區間內查詢 \([3, 3]\),在 \([4, 6]\) 區間內查詢 \([4, 6]\)。這樣子我們只需要合併 \([3, 3]\)\([4, 6]\) 區間就可以計算出 \([3, 6]\) 區間所需要維護的值。等遞迴到了 \([1, 3]\) 區間,這個節點左兒子所維護的值為 \([1, 2]\),其右兒子維護的值為 \([3, 3]\)。我們發現所需要的值只存在於右兒子中,因此只遞迴搜尋右兒子的值,即遞迴 \([3, 3]\) 區間。當遞迴到 \([3, 3]\) 區間時,我們發現這個區間正是答案所在的區間,因此直接將 \([3, 3]\) 區間內所存放的值加入累加器。同理,當遞迴到 \([4, 6]\) 區間時,查詢區間也正好覆蓋掉該區間,因此把 \([4, 6]\) 所維護的值也加入累加器。至此,線段樹的區間搜尋就完成了。

實現線段樹上區間查詢的程式碼如下,其中變數 lr 表示當前所查詢的區間邊界,root 為當前的根節點索引。

int interval_query(int l, int r, int L, int R, int root){
    int sum = 0;
    if (L <= l && r <= R) 
        // 與區間完全匹配,因此可以直接返回結果。
        return tree[root].sum;
    int mid = (l + r) >> 1;
    int llen = mid - l + 1;
    int rlen = r - mid;
    // 如果所查詢的部分/所有結果存在於左半邊,那麼就遞迴計算左半邊的結果。
    if (L <= mid) sum += interval_query(l, mid, L, R, root << 1);
    // 如果所查詢的部分/所有結果存在於右半邊,那麼就再遞迴計算右半邊的結果。
    if (R > mid) sum += interval_query(mid + 1, r, L, R, root << 1|1);
    return sum;
}

當然,如果要實現單點查詢的話,只需要令所查詢的左邊界等於右邊界,即令 \(L = R\) 即可。

線段樹的區間更新

更新線段樹同樣是一個遞迴的過程。給定一個需要更新的位置和新的值,從根節點開始,如果當前節點表示的區間包含需要更新的位置,則遞迴更新左右子樹,並將結果合併。區間更新的操作與區間查詢的操作幾乎類似。區間更新的時間複雜度也約為 \(O(\log_2 n)\)

同時,在更新區間後應該再次使用 push_up() 函式來保證父節點的資料被正確更新了。

線段樹區間更新的程式碼如下,變數 v 表示該區間所有元素要新增的值,其餘變數的意義與上述保持不變:

void interval_update(int l, int r, int L, int R, int v, int root) {
    if (l == r) {
        tree[root].sum += v;
        return;
    }
    int mid = (l + r) >> 1;
    if (L <= mid) interval_update(l, mid, L, R, v, root << 1);
    if (R > mid) interval_update(mid + 1, r, L, R, v, root << 1 | 1);
    // 更新當前節點的值
    push_up();
    return ; 
}

當然,如果要實現單點更新的話,只需要令所更新的左邊界等於右邊界,即令 \(L = R\) 即可。

線段樹的進一步最佳化 - 懶標記 (Lazy Tag)

在實際應用中,線段樹常常使用 懶標記 (Lazy Tag) 來最佳化某些操作。懶標記技術可以延遲對某些節點的更新,直到必須訪問這些節點時才進行更新,從而提高效率。

懶標記的概念:懶標記是一種延遲更新的技巧,用於處理區間更新問題。基本思想是對於一個更新操作,不立即更新所有受影響的節點,而是將更新資訊記錄下來,等到需要查詢這些節點的值時再執行更新。

例如,假設我們要更新區間 \([1, n]\) 所維護的資訊,其實不需要更新區間內每一個葉子結點所記錄的值。我們可以先只更新 \([1, n]\) 區間的值,待到後續要查詢 \([1, n]\) 區間內的子區間時,我們再更新受影響的節點。我們將此操作命名為 懶標記下放

在下放懶標記的時候,我們每次只向下下放一次即可,也並不必需要更新到葉子結點。因此,在進行區間查詢和區間更新的時候,需要呼叫 push_down() 函式。

懶標記下放 \(\mathtt{Push\space down}\) 的程式碼如下:

void push_down(int root, int inten, int rlen){
    // 如果存在懶標記就更新,否則就不更新。
    if (tree[root].lazy_tag){
        // 將父節點的懶標記遺傳給子節點。
        tree[root << 1].lazy_tag += tree[root].lazy_tag;
        tree[root << 1|1].lazy_tag += tree[root].lazy_tag;
        tree[root << 1].sum += inten * tree[root].lazy_tag;
        tree[root << 1|1].sum += rlen * tree[root].lazy_tag;
        // 清除父節點的懶標記。
        tree[root].lazy_tag = 0;
    }
    return ;
}

線段樹完整程式碼

以下是洛谷題目 P3372 【模板】線段樹 1 的完整程式碼,改程式碼包含本文所闡述的所有程式碼且應用了懶標記的思想:

#include <iostream>
#include <algorithm>
using namespace std;
#define int long long

const int MAXN = 2e5 + 5;

int n, m, t;
int x, y, k;
struct node{
    int sum;
    int lazy_tag;
} tree[MAXN << 2];
int arr[MAXN];

// 更新父節點
void push_up(int root){
    tree[root].sum = tree[root << 1].sum + tree[root << 1|1].sum;
    return ;
}

// 懶標記下放
void push_down(int root, int inten, int rlen){
    if (tree[root].lazy_tag){
        tree[root << 1].lazy_tag += tree[root].lazy_tag;
        tree[root << 1|1].lazy_tag += tree[root].lazy_tag;
        tree[root << 1].sum += inten * tree[root].lazy_tag;
        tree[root << 1|1].sum += rlen * tree[root].lazy_tag;
        tree[root].lazy_tag = 0;
    }
    return ;
}

// 構造線段樹
void build_tree(int l, int r, int root){
    if (l == r){
        tree[root] = (node){arr[l], 0};
        return ;
    }
    int mid = (l + r) >> 1;
    build_tree(l, mid, root << 1);
    build_tree(mid+1, r, root << 1|1);
    push_up(root);
    return ;
}

// 單點修改
void single_update(int l, int r, int k, int v, int root){
    if (l == r){
        tree[l].sum += v;
        return ;
    }
    int mid = (l + r) >> 1;
    if (k <= mid) single_update(l, mid, k, v, root << 1);
    else single_update(mid + 1, r, k, v, root << 1|1);
    push_up(root);
    return ;
}

// 區間修改
void interval_update(int l, int r, int L, int R, int v, int root){
    if (L <= l && r <= R){
        tree[root].lazy_tag += v;
        tree[root].sum += (r - l + 1) * v;
        return ;
    }
    int mid = (l + r) >> 1;
    int inten = mid - l + 1;
    int rlen = r - mid;
    // 下放懶標記
    push_down(root, inten, rlen);
    if (L <= mid) interval_update(l, mid, L, R, v, root << 1);
    if (R > mid) interval_update(mid+1, r, L, R, v, root << 1|1);
    push_up(root);
    return ;
}

// 區間查詢
int interval_query(int l, int r, int L, int R, int root){
    int sum = 0;
    if (L <= l && r <= R) return tree[root].sum;
    int mid = (l + r) >> 1;
    int inten = mid - l + 1;
    int rlen = r - mid;
    // 下放懶標記
    push_down(root, inten, rlen);
    if (L <= mid) sum += interval_query(l, mid, L, R, root << 1);
    if (R > mid) sum += interval_query(mid + 1, r, L, R, root << 1|1);
    return sum;
}

signed main(){
    scanf("%lld %lld", &n, &m);
    for (int i=1; i<=n; i++) scanf("%lld", &arr[i]);
    build_tree(1, n, 1);
    while(m--){
        scanf("%lld", &t);
        if (t == 1){
            scanf("%lld %lld %lld", &x, &y, &k);
            interval_update(1, n, x, y, k, 1);
        } else{
            scanf("%lld %lld", &x, &y);
            int ans = interval_query(1, n, x, y, 1);
            printf("%lld\n", ans);
        }
    }
    return 0;
}

小結

線段樹是一個相對複雜的資料結構,因此必然存在許多線段樹的變形題目,需要我們在做題時隨機應變。我們應該透過多刷題來提升對線段樹的熟練程度。

相關文章