树状数组学习笔记

一个轻量级的数据结构-树状数组

对于数据结构来说,树状数组的大多数功能,用线段树,甚至平衡树之类的都可以实现;但是由于树状数组十分好写且常数巨小,所以在有些卡常题里面,和时间紧张的时候还是一个非常不错的选择,但是树状数组的功能比较局限,这里稍微讲一下原理和一些功能及其实现。

Tips. 文中所说的 l o g log log均为 l o g 2 log_2 log2


树状数组或者二叉索引树也称作 B i n a r y   I n d e x e d   T r e e Binary\ Indexed\ Tree Binary Indexed Tree,又叫做 F e n w i c k Fenwick Fenwick树( F e n w i c k Fenwick Fenwick树这个名字,我第一次在lrj的蓝书上看见还以为是什么新奇玩意,结果是树状数组-_-||)。

树状数组主要维护的是一个前缀的东西(比如前缀和,前缀异或和,前缀最值等);而对于暴力的维护这个东西,每次是变量所有元素,复杂度最坏达到 O ( n 2 ) O(n^2) O(n2);而树状数组就是在暴力上进行一个优化,将其下标按照二进制来分层维护。

下面就从一个简单的例子,维护一个数列的单点修改和查询区间和,来讲解:

对于一个长度为 9 9 9的数列 [ 5 , 2 , 1 , 8 , 9 , 3 , 4 , 6 , 2 ] [5,2,1,8,9,3,4,6,2] [5,2,1,8,9,3,4,6,2],我们先将其下标从 1 ∼ 9 1\sim 9 19标号,然后构建树状数组。

树状数组开始也是一个线性的数组,如下图:

lz

然后我们可以把一些位置抽出来,使其不再只表示自身的值,而表示前 2 k 2^k 2k个位置的和,但是这个 2 k 2^k 2k如何定呢?总不能乱定吧,所以这里我们使用原数组下标的二进制的最末尾的 1 1 1所代表的值来定义 2 k 2^k 2k,如下图:

lz

然后我们来看,如何快速的来进行修改与查询。

对于修改一个位置的值,我们只需将其变化加到包含它的位置上即可,根据包含关系,我们可以将其建成一棵树,如下图:

lz

但是我们不能真的将树建出来,不仅空间变大,时间常数也会变大,代码复杂度变大,还不如打线段树(不过将树建出来似乎可以对其进行可持久化?XD.)。

我们来看,假如对于第一个位置的数加上 3 3 3,我们就会修改下面的 1 , 2 , 4 , 8 1,2,4,8 1,2,4,8位置上的值,也就是树上的 1 → 8 1\rightarrow 8 18这条路径,而将其下标变成二进制来看,就是 0001 , 0010 , 0100 , 1000 0001,0010,0100,1000 0001,0010,0100,1000,每次对于下标都加上了这个位置的 2 k 2^k 2k的值,所以我们在访问修改的时候不用建出树来,直接用二进制的规律在数组上跳即可,比如再看将第三个位置上的数加上 2 2 2,会修改 3 , 4 , 8 3,4,8 3,4,8这几个节点,再来看下表二进制的变换: 0011 , 0100 , 1000 0011,0100,1000 0011,0100,1000,发现 1000 = 0100 + 0100 ( 2 2 ) , 0100 = 0011 + 0001 ( 2 0 ) 1000=0100+0100(2^2),0100=0011+0001(2^0) 1000=0100+0100(22),0100=0011+0001(20),所以根据这个规律就可以不用建树啦!

修改如下图,橙色为第一个加 3 3 3,绿色为第三个加 2 2 2(两次操作互不影响):

lz

