树状数组和下面的线段树可是亲兄弟了,但他俩毕竟还有一些区别:
树状数组能有的操作,线段树一定有;
线段树有的操作,树状数组不一定有。
这么看来选择线段树不就 「得天下了」 ?
事实上,树状数组的代码要比线段树短得多,思维也更清晰,在解决一些单点修改的问题时,树状数组是不二之选。
1.什么是树状数组
顾名思义树状数组就是模拟树形结构根据一定的规律建造的数组,那么为什么不直接建树呢?因为了解线段树的都知道线段树的模板代码量是比较大的,如果我们能用模拟的方法解决问题,效率会高很多。
2.树状数组可以解决什么问题
我们来想一下这个问题
这里通过一个简单的题目展开介绍,先输入一个长度为n的arr
数组,然后我们有如下两种操作:
- 输入一个数m,输出数组中下标1~m的前缀和
- 对某个指定下标的数进行值的修改
多次执行上述两种操作
一般方法:
我们可以新建一个前缀和数组sum[n]
,来记录arr[0]-arr[n]
的和,当然这个操作很简单,
这样的话,区间查询操作就变成了sum[R] - sum[L-1]
了,表示的是L到R区间内的元素和,看起来是变成了O(1)。
但是这个时候如果我们需要更新某个下标对应的值,其后面所有的前缀和也需要发生改变。
我们发现,单点更新时间复杂度又变成了O(n)!!!
看来,鱼和熊掌不可兼得,不能将两者都维持到一个很低的复杂度上。那么,我想知道有没有一种方法,可以将整体的时间复杂度维持到一个比线性更快的水平上呢?
当然,答案就是线段树~咳咳,走错片场了不好意思。答案当然是先考虑树状数组啦
3.树状数组介绍
还是以解决上面的区间和问题为例
我们先看一下这个图
相信大家都不陌生,这是一个二叉树结构图,如果我们把arr
数组存到这个树最下面这一层的节点上,并且上面的父节点的值都是下面两个子节点值的和,是不是就可以解决这类区间问题了呢?
是的没错,但是这样的树形结构,叫做线段树。
而下面的树状数组和上图类似,但是省去了一些节点,所以在效率上会有所提高
黑色数组代表原来的数组(下面用A[i]代替),红色结构代表我们的树状数组(下面用C[i]代替),令每个位置存的是下面子节点的值的和,则有
原数组 A[i] , 树状数组C[i]
- C1 = A1
- C2 = A1 + A2
- C3 = A3
- C4 = A1 + A2 + A3 + A4
- C5 = A5
- C6 = A5 + A6
- C7 = A7
- C8 = A1 + A2 + A3 + A4 + A5 + A6 + A7 + A8
乍一看会很懵,这到底是按什么规律存的呢?
其实不难发现,在树状数组中所有奇数下标对应的都是原数组下标的值,而偶数下标对应的是和,但是偶数下标对应的和又有什么规律?
前人总结出了这个规律C[i]=A[i-2^k+1]+A[i-2^k+2]+......A[i];(k为i的二进制中从最低位到高位连续零的长度)
例如i=8
时,k=3
;
有了这个我们就知道 sum[7] = C[7] + C[6] + C[4];
那么又回到那个规律中,求区间和的问题转变成了求 2^k
?
这个问题前人的智慧又显现出来了!
他说 2^k = i&(-i)
,这个有一个专门的称呼,叫做lowbit
4. lowbit
我们可以先做一个测试来验证lowbit
到底对不对,根据上面所说的
假设 i=8,
那么根据规律k=3 , 2^3=8
,我用程序运行一下
int lowbit(int x)
{
return x&(-x);
}
int main()
{
int i;
while(cin>>i&&i)
{
int k = lowbit(i);
cout<<k<<endl;
}
return 0;
}
运行结果是
8
8
可以发现,我们测始其他的答案也是对的,那lowbit又是什么原理呢?
这里利用的负数的存储特性,负数是以补码存储的,对于整数运算 x&(-x)有
-
当x为0时,即 0 & 0,结果为0;
-
当x为奇数时,最后一个比特位为1,取反加1没有进位,故x和-x除最后一位外前面的位正好相反,按位与结果为0。结果为1。
-
当x为偶数,且为2的m次方时,x的二进制表示中只有一位是1(从右往左的第m+1位),其右边有m位0,故x取反加1后,从右到左第有m个0,第m+1位及其左边全是1。这样,x& (-x) 得到的就是x。
-
当x为偶数,却不为2的m次方的形式时,可以写作x= y * (2^k)。其中,y的最低位为1。实际上就是把x用一个奇数左移k位来表示。这时,x的二进制表示最右边有k个0,从右往左第k+1位为1。当对x取反时,最右边的k位0变成1,第k+1位变为0;再加1,最右边的k位就又变成了0,第k+1位因为进位的关系变成了1。左边的位因为没有进位,正好和x原来对应的位上的值相反。二者按位与,得到:第k+1位上为1,左边右边都为0。结果为2的k次方。
总结一下:x&(-x),当x为0时结果为0;x为奇数时,结果为1;x为偶数时,结果为x中2的最大次方的因子。
分----------------------------------------------------割
现在我们知道了lowbit
的原理,我们再看一下这个公式
C[i]=A[i-2^k+1]+A[i-2^k+2]+......A[i];
我们知道k为i的二进制中从最低位到高位连续零的长度,而小于i的节点的值肯定要小于i,那么它们的k绝对要小于i的k,而最长的就是k,因为它的二进制表示的数只能允许它右移k位,右移k位之后它就是叶子节点了,就只表示一个单一的A[]数组的值了,同时也是C[]树状数组的值。
所以得出结论 ,k 表示的 为i 这个节点表示的树的深度。
有了这点知识为基础,那么我们就可以知道,我们要修改某个元素的值,就会修改C[]的值,以及它的所有祖先节点的值。
下一步就是建立树状数组了~
5.建立树状数组
关于建树的过程,我们再来深入探讨一下,看一下下面的代码
void updata(int i,int k){ //在i位置加上k
while(i <= n){ //n表示原数组的最大下标
c[i] += k;
i += lowbit(i);
}
}
这个是建立树状数组的主要代码,对它稍作修改添加主要代码
void updata(int i,int k){ //在i位置加上k
while(i <= n){
c[i] += k;
cout<<i<<' '<<c[i]<<endl;
i += lowbit(i);
}
}
int main(){
int t;
n = 8;
memset(a,0,sizeof(a));
memset(c,0,sizeof(c));
updata(1,222);
cout<<"****************************************"<<endl;
updata(3,111);
return 0;
}
运行结果
1 222
2 222
4 222
8 222
****************************************
3 111
4 333
8 333
结合树状数组结构图来看一下
发现建树的规律了没有,前面是再建树过程中访问到的树的下标,后边是修改后的值。
每次更新一个树节点的值,都会一直往后更新直到最后。
6.单点更新和区间查询
我们还用c[i]表示树状数组,a[i]表示原数组
先初始化原数组和树状数组,然后读入更新数据 ,
int m,n;
int lowbit(int x)
{
return x&(-x);
}
int getsum(int x) //返回下标1-x元素的和
{
int sum=0;
for(int i=x;i>0;i-=lowbit(i))
{
sum += c[i];
}
return sum;
}
void add(int x,int y) //x表示值的下标,y是修改后的值
{
for(int i=x;i<=n;i+=lowbit(i))
{
c[i] += y; //更新树状数组
}
}
int main()
{
memset(a,0,sizeof(a));
memset(c,0,sizeof(c));
cin >> n;
for(int i=1;i<=n;i++)
{
cin>>a[i]; //读入原数组
add(i,a[i]); //更新树状数组
}
return 0;
}
这个模板我们就写好了,可以解决简单的区间求和,单点更新等操作,但是有时候我们需要用到区间更新,这时我们也可以首先考虑树状数组,如果实在不行,在使用线段树。
趁热打铁,先看一道非常简单的模板题 敌兵布阵
7.区间更新和单点查询
这里的单点查询是指查询下标从1-x的区间和,并不是真正意义上的查询单个元素的值。
如果题目是让你把x-y区间内的所有值全部加上k或者减去k,然后查询操作是问某个点的值,这种时候该怎么做呢?
如果使用上面的代码,也是可以完成的,但是这样的复杂度会非常高,这个时候,我们就不能再使用原数据的进行建树了,而是利用差分值
.
差分数组
众所周知,差分数组一般都被用来快速处理区间加减的操作,因为当原数组某个区间内的值变了,区间内的差值是不变的,只有D[x]
和D[y+1]
的值发生改变
利用一个新的数组记录原数组每一项和前一项的差值
用 d[i]
表示差分数组,
原数组是a[i]
那么
则
所以我们可以通过求d[i]
的前缀和查询树中某个点的值
我们就利用这个性质,建立树状数组
#include <bits/stdc++.h>
using namespace std;
int n,m;
int a[50005],c[50005]; //对应原数组和树状数组
int lowbit(int x){
return x&(-x);
}
void updata(int i,int k){ //在i位置加上k
while(i <= n){
c[i] += k;
i += lowbit(i);
}
}
int getsum(int i){ //求A[1 - i]的和
int res = 0;
while(i > 0){
res += c[i];
i -= lowbit(i);
}
return res;
}
int main(){
memset(a,0,sizeof(a));
memset(c,0,sizeof(c));
cin>>n;
//这里已经把a[0]、c[0]初始化为0了
for(int i=1;i<=n;i++)
{
cin>>a[i];
updata(i,a[i]-a[i-1]); //直接利用差分值建树
}
int x,y,k;
cin>>x>>y>>k;
//将区间[x,y]内的值增加或减少k
updata(x,k);
updata(y+1,-k);
cout<<getsum(3)<<endl; //单点查询
return 0;
}
8.区间更新和区间查询
这是最常用的部分,也是用线段树写着最麻烦的部分——但是现在我们有了树状数组!
我们先来看一下下面的公式
原数组:a[i] , 差分数组:d[i]
位置p的前缀和为
在等式的最右侧里可以发现d[1]被用了p次,d[2]被用了p-1次,所以可以把推导出
这就可以看出了,我们求区间和的话只需要维护两个数组的前缀和,分别维护d[i]
和d[i]*i
,
假设sum1[i]=d[i],而sum2[i]=d[i]*i
实现代码
void add(ll p, ll x){
for(int i = p; i <= n; i += i & -i)
sum1[i] += x, sum2[i] += x * p;
}
void range_add(ll l, ll r, ll x){
add(l, x), add(r + 1, -x);
}
ll ask(ll p){
ll res = 0;
for(int i = p; i; i -= i & -i)
res += (p + 1) * sum1[i] - sum2[i];
return res;
}
ll range_ask(ll l, ll r){
return ask(r) - ask(l - 1);
}
参考文章
附上线段树入门基础