天天看點

可持久化線段樹 主席樹 詳解

😊 | Powered By HeartFireY | Persistent Segment Tree
📕 | 需要的前導知識:線段樹(Segment Tree)、權值線段樹、可持久化資料結構理論(Persistent Data Structure Theory)

一、可持久化線段樹 簡介

可持久化線段樹,顧名思義,即對線段樹進行可持久化處理之後的線段樹。

在可持久化資料結構的理論中,我們對可持久化的概念有所了解:“可以傳回之前的某個狀态,并在該基礎上進行修改”。可持久化線段樹就是這樣一種結構。

我們從一般思路出發進行分析:想要讓線段樹可持久化,最樸素的方法就是每進行一次操作都建立一顆線段樹。但顯然這是不明智的做法:時間和空間上而言都是非常差的的算法。我們不妨繼續分析一下更新之後與更新之前的結構:每次更新都隻有少量的點被修改。是以大量的點可以繼續使用,無需重建立樹。

本文分兩個部分對可持久化線段樹進行探讨。

值得注意的是:我們一般認為主席樹就是可持久化線段樹,實際上兩者之間有區分:

可持久化線段樹單純指經過可持久化處理之後,能夠查詢、修改曆史節點資料的結構,建立在基礎線段樹的基礎之上;而主席樹建立在權值線段樹的基礎之上,能夠查詢修改曆史節點。二者本質差別在于紀念性可持久化的對象所維護的對象不同(一個維護權值、一個維護值域)。

是以,可持久化線段樹主席樹,準确的說,主席樹可持久化線段樹。

二、可持久化線段樹 基本結構與操作

1.基本結構、建樹操作

首先應該明确一點:由于可持久化結構重疊的特殊性,可持久化線段樹不能采用堆式儲存,是以隻能采取動态開點的方式進行儲存。

不同于後續節點的更新,對于最初的狀态下的線段樹我們采用一次建樹完成:

  1. 采用遞歸建樹,遞歸函數參數儲存左右邊界(初始化為)、目前元素的指針。同時儲存總計數;
  2. 遞歸邊界控制為,由于此時左邊界即為指向數組的指針,是以葉子節點指派
  3. 非葉子節點首先記錄左右子節點的下标,然後遞歸建左右子樹:
  4. 建立左右節點後非葉子節點 = 左右子節點的和:

根據以上建樹過程,我們以數組為例建立線段樹:

可持久化線段樹 主席樹 詳解

可以看到:不同于基礎線段樹,可持久化線段樹的下标不再遵循堆的規律,這種建樹方式我們稱為動态開點。

結構體封裝版:

#define
#define
#define
#define
struct node{
    int val, mark = INT_MIN;
    int ls, rs;
}tree[MAXN];

void build(int l = 1, int r = n, int p = 1){
    if(l == r) val(p) = a[l];
    else{
        ls(p) = ++cnt, rs(p) = ++cnt;
        int mid = (l + r) >> 1;
        build(l, mid, ls(p));
        build(mid + 1, r, rs(p));
        val(p) = val(ls(p)) + val(rs(p));
    }
}      

建樹完畢後,上述線段樹已經靜态化,後續的修改操作通過增加新節點來實作。

2.單點修改

假設現在要對加,則操作步驟如下:

首先建立新根結點,更新根節點記錄數組:

可持久化線段樹 主席樹 詳解

建立新節點後對其進行處理。由于我們要修改的元素位于右子樹,是以左子樹部分保持不變,可以繼續使用。是以将原左子樹與新節點相連,同時建立右子節點。

可持久化線段樹 主席樹 詳解

繼續處理,位于目前結點左子樹,右子樹保持不變繼續使用,是以建立左子節點,将原右子節點與新節點相連。

繼續通路左子節點發現到達葉子結點,對原資料進行處理後再逐層回溯,更新路徑上的結點值。

可持久化線段樹 主席樹 詳解

單點修改的操作到此結束。可以看到,對于最初版本的線段樹,其任何資料未被改變。

void update(int x, int d, int p, int q, int cl = 1, int cr = n){
    if (cl == cr) val(q) = val(p) + d; // 給葉子節點指派
    else{
        ls(q) = ls(p), rs(q) = rs(p); // 複制節點
        int mid = (cl + cr) / 2;
        if (x <= mid) ls(q) = ++cnt, update(x, d, ls(p), ls(q), cl, mid); 
        // 建立新節點作為左兒子,然後往左遞歸
        else rs(q) = ++cnt, update(x, d, rs(p), rs(q), mid + 1, cr); 
        // 建立新節點作為右兒子,然後往右遞歸
        val(q) = val(ls(q)) + val(rs(q)); // 根據子節點給目前節點指派
    }
}      

3.區間查詢

區間查詢與基礎線段樹幾乎一緻,隻需要額外關注查詢的版本對應的根節點即可。

int query(int l, int r, int p, int cl = 1, int cr = n){
    if (cl > r || cr < l) return 0;
    else if (cl >= l && cr <= r) return val(p);
    else{
        int mid = (cl + cr) / 2;
        return query(l, r, ls(p), cl, mid) + query(l, r, rs(p), mid + 1, cr);
    }
}      

4.區間修改

我們需要再次回到标記上進行讨論:

對于标記,我們其實有兩種實作方案:

  1. 标記上下傳,也就是我們最常用的操作;
  2. 标記永久化:标記時不用,而是在查詢的時候幹預資料。