那么对于区间求和,加入求 l ∼ r l\sim r lr的和,因为树状数组维护的前缀和,所以我们可以将其转化为 s u m ( 1 ∼ r ) − s u m ( 1 ∼ l − 1 ) sum(1\sim r)-sum(1\sim l-1) sum(1r)sum(1l1)来计算,同样的对于一个前缀和,假如为 1 ∼ 5 1\sim 5 15的和,我们同样只要将其包含的节点的值加上即可,我们来看前 5 5 5个的和,就是树上 5 5 5号节点的值加上 4 4 4号节点的值,而下标是 0101 , 0100 0101,0100 0101,0100,同样的我们发现 0100 = 0101 − 0001 ( 2 0 ) 0100=0101-0001(2^0) 0100=01010001(20),所以对于一个前缀和,我们加上的是它的最后一个下标,每次减去自己位置的 2 k 2^k 2k的直到为 0 0 0为止的这些位置的和,所以还是不用建树,直接在数组上跳即可。

而对于如何求得一个下标的 2 k 2^k 2k,根据 2 k 2^k 2k在前面的定义,我们可以使用一个函数 l o w b i t ( x ) lowbit(x) lowbit(x),这个函数表示求 x x x数的二进制下最末尾的 1 1 1所代表的值,例如
l o w b i t ( 9 ) = l o w b i t ( 1001 ) = 0001 = 2 0 l o w b i t ( 6 ) = l o w b i t ( 0110 ) = 0010 = 2 1 lowbit(9)=lowbit(1001)=0001=2^0\\ lowbit(6)=lowbit(0110)=0010=2^1 lowbit(9)=lowbit(1001)=0001=20lowbit(6)=lowbit(0110)=0010=21
但是这个函数总不能暴力实现去找吧,如果暴力的话这个函数的复杂度就变成了 l o g n logn logn了,我们需要更快的。

我们来看对于 9 9 9,它的二进制为 00001001 00001001 00001001(假设这里有8位),而对 − 9 -9 9它的二进制为 11110111 11110111 11110111(最高位表示正负,也就是开始有个 − 2 7 -2^7 27,然后在加上剩下的位的正数值, − 2 7 + 2 6 + 2 5 + 2 4 + 2 2 + 2 1 + 2 0 = − 9 -2^7+2^6+2^5+2^4+2^2+2^1+2^0=-9 27+26+25+24+22+21+20=9),我们发现它和 9 9 9与起来(也就是进行and运算)后就为 00000001 00000001 00000001,也就是它的最末尾的 1 1 1,所以我们的 l o w b i t lowbit lowbit函数就可以 O ( 1 ) O(1) O(1)的实现啦!

代码:

int lowbit(a){return a&(-a);}

那么树状数组的基本原理我们就懂了(还不懂的话去从头再看一遍自己在纸上模拟一下就懂了。)

代码实现非常简单了。

int n;//数组长度
int bit_tree[n];
void add(int a,int b){
	for(int i=a;i<=n;i+=lowbit(i)){
		bit_tree[i]+=b;
	}
}
int query_pre_sum(int a){
	int sum=0;
	for(int i=a;i>0;i-=lowbit(i)){
		sum+=bit_tree[i];
	}
	return sum;
}
int query_area(int l,int r){
	return query_pre_sum(r)-query_pre_sum(l-1);
}

复杂度分析:空间显然是 O ( n ) O(n) O(n)的,而每次单点修改,由于一个数字最多加 l o g n logn logn次就会达到上界,所以复杂度为 l o g n logn logn;而区间查询,就是两次前缀查询,而一个数字同样最多减 l o g n logn logn次,那么区间查询的复杂度就是 2 l o g n 2logn 2logn,所以对于长度为 n n n的,操作为 m m m个的,总的复杂度为 ( n + m ) l o g n (n+m)logn (n+m)logn(开始建立树状数组有 n l o g n nlogn nlogn的复杂度)。

其实有个 O ( n ) O(n) O(n)的建树状数组的方式,也就是预先处理一个最开始的前缀和,然后扫一遍,每次用 l o w b i t lowbit lowbit求一个位置是前多少个的和,然后用处理的前缀和算出来即可,复杂度为 O ( n ) O(n) O(n),空间多一个 O ( n ) O(n) O(n)

