浅谈树状数组

树状数组可是个好东西

树状数组(Binary Indexed Tree(B.I.T), Fenwick Tree)是一个查询和修改复杂度都为 O ( l o g 2 n ) O(log_2n) O(log2n)的数据结构。主要用于查询任意两位之间的所有元素之和,但是每次只能修改一个元素的值;经过简单修改可以在 O ( l o g 2 n ) O(log_2n) O(log2n)的复杂度下进行范围修改,但是这时只能查询其中一个元素的值(如果加入多个辅助数组则可以实现区间修改与区间查询,当然,本文也要介绍这种方法)。

树状数组长这样:

它像是一个简化前缀和的形式;可是表示方法很奇怪,看上面的图,你能发现什么规律吗?

如果想要查找一段区间,例如 A [ 3 ] → A [ 7 ] A[3] \to A[7] A[3]A[7]的和,我们要找到以下几块:

A [ 1 ] → A [ 7 ] A[1] \to A[7] A[1]A[7]之和: C [ 7 ] + C [ 6 ] + C [ 4 ] C[7] + C[6] + C[4] C[7]+C[6]+C[4]

A [ 1 ] → A [ 2 ] A[1] \to A[2] A[1]A[2]之和: C [ 2 ] C[2] C[2]

然后利用前缀和思想,相减即可得到 A [ 3 ] → A [ 7 ] A[3] \to A[7] A[3]A[7]的和。

看上去比较难理解,放一张图:

这是怎么找到的呢?先别急,我们来看看单点的修改:

如果更新了一个节点,例如把 A [ 5 ] A[5] A[5]加上一,我们需要依次找到 C [ 5 ] C[5] C[5]的父亲节点: C [ 5 ] → C [ 6 ] → C [ 8 ] C[5] \to C[6] \to C[8] C[5]C[6]C[8] ,各加上1就能得到了。

那我们应该怎么找这样的节点呢?

继续看上面那张图:在他的节点更新的时候,他的二进制表示数是如何变化的呢?

看看图里的例子:

C [ ( 111 ) 2 ] → C [ ( 110 ) 2 ] → C [ ( 100 ) 2 ] C[(111)_2] \to C[(110)_2] \to C[(100)_2] C[(111)2]C[(110)2]C[(100)2]

再来一组?( A [ 1 ] → A [ 5 ] A[1] \to A[5] A[1]A[5]之和)

C [ ( 101 ) 2 ] → C [ ( 100 ) 2 ] C[(101)_2] \to C[(100)_2] C[(101)2]C[(100)2]

或许你发现了什么?( A [ 1 ] → A [ 9 ] A[1] \to A[9] A[1]A[9]之和)

C [ ( 1001 ) 2 ] → C [ ( 1000 ) 2 ] C[(1001)_2] \to C[(1000)_2] C[(1001)2]C[(1000)2]

重点来了

  • 你或许会发现最后剩下数组下标的是2的整数次幂
  • 你或许会发现末尾有一坨0
  • 你或许会发现,每次变化会少掉一个1
  • 你或许会发现,每次变化少掉的1总是在低位

没错,数位为1的最低位!

我们就可以把求最低位1的这个运算叫做 L o w b i t Lowbit Lowbit

$ Lowbit(k) = k & -k $

每次变化减去一个 L o w b i t ( Lowbit( Lowbit(当前节点 ) ) )

再看看单点修改(可以借助上面的图理解一下):

举个例子( A [ 5 ] A[5] A[5]的所有父节点,祖先):

$C[(101)_2] \to C[(110)_2] \to C[(1000)_2] $

或许你又发现了什么?( A [ 4 ] A[4] A[4]的所有父节点,祖先)

C [ ( 100 ) 2 ] → C [ ( 1000 ) 2 ] C[(100)_2] \to C[(1000)_2] C[(100)2]C[(1000)2]

并没有发现什么

每次变化加上一个 L o w b i t ( Lowbit( Lowbit(当前节点 ) ) )

? ? ? ? ???? ????

(有疑问是很正常的,用代码解决)

至此我们就 讲完了

后面还有,不要走开

#include <cstdio>
#define lowbit(x) ((x)&(-x))
int tree[500010],a[500010],n;
void update(int x,int k) {
	while(x<=n) {
		tree[x]+=k;
		x+=lowbit(x);
	}
}
void build() {
	for(int i=1; i<=n; i++) update(i,a[i]);
}

int query(int x) {
	int sum=0;
	while(x) {
		sum+=tree[x];
		x-=lowbit(x);
	}
	return sum;
}
int main() {
	int m,mode,left,right;
	scanf("%d%d",&n,&m);
	for(int i=1; i<=n; i++) scanf("%d",&a[i]);
	build();
	for(int i=1; i<=m; i++) {
		scanf("%d%d%d",&mode,&left,&right);
        if(mode==1) update(left,right);
        else printf("%d\n",query(right)-query(left-1));
	}
	return 0;
}

嗯没错,以上是树状数组1的题解

接下来我们来讲树状数组2与线段树1的歪解

歪解总是比正解快不是吗

树状数组常数小,代码少,容易理解,内存占用少,在求解可用前缀和加减的区间信息或从1开始的区间信息这两个方面比线段树不知道好多少倍。唯一遗憾的是没有区间修改…

聪明的我们总有办法

我们可以当作维护两个数组(听上去就很恶心,而且这两个数组都不是原数据,而是差分的序列)

这两个数组用来干什么呢?怎么表示呢?

来揭晓答案~~

