[教程] 关于一种比较特别的线段树写法

关于一种比较特别的线段树写法

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

最后但愿有兴趣的读者能够尝试实现一下这种写法, 万一发现这玩意确实挺好用呢?

(厚脸皮求推荐)

相关文章
相关标签/搜索