代码大概如下:

int n;
int val[n],sum[n],bit_tree[n];

void build(){
	sum[0]=0;
	for(int i=1;i<=n;i++){
		sum[i]=sum[i-1]+val[i];
		int len=lowbit(i);
		bit_tree[i]=sum[i]-sum[i-len];
	}
}

注意事项:

  • 如果数组的下标从 0 0 0开始,由于 l o w b i t ( 0 ) = 0 lowbit(0)=0 lowbit(0)=0,所以会陷入死循环,所以我们可以将整个数组向后平移一位,变成下标从 1 1 1开始即可。
  • 循环判断时不要直接打for(int i=a;i;i-=lowbit(i)),最好打成for(int i=a;i>0;i-=lowbit(i)),因为有些时候,下标出错了,变成负数,那么同样会陷入死循环。

树状数组当然不会只有这么一个操作,有了上面的理论基础,我们可以来看看一些更高级的操作了。

一维树状数组

后面当然还有二维的树状数组

单点修改,区间查询

上面原理解释的样例就讲了。

区间修改,单点查询

由于维护的前缀和,所以我们使用差分,就可以一个点的值转换为前缀查询,而区间修改就变成了两个单点修改了。此时,这里用树状数组维护的数组的位置上的值表示的是(假设 v a l [ i ] val[i] val[i]为原来的 i i i的单点值) v a l [ i ] − v a l [ i − 1 ] ( v a l [ 0 ] = 0 ) val[i]-val[i-1](val[0]=0) val[i]val[i1](val[0]=0),代码如下:

int n;
int bit_tree[n];
void add(int a,int b){
	for(int i=a;i<=n;i+=lowbit(i)){
		bit_tree[i]+=b;
	}
}//这里每次修改的其实是一个后缀
void add_area(int l,int r,int v){
	add(l,v);add(r+1,-v);//差分后的修改方式,由于只会影响l~r,所以将r后的影响要消除
}
int query_pos(int a){
	int ans=0;
	for(int i=a;i>0;i-=lowbit(i)){
		ans+=bit_tree[i];
	}
	return ans;
}

区间修改,区间查询

对于区间修改操作,我们同样采用差分实现,但是对于区间查询就不能直接求得了。

所以,这里我们令一个位置上的值 p o s i = ∑ j = 1 i v a l j pos_i=\sum_{j=1}^i val_j posi=j=1ivalj,这里的 v a l j val_j valj为前面所说的差分后的数组,然后区间查询,我们同样转换为两个前缀和相减,那么对于一个前缀和的值就是 p r e i = ∑ j = 1 i p o s i pre_i=\sum_{j=1}^ipos_i prei=j=1iposi,将其做如下变换:

p r e i = ∑ j = 1 i ∑ k = 1 j v a l k pre_i=\sum_{j=1}^i\sum_{k=1}^jval_k prei=j=1ik=1jvalk

容易发现,其中每个 v a l k val_k valk被加了 i − k + 1 i-k+1 ik+1次,所以又可以转化为:

p r e i = ∑ j = 1 i v a l j × ( i − j + 1 ) p r e i = ( i + 1 ) × ∑ j = 1 i v a l j − ∑ j = 1 i v a l j × j pre_i=\sum_{j=1}^ival_j\times(i-j+1)\\ pre_i=(i+1)\times\sum_{j=1}^ival_j-\sum_{j=1}^ival_j\times j prei=j=1ivalj×(ij+1)prei=(i+1)×j=1ivaljj=1ivalj×j

所以我们维护两个树状数组,一个维护 v a l i val_i vali,一个维护 v a l i × i val_i\times i vali×i,查询就是前一个的前缀和乘以 i + 1 i+1 i+1再减去后一个的前缀和了。

代码如下:

