[数据结构入门]线段树


#0.0引入:

我们经常会遇到需要维护一个序列的问题,例如,给定一个整数序列,每次操作会修改某个位置或某个区间的值,或是询问你序列中的某个区间内所有数的和。或许你可能回去暴力出奇迹或者使用前缀和,但是当数据很大时,时间复杂度明显是受不了的。那么,就需要引入一种时间复杂度相对较小的数据结构线段树


注:本文用到了位运算优化常数,不会的小伙伴可看这篇博客【传送门


#1.0 基础篇

#1.1 关于线段树

线段树是一课二叉树树上的每一个结点对应序列的一段区间,如下图:

不难发现,根节点对应的区间是 \([0,n-1]\),而每个结点对应一个区间 \([l,r]\),且当 \(l=r\) 时,该结点为叶结点,无左右儿子,否则令 \(mid = \dfrac{l+r}{2}\);,则左儿子对应的区间为 \([l,mid]\),右儿子为 \([mid+1,r]\)。同时,也不难发现,最后一行有 \(n\) 个结点,倒数第二行有 \(\dfrac{n}{2}\)个结点,倒数第三行有 \(\dfrac{n}{4}\) 个结点,以此便可以推出一个线段树有\(n+\dfrac{n}{2}+\dfrac{n}{4}+\cdots+1=2\times n+1\)个结点,但是要注意,线段树数组务必要开4倍。设线段树的深度为 \(h\) ,那么 \(h\)\(O(\log n)\) 级别,当我们需要维护的序列长度为** \(2\) 的整数次幂时,这个线段数为满二叉树,否则最后一层可能不满

#1.2 建树

注:接下来的一系列操作都以求区间和为例

设序列a:5,3,7,9,6,4,1,2,我们将其用线段树构建出来,即

对应的区间和如图

从图中可以看出除叶结点区间和为它本身外,其他节点的区间和均为其左右儿子区间和之和。以此,可以通过递归建树,并在递归后对其区间和进行维护

void build(int k,int l,int r){   //k为结点编号,l为区间左端点,r为区间右端点 
    if (l == r){  //如果该点为叶结点 
        sum[k] = a[l];  //区间和即为本身 
        return;
    }
    int mid  = l + r >> 1; //取中间值,位运算优化常数 
    build(k << 1,l,mid); //构建左子树 
    build(k << 1 | 1,mid + 1,r); //构建右子树 
    sum[k] = sum[k << 1] + sum[k << 1 | 1]; //维护该区间区间和 
}

#1.3 区间查询

如果我们要查询区间和[0,6],那么我们需访问3个区间

那么在查询过程中,会有以下三种情况:

  • 当前区间与需查询区间无交集,返回0
  • 当前区间被需查询区间完全包含,返回该结点对应区间和
  • 当前区间与需查询区间有交集,但不被完全包含,递归其左右子树进行查询
int query(int k,int x,int y,int l,int r){ //k为结点编号,x为当前区间左端点,y为当前区间右端点,
                                          //l为需查询区间左端点,r为需查询区间右端点 
    if (x > r || y < l) return 0; //如果无交集,返回0 
    if (x >= l && y <= r) return sum[k]; //如果完全包含,返回该结点区间和 
    int res = 0,mid = x + y >> 1;
    res = query(k << 1,x,mid,l,r); //递归左子树 
    res += query(k << 1 | 1,mid + 1,y,l,r); //递归右子树 
    return res;
}

#1.4 单点修改

假设要将 \(a_1\)\(2\),那么我们要重新计算 \(4\) 个结点的值

\(\Downarrow\)

那么,在修改时可以用递归的形式修改

在递归中,可能会有以下几种情况:

  • 当前区间不包括需修改区间,直接返回
  • 找到需修改的叶结点,修改,返回
  • 有包含,但不是叶结点,递归修改左右子树
void change(int k,int l,int r,int p,int v){
    if (l > p || r < p) return; //若该区间不包含需修改点,直接返回 
    if (l == r && l == p){ //若当前即为需修改结点 
        sum[l] += v; //修改 
        return;
    }
    int mid = l + r >> 1;
    change(k << 1,l,mid,p,v);//递归修改左子树 
    change(k << 1 | 1,mid + 1,r,p,v);//递归修改右子树 
    sum[k] = sum[k << 1] + sum[k << 1 | 1];//维护区间和 
} 

