[教程] 關於一種比較特別的線段樹寫法

關於一種比較特別的線段樹寫法

這篇NOIP水平的blog主要是爲了防止我AFO後寫法失傳而寫的(大霧)數組

前言

博主日常寫線段樹的時候常常用一種結構體飛指針的寫法, 這種寫法具備若干優點:安全

  • 條理清晰不易寫掛, 且不須要藉助宏定義就能夠實現這一點
  • 能夠在很小的修改的基礎上實現線段樹的各類靈活運用, 好比:
    • 可持久化
    • 動態開點
    • 線段樹合併
  • 出錯會報RE方便用gdb一類工具快速定位錯誤(平衡樹也能夠用相似寫法, 一秒定位板子錯誤)
  • 並且將線段樹函數中相對比較醜陋的部分參數隱式傳入, 因此(可能)看上去比較漂亮一些
  • 在使用內存池而不是動態內存的狀況下通常比普通數組寫法效率要高
  • 原生一體化, 在數據結構之間嵌套時能夠直接套用而沒必要進行各類兼容性修改
  • 接口做爲成員函數出現, 不會出現標識符衝突(重名)的狀況

下面就以線段樹最基礎的實現例子: 在 \(O(n+q\log n)\) 的時間複雜度內對長度爲 \(n\) 的序列進行 \(q\) 次區間加法區間求和爲例來介紹一下這種寫法.數據結構

對某道題目的完整實現或者其餘的例子能夠參考個人其餘博文中的附帶代碼或者直接查詢我在UOJ/LOJ的提交記錄.函數

(可能我當前的寫法並無作到用指針+結構體所能作到的最優美的程度並且沒有作嚴格封裝, 求dalao輕噴)工具

注意這篇文章的重點是寫法而不是線段樹這個知識點qwq...性能

前置技能是要知道對某個對象調用成員函數的時候有個 this 指針指向調用這個函數的來源對象.this

定義

定義一個結構體 Node 做爲線段樹的結點. 這個結構體的成員變量與函數定義以下:spa

struct Node{
    int l;
    int r;
    int add;
    int sum;
    Node* lch;
    Node* rch;
    Node(int,int);
    void Add(int);
    void Maintain();
    void PushDown();
    int Query(int,int);
    void Add(int,int,int);
};

其中:指針

  • lr 分別表示當前結點所表明的區間的左右端點
  • add 是區間加法的惰性求值標記
  • sum 是當前區間的和
  • lchrch 分別是指向當前結點的左右子結點的指針
  • Node(int,int) 是構造函數, 用於建樹
  • void Add(int d) 是一個輔助函數, 將當前結點所表明的區間中的值都加上 \(d\).
  • void Maintain() 是用子結點信息更新當前結點信息的函數
  • void PushDown() 是下傳惰性求值標記的函數
  • int Query(int l,int r) 對區間 \([l,r]\) 求和
  • void Add(int l,int r,int d) 對區間 \([l,r]\) 中的值都加上 \(d\).

建樹

我的通常選擇在構造函數中建樹. 寫法以下(此處初值爲 \(0\)):code

Node(int l,int r):l(l),r(r),add(0),sum(0){
    if(l!=r){
        int mid=(l+r)>>1;
        this->lch=new Node(l,mid);
        this->rch=new Node(mid+1,r);
        this->Maintain(); // 由於初值爲 0 因此此處能夠不加
    }
}

這個實現方法利用了 new Node() 會新建一個結點並返回一個指針的性質遞歸創建了一棵線段樹.

new Node(l,r) 實際上就是創建一個包含區間 \([l,r]\) 的線段樹. 其中 \(l\)\(r\) 在保證 \(l\le r\) 的狀況下能夠任意.

注意到我在 \(l=r\) 的時候並無對 lchrch 賦值, 也就是說是野指針. 爲何保留這個野指針不會出現問題呢? 咱們到查詢的時候再作解釋.

實際使用的時候能夠這樣作:

int main(){
    Node* Tree=new Node(1,n);
}

而後就能夠創建一棵包含區間 \([1,n]\) 的線段樹了.

區間加法

在這個例子中要進行的修改是 \(O(\log n)\) 時間複雜度內的區間加法, 那麼須要先實現惰性求值, 當操做深刻到子樹中的時候下傳標記進行計算.

惰性求值

首先實現一個小的輔助函數 void Add(int):

void Add(int d){
    this->add+=d;
    this->sum+=(this->r-this->l+1)*d;
}

做用是給當前結點所表明的區間加上 \(d\). 含義很明顯就不解釋了.

有了這個小輔助函數以後能夠這樣無腦地寫 void PushDown():

void PushDown(){
    if(this->add!=0){
        this->lch->Add(this->add);
        this->rch->Add(this->add);
        this->add=0;
    }
}

這兩個函數中全部 this-> 由於沒有標識符重複的狀況實際上是能夠去掉的, 博主的我的習慣是保留.

維護

子樹修改後顯然祖先結點的信息是須要更新的, 因而這樣寫:

void Maintain(){
    this->sum=this->lch->sum+this->rch->sum;
}

修改

主要的操做函數能夠寫成這樣:

void Add(int l,int r,int d){
    if(l<=this->l&&this->r<=r)
        this->Add(d);
    else{
        this->PushDown();
        if(l<=this->lch->r)
            this->lch->Add(l,r,d);
        if(this->rch->l<=r)
            this->rch->Add(l,r,d);
        this->Maintain();
    }
}

其中判交部分寫得很是無腦, 並且全程沒有各類 \(\pm1\) 的煩惱.