二者的一大差別在于,标記上下傳會引發修改結點路徑上的點的更新,而标記永久化不會影響樹上的點。

這點差別是非常有意義的:考慮一棵可持久化線段樹,如果從某節點上傳,則到根節點路徑上的點都會被修改,而可持久化結構導緻了某些結點的複用,這會引發多個版本的線段樹更新,無法指定是哪一版本;同理,下傳标記也會引發不必要的點的更新。是以,我們隻能通過标記永久化對可持久化線段樹進行修改。

于是,我們在查詢時需要額外帶上一個标記:

我們不妨再回顧一下查詢的過程:

可持久化線段樹 主席樹 詳解

我們曾線上段樹的查詢過程中了解到:查詢的過程就是不斷地查詢區間的交集,以此不斷地縮小查找範圍。

如上圖所示,假設查詢到①所示的區間則傳回該節點的值,因為他是待查詢區間的子集;②所示的區間則需要向右遞歸搜尋、③向左遞歸搜尋。

不同于基礎線段樹,可持久化線段樹需要進行額外加永久化标記的操作,即:查詢到待查區間的子集時,傳回(該節點值子集元素個數永久化标記)

同時,遞歸查詢時也要額外對标記進行累加。這點也十分容易了解:

我們思考永久标記的意義:對于目前節點所管轄的區間元素全部加某個值(或者做出某種改變),是以向下查詢子集時應該帶上目前節點的标記,相當于将标記進行了下放。

ll query(int l, int r, int p, int cl = 1, int cr = n, ll mk = 0){
    if (cl > r || cr < l) return 0;
    else if (cl >= l && cr <= r) return val(p) + mk * (cr - cl + 1); // 加上帶的标記
    else{
        int mid = (cl + cr) >> 1;
        return query(l, r, ls(p), cl, mid, mk + mark(p)) + query(l, r, rs(p), mid + 1, cr, mk + mark(p)); // 帶着标記傳遞
    }
}      

對于操作,其基本過程如下:

  1. 首先對目前節點進行複制;
  2. 判斷:如果目前節點管轄區間合法,且是待修改區間的子集,那麼對目前新的節點(複制後的節點)的标記執行加操作;
  3. 如果不滿足條件,則判斷待修改區間是否由左子樹管轄、是否由右子樹管轄,如果是則複制節點,遞歸向左\右建點更新。

值得注意的是,由于缺少傳遞标記,是以由子節點給目前節點指派可能會發生錯誤。

為了得到全包含的區間并計算真實長度,我們隻需要定義區間為即可(保證其為帶查詢區間的子集)。

void update(int l, int r, int d, int p, int q, int cl = 1, int cr = n){
    ls(q) = ls(p), rs(q) = rs(p), mark(q) = mark(p); // 複制節點
    if (cl >= l && cr <= r && cr > cl) mark(q) += d;
    else{
        int mid = (cl + cr) >> 1;
        if (cl <= r && mid >= l) ls(q) = ++cnt, update(l, r, d, ls(p), ls(q), cl, mid);// 提前進行判斷,以免建立不必要的節點
        if (mid + 1 <= r && cr >= l) rs(q) = ++cnt, update(l, r, d, rs(p), rs(q), mid + 1, cr);
            
    }
    val(q) = val(p) + (min(cr, r) - max(cl, l) + 1) * d; // 根據需要更新的區間長度計算目前節點的值
}      

三、主席樹

差別于一般的可持久化線段樹,主席樹是可持久化權值線段樹。

下面簡單介紹一下主席樹的構造過程:

1.建立空樹

void build(int l = 1, int r = n, int p = 1){
    val(p) = 0;
    if (l != r){
        ls(p) = ++cnt, rs(p) = ++cnt;
        int mid = (l + r) / 2;
        build(l, mid, ls(p));
        build(mid + 1, r, rs(p));
    }
}      

2.區間離散化

int C[MAXN], L[MAXN], ori[MAXN];
void discretize(int A[], int n){
    memcpy(C, A, sizeof(int) * n);     // 複制
    sort(C, C + n);                    // 排序
    int l = unique(C, C + n) - C;      // 去重
    for (int i = 0; i < n; ++i){
        L[i] = lower_bound(C, C + l, A[i]) - C + 1; // 查找
        ori[L[i]] = A[i]; // 儲存離散化後的數對應的原數
    }      

3.插入離散化資料

for (int i = 0; i < n; i++){
    roots[i + 1] = ++cnt;
    update(L[i], 1, roots[i], roots[i + 1]);
}      

4.查詢區間第大/小

我們已經有了求​

​[1, r]​

​​内第k大數的方法(查詢相應曆史版本即可),而求​

​[l, r]​

​​内第k小數,隻需對​

​kth​

​​函數稍作修改。注意,我們在求​

​kth​

​​時會用到目前左兒子對應的區間和,現在要排除​

​[1, l-1]​

​,那麼把這部分和減去即可。

這一部分的思想與權值樹狀數組很相似。
int kth(int k, int p, int q, int cl = 1, int cr = n){
    if (cl == cr) return ori[cl];
    int mid = (cl + cr) / 2;
    if (val(ls(q)) - val(ls(p)) >= k) return kth(k, ls(p), ls(q), cl, mid); // 往左搜
    else return kth(k - (val(ls(q)) - val(ls(p))), rs(p), rs(q), mid + 1, cr); // 往右搜
}