(下文中 A i A_i Ai表示原数据,假设 C 1 = A 1 C_1=A_1 C1=A1

我们像维护正常的树状数组那样,维护以下两组数据:

C i = A i − A i − 1 C_i=A_i-A_{i-1} Ci=AiAi1

D i = C i × ( i − 1 ) D_i=C_i \times (i-1) Di=Ci×(i1)

那么

根据差分的思想

如果我们修改 A l → A r A_l \to A_r AlAr的区间,统一加上 P P P,只需要:

C l + = P , C r + 1 − = P , D l + = P ∗ ( l − 1 ) , D r + 1 − = P ∗ ( r + 1 − 1 ) C_l+=P,C_{r+1}-=P,D_l+=P*(l-1),D_{r+1}-=P*(r+1-1) Cl+=P,Cr+1=P,Dl+=P(l1),Dr+1=P(r+11)

也就是说(放心,两个式子没有差别)

C l + = P , C r + 1 − = P , D l + = P ∗ ( l − 1 ) , D r + 1 − = P ∗ r C_l+=P,C_{r+1}-=P,D_l+=P*(l-1),D_{r+1}-=P*r Cl+=P,Cr+1=P,Dl+=P(l1),Dr+1=Pr

难题来了!查找区间呢?

看看以下的推导(求解 A [ 1 ] → A [ n ] A[1] \to A[n] A[1]A[n]的和):

通过基本树状数组,我们应该可以在 O ( l o g 2 n ) O(log_2 n) O(log2n)的时间内求出以下两个

E i = ∑ j = 1 i C i E_i=\sum\limits_{j=1}^iC_i Ei=j=1iCi

F i = ∑ j = 1 i D i F_i=\sum\limits_{j=1}^iD_i Fi=j=1iDi

你会发现,我们求的是C,D两数组的前缀和

然后把 F i F_i Fi变化一下

F i = ∑ j = 1 i D i = ∑ j = 1 i C i × ( i − 1 ) F_i=\sum\limits_{j=1}^iD_i=\sum\limits_{j=1}^iC_i \times (i-1) Fi=j=1iDi=j=1iCi×(i1)

那么我们要求的 A i A_i Ai的前缀和应该是

∑ i = 1 n A i = ∑ i = 1 n ∑ j = 1 i C i = ∑ i = 1 n ( n − i + 1 ) × C i = n × ∑ i = 1 n C i − ∑ i = 1 n C i × ( i − 1 ) = n × E i − F i \sum\limits_{i=1}^nA_i=\sum\limits_{i=1}^n\sum\limits_{j=1}^iC_i=\sum\limits_{i=1}^n(n-i+1) \times C_i=n \times\sum\limits_{i=1}^nC_i-\sum\limits_{i=1}^nC_i \times (i-1)=n \times E_i - F_i i=1nAi=i=1nj=1iCi=i=1n(ni+1)×Ci=n×i=1nCii=1nCi×(i1)=n×EiFi

当然也可以这样变

维护一个 G i G_i Gi来替换 F i F_i Fi,也就是

G i = C i × k G_i=C_i \times k Gi=Ci×k

∑ i = 1 n A i = ∑ i = 1 n ∑ j = 1 i C i = ∑ i = 1 n ( n − i + 1 ) × C i = ( n + 1 ) × ∑ i = 1 n C i − ∑ i = 1 n C i × i = ( n + 1 ) × E i − G i \sum\limits_{i=1}^nA_i=\sum\limits_{i=1}^n\sum\limits_{j=1}^iC_i=\sum\limits_{i=1}^n(n-i+1) \times C_i=(n+1) \times\sum\limits_{i=1}^nC_i-\sum\limits_{i=1}^nC_i \times i=(n+1) \times E_i - G_i i=1nAi=i=1nj=1iCi=i=1n(ni+1)×Ci=(n+1)×i=1nCii=1nCi×i=(n+1)×EiGi

有兴趣的读者可以尝试一下上面那种实现方式。

所以,再用代码解释一切

#include <cstdio>
#define M 100100
#define ll long long
#define lb(x) ((x)&(-x))
ll a[M],sum[M],sum2[M];
int n;
void one_up(int k,ll p) {
	for(int i=k; i<=n; i+=lb(i)) {
		sum[i]+=p;
		sum2[i]+=p*(k-1);
	}
}
ll one_qu(int k) {
	ll ans=0;
	for(int i=k; i; i-=lb(i)) ans+=k*sum[i]-sum2[i];
	return ans;
}
void Update(int x,int y,ll p) {
	one_up(x,p);
	one_up(y+1,-p);
}
ll Query(int x,int y) {
	return one_qu(y)-one_qu(x-1);
}
int main() {
	int m,md,l,r;
	ll p;
	scanf("%d%d",&n,&m);
	for(int i=1; i<=n; i++) scanf("%d",&a[i]),Update(i,i,a[i]);
	for(int i=1; i<=m; i++) {
		scanf("%d%d%d",&md,&l,&r);
		if(md==1) scanf("%lld",&p),Update(l,r,p);
		else if(md==2) printf("%lld\n",Query(l,r));
	}
    return 0;
}

以上是线段树1的AC代码

至于第二种做法,只要稍微改一下就好了(明明都差不多的,怎么就能叫"第二种做法"?!!)

下面展示代码片段

void one_up(int k,ll p) {
	for(int i=k; i<=n; i+=lb(i)) {
		sum[i]+=p;
		sum2[i]+=p*k;
	}
}
ll one_qu(int k) {
	ll ans=0;
	for(int i=k; i; i-=lb(i)) ans+=(k+1)*sum[i]-sum2[i];
	return ans;
}

事实证明,这常数可是不一般的大…

所以我可以同时发三份题解吗

U p d a t e d   D e c . 8 t h   21 : 54 Updated \space Dec.8^{th} \space 21:54 Updated Dec.8th 21:54刚刚发现上面的公式有个地方打错了,赶紧改

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值