整理:线段树

关于线段树的整理

1.什么是线段树

\(OI\ Wiki\)

线段树是算法竞赛中常用的用来维护 区间信息 的数据结构。

线段树可以在 \(O(nlogn)\) 的时间复杂度内实现单点修改,区间修改,区间查询(区间求和,求区间最大值,求区间最小值)等操作。

2.线段树与其他类似数据结构的对比

时间复杂度 空间复杂度 适用范围 码量
\(分块\) \(O(n\sqrt n)\) \(O(n\sqrt n)\) 巨大 大
\(树状数组\) \(O(nlogn)(常数小)\) \(O(n)\) 小 小
\(线段树\) \(O(nlongn)(常数大)\) \(O(n)\) 大 巨大

3.线段树的基本实现

我们首先来考虑线段树的基本操作,合并两个小区间的信息得到更大的区间信息。

void pushup(int p){c[p]=cal(c[ls],c[rs]);}

其中 \(cal\) 表示合并操作。

下面是建树过程。

void build(int p,int l,int r){
	if(l==r){
        c[p]=val;//val 表示初始值
        return ;
    }
    int mid=l+r>>1;
    build(ls,l,mid),build(rs,mid+1,r);
    pushup(p);
}

为什么线段树要这样建树呢?

我们将整段需要维护的区间尽可能平均的分成两份,可以简单看出,线段树应当有 \(\log n\) 层,这使得我们下面的修改和查询都是 \(\log n\) 的时间复杂度。

而这样,线段树将是一棵满二叉树,当区间大小为 \(2\) 的正整数幂时,线段树是一颗完全二叉树,那么我们就可以使用数组记录线段树,对于节点 \(p\),左儿子为 \(2\times p\),右儿子为 \(2\times p +1\)。

对于修改操作,我们可以分为两种,一种为单点修改,另一种为区间修改。

我们先来考虑单点修改,以区间加为例,假设修改的位置为 \(x\)。

void change(int p,int l,int r,int x,int v){
	if(l==r){//到达叶子节点,此时叶子节点管理的区间一定为 x 这单个点
		c[p]+=v;
        return ;
    }
    int mid=l+r>>1;
    if(mid>=x)change(ls,l,mid,x,v);//mid>=x 表示需要修改的点在左儿子的区间内
    else change(rs,mid+1,r,x,v);//在右儿子的区间内
    pushup(p);//此时它的儿子的信息改变,需要重新合并获得正确的信息
}

那么区间修改呢?

这里就引入了一个重要概念,懒惰标记。

何为懒惰标记?对于修改,我们可以进行一定的延后,这时对于本需要对儿子节点修改但是还没有修改的节点打上标记,记录修改。

具体什么意思呢?

我们假设修改区间为 \([L,R]\),此时访问的节点管辖区间为 \([l,r]\),那么当 \(L\le l\ \land\ r\le R\) 时,修改区间完全包含了 \([l,r]\) 区间,我们可以直接修改当前节点的值,然后打上标记,最后结束递归。

为什么这样做是对的呢?

我们可以先看一下线段树的区间查询操作的一部分。

Typename query(int p,int l,int r,int L,int R){
	if(L<=l&&r<=R)return c[p];
    //do something......
}

我们发现,当询问区间完全包含当前区间时,我们直接返回了当前节点的值,而不访问其子节点。

我们回到上面的打标记问题。

我们直接修改了当前节点的值,然后打上标记,将修改操作延后,当查询时,如果询问区间完全包含当前区间,我们可以通过返回当前节点的值来得到正确的区间信息。

而这衍生出了另一个问题,也就是标记下传。

我们发现,并不是每一次,询问区间都可以完全包含当前区间,这时需要访问子节点,但是由于懒惰标记的存在,子节点并没有被更新,此时我们需要将延后的操作落实,更新子节点的值,并且下传标记,对子节点打上标记,表示将子节点的子节点的操作延后。

注意,无论是修改还是查询,只要访问了子节点,就应当将标记下传。

下传操作和上面的上传操作一样,都是线段树的基本操作。

void pushdown(int p){
	Tag(ls,tag[p]);
    Tag(rs,tag[p]);
    tag[p]=0;
}

其中 \(Tag\) 操作表示打标记和修改的过程,注意清空当前节点的标记。

那么区间修改和区间查询的代码可以写出,这里区间修改以区间加为例。

void change(int p,int l,int r,int L,int R,int v){
	if(L<=l&&r<=R){
		c[p]+=v;
        tag[p]+=v;//懒惰标记
        return ;
    }
    pushdown(p);//下传标记
    int mid=l+r>>1;
    if(mid>=L)change(ls,l,mid,L,R,v);
    if(mid<R)change(rs,mid+1,r,L,R,v);
    pushup(p);//注意重新上传区间
}
Typename query(int p,int l,int r,int L,int R){
	if(L<=l&&r<=R)return c[p];
    pushdown(p);//下传标记
    if(mid>=L&&mid<R)return cal(query(ls,l,mid,L,R),query(rs,mid+1,r,L,R));
    if(mid>=L)return query(ls,l,mid,L,R);
    return query(rs,mid+1,r,L,R);
}

但是懒惰标记难道是万能的吗?

显然不是,懒惰标记维护的操作要满足操作可加性,也就说,标记的先后顺序与最后导出的结果无关,比如加,乘,或者覆盖。

这样,我们就得到了线段树的基本代码,这里以维护区间和,支持区间加,单点加的线段树为例。

struct Segment_tree{
    int c[N<<2],tag[N<<2];
    #define ls p<<1
    #define rs p<<1|1
    #define mid (l+r>>1)
	void pushup(int p){c[p]=c[ls]+c[rs];}
    void Tag(int p,int v){
		c[p]+=v;
        tag[p]+=v;
    }
    void pushdown(int p){
		if(!tag[p])return ;
        Tag(ls,tag[p]);
        Tag(rs,tag[p]);
        tag[p]=0;
    }
    void build(int p,int l,int r){
        if(l==r)return void(c[p]=0);
        build(ls,l,mid),build(rs,mid+1,r);
        pushup(p);
    }
    void change(int p,int l,int r,int x,int v){
		if(l==r)return void(c[p]+=v);
        pushdown(p);
        if(mid>=x)change(ls,l,mid,x,v);
        else change(rs,mid+1,r,x,v);
        pushup(p);
    }
    void change(int p,int l,int r,int L,int R,int v){
        if(L<=l&&r<=R)return Tag(p,v);
        pushdown(p);
        if(mid>=L)change(ls,l,mid,L,R,v);
        if(mid<R)change(rs,mid+1,r,L,R,v);
        pushup(p);
    }
    int query(int p,int l,int r,int L,int R){
        if(L<=l&&r<=R)return c[p];
        pushdown(p);
        if(mid>=L&&mid<R)return query(ls,l,mid,L,R)+query(rs,mid+1,r,L,R);
        if(mid>=L)return query(ls,l,mid,L,R);
        return query(rs,mid+1,L,R);
    }
}

我们可以发现,码量十分之大。

相比之下,树状数组就十分简短,所以可以写树状数组的场合尽量写树状数组吧。

4.线段树的时空复杂度分析

在上面的代码中我们可以发现,储存线段树的数组我们开了四倍空间,为什么呢?
因为在最优情况下,线段树是一颗满二叉树,此时只需要两倍空间,但是在其他情况下,完全二叉树就需要四倍空间储存了。

单次操作时间复杂度显然为 \(O(\log n)\),但是带一个 \(4\) 的巨大常数。

为什么呢?

以查询操作为例,我们发现最劣情况为,每一次操作区间都需要分成两份,右儿子被完全包含,左儿子没有被完全包含,这样,我们需要访问 \(O(4\times\log n)\) 个节点才能获得完整信息。

posted @ 2025-04-08 18:38  陈牧九  阅读(109)  评论(0)    收藏  举报