#2.0 进阶篇

有时我们需要解决的不只是单点修改、区间询问,而是区间修改、区间询问,那么单纯使用以上的方法已经无法解决问题了,因为我们没有办法高效完成区间修改。以区间内所有数同时加上一个值,以及单点询问为例进行引入。

#2.1 区间修改,单点查询

考虑在每个结点上维护一个值 \(addsum\) ,表示这个结点所对应的区间内的所有数都加上了 \(addsum\) 。注意到根节点到叶结点 \([i,i]\) 的路径会经过所有包含点 \([i,i]\) 的区间对应结点,并且路径上所有结点都包括 \([i,i]\) 这个点,所以路径经过的所有结点的 \(addsum\) 的值加起来便是当前结点的值

#2.1.1 区间修改

我们还是采用递归的方式,对所求区间进行修改,我们在递归时,会遇上以下几种情况:

  • 当前区间不包括所修改区间,直接返回
  • 需修改区间完全包含当前区间,给当前区间的 \(addsum\) 加上需加的值
  • 否则对该结点的两个子树进行递归
void modify(int k,int l,int r,int x,int y,int v){//给区间[x,y]加上v 
    if (l > y || r < x) return; //无交集,直接返回
    if (l >= x && r <= y){//完全包含 
        addsum[k] += v;
        return;
    } 
    int mid = l + r >>1;
    modift(k << 1,l,mid,x,y,v);//递归左子树 
    modift(k << 1 | 1,mid + 1,r,x,y,v);//递归右子树 
}

#2.1.2 单点查询

在查询时,要从根结点查询至目标结点,会有以下几种情况:

  • 当前为叶子结点(需查询结点),返回当前结点的 \(addsum\)
  • 目标结点编号小于等于当前结点对应区间的中点编号,说明目标结点在左子树,返回递归左子树得到的值加上该点的 \(addsum\)
  • 否则说明目标结点在右子树,返回递归右子树得到的值加上该点的 \(addsum\)
int query(int k,int l,int r,int p){
    if (l == r) return addsum[k];
    int mid = l + r >> 1;
    if (p <= mid)
      return query(k << 1,l,mid,p) + addsum[k];
    else
      return query(k << 1 | 1,mid + 1,r,p) + addsum[k];
} 

再回到区间修改区间查询这一问题上,此时打标记的方法也不可用了,若是遍历所有会产生影响的结点并将其加起来,复杂度无法接受,那么常用的方法有两种:一是标记下传,二是标记永久化。这俩兄弟有个非常nb的名字——Lazy-Tag(懒标记)

#2.2 区间修改 区间查询

#2.2.1 标记下传

执行修改操作时,我们找到对应的结点,修改 \(add(addsum)\),并且得到更新的 \(sum\) 值,而这个结点所有的祖先都能通过 \(sum_k=sum_{2k}+sum_{2k+1}\) 得到当前修改后正确的区间和,而这个结点的子节点无法立即更新,因为这样的子节点太多,所以时间复杂度他又炸了。。。

解决的方案是(敲黑板):当我们用到这些子结点的信息时再进行更新,其实就是在某个结点递归下去,将当前节点的 \(add\) 下传到两个子结点,更新两个子结点的 \(sum\)\(add\),并将当前结点的 \(add\) 清零。这样操作以后,当我们访问一个节点时,根节点到他的路径上的 \(add\) 值都已经下传更新到它的 \(sum\) 上了,所以它的 \(sum\) 便是它对应的区间和

#2.2.1.1 打标记

