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

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

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

最后希望有兴趣的读者可以尝试实现一下这种写法, 万一发现这玩意确实挺好用呢?

(厚脸皮求推荐)

posted @ 2019-06-15 20:56  rvalue  阅读(1594)  评论(3编辑  收藏  举报