loj 132
#include<cstdio>
#include<cstring>
#include<algorithm>
#define lowbit(a) ((a)&(-(a)))
#define ll long long
using namespace std;
const int M=1e6+10;
int n,m,x;
ll bit1[M],bit2[M];//val[i],val[i]*i
void add(int a,ll b){
	ll t=a;
	for(;a<=n;a+=lowbit(a)){
		bit1[a]+=b;bit2[a]+=b*t;
	}
}
ll query(int a){
	ll t1=0,t2=0,ls=a;
	for(;a;a-=lowbit(a)){
		t1+=bit1[a];
		t2+=bit2[a];
	}
	t1*=(ls+1);
	return t1-t2;
}
ll range_query(int l,int r){
	return query(r)-query(l-1);
}
void range_add(int l,int r,ll v){
	add(l,v);add(r+1,-v);
}
int l,r,opt;
int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++){
		scanf("%d",&x);
		range_add(i,i,x);
	}
	while(m--){
		scanf("%d%d%d",&opt,&l,&r);
		if(opt==1){
			scanf("%d",&x);
			range_add(l,r,x);
		}else{
			printf("%lld\n",range_query(l,r));
		}
	}
	return 0;
}

单点修改,区间最值查询

其实树状数组还可以做这个,原理差不多,从维护 2 k 2^k 2k的前缀和变成前缀最值了,但是由于区间最值不能转换成前缀最值来算,所以稍微复杂一点,复杂度也变成 l o g 2 n log^2n log2n

代码如下:

区间最大
int max_area(int l,int r){
    int ans=0;
    while(l<=r){
        ans=max(ans,val[r]);r--;//每次往后跳一个
        for(;r-lowbit(r)>=l;r-=lowbit(r)) ans=max(ans,maxv[r]);//看r最多跳到哪里,而不超过l
    }
    return ans;
}
int change_pos(int p,int a){
    val[p]=a;
    for(int i=p;i<=n;i+=lowbit(i)){
        maxv[i]=val[i];//修改的时候重新计算值
        for(int j=1;j<lowbit(i);j<<=1) maxv[i]=max(maxv[i],maxv[i-j]);
    }
}
void init(int p){
     for(int i=p;i<=n;i+=lowbit(i)) maxv[i]=max(maxv[i],val[p]);//初始化一个位置的值可以这样写。
}

题目

对于模板题,luogu的线段树的一个可以来练练,然后loj上130~132有非常全的板子。


二维树状数组

肯定还有三维的啦,不过这里不详细讲

暴力

对于一个 n × m n\times m n×m的矩阵,我们维护它的矩阵前缀和。暴力的做法就是对于每一行开一个一维树状数组,随便怎么暴力算吧,一次操作的复杂度最坏为 O ( n l o g m ) O(nlogm) O(nlogm),但是既然一维的可以做到 l o g n logn logn,那么二维的可不可以做到 l o g n l o g m lognlogm lognlogm呢?当然可以。

单点修改,区间查询

首先,空间是优化不到 O ( n ) O(n) O(n)的,只能是 O ( n m ) O(nm) O(nm)

同样的我们维护一维的前缀,对于一维的前缀我们再维护一个前缀,也就是 b i t t r e e [ i ] [ j ] bittree[i][j] bittree[i][j]表示左上角 ( 0 , 0 ) ∼ ( i , j ) (0,0)\sim(i,j) (0,0)(i,j)右下角的矩阵的和,单点修改的时候就是类似于一维的修改,只不过区间求和依然转换为前缀求和,但是计算方式要复杂一点,求一个区间的和 ( x 1 , y 1 ) ∼ ( x 2 , y 2 ) (x_1,y_1)\sim (x_2,y_2) (x1,y1)(x2,y2),如果是二维上的话,就转换为如下图的方式计算:

lz

