『学习笔记』线段树

线段树和树状数组都是用来优化序列操作的数据结构。

线段树理解容易,常数大,解决问题范围广;树状数组理解比较困难,常数非常小,能解决的问题就没有线段树多了,可以说树状数组能解决的问题是线段树能解决的问题的子集。

基本概念

线段树是一个二叉树,每个节点表示一个区间。

对于任意节点,要么是叶子节点,要么两个儿子都存在。

它可以快速在序列上修改及查询元素,可以是区间修改或查询。每次修改或查询的时间复杂度为 \(\mathcal{O}(\log n)\)。在使用之前,还需花费 \(\mathcal{O}(n)\) 的时间建树。

那么每个节点存什么?

  • 如果是叶子节点,就存需要执行操作的序列的对应项。具体是哪项下面再说。
  • 否则,就存这个节点的左右儿子之和或最小值、最大值、乘积等等。以求和为例,计算公式为 \(t_x=t_{\operatorname{left\_son}(x)}+t_{\operatorname{right\_son}(x)}\)这个节点的值就是这个节点表示的区间之和

为方便表示,本文中的 ls(x) 均代表 \(x\) 节点的左儿子节点,rs(x) 同理。

每个节点的儿子表示的区间都是当前节点区间的一半,左儿子表示的是 \(\left[l,\left\lfloor \dfrac{l+r}{2} \right\rfloor\right]\),右儿子表示的是 \(\left[\left\lfloor \dfrac{l+r}{2} \right\rfloor+1,r\right]\)

例如,要使用一个长度为 \(8\) 的序列 \(a=[1,1,4,5,1,4,1,9]\) 构造一棵线段树,那么这棵线段树长这样:

各个叶子节点的值都是根据 \(t_x=t_{\operatorname{ls}(x)}+t_{\operatorname{rs}(x)}\) 来计算的。

图中每个节点上面写的是这个节点包含的区间,下面写的是各个节点的值。

非叶子节点的值的计算过程也写了上去。

可以发现,表示第 \(i\) 个数的叶子节点的值就是 \(a_i\)

线段树差不多就长这样子,下面来看详细的操作过程。

实现

如何存储

我们可以使用二叉堆的方式存储:根节点的位置为 \(1\),每个节点的左右儿子的位置分别为 \(i \times 2\)\(i \times 2+1\)

也就是说,你遍历存储这棵树的数组,和层次遍历这棵树一样。

若有空缺的位置,需要留着。

因为存储的节点除了最后一层还有许多个节点,所以数组长度要比 \(n\) 大。

有人计算过,存树的数组需要开到 \(4n\) 才行。

树的节点结构体定义如下:

struct node{
    int l,r; // 表示区间
    T v; // 当前值
}t[N<<2]; // 线段树存储数组

建树

呵呵,正文终于开始了。

上面提到过,建树要用 \(\mathcal{O}(n)\) 的时间复杂度进行。因为有 \(n\) 个元素。

使用深度优先搜索的方式来遍历整棵树。遍历的中间,为各个节点的 \(l\)\(r\) 赋值。

遍历到叶子节点(当前的 \(l=r\))时,这个叶子节点的值就应该是 \(a_l\)

两个儿子都遍历过后,需要通过已经处理好的儿子实时计算当前节点的值。

流程大概是这样的:

为方便起见,我们定义一个函数 pushup 用来计算当前节点的值。

inline void pushup(int rt){
    t[rt].v=t[ls(rt)].v+t[rs(rt)].v; // 计算当前节点的值
}

通过修改 pushup 函数,可以直接修改线段树维护内容。

例如,将其修改为维护最大值的线段树:

inline void pushup(int rt){
    t[rt].v=max(t[ls(rt)].v,t[rs(rt)].v);
}

看代码吧!还是代码形象一点:

void build(int rt,int l,int r){ // rt 表示当前节点,l 和 r 表示当前节点表示的区间
    t[rt].l=l,t[rt].r=r; // 首先的一步就是指定当前节点表示的区间范围
    if(l==r){ // 叶子节点情况
        t[rt].v=a[l]; // 为叶子节点赋值
        return; // 碰到叶子节点了就要回溯了
    }
    int mid=l+r>>1; // 计算区间分界点
    build(ls(rt),l,mid); // 递归遍历左儿子
    build(rs(rt),mid+1,r); // 递归遍历右儿子
    pushup(rt); // 计算当前节点值
}

应该很好理解,就是常数...

单点修改

废话了那么多,终于开始说操作了...

单点修改,就是要修改数列中的一个数。

那么在线段树中,就是修改其中一个叶子节点,我们需要修改某个叶子节点后维护整棵线段树,使其还是保持原来的特性(非叶子节点等于两个儿子的和等特性)。

例如,要修改下标为 \(6\) 的数为 \(8\)

从根节点一直向下找,查找要修改的叶子节点。

若当前搜索的的节点不是叶子节点,那么就需要判断需要修改的叶子节点在左儿子里还是右儿子里:

  1. \(mid \gets \left\lfloor \dfrac{l+r}{2} \right\rfloor\)
  2. 若下标 \(idx \leq mid\),则说明在左儿子里,向左儿子中搜索。
  3. 否则,就去右儿子。

