线段树
线段树算是一种较为常用的数据结构,它在落谷中的定位是树形结构:
比如说
有趣
线段树的使用
线段树既然是一个算法(←废话),那么它一定有自身的用处。
经研究发现,计算最值的最佳算法是RMQ,O(1)的查询和O(nlogn)的预处理几乎是这类问题的时间复杂度缩短到最低,但是一旦进行了单点修改之后,就要重新进行一次处理。
而计算区间和的最优解是前缀和算法,O(1)的查询和O(n)的预处理为此提供了绝妙的先决条件。但如上而言,一旦进行单点修改,依然要重新预处理。对此,我们不得不引用线段树来解决这类问题。
结构预览
形似如此,单点修改的影响降至O(logn),每次查询为O(logn),建树时间也只在n的数量级上。
线段树的初级操作
建树
线段树的构建时间复杂度为O(n),空间占用为2n,(我也不知道网上为什么那么多人用4n的数组),常规运用递归实现。代码如下:
//建树
void bulid(int k,int l,int r)
{
if(l == r)
{
ans[k] = maxx[k] = minn[k] = a[l];
return;
}
int mid = (l + r) >> 1;
bulid(k << 1, l, mid);
bulid((k << 1)|1, mid+1, r);
ans[k] = ans[k << 1] + ans[(k << 1)|1];
maxx[k] = max(maxx[k << 1], maxx[(k << 1)|1]);
minn[k] = min(minn[k << 1], minn[(k << 1)|1]);//预处理区间和和区间最值
}
查询
经常性使用的查询只用两种:区间最值以及区间和,这里也只对这两种进行一个讲解,有其他需求在下方留言,尽量添加。代码如下:
//区间最值查询
int RMQ(int k,int l,int r,int x,int y) { //此为最大值查询,最小值查询同理,稍加修改即可
if (l >= x&&r <= y) return maxx[k];
int mid = (l + r) >> 1,res=0;
if (mid >= x)
res = max(res, RMQ(k << 1,l,mid,x,y));
if (mid < y)
res = max(res, RMQ((k << 1)|1,mid+1,r,x,y));
return res;
}
//区间和查询
int RSQ(int k,int l,int r,int x,int y) {
if (l >= x&&r <= y) return ans[k];
if (l < y || r > x) return 0;
int mid = (l + r) >> 1;
return RSQ(k << 1,l,mid,x,y)+RSQ((k << 1)|1,mid+1,r,x,y);
}
因为在线段树中,一个区间被拆分成两个区间,这个区间的信息就是这两个自区间的信息总和,对这两个子区间的信息进行O(1)的处理就能得到父区间的信息,所以递归是最有做法。
单点修改
单点修改是在线段树中比较不常见的操作,因为它可以用区间修改来代替,但我们在这里还是稍微提一下比较好。代码如下:
//单点修改
void PC(int k,int l,int r,int p,int v) {
if (l == r&&l == p) {
a[l] = v;ans[k] = v;maxx[k] = v;minn[k] = v;
return;
}int mid = (l + r) >> 1;
if (mid >= p) PC(k << 1,l,mid,p,v);
else PC((k << 1)|1,mid+1,p,v);
ans[k] = ans[k << 1] + ans[(k << 1)|1];
maxx[k] = max(maxx[k << 1], maxx[(k << 1)|1]);
minn[k] = min(minn[k << 1], minn[(k << 1)|1]);//再处理区间和和区间最值
}
易知该函数的时间为O(logn)
新的问题
区间更新
区间更新的是线段树的核心。更新区间即更新最底层的叶子结点,而上层的结点与叶子结点的值息息相关,牵一发而动全身。如果我们还是按建立线段树的方法,先向下递归,递归到叶子节点时更新值,再回溯上来整合信息,这种更新会很慢,而且某些结点的更新,可能对于我们暂时还用不上。那么这就浪费了极大的时间。
怎么处理这个问题呢?
问题处理
对于这个问题,常见的做法是标记化修改。给每个节点都引入一个tag延时标记来储存已经存在却暂时无用的修改。当我们遍历到一个延迟标记不为0的结点时,若要递归遍历其子节点,那么就在递归前进行一次延迟标记向下传递操作,更新其子节点的值和延迟标记,并清空父节点的延迟标记。这样,我们就可以加快区间更新的速度。很明显,它可以节省较多的时间。
代码实现
粗糙解法
//加法标记下传
void push(int k,int l,int r) {
int mid = (l + r) >> 1;
plus[k << 1] += plus[k];
plus[(k << 1)|1] += plus[k]; //延迟标记下传
ans[k << 1] += plus[k] * (mid-l+1);
ans[(k << 1)|1] += plus[k] * (r-mid); //更新子区间和
plus[k] = 0; //清零标记
}
//乘法标记下传
void down(int k,int l,int r) {
int mid = (l + r) >> 1;
ride[k << 1] += ride[k]; //假装ride是乘法的乘
ride[(k << 1)|1] += ride[k];
ans[k << 1] *= ride[k];
ans[(k << 1)|1] *= ride[k];
ride[k]=0;
}
//区间更新
//加法
void add(int k,int l,int r,int x,int y,int v) {
if (l >= x&&r <= y) {
ans[k] += v * (y-x+1);
plus[k] += v;
return;
}
push(k,l,r);
int mid = (l + r) >> 1;
if (l <= mid)
add(k << 1,l,mid,x,y,v);
if(r > mid)
add((k << 1)|1,mid+1,r,x,y,v);
ans[k]=ans[k << 1]+ans[(k << 1)|1];
}
//乘法
void mult(int k,int l,int r,int x,int y,int v) {
if (l >= x&&r <= y) {
ans[k] *= v;
ride[k] += v;
return;
}
down(k,l,r);
int mid = (l + r) >> 1;
if (l <= mid)
mult(k << 1,l,mid,x,y,v);
if(r > mid)
mult((k << 1)|1,mid+1,r,x,y,v);
ans[k]=ans[k << 1]+ans[(k << 1)|1];
}
优质解法
void Add(int k,int l,int r,int v) //
{
add[k] += v;
sum[k] += (r-l+1)*v;
return;
}
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;
}
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];
}
上述只是加法的做法,当然,乘法只要在这个基础上稍作修改并且添加一句乘(加)法标记下传就可以,在此处也不在过多进行介绍,区间查询之前也有提到过,不多作解释。
ps:另外,解法的优劣全凭个人喜好而定,此处的分类只是个人看法,仅供参考。
标记永久化
对于区间修改,另外一种方法就是不下传标记,改为在询问过程中计算各节点对当前询问的影响。(然而对于这种方法,博主至今不知道如何处理好加与乘的关系)为了保证询问的复杂度,子节点的影响需要在修改操作时就计算好。因此实际上,sum的值表示这个区间内所有数共同加上的值,除了add之外。需要注意的是区间的add内可能有一部分在祖先上,这需要在递归时累加。
这种标记永久化的写法在树套树以及可持久化数据结构中较为方便。
code:
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;
}
非递归实现
非递归的思路何其精妙,此处稍作提及,各位有兴趣可以去寻找清华大学张琨玮《统计的力量》。
非递归实现有什么优点呢?它的代码简单,特别是点修改和区间查询,速度快,建树简单,遍历简单。总之尽量使用非递归吧。
但如果题目要求支持区间修改,那么代码将会变得比较复杂,所以需要作一个取舍;不过如果是在所有修改结束后一次性推下所有标记,那么还是有可行性的。
那么不多说,先甩一波代码。
code:
ps:这里只囊括了几个简单操作。
建树
void build(int n){ //n为原数组长度,目的计算扩增序列长度
N=1;while(N < n+2) N <<= 1;
for (int i=1; i <= n; i++) sum[N+i]=a[i];
for (int i=N-1; i > 0; --i) {
sum[i]=sum[i<<1]+sum[(i<<1)|1];
add[i]=0;
}
}
单点修改
void updata(int p,int v) {
for (int i=N+p; i; i >>= 1)
sum[i] += v;
}
区间修改
void update(int l,int r,int v){
int s,t,ln=0,rn=0,x=1;
for(s=N+l-1,t=N+r+1; s^t^1; s >>= 1,t >>= 1,x <<= 1){
sum[s] += v*ln;
sum[t] += C*rn;
if(~s&1) add[s^1] += v,sum[s^1] += v*x,ln+=x;
if( t&1) add[t^1] += v,sum[t^1] += v*x,rn+=x;
}
for(; s; s >>= 1,t >>= 1){
sum[s] += v*ln;
sum[t] += v*rn;
}
}
区间查询
int query(int l,int r){
int s,t,ln=0,rn=0,x=1;
int ans=0;
for(s=N+l-1,t=N+r+1; s^t^1; s >>= 1,t >>= 1,x <<= 1){
if(add[s]) ans += add[s]*ln;
if(add[t]) ans += add[t]*rn;
if(~s&1) ans += sum[s^1],ln += x;
if( t&1) ans += sum[t^1],rn += x;
}
for(; s; s >>= 1,t >>= 1){
ans += add[s]*ln;
ans += add[t]*rn;
}
return ans;
}
单点修改下的区间查询
int query(int L,int R){
int ans=0;
for(int s=N+l-1,t=N+r+1; s^t^1; s >>= 1,t >>= 1){
if(~s&1) ans += sum[s^1];
if( t&1) ans += sum[t^1];
}
return ans;
}
结尾
那么,关于线段树的个人理解也就只有这么点了,以后应该会对扫描线和主席树作一个讲解,谢谢观看。