p r e [ i ] [ j ] pre[i][j] pre[i][j]表示 ( 0 , 0 ) ∼ ( i , j ) (0,0)\sim(i,j) (0,0)(i,j)矩阵的和,那么 ( x 1 , y 1 ) ∼ ( x 2 , y 2 ) (x_1,y_1)\sim(x_2,y_2) (x1,y1)(x2,y2)计算方式如下:
s u m = p r e [ x 2 ] [ y 2 ] − p r e [ x 1 − 1 ] [ y 2 ] − p r e [ x 2 ] [ y 1 − 1 ] + p r e [ x 1 − 1 ] [ y 1 − 1 ] sum=pre[x_2][y_2]-pre[x_1-1][y_2]-pre[x_2][y_1-1]+pre[x_1-1][y_1-1] sum=pre[x2][y2]pre[x11][y2]pre[x2][y11]+pre[x11][y11]

蓝色部分多减了一次所以要加回来。

区间修改,单点查询

和一维同理,换成差分数组, d [ i ] [ j ] = v a l [ i ] [ j ] − v a l [ i − 1 ] [ j ] − v a l [ i ] [ j − 1 ] + v a l [ i − 1 ] [ j − 1 ] d[i][j]=val[i][j]-val[i-1][j]-val[i][j-1]+val[i-1][j-1] d[i][j]=val[i][j]val[i1][j]val[i][j1]+val[i1][j1]即可,维护这个数组的前缀和,单点查询便是这个点的前缀和,区间修改就变成那四个点 ( x 1 − 1 , y 1 − 1 ) , ( x 1 − 1 , y 2 ) , ( x 2 , y 1 − 1 ) , ( x 2 , y 2 ) (x_1-1,y_1-1),(x_1-1,y_2),(x_2,y_1-1),(x_2,y_2) (x11,y11),(x11,y2),(x2,y11),(x2,y2)分别加上 v , − v , − v , v v,-v,-v,v v,v,v,v v v v为要在这个区间加上的值)即可。

区间修改,区间查询

同样,转换为差分数组,求前缀的前缀,推一波式子:

我们令 d i , j d_{i,j} di,j为前面的那个差分数组,那么一个单点的值 v a l i , j = ∑ x = 1 i ∑ y = 1 j d x , y val_{i,j}=\sum_{x=1}^i\sum_{y=1}^jd_{x,y} vali,j=x=1iy=1jdx,y,前缀和就是 s u m x , y = ∑ i = 1 x ∑ j = 1 y v a l x , y sum_{x,y}=\sum_{i=1}^x\sum_{j=1}^yval_{x,y} sumx,y=i=1xj=1yvalx,y,那么展开如下:

s u m x , y = ∑ i = 1 x ∑ j = 1 y ∑ k = 1 i ∑ h = 1 j d h , k sum_{x,y}=\sum_{i=1}^x\sum_{j=1}^y\sum_{k=1}^i\sum_{h=1}^jd_{h,k} sumx,y=i=1xj=1yk=1ih=1jdh,k

通过观察推导可以发现,一个 d i , j d_{i,j} di,j被加了 ( x − i + 1 ) × ( y − j + 1 ) (x-i+1)\times(y-j+1) (xi+1)×(yj+1)次,然后同理得到:
s u m x , y = ∑ i = 1 x ∑ j = 1 y d i , j × ( x − i + 1 ) × ( y − j + 1 ) s u m x , y = ( x + 1 ) × ( y + 1 ) × ∑ i = 1 x ∑ j = 1 y d i , j − ( y + 1 ) ∑ i = 1 x ∑ j = 1 y d i , j × i − ( x + 1 ) ∑ i = 1 x ∑ j = 1 y d i , j × j + ∑ i = 1 x ∑ j = 1 y d i , j × i × j sum_{x,y}=\sum_{i=1}^x\sum_{j=1}^yd_{i,j}\times(x-i+1)\times(y-j+1)\\ sum_{x,y}=(x+1)\times(y+1)\times\sum_{i=1}^x\sum_{j=1}^yd_{i,j}-(y+1)\sum_{i=1}^x\sum_{j=1}^yd_{i,j}\times i-(x+1)\sum_{i=1}^x\sum_{j=1}^yd_{i,j}\times j+\sum_{i=1}^x\sum_{j=1}^yd_{i,j}\times i\times j sumx,y=i=1xj=1ydi,j×(xi+1)×(yj+1)sumx,y=(x+1)×(y+1)×i=1xj=1ydi,j(y+1)i=1xj=1ydi,j×i(x+1)i=1xj=1ydi,j×j+i=1xj=1ydi,j×i×j

