Double_Light
2024-10-03 19:09:02
线段树是所有数据结构中最重要的之一。本文主要用于帮助线段树的初学者更好地理解与掌握它,例题难度均不大于中位蓝,想进一步进阶的大佬们可以跳过这篇文章。
本文长度较长,可以选择性地观看。
本文的结构上参考了 @Hoks 的这篇和这篇学习笔记,内容部分参考了 OI-Wiki。
线段树是一种可以支持区间修改、区间查询的数据结构,本质上是一棵二叉树。线段树的修改与查询操作都是
对于树上的每一个节点,我们需要维护一段区间的信息。
让每个节点的子节点维护这段区间的子区间信息,且各个子节点之间维护的区间不重不漏,然后再用恰当的方式进行子节点向父亲节点的合并以及父节点向子节点的传递信息,这就是线段树的基本思想。
直觉上,少的节点肯定比多的节点好维护,所以每次修改时从上向下传递信息。
由于线段树是一棵二叉树,所以容易想到如下结构:
这样做的好处是,由于每一层线段树每个节点所维护的区间长度都是父亲节点的一半,所以线段树深度为
考虑这棵线段树的节点数量与维护序列长度
上文提到线段树的深度最大为
以每个节点维护区间和为例。
由于二叉树的递归性质,所以在线段树上进行操作用递归实现比较方便。
假设线段树上当前节点编号为
参考代码如下:
void pushup(int x){//更新线段树 x 号节点
tr[x]=tr[2*x]+tr[2*x+1];//tr 为线段树数组
}
void build(int x,int l,int r){
if(l==r)tr[x]=a[l];
else{
int mid=(l+r)/2;
build(2*x,l,mid);//更新左儿子
build(2*x+1,mid+1,r);//更新右儿子
pushup(x);
}
}
假设现在需要求出序列区间
同样考虑递归。假设当前位于
由于
代码:
int query(int x,int l,int r,int from,int to){
int ans=0;
if(from<=l&&r<=to)return tr[x];
if(from<=mid)ans+=query(2*x,l,mid,from,to);
if(to>mid)ans+=query(2*x+1,mid+1,r,from,to);
return ans;
}
由于每次执行递归的节点只有可能是查询区间的两个端点所在的维护区间,所以每一层最多递归两个节点,总时间复杂度
考虑将第
如果 pushup
函数更新递归路径上的节点。
容易发现时间复杂度为
代码:
void update(int x,int l,int r,int k,int v){
if(l==r&&l==k){
tr[x]+=v;
return ;
}
if(k<=mid)update(2*x,l,mid,k,v);
else update(2*x+1,mid+1,r,k,v);
pushup(x);
}
区间修改时,需要把修改区间中覆盖的每个节点都修改一遍,时间复杂度退化到
当区间修改把一个区间修改完之后,如果要往下一直递归修改,会导致时间复杂度爆掉。此时我们在这个节点打一个标记
等到下一次区间修改或区间查询需要用到这个节点的子节点的维护值时,再把两个子节点的维护值更新,并且将该节点的懒标记清空(两个子节点的值之和已经与父节点维护值相同),将两个子节点的懒标记加上原来该节点的懒标记值(这两个子节点的四个子节点现在比她们的父节点又少了
这样复杂度就会变成
参考代码:
void pushdown(int x,int l,int r){
if(l==r)return ;
tr[2*x].sum+=(mid-l+1)*tr[x].tag;
tr[2*x+1].sum+=(r-mid)*tr[x].tag;
tr[2*x].tag+=tr[x].tag;//注意这里需要 + 而不是直接赋值
tr[2*x+1].tag+=tr[x].tag;
tr[x].tag=0;
}
void update(int x,int l,int r,int from,int to,int v){
if(from<=l&&r<=to){
tr[x].sum+=(r-l+1)*v;//区间长度*更新值
tr[x].tag+=v;
return ;
}
pushdown(x,l,r);//更新懒标记与子节点的值
if(from<=mid)update(2*x,l,mid,from,to,v);
if(to>mid)update(2*x+1,mid+1,r,from,to,v);
pushup(x);
}
注意有区间修改操作时区间查询向下递归前也需要调用 pushdown
函数下传懒标记。
例
1 :P3372 【模板】线段树 1给定一个长度为
n 的数列,q 次操作,操作为区间加或查询区间和。
解析:直接把上面的代码拼到一起即可。
参考代码。
例
2 :P1816 忠诚给定一个长度为
n 的数列,q 次询问,查询区间最小值。
解析:线段树每一个区间维护区间最小值,由于没有修改操作,不需要写懒标记和 update
。
参考代码。
例
3 :P3870 [TJOI2009] 开关给定一个长度为
n 的0-1 数列,q 次操作,每次操作为区间取反或区间求和。数列一开始都为0 。
解析:每次修改更新时只需要更新为区间长度减去原线段树值即可。由于初始序列全为
例
4 :P3373 【模板】线段树 2给定一个长度为
n 的数列,三种操作:区间乘、区间加、区间求和。
解析:
本题主要涉及两种标记:乘法懒标记和加法懒标记。
这样本题涉及了三种更新:线段树维护的区间和对区间和的更新、标记对线段树维护值的更新和标记对标记的更新。
首先考虑节点对节点的更新:很简单,就是 P3372 中 pushup
的更新。
然后考虑标记对标记的更新:如果打上一个区间加标记,那么该标记对区间乘标记没有影响;反之区间乘标记会使原来的区间加标记乘上这次更新的乘数。
最后是标记对节点的更新:先乘上乘标记,再加上加标记。顺序不能调换,否则会使加标记乘上不该乘的乘标记。
代码好久之前写的,很丑。
不对维护序列本身建线段树,而是对维护序列的值域(即
有时值域可能过大,此时需要先将数据离散化。
光描述可能有些抽象,让我们看几道例题。
例
5 :P1908 逆序对给定一个长度为
n 的数列,求数列中逆序对的数量。
解析:
很多人都写过本题的归并排序写法,但是树状数组/线段树写法的泛用性更强一些。
考虑先将原序列离散化。注意到
接下来建一棵值域为
在修改操作完成之后,查询
拿归并排序写的,就不放代码了。
例
6 :P10814 【模板】离线二维数点给定一个长度为
n 的数列a ,q 次询问,每次询问给定三个数l,r,x ,求区间[l,r] 中不大于x 的元素的数量。注:本题轻微卡常,建议优化读入。
解析:
本题不需要离散化,还算良心。
询问区间
这样就拆成了
对
这样就可以
略微抽象的代码。
由于二叉树的性质,如果把二分的过程放到线段树上实现,二分的
例
7 :P1168 中位数给定一个长度为
n 的数列a ,对于所有n 以内的奇数求区间[1,i] 的中位数。解析:
首先考虑将
那么如何查询呢?由于权值线段树中每个节点的右节点所维护的
考虑线段树每个节点维护区间和。假设我们现在需要查询线段树维护区间
拿对顶堆写的,不放代码了。
容易发现朴素的线段树有时会浪费掉空间,所以可以不让二叉树的两个儿子编号为
假设当前节点左儿子编号为
建树也同理。
例
8 :P3834 【模板】可持久化线段树 2给定
n 个整数组成的序列a ,q 次询问,每次询问对于指定的闭区间[l,r] 查询其区间内的第k 小值。
为了解决这个问题,我们引入可持久化值域线段树。
可持久化线段树又名主席树,与普通线段树的区别在于可以维护历史区间信息,也就是可以维护某一次修改前的历史版本信息。缺点在于空间复杂度为
由于普通的线段树每次更新时会覆盖原来节点维护的信息,所以考虑保留原来的节点。
为了维护新的更新值,可以新开一个节点来维护信息。
下面以例
首先离散化。假设离散化后的数组仍叫做
考虑比较暴力的做法,建
容易发现由于每个
我们刚刚介绍到了动态开点线段树,这个新建节点的操作正好可以用动态开点线段树来实现。
考虑代码实现。
令
首先建一棵树编号为
建空树的代码如下:
int build(int l,int r){
int rt=++tot;//rt 代表这个节点的节点编号,tot 代表节点数量
if(l==r)return rt;
tr[rt].ls=build(l,mid);//ls 代表左儿子节点编号,rs 代表右儿子节点编号
tr[rt].rs=build(mid+1,r);
return rt;
}
rt[0]=build(1,n);
考虑更新。正常的动态开点线段树写法。
#define mid (l+r)/2
int update(int k,int l,int r,int rt){
//k 代表插入的值,[l,r] 表示该节点维护的区间
int dir=++tot;//dir 代表新建的节点,rt 表示上一棵线段树与 dir 维护区间对应的节点编号
tr[dir].ls=tr[rt].ls;
tr[dir].rs=tr[rt].rs;
tr[dir].sum=tr[rt].sum+1;//加上 k 这个数
if(l==r)return dir;
//更新子节点
if(k<=mid)tr[dir].ls=update(k,l,mid,tr[dir].ls);
else tr[dir].rs=update(k,mid+1,r,tr[dir].rs);
return dir;
}
rt[i]=update(a[i],1,len,tr[i-1].rt);//len 为离散化之后的值域
线段树上二分,查询左子树中
int query(int u,int v,int l,int r,int k){
//第 v 棵树减去第 u 棵树对应节点,查询区间 [l,r] 内第 k 小
int x=tr[tr[v].ls].sum-tr[tr[u].ls].sum;
//查询左儿子维护区间中的 ai 个数
if(l==r)return l;
if(k<=x)return query(tr[u].ls,tr[v].ls,l,mid,k);
else return query(tr[u].rs,tr[v].rs,mid+1,r,k-x);
}
query(rt[l-1],rt[r],1,len,k);
完整代码。
例
9 :P3919 【模板】可持久化线段树 2给定
n 个整数组成的序列a ,q 次操作:
- 在某个历史版本上修改某一个位置上的值
- 访问某个历史版本上的某一位置的值 编号为
x 的历史版本意思是执行完毕第x 次操作的序列。版本0 是指初始数组。解析:板子题,基本上代码和上面的思路一样。版本就相当于建的第几棵树。
AC 代码。
例
10 :P1383 高级打字机给定一个小写字母构成的字符串,
n 次操作:
- 在末尾插入小写字母
x 。- 查询第
x 个字母输出。- 撤销最后
x 次插入或撤销操作。
解析:
撤销修改操作就相当于这棵树与第
主席树的叶节点维护节点对应字母权值,非叶结点除了左右儿子外什么都不需要维护。
Code.
有时在动态规划的状态转移方程中,涉及到查询区间求和或区间最值或区间平方和等等操作,这时就可以利用数据结构进行优化。
接下来看几道例题。
例
11 :AT_chokudai_S001_h LIS给定一个长为
n 的序列a ,求这个序列的最长单调上升子序列(LIS)长度。
解析:
本题可以用二分来解决,但是此处讲解数据结构做法。
设
考虑建值域线段树,对于第
答案即为
例
12 :AT_dp_q Flowers有一排花,一共
n 盆,第i 盆花的高度为h_i ,权值为a_i ,保证h_i 互不相同。现在拿走一些花,使剩下的花高度单调递增,问剩下的花权值之和最大是多少。
解析:
设
发现满足条件的
代码。