我们在给一个数打标记时,给他打上了标记,就意味着它疫检合格了(不是下面的所有子节点都加上了这个数,那么它的区间和便要进行维护(\(l\) 为区间左端点,\(r\) 为区间右端点,\(v\) 为所加的数),他的区间和变成了 \(sum_k = sum_k + (r-1+1)\cdot v.\)

void Add(int k,int l,int r,int v){
    add[k] += v;
    sum[k] += (r - l + 1) * v;
}

#2.2.1.2 标记下传

我们这里又双叒叕递归实现,如果需下传的这个结点的add为0,跳出;否则下传至左右子树(其实就是调用一下刚刚的Add)

注意:记得将该点add清零!!!

void pushdown(int k,int l,int r,int mid){
    if (add[k] == 0) return;
    Add(k << 1,l,mid,add[k]);
    Add(k << 1 | 1,mid + 1,r,add[k]);
    add[k] = 0;
}

#2.2.1.3 区间修改

我们这里又双叒叕递归实现;他会有以下几种情况:

  • 需修改区间完全包含当前区间,给当前区间打标记,就可以返回了
  • 不完全包含的情况下,先下传一下标记,之后又有两种情况,最后维护一下该节点区间和
    • 需修改区间的左边界在当前区间中点左边,遍历一次左子树
    • 需修改区间的右边界在当前区间中点右边,遍历一次右子树
void modify(int k,int l,int r,int x,int y,int v){
    if (l >= x && r <= y) return Add(k,l,r,v);
    int mid = l + r >> 1;
    pushdown(k,l,r,mid);
    if (x <= mid) modify(k << 1,l,mid,x,y,v);
    if (mid < y) modify(k << 1 | 1,mid + 1,r,x,y,v);
    sum[k] = sum[k << 1] + sum[k << 1 | 1];
}

#2.2.1.4 区间查询

我们这里又双叒叕递归实现;他会有以下几种情况:

  • 需查询区间完全包含当前区间,就可以返回当前区间和
  • 不完全包含的情况下,先下传一下标记,之后又有两种情况,定义 \(res\) 储存结果
    • 需查询区间的左边界在当前区间中点左边,遍历一次左子树,\(res\) 加一次
    • 需查询区间的右边界在当前区间中点右边,遍历一次右子树,\(res\) 加一次
int query(int k,int l,int r,int x,int y){
    if (l >= x && r <= y)
      return sum[k];
    int mid = l + r >> 1;
    int res = 0;
    pushdown(k,l,r,mid);
    if (x <= mid)
      res += query(k << 1,l,mid,x,y);
    if (mid < y)
      res += query(k << 1 | 1,mid + 1,r,x,y);
    return res;
}

#2.2.2 标记永久化

另一种方法是不下传 \(add\) 标 记,改为在询问过程中计算每个遇到的节点对当前询问的影响。为了保证询问的时间复杂度,子节点的影响须在修改操作时就计算好。因此实际上,\(sum\) 这个值表示这个区间内所有的数共同加上的值,\(sum\) 表示这个区间内除了\(add\) 之外其他值的和。

当然,区间的 \(add\) 值可能有一部分在祖先结点上,这在递归时累加即可,与区间修改单点查询相似

void modify(int k,int l,int r,int x,int y,int v){ //区间修改 
    if (l >= x && r <= y){
        add[k] += v;
        return;
    }
    sum[k] += (min(r,y) - max(l,x) + 1) * v;
    int mid = l + r >> 1;
    if (x <= mid) modify(k << 1,l,mid,x,y,v);
    if (mid < y) modify(k << 1 | 1,mid + 1,r,x,y,v);
}
int query(int k,int l,int r,int x,int y){ //查询 
    if (l >= x && r <= y)
      return sum[k] + (r - l + 1) * add[k];
    int mid = l + r >> 1;
    int res = (min(r,y) - max(l,x) + 1) * add[k];
    if (x <= mid)
      res += query(k << 1,l,mid,x,y);
    if (mid < y) 
      res += query(k << 1 | 1,mid + 1,r,x,y);
    return res;
}

板子题:【传送门


更新日志及说明

更新

  • 初次完成编辑 - \(2019.12.8\)
  • 更换 markdown 编辑器,采用 \(\LaTeX\) 渲染公式,重新发布 - \(2021.1.28\)

个人主页

欢迎到以下地址支持作者!
Github戳这里
Bilibili戳这里
Luogu戳这里

posted @ 2019-11-28 20:39  Dfkuaid  阅读(518)  评论(0编辑  收藏  举报