所以和一维一样,我们维护四个二维树状数组,分别是 d i , j , d i , j × i , d i , j × j , d i , j × i × j d_{i,j},d_{i,j}\times i,d_{i,j}\times j,d_{i,j}\times i\times j di,j,di,j×i,di,j×j,di,j×i×j即可。

代码如下:

模板题-上帝造题的七分钟

#include<cstdio>
#include<cstring>
#include<algorithm>
#define lowbit(i) (i&(-i))
using namespace std;
const int M=2050;
int n,m;
struct bittree{
    int bit[M][M];
    bittree(){memset(bit,0,sizeof(bit));}
    void add(int a,int b,int v){
        for(;a<=n;a+=lowbit(a))
        for(int j=b;j<=m;j+=lowbit(j))
        bit[a][j]+=v;
    }
    int query(int a,int b){
        int ans=0;
        for(;a;a-=lowbit(a))
        for(int j=b;j;j-=lowbit(j))
        ans+=bit[a][j];
        return ans;
    }
}bt,bti,btj,btij;
int t1,t2,t3,t4;
void update(int a,int b,int v){
    bt.add(a,b,v);
    bti.add(a,b,v*a);
    btj.add(a,b,v*b);
    btij.add(a,b,v*a*b);
}
int getans(int a,int b)
{return bt.query(a,b)*(a*b+a+b+1)-bti.query(a,b)*(b+1)-btj.query(a,b)*(a+1)+btij.query(a,b);}
void add(int a,int b,int c,int d,int v){
    t1=min(a,c);t2=min(b,d);t3=max(a,c);t4=max(d,b);
    update(t3+1,t4+1,v);
    update(t1,t2,v);
    update(t3+1,t2,-v);
    update(t1,t4+1,-v);
}
int query(int a,int b,int c,int d){
    t1=min(a,c);t2=min(b,d);t3=max(a,c);t4=max(d,b);
    return getans(t3,t4)-getans(t3,t2-1)-getans(t1-1,t4)+getans(t1-1,t2-1);
}
int a,b,c,d,v;
char s[2];
int main(){
    scanf("%*c%d%d",&n,&m);
    while(scanf("%s%d%d%d%d",s,&a,&b,&c,&d)==5){
        if(s[0]=='L'){
            scanf("%d",&v);
            add(a,b,c,d,v);
        }else{
            printf("%d\n",query(a,b,c,d));
        }
    }
    return 0;
}

同样的loj上也有模板题,二维的修改和查询复杂度就是 O ( l o g n l o g m ) O(lognlogm) O(lognlogm)的了,空间为 O ( n m ) O(nm) O(nm),开始建树同样有 O ( n m ) O(nm) O(nm)的和 O ( n m l o g n l o g m ) O(nmlognlogm) O(nmlognlogm)


对于二维区间最值和一维类似,由于过于复杂,这里不详细说明。

而对于三维及三维以上的树状数组,其实和一二维的同理,但不仅复杂,代码量和复杂度急剧上升,所以除了毒瘤外一般不考考到了不要打我QWQ


End

参考文章

其实二维的数据结构,线段树除了树套树,四叉树的复杂度不对且非常高,所以目前常见的二维数据结构多半是树状数组不要给我说K-D tree

有问题或者文章有错误请及时指出并联系博主,博主一定及时咕咕咕回答或者更改。

  • 4
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

VictoryCzt

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值