代码如下:

int mid=t[rt].l+t[rt].r>>1; // 找中间点
if(idx<=mid) update(ls(rt),idx,v); // 进左儿子
else update(rs(rt),idx,v); // 右儿子

最终一定会找到一个叶子节点,它就是我们需要修改的。

单纯修改叶子节点会破坏整棵线段树的平衡,所以回溯时需要更新查找需要更改的叶子节点时经过的节点。

在函数末尾加上一句 pushup(rt) 即可。

完整代码:

// rt 是当前节点,idx 是需要修改的数的下标,v 是要替换的数(或累加的数)
void update(int rt,int idx,T v){
    if(t[rt].l==t[rt].r){ // 找到叶子节点的情况
        t[rt].v=v; // 修改
        return; // 回溯
    }
    int mid=t[rt].l+t[rt].r>>1;
    if(idx<=mid) update(ls(rt),idx,v);
    else update(rs(rt),idx,v);
    pushup(rt); // 找到叶子节点后需要将路径上的所有节点都更新一下,从下向上更新
}

很容易看出来,时间复杂度是 \(\mathcal{O}(\log n)\)。别看比暴力还差,区间查询可是 \(\mathcal{O}(\log n)\) 的。

单点查询

没什么好说的,就是从一棵树上找到叶子节点,return 就是了。

这个应该看代码就够了。

T query(int rt,int idx){ // 参数就不多说了
    if(t[rt].l==t[rt].r){ // 找到目标
        return t[rt].v;
    }
    int mid=t[rt].l+t[rt].r>>1;
    if(idx<=mid) return query(ls(rt),idx); // 在左儿子中
    else return query(rs(rt),idx); // 右儿子
    // 这里不需要 pushup,因为没有任何修改
}

区间查询

我们之所以维护整棵线段树就是为了使这个操作的时间复杂度变为 \(\mathcal{O}(\log n)\)

暴力查询时是一个一个累加,但有了线段树就不一样了。

线段树的节点除了叶子节点都存储的是一个区间的和,若某个节点表示的区间在查询区间之内,那么就可以 \(\mathcal{O}(1)\) 地累加出这个节点表示的区间的和。

那如果当前节点表示的区间和查询区间有交集,但并不是查询区间的子集,咋办?

直接看看左右儿子表示的区间是否与查询区间有交集,如果有,则进入相应的儿子查询(两个儿子随便去,但没有都不去的情况,那样当前节点表示的区间要么是查询区间的子集,要么就与查询区间没关系)。

好像有点不好理解...看图吧。

应该步骤写的很清楚了,可以通过代码进一步理解。

T query(int rt,int l,int r){ // l 和 r 表示查询区间!不是当前节点表示区间!
    if(l<=t[rt].l && t[rt].r<=r){ // 刚好是查询区间的子集
        return t[rt].v; // 直接返回
    }
    T res=0; // 因为左右儿子都可能去,所以定义一个变量累加
    int mid=t[rt].l+t[rt].r>>1;
    if(l<=mid) res+=query(ls(rt),l,r); // 若查询区间左端点在左儿子右端点之前,则表示左儿子包含
    if(r>mid) res+=query(rs(rt),l,r); // 查询区间右端点在右儿子左端点之后,同上
    return res;
}

区间修改

说了那么多,就差你一个区间修改了。

如果你直接用单点修改的方法一个一个改,那么时间复杂度就变成 \(\mathcal{O}(n \log n)\) 了,比暴力还差。

那我们可不可以参考区间查询的思想呢?一次修改一个区间?那就需要一个叫懒标记的东西了。

懒标记

我们给节点的结构体加一个变量,叫 \(tag\),懒标记的意思。它表示这个节点之下的所有节点的 \(v\) 都需要加上这个 \(tag\)

这样的话,一次修改一个区间就能实现了:若需修改区间包含某个节点表示的区间,直接将这个节点的 \(tag\) 加上需要增加的值。

可以这样理解懒标记:放寒假了,老师每过一段时间给你布置一次作业(修改一次),你却只是记住有哪些作业(修改懒标记),在开学时(查询)才写(将标记下传)。

除了查询,修改时也需要下传懒标记,节点后代修改(或查询)时需要。

接下来说说如何下传懒标记:

首先一步,就是将懒标记给左右儿子都加上。

还需要修改两个儿子的值,都是修改成儿子表示的区间长度乘上父亲节点的懒标记。因为儿子包含的每一个数都要加上父节点的懒标记,所以要将懒标记乘上长度。

我们将下传懒标记的函数定义为 pushdown()

inline void pushdown(int rt){
    t[ls(rt)].tag+=t[rt].tag; // 懒标记传下去
    t[ls(rt)].v+=t[rt].tag*(t[ls(rt)].r-t[ls(rt)].l+1); // 修改值
    // 右儿子同上
    t[rs(rt)].tag+=t[rt].tag;
    t[rs(rt)].v+=t[rt].tag*(t[rs(rt)].r-t[rs(rt)].l+1);
    t[rt].tag=0; // 记得将父节点的懒标记置 0
}

题单

posted @ 2022-03-09 21:32  仙山有茗  阅读(73)  评论(0编辑  收藏  举报