树状数组
树状数组的基本用途是维护序列的前缀和,相比前缀和数组,树状数组优势在于高效率的单点修改,单点增加(前缀和数组单点修改效率比较低)
因为树状数组的思想,原理还是很好理解的,就直接讲基本算法;
1 lowbit函数
关于lowbit这个函数,可能会有点难以理解,但其实你不理解也没关系,把模板背下来就好
根据任意正整数关于2的不重复次幂的唯一分解性质,例如十进制21用二进制表示为10101,其中等于1的位是第0,2,4(最右端是第0位)位,即21被二进制分解成\(2^4+2^2+2^0\);
进一步地,整个区间[1,21]可以分成如下3个小区间:
长度为\(2^4\)的小区间[1,\(2^4\)];
长度为\(2^2\)的小区间[\(2^4+1\),\(2^4+2^2\)];
长度为\(2^0\)的小区间[\(2^4+2^2+1\),\(2^4+2^2+2^0\)];
对于给定的初始序列A,我们可以建立一个数组c,c[x]表示序列A的区间[x-lowbit(x)+1,x)]中所有数的和;
int lowbit(int x){return x&-x;}
2 单点增加操作
void update(int x,int y){
for(;x<=n;x+=lowbit(x))
c[x]+=y;
}
3 查询前缀和
int sum(int x){
int ans=0;
for(;x;x-=lowbit(x)) ans+=c[x];
return ans;
}
4 扩展
上述查询前缀和是统计[1,x]的前缀和,若要统计区间[x,y]的和,则调用sum函数即可:sum(y)-sum(x-1);
多维树状数组:
(扩充为m维)将原来的修改和查询函数中的一个循环,改成m个循环m维数组c中的操作;
以\(n*m\)的二维数组为例:
将(x,y)的值加上z,不是把区间[x,y]中的每个值加z
int update(int x,int y,int z){
int i=x;
while(i<=n){
int j=y;
while(j<=m){
c[i][j]+=z;
j+=lowbit(j);
}
i+=lowbit(i);
}
}
int sum(int x,int y){
int res=0,i=x;
while(i>0){
int j=y;
while(j>0){
res+=c[i][j];
j-=lowbit(j);
}
i-=lowbit(i);
}
return res;
}
注意树状数组的下标绝对不能为0,因为lowbit(0)=0,这样会陷入死循环
两道模板题,多打打模板~~
https://www.luogu.org/problemnew/show/P3374
https://www.luogu.org/problemnew/show/P3368
线段树
线段树是一种基于分治思想的二叉树结构,比树状数组更加通用,下文总结会比较这两种数据结构;
线段树的基本用途是对序列进行维护,支持查询与修改指令;
线段树的每个节点都代表一个区间;
线段树具有唯一的根节点,代表的是整个区间,即[1,n];
线段树的每个叶节点都代表一个长度为1的区间,即[x,x];
对于每个内部节点[l,r],它的左子节点是[l,mid],右子节点是[mid+1,r],其中mid=(l+r)/2(向下取整);(子节点与父节点的性质近似于二叉堆的结构);
**保存的数组长度要不小于4*n;**
1 建树:线段树的二叉树结构可以很方便地从下往上传递信息;
下面以区间最大值为例:
struct TREE{
int l,r;
int dat;
}t[n*4];
//一般会用结构体存储线段树
void build(int p,int l,int r){
t[p].l=l;t[p].r=r;//节点p代表区间[l,r]
if(l==r){t[p].dat=a[l];return;}//叶节点
int mid=(l+r)/2;
build(p*2,l,mid);//左子节点
build(p*2+1,mid+1,r);//右子节点
t[p].dat=max(t[p*2].dat,t[p*2+1].dat);
//从下往上传递信息
}
build(1,1,n);//调用入口
2 单点修改:线段树中,根节点(编号为1的节点)是执行各种指令的入口;上述建树过程中的调用入口的第一个1就是这个道理;
我们还是以区间最大值问题为例
void change(int p,int x,int v){
if(t[p].l==t[p].r){t[p].dat=v;return;}
//找到叶节点
int mid=(t[p].l+t[p].r)/2;
if(x<=mid) change(p*2,x,v);
else change(p*2+1,x,v);
//判断x属于哪边区间
t[p].dat=max(t[p*2].dat,t[p*2+1].dat);
}
change(1,x,v);
3 区间查询
以查询区间最大值为例:
若[l,r]完全覆盖了当前节点代表的区间,则立即回溯,并且该节点的dat值为候选答案
若左子节点与[l,r]有重叠部分,则递归访问左子节点;
若右子节点与[l,r]有重叠部分,则递归访问右子节点;
(这里自己稍微理解一下就懂了,或者自己画个图)
假设我们查询区间[2,7]的最大值,现在有一个节点的代表区间为[3,5],那么属于完全覆盖的情况,return,并且该节点的dat值为候选答案;
现在还有[2,3]和[5,7]这两个区间没有处理,我们继续寻找节点,如果有一个节点与该区间有交集,即有重叠的区间,我们就继续向下访问该节点,直至找到一个节点完全被[2,3]或[5,7]覆盖,那么跟上面一样处理即可;(记住我们是从上往下访问线段树,所以区间范围从上往下是越来越小的)
int ask(int p,int l,int r){
if(l<=t[p].l&&r>=t[p].r)return t[p].dat;
int mid=(t[p].l+t[p].r)/2;
int val=-(1<<30);
if(l<=mid) val=max(val,ask(p*2,l,r));
if(r>mid) val=max(val,ask(p*2+1,l,r));
return val;
}
ask(1,l,r);
延迟标记:标识该节点曾经被修改,但其子节点尚未被修改(即一个节点被打上延迟标记的同时,它自身保存的信息应该已经被修改完毕)
通俗地讲,我们在进行区间增加操作时,如果去更改区间中的每个数(即遍历到每个叶节点),时间复杂度会增加到O(N);
试想一下,有可能我们修改了该区间,但所有的查询中与该区间没有关系(即该区间没有对我们的答案产生贡献),相当于我们做了一次无用的操作;
于是就可以用到延迟标记,我们在执行修改指令时,给节点p一个标记,标识该节点曾经被修改,但其子节点尚未被修改,如果在后续的查询指令中,我们需要知道p节点的子节点的信息,如果p节点有标记,那么更新p的两个子节点,同时给p的两个子节点打上延迟标记,然后清除p的标记;
以区间增加问题为例:
void spread(int p){
if(t[p].add){//如果节点P有标记
t[p*2].sum+=t[p].add*(t[p*2].r-t[p*2].l+1);
//更新左子节点信息
t[p*2+1].sum+=t[p].add*(t[p*2+1].r-t[p*2+1].l+1);
t[p*2].add+=t[p].add;//给左子节点打延迟标记
t[p*2+1].add+=t[p].add;
t[p].add=0;//清除p的标记
}
}
void change(int p,int x,int y,int v){
if(x<=t[p].l&&y>=t[p].r){//完全覆盖
t[p].sum+=v*(t[p].r-t[p].l+1);
//更新节点信息
t[p].add+=v;//给节点打上延迟标记
return;
}
spread(p); //下传延迟标记
int mid=(t[p].l+t[p].r)/2;
if(x<=mid) change(p*2,x,y,v);
if(y>mid) change(p*2+1,x,y,v);
t[p].sum=t[p*2].sum+t[p*2+1].sum;
}
long long ask(int p,int l,int r){
if(l<=t[p].l&&r>=t[p].r)
return t[p].sum;
spread(p);//下传延迟标记
int mid=(t[p].l+t[p].r)/2;
long long ans=0;
if(l<=mid) ans+=ask(p*2,l,r);
if(r>mid) ans+=ask(p*2+1,l,r);
return ans;
}
最后比较一下树状数组和线段树:
树状数组可以实现单点修改和区间求和(如果将序列进行差分,那么还可以实现区间价值和单点询问);
线段树能实现树状数组的所有操作,除此之外,还可以通过标记下传或标记永久化进行区间修改和区间询问;
但树状数组的常数比线段树小,实现也较为简单(线段树真的是随随便便100行,看来我还是太蒻了)
总之,能用树状数组就用树状数组吧,毕竟一般都只能用线段树;
原文地址:https://www.cnblogs.com/PPXppx/p/9898600.html