注意第一行的 this->l/this->rl/r 是有區別的. this->l/this->r 指的是線段樹所表明的"這個"區間, 而 l/r 則表明要修改的區間.

以前留下了一個野指針的問題. 顯然每次調用的時候都保持查詢區間和當前結點表明的區間有交集, 那麼遞歸到葉子的時候依然有交集的話必然會覆蓋整個結點(由於葉子結點只有一個點啊喂). 因而就能夠保證代碼不出問題.

使用

在主函數內能夠這樣使用:

int main(){
    Node* Tree=new Node(1,n);
    Tree->Add(l,r,d); // Add d to [l,r]
}

區間求和

按照線段樹的分治套路, 咱們只須要判斷求和區間是否徹底包含當前區間, 若是徹底包含則直接返回, 不然下傳惰性求值標記並分治下去, 對和求和區間相交的子樹遞歸求和. 下面直接實現剛剛描述的分治過程.

int Query(int l,int r){
    if(l<=this->l&&this->r<=r)
        return this->sum;
    else{
        int ans=0;
        this->PushDown();
        if(l<=this->lch->r)
            ans+=this->lch->Query(l,r);
        if(this->rch->l<=r)
            ans+=this->rch->Query(l,r);
        return ans;
    }
}

其實在查詢的時候, 有時候會維護一些特殊運算, 好比矩陣乘法/最大子段和一類的東西. 這個時候可能須要過一下腦子才能知道 ans 的初值是啥. 然而實際上咱們直接用下面這種寫法就能夠避免臨時變量與單位元初值的問題:

int Query(int l,int r){
    if(l<=this->l&&this->r<=r)
        return this->sum;
    else{
        this->PushDown();
        if(r<=this->lch->r)
            return this->lch->Query(l,r);
        if(this->rch->l<=l)
            return this->rch->Query(l,r);
        return this->lch->Query(l,r)+this->rch->Query(l,r);
    }
}

其中加法能夠被改成任何知足結合律的運算.

主函數內能夠這樣使用:

int main(){
    Node* Tree=new Node(1,n);
    Tree->Add(l,r,d); // Add d to [l,r]
    printf("%d\n",Tree->Query(l,r)); // Query sum of [l,r]
}

可持久化

下面以進行單點修改區間求和並要求可持久化爲例來講明.

先實現一個構造函數用來把原結點的信息複製過來:

Node(Node* ptr){
    *this=*ptr;
}

而後每次修改的時候先複製一遍結點就完事了. 簡單無腦. (下面實現的是將下標爲 \(x\) 的值改爲 \(d\))

void Modify(int x,int d){
    if(this->l==this->r) //若是是葉子
        this->sum=d;
    else{
        if(x<=this->lch->r){
            this->lch=new Node(this->lch);
            this->lch->Modify(x,d);
        }
        else{
            this->rch=new Node(this->rch);
            this->rch->Modify(x,d);
        }
        this->Maintain();
    }
}

其實對於單點的狀況還能夠用問號表達式(或者三目運算符? 隨便怎麼叫了)搞一搞:

void Modify(int x,int d){
    if(this->l==this->r) //若是是葉子
        this->sum=d;
    else{
        (x<=this->lch->r?
         this->lch=new Node(this->lch):
         this->rch=new Node(this->rch)
        )->Modify(x,d);
        this->Maintain();
    }
}

動態開點

動態開點的時候咱們就不能隨便留着野指針了. 由於咱們須要經過判空指針來判斷當前子樹有沒有被創建.

那麼構造函數咱們改爲這樣:

Node(int l,int r):l(l),r(r),add(0),sum(0),lch(NULL),rch(NULL){}

而後就須要注意到處判空了, 由於此次不能假定只要當前點不是葉子就能夠安全訪問子節點了.

遇到空結點若是要求和的話就忽略, 若是須要進入子樹進行操做的話就新建.

並且在判斷是否和子節點有交集的時候也不能直接引用子節點中的端點信息了, 有可能須要計算 int mid=(this->l+this->r)>>1. 通常查詢的時候沒有計算的必要, 由於發現結點爲空以後不須要和它判交.

內存池

有時候動態分配內存可能會形成少量性能問題, 若是被輕微卡常能夠嘗試使用內存池.

內存池的意思就是一開始分配一大坨最後再用.

方法就是先開一塊內存和一個尾指針, POOL_SIZE 爲使用的最大結點數量:

Node Pool[POOL_SIZE]
Node* PTop=Pool;

而後將全部 new 替換爲 new(PTop++) 就能夠了. new(ptr) 的意思是對僞裝 ptr 指向的內存是新分配的, 而後調用構造函數並返回這個指針.

缺陷

顯然這個寫法也是有必定缺陷的, 目前發現的有以下幾點:

  • 由於指針不能經過位運算快速獲得LCA位置或 \(k\) 級祖先的位置因而跑得不如zkw線段樹快.
  • 由於要在結點內存儲左右端點因此內存開銷相對比較大. 可是寫完後能夠經過將 this->l/this->r 替換爲 thisl/thisr 再作少量修改做爲參數傳入便可緩解.
  • 看上去寫得比較長. 可是實際上若是將函數寫在結構體裏面而不是事先聲明, 而且將冗餘的 this-> 去掉的話並無長不少(畢竟參數傳得少了啊喂).
  • 不能魯棒處理 \(l>r\) 的狀況. 由於遞歸的時候須要一直保證查詢區間與當前區間有交集, 空集顯然就GG了...

最後但願有興趣的讀者能夠嘗試實現一下這種寫法, 萬一發現這玩意確實挺好用呢?

(厚臉皮求推薦)

相關文章
相關標籤/搜索