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


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

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

前言

博主平常寫線段樹的時候經常用一種結構體飛指針的寫法, 這種寫法具有若干優勢:

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

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

對某道題目的完整實現或者其他的例子可以參考我的其他博文中的附帶代碼或者直接查詢我在UOJ/LOJ的提交記錄.

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

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

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

定義

定義一個結構體 Node 作為線段樹的結點. 這個結構體的成員變量與函數定義如下:

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\)):

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了...

最后希望有興趣的讀者可以嘗試實現一下這種寫法, 萬一發現這玩意確實挺好用呢?

(厚臉皮求推薦)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM