整理:线段树

关于线段树的整理

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);
}

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

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

但是,如果不满足可加性的操作就不能用懒惰标记了吗?

当然也可以,我们把修改函数略微更改。

void change(int p,int l,int r,int L,int R,int v){
	if(L<=l&&r<=R){
        pushdown(p);
		Tag(p);
        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);//注意重新上传区间
}

我们在下一次修改来时直接将上一次的标记下传就解决了顺序问题。

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

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  陈牧九  阅读(44)  评论(0)    收藏  举报