树状数组
欲完成修改值和查询区间和两种操作
给出一个长度为n的数组,完成以下两种操作:
- 将第 i i i个数加上 k k k
- 查询区间 [ i , j ] [i,j] [i,j]内每个数的和
求前缀和的做法时间复杂度为 O ( n ) O(n) O(n)
- 单点修改: O ( 1 ) O(1) O(1)
- 区间和查询: O ( n ) O(n) O(n)
两种操作一起的时间复杂度取决于时间复杂度大的那个
那么对于
m
m
m次查询,长度为
n
n
n的数组,时间复杂度就是
m
.
n
m.n
m.n,只要数据范围超过
1
e
6
1e6
1e6,绝对超时
查询区间和以前的做法要么就是查询很慢,修改很快,那怎么办呢,那就存储前缀和来提高查询速度,但这样一来修改了之后要更新这些前缀和,更新又很慢;
树状数组就完美地综合了这两种做法,存储后缀和,更新后缀和,通过 l o w b i t lowbit lowbit来限定后缀和的长度,利用二进制使得查询、更新的时间复杂度都在 O ( l o g n ) O(logn) O(logn)。
使用树状数组时间复杂度降为 O ( l o g n ) O(logn) O(logn)
- 单点修改: O ( l o g n ) O(logn) O(logn)
- 区间查询:
O
(
l
o
g
n
)
O(logn)
O(logn)
两种操作的时间复杂度持平了,快的没那么快,慢的没那么慢
lowbit
lowbit()函数用来取一个二进制最低位的一与后边的0组成的数
例:
5(101),lowbit(5)=1(1)
12(1100),lowbit(12)=4(100)
1、x&(-x)
int lowbit(int x)
{
return x&(-x);
}
原理,二进制数的负数是原码对应的补码,即各位取反加一
12(1100),-12(0100)
(正数补码反码都和原码一样啦)
曾记否,负数一般用补码表示从而进行运算,补码的朴素求法就是对各位取反再加1,但在计算机组成原理中介绍了一种更高明的求负数补码做法:以最低位的1为基准(该位保持不变),左侧全部取反,右侧的0保持不变
这样一来,x&(-x), 最低位的1左侧变为全0,右侧的全0继续保持,从而 保留二进制下最低位出现的1的位置,其余位置置0
2、x- ( x&(x-1) )
先消掉最后一位1,然后再用原数减去消掉最后一位1后的数,答案就是lowbit(x)的结果
曾记否,x&(x-1)用于消去最低位的1,因为
x
−
1
x-1
x−1, 根据小学数学减法运算的借位原则(滑稽),对一个二进制数进行减1,那么会出现从这个这个数的最
后一个1开始到最后的所有数都取反,即构成一个01111⋯的串, x-1相对于x,最低位的1左边不变,最低位的1变为0,最低位1的右边由全0变为全1,则x&(x-1)就可以消去最低位的0
可用于求一个二进制数中各位上1的总个数
int x;
cin>>x;
int cnt=0;
while(x){
x=x&(x-1);
cnt++;
}
cout<<cnt;
3、x&(x^(x-1))
记前两种就好啦
任何数x和1相与得到非x
任何数x和0相与得到x本身
lowbit累计1出现的次数
int x;
cin>>x;
int cnt=0;
while(x){
x-=(x&-x);
cnt++;
}
cout<<cnt;
我们可以使用lowbit运算统计一个整数的二进制形式下1的个数。
实现原理很简单啦,就是:我们先用lowbit运算找出lowbit(x),然后用原数减去这个数,依次循环,直到为0为止。
树状数组思想
求前缀和操作
类比在求前缀和数组中求前缀和(有n个前缀和)的做法,树状数组中求的是区间和(求近似
l
o
g
n
log n
logn个区间),那么我们怎么将长度为
x
x
x的区间划分这近似
l
o
g
n
logn
logn个子区间呢?
我们需要从
x
x
x的二进制表示寻找灵感,假设x的二进制表示有y位,那么
2
y
2^{y}
2y>x,即
y
<
l
o
g
x
y<log x
y<logx,也就是说x的二进制表示位数恰好近似
l
o
g
x
log x
logx,x的二进制表示中
1
1
1的位数不超过
l
o
g
x
log x
logx,我们可以从右往左构想划分的这近似
l
o
g
n
log n
logn个区间。
首先找到x的二进制表示最低位的
1
1
1,若拿掉这个
1
1
1,那么x的值就会相应地减小这个
1
1
1对应的数量级大小
比如 14对应二进制表示 1110,最低位的1对应的数量级是2,拿掉1后对应的数是12=14 - 2= ( 1110 − 10 ) 2 (1110-10)_2 (1110−10)2= ( 1100 ) 2 (1100)_2 (1100)2
按照这种做法,依次找到x最低位的1,并把该1拿去,这位1对应的数量级大小对应的是从后往前划分子区间的长度
像y总表示的那样,每个子区间(从右往左划分的第
a
+
1
a+1
a+1个子区间)可表示位
(
x
−
2
i
1
−
2
i
2
−
2
i
3
…
…
−
2
i
a
−
2
i
a
+
1
(x-2^{i_1}-2^{i_2}-2^{i_3}……-2^{i_a}-2^{i_{a+1}}
(x−2i1−2i2−2i3……−2ia−2ia+1 ~
x
−
2
i
1
−
2
i
2
−
2
i
3
…
…
−
2
i
a
]
x-2^{i_1}-2^{i_2}-2^{i_3}……-2^{i_a}]
x−2i1−2i2−2i3……−2ia]
左开右闭,左界限代表拿掉了
x
x
x的第
a
+
1
a+1
a+1个1之后的大小
每个子区间
(
L
(L
(L~
R
]
R]
R]的长度一定是R的二进制表示最后一位1对应的幂次。
上图就非常形象地展示了各个子区间的划分情况
上方y总举的例子是针对于R为 2的k次幂的情况,
子区间元素之和
t
r
e
e
[
R
]
=
t
r
e
e
[
R
−
l
o
w
b
i
t
(
R
)
+
1
,
R
]
=
a
x
+
t
r
e
e
[
R
−
1
]
+
t
r
e
e
[
R
−
1
−
l
o
w
b
i
t
(
R
−
1
)
]
+
t
r
e
e
[
R
−
1
−
l
o
w
b
i
t
(
R
−
1
)
−
l
o
w
b
i
t
(
R
−
1
−
l
o
w
b
i
t
(
R
−
1
)
)
+
…
…
]
tree[R]=tree[R-lowbit(R)+1,R]=a_x+tree[R-1]+tree[R-1-lowbit(R-1)]+tree[R-1-lowbit(R-1)-lowbit( R-1-lowbit(R-1) )+……]
tree[R]=tree[R−lowbit(R)+1,R]=ax+tree[R−1]+tree[R−1−lowbit(R−1)]+tree[R−1−lowbit(R−1)−lowbit(R−1−lowbit(R−1))+……]
首先进行
x
−
1
x-1
x−1操作就可以最低位1变为0,其右边的所有0全部变为1,这所有通过
x
−
1
x-1
x−1操作变为0的1中,每一个1的数量级都对应着一个子区间的长度。
当然了,对于R为奇数的情况,要求R的前缀和 也是一样的操作,只是拿掉的第一个1就是最末尾的1,对应的区间段就是1
如下图
其实我不太明白为什么要"看似多余"地先进行
x
−
1
x-1
x−1(当然了这种说法完全正确),从图上就可以看出来,要求R的前缀和,不管x是奇数还是偶数,都是依次找到最低位1对应的从右往左的子区间,并拿掉这个1,把找到的所有1对应的子区间的tree值累加。
举两个栗子
譬如R=16, R的前缀和,直接就是一个tree[16]这一个区间,
(
10000
)
2
(10000)_2
(10000)2拿掉一个1就变为全0找不到1了,所以求16的前缀和只用
t
r
e
e
[
16
]
tree[16]
tree[16]这一个区间代表就好了
再譬如第二张图中,
R
=
7
,
R
R=7,R
R=7,R的前缀和就是
t
r
e
e
[
7
]
、
t
r
e
e
[
6
]
、
t
r
e
e
[
4
]
tree[7]、tree[6]、tree[4]
tree[7]、tree[6]、tree[4]这3个子区间的和。
按照模板代码也是这个说法,就是寻找低位1对应的子区间的和进行累加,至于这些子区间对应的tree值,在确定a数组的元素时,就可以通过update函数进行更新了
值得注意是,tree【i】的值代表的可不是数组a【i】的前缀和,而是以a【i】为区间右端点,长度为lowbit(i)的子区间的元素和。
int sum(int x){
int res=0;
for(int i=x;i;i-=lowbit(i)){
res+=tree[x];
}
return res;
}
修改更新操作
可以理解为一个递归的操作,x、x-lowbit(x) 、
根据插入各元素进而更新子区间和的规律可知,加入a[i],只会对tree【j】造成更新(j>=i),
更新元素a【i】的值之后,找到所有包含a【i】的子区间和
每修改一个元素后,它所直接影响到的子区间是唯一的,首先是更新a【i】这个节点直接的父区间,再到父区间的父区间……
非常巧妙地发现,
一个子区间和它的所有子区间,它们的右边界的关系无非就是父区间的最低位1的数量级大于子区间最低位1的数量级,只要将子区间右边界加上lowbit(子区间右边界),就能得到其直接父区间的右边界,p=R+lowbit(R)
看图中例子。
每更新一个父区间,a【i】末尾的0会增加1个,由于a【i】最多只有
l
o
g
a
[
i
]
loga[i]
loga[i]位,它所影响到的区间最多是
l
o
g
a
[
i
]
log a[i]
loga[i]个。因此修改更新操作的时间复杂度也是
l
o
g
a
[
i
]
log a[i]
loga[i](这里说的a【i】就是在求前缀和步骤中,某个子区间的右边界R或者说是y总讲解中的右边界x)
void update(int x,int c){
//x既代表元素数组下标,也是所属子区间的右边界
//n是数组长度,整个区间的右边界
for(int i=x;i<=n;i+=lowbit(i)){
tree[i]+=c;
}
}
注意点
搞清楚树状数组利用的下标所代表的含义、变量、范围(要对哪个下标确定的前缀和范围进行查询),在update函数中需要用到下标最大值,如果不能明确找到,就用题目给的数据范围
树状数组下标必须从1开始,否则在update函数中陷入死循环,对于题目给的下标从0开始时,必须把所有下标加1
练兵场
【模板】树状数组 1——单点更新,区间查询
#include<iostream>
#include<algorithm>
using namespace std;
const int N=5e5+10;
//int a[N];
int tree[N];
int n,m;
int lowbit(int x){
return x&(-x);
}
void update(int x,int c){
for(int i=x;i<=n;i+=lowbit(i)){
tree[i]+=c;
}
}
//请记住,这些进行lowbit操作、和遍历的变量表示的都是
//区间下标(区间右边界)
int getSum(int x){//获得1~x这个区间段的和,x的前缀和
int res=0;
for(int i=x;i;i-=lowbit(i)){
res+=tree[i];
}
return res;
}
int main(){
cin>>n>>m;
int v;
for(int i=1;i<=n;i++){
cin>>v;
update(i,v);
}
//注意了,为什么update比add要更贴切
//对于长度位n的数组a来讲,一开始有着初始值但只消获取其子区间和tree[i]
//至于后来对数组a中的某个元素a[i]进行修改,也可以直接更新子区间和
//可以当作,传入a数组中n个元素的值时就是进行了n次对子区间的更新操作
//一开始就当作a数组元素全0
int op,x,k,y;
while(m--){
cin>>op;
if(op==1){
cin>>x>>k;
update(x,k);//x是区间下标,是a[x]所属子区间的右边界
}
else{
cin>>x>>y;
cout<<getSum(y)-getSum(x-1);
//x~y这段子区间的和 ,getSum求的是前缀子区间的和
if(m)cout<<endl;
}
}
return 0;
}
241. 楼兰图腾(y轴上区间段)
题意:在二维坐标系中给出n个点的坐标,它们的横坐标分别为1~n,纵坐标输入给定。求横坐标相邻的三个点组成’V’和’A’的情况总和
思路:集合思想,针对每一个点(x,y),求出它左边纵坐标大于y的点的总个数 和 它右边纵坐标大于y的点的总个数,相乘就是组成’V’的个数。同理,针对每一个点(x,y),求出它左边纵坐标小于y的点的总个数 和 它右边纵坐标小于y的点的总个数,相乘就是组成’A’的个数。累加各个顶点组合情况就ok
一句话实现思路:y轴上的区间段 利用树状数组 求前缀和、修改元素值
从时间复杂度稍微简化点考虑对于每个顶点,都要掌握它左边比它高的点
如果暴力时间复杂度是O(n^2),肯定TLE
由于只有区间段求和和更新两个操作,想到树状数组
掌握顶点(x,y)左边比它高的点的个数之和,所以是更高的顶点数之和
考虑区间和的含义,这里应该考虑y轴上的区间段,以y轴正方向为左求前缀和
这里考虑的是点的个数,每个顶点对前缀和贡献是1
由于考虑的是点(x,y)左边的点,那么对于Greater数组的值,只能是在加入点(x,y)以前得到的,试想一股脑加入所有的点,那么大于y值的点可能
有多个,在点(x,y)以后加入相当于更新了y轴区间段上某个元素的值,那么无法判断是在点(x,y)左边还是右边,而是把高于点(x,y)的所有点数都算进来了
注意因为是y轴上的区间段,树状数组中所有描述区间边界和下标的变量都是点的y值 ,写的过程中千万注意这一点,一不小心惯性思维就会将x轴坐标作为区间段的边界
#include<iostream>
#include<algorithm>
using namespace std;
typedef long long ll;
const int N=2e5+10;
int a[N];
int tree[N];
int n;
int Greater[N];
int Lower[N];
int lowbit(int x){
return x&(-x);
}
void update(int x,int c){
//因为y1~yn是1到n的一个排列,整个y轴上区间的右边界是n
for(int i=x;i<=n;i+=lowbit(i)){
tree[i]+=c;
}
}
int getSum(int x){
int res=0;
for(int i=x;i;i-=lowbit(i)){
res+=tree[i];
}
return res;
}
int main(){
cin>>n;
// int v;
// for(int i=1;i<=n;i++){
// cin>>v;
// update(i,v);
按照树状数组的套路,就是根据输入的n个值一次性将区间和更新n次
// }
for(int i=1;i<=n;i++){
cin>>a[i];
}//为什么一定要保存起来,
// 因为做到后面发现要求后缀数组(从后往前加时对应的前缀和数组)
for(int i=1;i<=n;i++){
// cin>>y;
int y=a[i];
Greater[i]=getSum(n)-getSum(y);//tree数组放的是y轴区间段上的子区间和
//在前面已加入树状数组的所有数中统计在区间[y + 1, n]的数字的出现次数
Lower[i]=getSum(y-1);
update(y,1); //将y加入树状数组,即数字y出现1次
}//注意因为是y轴上的区间段,树状数组中所有描述区间边界和下标的变量都是点的y值
fill(tree,tree+N,0);
ll res1=0;
ll res2=0;
for(int i=n;i>=1;i--){
int y=a[i];
res1+=(ll)Greater[i]*(getSum(n)-getSum(y));
res2+=(ll)Lower[i]*(getSum(y-1));
update(y,1);
}
cout<<res1<<" "<<res2;
return 0;
}
三、Cows(树状数组)
给定每头牛的吃草范围,问对于每头牛,有几头牛的吃草范围完全包括它的吃草范围
搞清楚树状数组利用的下标所代表的含义、变量、范围(要对哪个下标确定的前缀和范围进行查询),在update函数中需要用到下标最大值,如果不能明确找到,就用题目给的数据范围
树状数组下标必须从1开始,否则在update函数中陷入死循环,对于题目给的下标从0开始时,必须把所有下标加1
1、Hint建议用scanf输入时记得添加,ios:sync_with_stdio(false);
cin.tie(0)
不然就可能超时
2、区间完全相等时不能算作更强,因此需要特判
#include<iostream>
#include<algorithm>
using namespace std;
typedef long long ll;
const int N=1e5+5;
struct node{
int l,r;
int id;
// node(int l,int r,int id):l(l),r(r),id(id){}
bool operator<(const node& p)const{
if(r==p.r)return l<p.l;
return r>p.r;
}
//给所有牛排序,更强的牛排在前面,
//等求每头牛res时,才能遍历过所有可能比它强的
//何谓更强,右边界更大,左边界更小
//当然了,也可以更强的排在后面,从后往前遍历就是了
}a[N];
int res[N];//对于每头牛,比它更强的牛的数量
//排好序之后,之前遍历过的牛的右边界一定大于或等于现在牛的右边界
//于是只要考虑现在牛的左边界的左边有多少之前遍历过的牛的左边界
int maxx;
int tree[N];//树状数组
int lowbit(int x){
return x&(-x);
}
void update(int x,int c){
for(int i=x;i<=N;i+=lowbit(i)){
tree[i]+=c;
}
}
int getSum(int x){
int ans=0;
for(int i=x;i;i-=lowbit(i)){
ans+=tree[i];
}
return ans;
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
int n;
while(cin>>n&&n){
fill(tree,tree+N,0);
fill(res,res+N,0);
for(int i=1;i<=n;i++){
cin>>a[i].l>>a[i].r;
a[i].l++;//!!!因为l,r范围从0开始,树状数组下标必须从1开始
a[i].r++;
a[i].id=i;
}
sort(a+1,a+n+1);
// maxx=a[n].l+1;以右边界为第一关键字的,不能确定最大的左边界
// 可以看到我们利用树状数组 update和getSum都是根据 吃草区间的左边界为下标的
// 首先该下标必须从1开始,为0的话在update函数中就会陷入死循环
// 其次要确定该下标的最大范围,左边界的最大值是不能确定的(除非逐一比较)
// 干脆就取maxx为题目所给的最大范围N
for(int i=1;i<=n;i++){
if((a[i].r==a[i-1].r)&&(a[i].l==a[i-1].l))
res[a[i].id]=res[a[i-1].id];
else
res[a[i].id]=getSum(a[i].l);
update(a[i].l,1);
}
for(int i=1;i<=n;i++){
cout<<res[i];
if(i!=n)cout<<" ";
}
cout<<endl;
}
return 0;
}